Rework configurations (#1352)

- [x] All configs should support ENV variable overrides over DB values
- [ ] ~Adding a new field to DB value should automatically write ENV
config to DB on app boot (so that we don't need migrations)~
- [x] Validate configs and report human-readable errors when something
is wrong, telling where it's invalid (eg. env key X) and what's wrong
with it
- [x] Reuse Changeset validations (we still have a DB schema and UI
form, and want to make sure it's valid)
- [x] Auto-generate docs
- [x] Merge `Config` and `Configurations` into one `Config` context
- [x] Lock out UI fields for configurations when there is an ENV
override
- [x] Lock out corresponding REST API configuration field if overridden
via ENV var
- [x] Log a warning when deprecated legacy var is used
- [x] Document precedence: ENV -> Legacy ENV -> File -> DB
- [x] Change type to `inet[]` for `configurations.{default_client_dns,
default_client_allowed_ips}`, `devices.{dns, allowed_ips}`,
- [x] Drop `EctoNetwork` dep
- [x] `s/phoenix_port/phoenix_http_port` because it doesn't configure
HTTPS server
- [x] Do not load DB configs when config can be resolved from other
sources

Maybe:
- [ ] ~Auto-generate Ecto types to automatically cast/dump values
to/from DB~
- [ ] Allow JSON file config source
- [x] DB-related configs will not be validated?

Closes #1162
Closes #1313
Closes #1374
Closes #1432
This commit is contained in:
Andrew Dryga
2023-02-21 10:38:53 -06:00
committed by GitHub
parent 38ba7493f5
commit af431c0a6f
155 changed files with 5624 additions and 2590 deletions

View File

@@ -125,12 +125,11 @@
{Credo.Check.Refactor.MapJoin, []},
{Credo.Check.Refactor.NegatedConditionsInUnless, []},
{Credo.Check.Refactor.NegatedConditionsWithElse, []},
{Credo.Check.Refactor.Nesting, []},
{Credo.Check.Refactor.Nesting, [max_nesting: 3]},
{Credo.Check.Refactor.UnlessWithElse, []},
{Credo.Check.Refactor.WithClauses, []},
{Credo.Check.Refactor.FilterFilter, []},
{Credo.Check.Refactor.RejectReject, []},
{Credo.Check.Refactor.RedundantWithClauseResult, []},
#
## Warnings

View File

@@ -294,8 +294,8 @@ jobs:
image: vihangk1/docker-test-saml-idp:latest
env:
SIMPLESAMLPHP_SP_ENTITY_ID: 'urn:firezone.dev:firezone-app'
SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE: 'http://localhost:4002/auth/saml/sp/consume/mysamlidp'
SIMPLESAMLPHP_SP_SINGLE_LOGOUT_SERVICE: 'http://localhost:4002/auth/saml/sp/logout/mysamlidp'
SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE: 'http://localhost:13000/auth/saml/sp/consume/mysamlidp'
SIMPLESAMLPHP_SP_SINGLE_LOGOUT_SERVICE: 'http://localhost:13000/auth/saml/sp/logout/mysamlidp'
SIMPLESAMLPHP_SP_NAME_ID_FORMAT: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
SIMPLESAMLPHP_SP_NAME_ID_ATTRIBUTE: 'email'
SIMPLESAMLPHP_IDP_AUTH: 'example-userpass'

4
.gitignore vendored
View File

@@ -63,5 +63,9 @@ npm-debug.log
# Test screenshots
apps/fz_http/screenshots
# WG configs generated in acceptance tests
*.conf
# Auto generated private key
apps/fz_http/priv/wg_dev_private_key
apps/fz_http/priv/static/uploads

View File

@@ -1,20 +0,0 @@
# FzCommon
**TODO: Add description**
## Installation
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
by adding `fz_common` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:fz_common, "~> 0.1.0"}
]
end
```
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
be found at [https://hexdocs.pm/fz_common](https://hexdocs.pm/fz_common).

View File

@@ -1,28 +0,0 @@
defmodule FzCommon.FzInteger do
@moduledoc """
Utility functions for working with Integers.
"""
# Postgres max int size is 4 bytes
@max_integer 2_147_483_647
@byte_size_opts [
precision: 2,
delimiter: ""
]
def clamp(num, min, _max) when is_integer(num) and num < min, do: min
def clamp(num, _min, max) when is_integer(num) and num > max, do: max
def clamp(num, _min, _max) when is_integer(num), do: num
def max_pg_integer do
@max_integer
end
def to_human_bytes(nil), do: to_human_bytes(0)
def to_human_bytes(bytes) when is_integer(bytes) do
FileSize.from_bytes(bytes, scale: :iec)
|> FileSize.format(@byte_size_opts)
end
end

View File

@@ -1,15 +0,0 @@
defmodule FzCommon.FzKernelVersion do
@moduledoc """
Helpers related to kernel version
"""
@doc """
Compares version tuple to current kernel version
"""
def is_version_greater_than?(val) do
case :os.version() do
v when is_tuple(v) -> v > val
_ -> false
end
end
end

View File

@@ -1,38 +1,4 @@
defmodule FzCommon.FzNet do
@moduledoc """
Network utility functions.
"""
import FzCommon.FzRegex
# XXX: Consider using CIDR for this
def ip_type(str) when is_binary(str) do
charlist =
str
# remove CIDR range if exists
|> String.split("/")
|> List.first()
|> String.to_charlist()
case :inet.parse_ipv4_address(charlist) do
{:ok, _} ->
"IPv4"
{:error, _} ->
case :inet.parse_ipv6_address(charlist) do
{:ok, _} -> "IPv6"
{:error, _} -> "unknown"
end
end
end
def valid_cidr?(cidr) when is_binary(cidr) do
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())
end
@doc """
Standardize IP addresses and CIDR ranges so that they can be condensed / shortened.
"""
@@ -48,27 +14,6 @@ defmodule FzCommon.FzNet do
end
end
def valid_fqdn?(fqdn) when is_binary(fqdn) do
String.match?(fqdn, fqdn_regex())
end
def valid_hostname?(hostname) when is_binary(hostname) do
String.match?(hostname, host_regex())
end
def to_complete_url(str) when is_binary(str) do
case URI.new(str) do
{:ok, %{host: nil, scheme: nil}} ->
{:ok, "https://" <> str}
{:ok, _} ->
{:ok, str}
err ->
err
end
end
def endpoint_to_ip(endpoint) do
endpoint
|> String.replace(~r{:\d+$}, "")

View File

@@ -1,25 +0,0 @@
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

@@ -1,26 +0,0 @@
defmodule FzCommon.FzString do
@moduledoc """
Utility functions for working with Strings.
"""
def sanitize_filename(str) when is_binary(str) do
str
|> String.replace(~r/[^a-zA-Z0-9]+/, "_")
end
def to_boolean(str) when is_binary(str) do
as_bool(String.downcase(str))
end
defp as_bool("true") do
true
end
defp as_bool("false") do
false
end
defp as_bool(unknown) do
raise "Unknown boolean: string #{unknown} not one of ['true', 'false']."
end
end

View File

@@ -31,7 +31,6 @@ defmodule FzCommon.MixProject do
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:file_size, "~> 3.0.1"},
{:cidr, github: "firezone/cidr-elixir"},
{:posthog, "~> 0.1"},
{:argon2_elixir, "~> 2.0"}

View File

@@ -1,6 +0,0 @@
defmodule FzCommonTest do
use ExUnit.Case
doctest FzCommon
# XXX: Ensure command injection is NOT POSSIBLE
end

View File

@@ -1,54 +0,0 @@
defmodule FzCommon.FzIntegerTest do
use ExUnit.Case, async: true
alias FzCommon.FzInteger
describe "max_pg_integer/0" do
test "returns max integer for postgres" do
assert 2_147_483_647 == FzInteger.max_pg_integer()
end
end
describe "clamp/3" do
test "clamps to min" do
min = 1
max = 5
num = 0
assert 1 == FzInteger.clamp(num, min, max)
end
test "clamps to max" do
min = 1
max = 5
num = 7
assert 5 == FzInteger.clamp(num, min, max)
end
test "returns num if in range" do
min = 1
max = 5
num = 3
assert 3 == FzInteger.clamp(num, min, max)
end
end
describe "to_human_bytes/1" do
@expected [
{nil, "0.00 B"},
{1_023, "1023.00 B"},
{1_023_999_999_999_999_999_999, "888.18 EiB"},
{1_000, "1000.00 B"},
{1_115, "1.09 KiB"},
{987_654_321_123_456_789_987, "856.65 EiB"}
]
test "handles expected cases" do
for {bytes, str} <- @expected do
assert FzInteger.to_human_bytes(bytes) == str
end
end
end
end

View File

@@ -3,100 +3,6 @@ defmodule FzCommon.FzNetTest do
alias FzCommon.FzNet
describe "ip_type/1" do
test "it detects IPv4 addresses" do
assert FzNet.ip_type("127.0.0.1") == "IPv4"
end
test "it detects IPv6 addresses" do
assert FzNet.ip_type("::1") == "IPv6"
end
test "it reports \"unknown\" for unknown type" do
assert FzNet.ip_type("invalid") == "unknown"
end
end
describe "valid_ip?/1" do
test "10 is an invalid IP" do
refute FzNet.valid_ip?("10")
end
test "1.1.1. is an invalid IP" do
refute FzNet.valid_ip?("1.1.1.")
end
test "foobar is an invalid IP" do
refute FzNet.valid_ip?("foobar")
end
test "1.1.1.1 is a valid IP" do
assert FzNet.valid_ip?("1.1.1.1")
end
test "::1 is a valid IP" do
assert FzNet.valid_ip?("1.1.1.1")
end
end
describe "valid_host?/1" do
test "foobar is valid" do
assert FzNet.valid_hostname?("foobar")
end
test "-foobar is invalid" do
refute FzNet.valid_hostname?("-foobar")
end
test "1234 is valid" do
assert FzNet.valid_hostname?("1234")
end
end
describe "valid_fqdn?/1" do
test "foobar is invalid" do
refute FzNet.valid_fqdn?("foobar")
end
test "-foobar is invalid" do
refute FzNet.valid_fqdn?("-foobar")
end
test "foobar.com is valid" do
assert FzNet.valid_fqdn?("foobar.com")
end
test "ff99.example.com is valid" do
assert FzNet.valid_fqdn?("ff00.example.com")
end
end
describe "valid_cidr?/1" do
test "::/0f is an invalid CIDR" do
refute FzNet.valid_cidr?("::/0f")
end
test "0.0.0.0/0f is an invalid CIDR" do
refute FzNet.valid_cidr?("0.0.0.0/0f")
end
test "0.0.0.0 is an invalid CIDR" do
refute FzNet.valid_cidr?("0.0.0.0")
end
test "foobar is an invalid CIDR" do
refute FzNet.valid_cidr?("foobar")
end
test "0.0.0.0/0 is a valid CIDR" do
assert FzNet.valid_cidr?("::/0")
end
test "::/0 is a valid CIDR" do
assert FzNet.valid_cidr?("::/0")
end
end
describe "standardized_inet/1" do
test "sanitizes CIDRs with invalid start" do
assert "10.0.0.0/24" == FzNet.standardized_inet("10.0.0.5/24")
@@ -111,30 +17,6 @@ defmodule FzCommon.FzNetTest do
end
end
describe "to_complete_url/1" do
@tag cases: [
{"foobar", "https://foobar"},
{"google.com", "https://google.com"},
{"127.0.0.1", "https://127.0.0.1"},
{"8.8.8.8", "https://8.8.8.8"},
{"https://[fd00::1]", "https://[fd00::1]"},
{"http://foobar", "http://foobar"},
{"https://foobar", "https://foobar"}
]
test "parses valid string URIs", %{cases: cases} do
for {subject, expected} <- cases do
assert {:ok, ^expected} = FzNet.to_complete_url(subject)
end
end
@tag cases: ["<", "{", "["]
test "returns {:error, _} for invalid URIs", %{cases: cases} do
for subject <- cases do
assert {:error, _} = FzNet.to_complete_url(subject)
end
end
end
describe "endpoint_to_ip/1" do
test "IPv4" do
assert "192.168.1.1" == FzNet.endpoint_to_ip("192.168.1.1:4562")

View File

@@ -1,29 +0,0 @@
defmodule FzCommon.FzStringTest do
use ExUnit.Case, async: true
alias FzCommon.FzString
describe "sanitize_filename/1" do
test "sanitizes sequential spaces" do
assert "Factory_Device" == FzString.sanitize_filename("Factory Device")
end
end
describe "to_boolean/1" do
test "converts to true" do
assert true == FzString.to_boolean("True")
end
test "converts to false" do
assert false == FzString.to_boolean("False")
end
test "raises exception on unknowns" do
message = "Unknown boolean: string foobar not one of ['true', 'false']."
assert_raise RuntimeError, message, fn ->
FzString.to_boolean("foobar")
end
end
end
end

View File

@@ -49,3 +49,18 @@ pre {
max-width: 600px;
overflow: hidden;
}
.switch {
&:hover input[type=checkbox]:disabled+.check {
background: rgba(181, 181, 181, 1);
}
&:hover input[type=checkbox]:checked:disabled+.check {
background: rgba(94, 0, 214, 1);
}
input[type=checkbox]:disabled+.check {
opacity: 0.5;
cursor: not-allowed;
}
}

View File

@@ -1,18 +1,17 @@
defmodule FzHttp.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
alias FzHttp.Telemetry
def start(_type, _args) do
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
Telemetry.fz_http_started()
opts = [strategy: :one_for_one, name: __MODULE__.Supervisor]
Supervisor.start_link(children(), opts)
supervision_tree_mode = FzHttp.Config.fetch_env!(:fz_http, :supervision_tree_mode)
result =
supervision_tree_mode
|> children()
|> Supervisor.start_link(strategy: :one_for_one, name: __MODULE__.Supervisor)
:ok = after_start()
result
end
# Tell Phoenix to update the endpoint configuration
@@ -22,8 +21,6 @@ defmodule FzHttp.Application do
:ok
end
defp children, do: children(FzHttp.Config.fetch_env!(:fz_http, :supervision_tree_mode))
defp children(:full) do
[
FzHttp.Server,
@@ -63,4 +60,14 @@ defmodule FzHttp.Application do
FzHttp.Vault
]
end
if Mix.env() == :prod do
defp after_start do
FzHttp.Config.validate_runtime_config!()
end
else
defp after_start do
:ok
end
end
end

View File

@@ -0,0 +1,43 @@
defmodule FzHttp.Auth do
alias FzHttp.Config
def fetch_oidc_provider_config(provider_id) do
with {:ok, provider} <- fetch_provider(:openid_connect_providers, provider_id) do
external_url = FzHttp.Config.fetch_env!(:fz_http, :external_url)
redirect_uri = provider.redirect_uri || "#{external_url}/auth/oidc/#{provider.id}/callback/"
{:ok,
%{
discovery_document_uri: provider.discovery_document_uri,
client_id: provider.client_id,
client_secret: provider.client_secret,
redirect_uri: redirect_uri,
response_type: provider.response_type,
scope: provider.scope
}}
end
end
def auto_create_users?(field, provider_id) do
fetch_provider!(field, provider_id).auto_create_users
end
defp fetch_provider(field, provider_id) do
Config.fetch_config!(field)
|> Enum.find(&(&1.id == provider_id))
|> case do
nil -> {:error, :not_found}
provider -> {:ok, provider}
end
end
defp fetch_provider!(field, provider_id) do
case fetch_provider(field, provider_id) do
{:ok, provider} ->
provider
{:error, :not_found} ->
raise RuntimeError, "Unknown provider #{provider_id}"
end
end
end

View File

@@ -1,14 +1,173 @@
defmodule FzHttp.Config do
@moduledoc """
This module provides set of helper functions that are useful when reading application runtime configuration overrides
in test environment.
alias FzHttp.Repo
alias FzHttp.Config.{Definition, Definitions, Validator, Errors, Fetcher}
alias FzHttp.Config.Configuration
def fetch_source_and_config!(key) do
db_config = maybe_fetch_db_config!(key)
env_config = System.get_env()
case Fetcher.fetch_source_and_config(Definitions, key, db_config, env_config) do
{:ok, source, config} ->
{source, config}
{:error, reason} ->
Errors.raise_error!(reason)
end
end
def fetch_source_and_configs!(keys) when is_list(keys) do
db_config = maybe_fetch_db_config!(keys)
env_config = System.get_env()
for key <- keys, into: %{} do
case Fetcher.fetch_source_and_config(Definitions, key, db_config, env_config) do
{:ok, source, config} ->
{key, {source, config}}
{:error, reason} ->
Errors.raise_error!(reason)
end
end
end
def fetch_config(key) do
db_config = maybe_fetch_db_config!(key)
env_config = System.get_env()
with {:ok, _source, config} <-
Fetcher.fetch_source_and_config(Definitions, key, db_config, env_config) do
{:ok, config}
end
end
def fetch_config!(key) do
case fetch_config(key) do
{:ok, config} ->
config
{:error, reason} ->
Errors.raise_error!(reason)
end
end
def fetch_configs!(keys) do
for {key, {_source, value}} <- fetch_source_and_configs!(keys), into: %{} do
{key, value}
end
end
defp maybe_fetch_db_config!(keys) when is_list(keys) do
if Enum.any?(keys, &(&1 in FzHttp.Config.Configuration.__schema__(:fields))) do
fetch_db_config!()
else
%{}
end
end
defp maybe_fetch_db_config!(key) do
if key in FzHttp.Config.Configuration.__schema__(:fields) do
fetch_db_config!()
else
%{}
end
end
@doc """
Similar to `compile_config/2` but raises an error if the configuration is invalid.
This function does not resolve values from the database because it's intended use is during
compilation and before application boot (in `config/runtime.exs`).
If you need to resolve values from the database, use `fetch_config/1` or `fetch_config!/1`.
"""
def compile_config!(module \\ Definitions, key, env_configurations \\ System.get_env()) do
case Fetcher.fetch_source_and_config(module, key, %{}, env_configurations) do
{:ok, _source, value} ->
value
{:error, reason} ->
Errors.raise_error!(reason)
end
end
def validate_runtime_config!(
module \\ Definitions,
db_config \\ fetch_db_config!(),
env_config \\ System.get_env()
) do
module.configs()
|> Enum.flat_map(fn {module, key} ->
case Fetcher.fetch_source_and_config(module, key, db_config, env_config) do
{:ok, _source, _value} -> []
{:error, reason} -> [reason]
end
end)
|> case do
[] -> :ok
errors -> Errors.raise_error!(errors)
end
end
def fetch_db_config! do
Repo.one!(Configuration)
end
def change_config(%Configuration{} = config \\ fetch_db_config!(), attrs \\ %{}) do
Configuration.Changeset.changeset(config, attrs)
end
def update_config(%Configuration{} = config, attrs) do
with {:ok, config} <- Repo.update(Configuration.Changeset.changeset(config, attrs)) do
FzHttp.SAML.StartProxy.refresh(config.saml_identity_providers)
{:ok, config}
end
end
def put_config!(key, value) do
with {:ok, config} <- update_config(fetch_db_config!(), %{key => value}) do
config
else
{:error, reason} -> raise "cannot update config: #{inspect(reason)}"
end
end
def config_changeset(changeset, schema_key, config_key \\ nil) do
config_key = config_key || schema_key
{type, {_resolve_opts, validate_opts, _dump_opts, _debug_opts}} =
Definition.fetch_spec_and_opts!(Definitions, config_key)
with {_data_or_changes, value} <- Ecto.Changeset.fetch_field(changeset, schema_key),
{:error, values_and_errors} <- Validator.validate(config_key, value, type, validate_opts) do
values_and_errors
|> List.wrap()
|> Enum.flat_map(fn {_value, errors} -> errors end)
|> Enum.uniq()
|> Enum.reduce(changeset, fn error, changeset ->
Ecto.Changeset.add_error(changeset, schema_key, error)
end)
else
:error -> changeset
{:ok, _value} -> changeset
end
end
def vpn_sessions_expire? do
freq = fetch_config!(:vpn_session_duration)
freq > 0 && freq < Configuration.Changeset.max_vpn_session_duration()
end
if Mix.env() != :test do
defdelegate fetch_env!(app, key), to: Application
else
def put_env_override(app \\ :fz_http, key, value) do
Process.put(key_function(app, key), value)
Process.put(pdict_key_function(app, key), value)
:ok
end
def put_system_env_override(key, value) do
Process.put({FzHttp.Config.Resolver, key}, {:env, value})
:ok
end
@@ -24,51 +183,17 @@ defmodule FzHttp.Config do
def fetch_env!(app, key) do
application_env = Application.fetch_env!(app, key)
pdict_key = key_function(app, key)
pdict_key_function(app, key)
|> FzHttp.Config.Resolver.fetch_process_env()
|> case do
{:ok, override} ->
override
with :error <- fetch_process_value(pdict_key),
:error <- fetch_process_value(get_last_pid_from_pdict_list(:"$ancestors"), pdict_key),
:error <- fetch_process_value(get_last_pid_from_pdict_list(:"$callers"), pdict_key) do
application_env
else
{:ok, override} -> override
:error ->
application_env
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
# String.to_atom/1 is only used in test env
defp key_function(app, key), do: String.to_atom("#{app}-#{key}")
defp pdict_key_function(app, key), do: {app, key}
end
end

View File

@@ -0,0 +1,56 @@
defmodule FzHttp.Config.Caster do
@moduledoc """
This module allows to cast values to a defined type.
Notice that only the Ecto types that don't allow to use `c:Ecto.Type.cast/1`
to cast a binary needs to be casted this way,
"""
def cast(value, {:array, separator, type, _opts}) do
cast(value, {:array, separator, type})
end
def cast(value, {:array, separator, type}) when is_binary(value) do
value
|> String.split(separator)
|> Enum.map(&cast(&1, type))
|> Enum.reduce_while({:ok, []}, fn
{:ok, value}, {:ok, acc} -> {:cont, {:ok, [value | acc]}}
{:error, reason}, {:ok, _acc} -> {:halt, {:error, reason}}
end)
|> case do
{:ok, values} -> {:ok, Enum.reverse(values)}
{:error, reason} -> {:error, reason}
end
end
def cast(json, :embed) when is_binary(json), do: Jason.decode(json)
def cast(json, {:embed, _schema}) when is_binary(json), do: Jason.decode(json)
def cast(json, :map) when is_binary(json), do: Jason.decode(json)
def cast(json, {:map, _term}) when is_binary(json), do: Jason.decode(json)
def cast(json, :json_array) when is_binary(json), do: Jason.decode(json)
def cast(json, {:json_array, _term}) when is_binary(json), do: Jason.decode(json)
def cast("true", :boolean), do: {:ok, true}
def cast("false", :boolean), do: {:ok, false}
def cast("", :boolean), do: {:ok, nil}
def cast(value, :integer) when is_binary(value) do
case Integer.parse(value) do
{value, ""} ->
{:ok, value}
{value, remainder} ->
{:error,
"cannot be cast to an integer, " <>
"got a reminder #{remainder} after an integer value #{value}"}
:error ->
{:error, "cannot be cast to an integer"}
end
end
def cast(nil, :integer), do: {:ok, nil}
def cast(value, _type), do: {:ok, value}
end

View File

@@ -0,0 +1,45 @@
defmodule FzHttp.Config.Configuration do
use FzHttp, :schema
alias FzHttp.Config.Logo
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, {:array, :string}, default: []
field :default_client_allowed_ips, {:array, FzHttp.Types.INET}, default: []
# 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.Config.Configuration.OpenIDConnectProvider,
on_replace: :delete
embeds_many :saml_identity_providers,
FzHttp.Config.Configuration.SAMLIdentityProvider,
on_replace: :delete
timestamps()
end
end

View File

@@ -0,0 +1,70 @@
defmodule FzHttp.Config.Configuration.Changeset do
use FzHttp, :changeset
import FzHttp.Config, only: [config_changeset: 2]
# Postgres max int size is 4 bytes
@max_vpn_session_duration 2_147_483_647
@fields ~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
@spec changeset(
{map, map}
| %{
:__struct__ => atom | %{:__changeset__ => map, optional(any) => any},
optional(atom) => any
},
:invalid | %{optional(:__struct__) => none, optional(atom | binary) => any}
) :: any
def changeset(configuration, attrs) do
changeset =
configuration
|> cast(attrs, @fields)
|> cast_embed(:logo)
|> cast_embed(:openid_connect_providers,
with: {FzHttp.Config.Configuration.OpenIDConnectProvider, :changeset, []}
)
|> cast_embed(:saml_identity_providers,
with: {FzHttp.Config.Configuration.SAMLIdentityProvider, :changeset, []}
)
|> trim_change(:default_client_dns)
|> trim_change(:default_client_endpoint)
Enum.reduce(@fields, changeset, fn field, changeset ->
config_changeset(changeset, field)
end)
|> ensure_no_overridden_changes()
end
defp ensure_no_overridden_changes(changeset) do
changed_keys = Map.keys(changeset.changes)
configs = FzHttp.Config.fetch_source_and_configs!(changed_keys)
Enum.reduce(changed_keys, changeset, fn key, changeset ->
case Map.fetch!(configs, key) do
{{:env, source_key}, _value} ->
add_error(
changeset,
key,
"cannot be changed; " <>
"it is overridden by #{source_key} environment variable"
)
_other ->
changeset
end
end)
end
def max_vpn_session_duration, do: @max_vpn_session_duration
end

View File

@@ -1,4 +1,4 @@
defmodule FzHttp.Configurations.Configuration.OpenIDConnectProvider do
defmodule FzHttp.Config.Configuration.OpenIDConnectProvider do
@moduledoc """
OIDC Config virtual schema
"""
@@ -59,9 +59,10 @@ defmodule FzHttp.Configurations.Configuration.OpenIDConnectProvider do
# Don't allow users to enter reserved config ids
|> validate_exclusion(:id, @reserved_config_ids)
|> validate_discovery_document_uri()
|> Validator.validate_uri([
:redirect_uri
])
|> Validator.validate_uri(:redirect_uri)
|> validate_inclusion(:response_type, ~w[code])
|> validate_format(:scope, ~r/openid/, message: "must include openid scope")
|> validate_format(:scope, ~r/email/, message: "must include email scope")
end
def validate_discovery_document_uri(changeset) do

View File

@@ -1,4 +1,4 @@
defmodule FzHttp.Configurations.Configuration.SAMLIdentityProvider do
defmodule FzHttp.Config.Configuration.SAMLIdentityProvider do
@moduledoc """
SAML Config virtual schema
"""

View File

@@ -0,0 +1,143 @@
defmodule FzHttp.Config.Definition do
@moduledoc """
This module provides a DSL to define application configuration, which can be read from multiple sources,
casted and validated.
## Examples
defmodule MyConfig do
use FzHttp.Config.Definition
@doc "My config key"
defconfig :my_key, :string, required: true
end
iex> MyConfig.my_key()
{:string, [required: true]}
iex> MyConfig.configs()
[:my_key]
iex> MyConfig.fetch_doc(:my_key)
{:ok, "My config key"}
"""
alias FzHttp.Config.Errors
@type array_opts :: [{:validate_unique, boolean()} | {:validate_length, Keyword.t()}]
@type type ::
Ecto.Type.t()
| {:embed, Ecto.Schema.t()}
| {:json_array, type()}
| {:json_array, type(), array_opts()}
| {:array, separator :: String.t(), type()}
| {:array, separator :: String.t(), type(), array_opts()}
| {:one_of, type()}
@type legacy_key :: {:env, var_name :: String.t(), removed_at :: String.t()}
@type changeset_callback ::
(changeset :: Ecto.Changeset.t(), key :: atom() -> Ecto.Changeset.t())
| (type :: term(), changeset :: Ecto.Changeset.t(), key :: atom() -> Ecto.Changeset.t())
| {module(), atom(), [term()]}
@type dump_callback :: (value :: term() -> term())
@type opts :: [
default: term,
sensitive: boolean(),
dump: dump_callback(),
legacy_keys: [legacy_key()],
changeset: changeset_callback()
]
defmacro __using__(_opts) do
quote do
import FzHttp.Config.Definition
import FzHttp.Config, only: [compile_config!: 1]
# Accumulator keeps the list of defined config keys
Module.register_attribute(__MODULE__, :configs, accumulate: true)
# A `configs/0` function is injected before module is compiled
# exporting the aggregated list of config keys
@before_compile FzHttp.Config.Definition
@doc "See `FzHttp.Config.Definition.fetch_doc/2`"
def fetch_doc(key), do: fetch_doc(__MODULE__, key)
end
end
@doc """
Simply exposes the `@configs` attribute as a function after all the `defconfig`'s are compiled.
"""
defmacro __before_compile__(_env) do
quote do
def configs, do: @configs
end
end
@doc """
Defines a configuration key.
Behind the hood it defines a function that returns a tuple with the type and options, a function is used
to allow for `@doc` blocks with markdown to be used to document the configuration key.
## Type
The type can be one of the following:
* any of the primitive types supported by `Ecto.Schema`;
* a module that implements Ecto.Type behaviour;
* `{:array, binary_separator, type}` - a list of values of the given type, separated by the given separator.
Separator is only used when reading the value from the environment variable or other binary storages;
* `{:one_of, [type]}` - a value of one of the given types.
"""
defmacro defconfig(key, type, opts \\ []) do
quote do
@configs {__MODULE__, unquote(key)}
@spec unquote(key)() :: {FzHttp.Config.Definition.type(), FzHttp.Config.Definition.opts()}
def unquote(key)(), do: {unquote(type), unquote(opts)}
end
end
def fetch_spec_and_opts!(module, key) do
{type, opts} = apply(module, key, [])
{resolve_opts, opts} = Keyword.split(opts, [:legacy_keys, :default])
{validate_opts, opts} = Keyword.split(opts, [:changeset])
{debug_opts, opts} = Keyword.split(opts, [:sensitive])
{dump_opts, opts} = Keyword.split(opts, [:dump])
if opts != [], do: Errors.invalid_spec(key, opts)
{type, {resolve_opts, validate_opts, dump_opts, debug_opts}}
end
def fetch_doc(module) do
with {:docs_v1, _, _, _, module_doc, _, _function_docs} <- Code.fetch_docs(module) do
fetch_en_doc(module_doc)
end
end
@doc """
Returns EN documentation chunk of a given function in a module.
"""
def fetch_doc(module, key) do
with {:docs_v1, _, _, _, _module_doc, _, function_docs} <- Code.fetch_docs(module) do
function_docs
|> fetch_function_docs(key)
|> fetch_en_doc()
end
end
defp fetch_function_docs(function_docs, function) do
function_docs
|> Enum.find_value(fn
{{:function, ^function, _}, _, _, doc, _} -> doc
_other -> nil
end)
end
defp fetch_en_doc(md) when is_map(md), do: Map.fetch(md, "en")
defp fetch_en_doc(_md), do: :error
end

View File

@@ -0,0 +1,807 @@
defmodule FzHttp.Config.Definitions do
@moduledoc """
Most day-to-day config of Firezone can be done via the Firezone Web UI,
but for zero-touch deployments we allow to override most of configuration options
using environment variables.
Read more about configuring Firezone in our [configure guide](/deploy/configure).
## Errors
Firezone will not boot if the configuration is invalid, providing a detailed error message
and a link to the documentation for the configuration key with samples how to set it.
## Naming
If environment variables are used, the configuration key must be uppercased.
The database variables are the same as the configuration keys.
## Precedence
The configuration precedence is as follows:
1. Environment variables
2. Database values
3. Default values
It means that if environment variable is set, it will be used, regardless of the database value,
and UI to edit database value will be disabled.
"""
use FzHttp.Config.Definition
alias FzHttp.Config.Dumper
alias FzHttp.Types
alias FzHttp.Config.{Configuration, Logo}
def doc_sections do
[
{"WebServer",
[
:external_url,
:phoenix_secure_cookies,
:phoenix_listen_address,
:phoenix_http_port,
:phoenix_external_trusted_proxies,
:phoenix_private_clients
]},
{"Database",
[
:database_host,
:database_port,
:database_name,
:database_user,
:database_password,
:database_pool_size,
:database_ssl_enabled,
:database_ssl_opts,
:database_parameters
]},
{"Admin Setup",
"""
Options responsible for initial admin provisioning and resetting the admin password.
For more details see [troubleshooting guide](/administer/troubleshoot/#admin-login-isnt-working).
""",
[
:reset_admin_on_boot,
:default_admin_email,
:default_admin_password
]},
{"Secrets and Encryption",
"""
Your secrets should be generated during installation automatically and persisted to `.env` file.
All secrets should be a **base64-encoded string**.
""",
[
:guardian_secret_key,
:database_encryption_key,
:secret_key_base,
:live_view_signing_salt,
:cookie_signing_salt,
:cookie_encryption_salt
]},
{"Devices",
[
:allow_unprivileged_device_management,
:allow_unprivileged_device_configuration,
:vpn_session_duration,
:default_client_persistent_keepalive,
:default_client_mtu,
:default_client_endpoint,
:default_client_dns,
:default_client_allowed_ips
]},
# {"Limits",
# [
# :max_devices_per_user
# ]},
{"Authorization",
[
:local_auth_enabled,
:disable_vpn_on_oidc_error,
:saml_entity_id,
:saml_keyfile_path,
:saml_certfile_path,
:openid_connect_providers,
:saml_identity_providers
]},
{"WireGuard",
[
:wireguard_port,
:wireguard_ipv4_enabled,
:wireguard_ipv4_masquerade,
:wireguard_ipv4_network,
:wireguard_ipv4_address,
:wireguard_ipv6_enabled,
:wireguard_ipv6_masquerade,
:wireguard_ipv6_network,
:wireguard_ipv6_address,
:wireguard_private_key_path,
:wireguard_interface_name,
:gateway_egress_interface,
:gateway_nft_path
]},
{"Outbound Emails",
[
:outbound_email_from,
:outbound_email_adapter,
:outbound_email_adapter_opts
]},
{"Connectivity Checks",
[
:connectivity_checks_enabled,
:connectivity_checks_interval
]},
{"Telemetry",
[
:telemetry_enabled,
:telemetry_id
]}
]
end
##############################################
## Web Server
##############################################
@doc """
The external URL the web UI will be accessible at.
Must be a valid and public FQDN for ACME SSL issuance to function.
You can add a path suffix if you want to serve firezone from a non-root path,
eg: `https://firezone.mycorp.com/vpn`.
"""
defconfig(:external_url, :string,
changeset: fn changeset, key ->
changeset
|> FzHttp.Validator.validate_uri(key)
|> FzHttp.Validator.normalize_url(key)
end
)
@doc """
Enable or disable requiring secure cookies. Required for HTTPS.
"""
defconfig(:phoenix_secure_cookies, :boolean,
default: true,
legacy_keys: [{:env, "SECURE_COOKIES", "0.9"}]
)
defconfig(:phoenix_listen_address, Types.IP, default: "0.0.0.0")
@doc """
Internal port to listen on for the Phoenix web server.
"""
defconfig(:phoenix_http_port, :integer,
default: 13_000,
changeset: fn changeset, key ->
Ecto.Changeset.validate_number(changeset, key,
greater_than: 0,
less_than_or_equal_to: 65_535
)
end
)
@doc """
List of trusted reverse proxies.
This is used to determine the correct IP address of the client when the
application is behind a reverse proxy by skipping a trusted proxy IP
from a list of possible source IPs.
"""
defconfig(:phoenix_external_trusted_proxies, {:array, ",", {:one_of, [Types.IP, Types.CIDR]}},
default: [],
legacy_keys: [{:env, "EXTERNAL_TRUSTED_PROXIES", "0.9"}]
)
@doc """
List of trusted clients.
This is used to determine the correct IP address of the client when the
application is behind a reverse proxy by picking a trusted client IP
from a list of possible source IPs.
"""
defconfig(:phoenix_private_clients, {:array, ",", {:one_of, [Types.IP, Types.CIDR]}},
default: [],
legacy_keys: [{:env, "PRIVATE_CLIENTS", "0.9"}]
)
##############################################
## Database
##############################################
@doc """
PostgreSQL host.
"""
defconfig(:database_host, :string, default: "postgres")
@doc """
PostgreSQL port.
"""
defconfig(:database_port, :integer, default: 5432)
@doc """
Name of the PostgreSQL database.
"""
defconfig(:database_name, :string, default: "firezone")
@doc """
User that will be used to access the PostgreSQL database.
"""
defconfig(:database_user, :string, default: "postgres", sensitive: true)
@doc """
Password that will be used to access the PostgreSQL database.
"""
defconfig(:database_password, :string, sensitive: true)
@doc """
Size of the connection pool to the PostgreSQL database.
"""
defconfig(:database_pool_size, :integer,
default: fn -> :erlang.system_info(:logical_processors_available) * 2 end,
legacy_keys: [{:env, "DATABASE_POOL", "0.9"}]
)
@doc """
Whether to connect to the database over SSL.
If this field is set to `true`, the `database_ssl_opts` config must be set too
with at least `cacertfile` option present.
"""
defconfig(:database_ssl_enabled, :boolean,
default: false,
legacy_keys: [{:env, "DATABASE_SSL", "0.9"}]
)
@doc """
SSL options for connecting to the PostgreSQL database.
Typically, to enabled SSL you want following options:
* `cacertfile` - path to the CA certificate file;
* `verify` - set to `verify_peer` to verify the server certificate;
* `fail_if_no_peer_cert` - set to `true` to require the server to present a certificate;
* `server_name_indication` - specify the hostname to be used in TLS Server Name Indication extension.
See [Ecto.Adapters.Postgres documentation](https://hexdocs.pm/ecto_sql/Ecto.Adapters.Postgres.html#module-connection-options).
For list of all supported options, see the [`ssl`](http://erlang.org/doc/man/ssl.html#type-tls_client_option) module documentation.
"""
defconfig(:database_ssl_opts, :map,
default: %{},
dump: &Dumper.dump_ssl_opts/1
)
defconfig(:database_parameters, :map,
default: %{application_name: "firezone-#{Application.spec(:fz_http, :vsn)}"},
dump: &Dumper.keyword/1
)
##############################################
## Admin Setup
##############################################
@doc """
Set this variable to `true` to create or reset the admin password every time Firezone
starts. By default, the admin password is only set when Firezone is installed.
Note: This **will not** change the status of local authentication.
"""
defconfig(:reset_admin_on_boot, :boolean, default: false)
@doc """
Primary administrator email.
"""
defconfig(:default_admin_email, :string,
default: nil,
sensitive: true,
legacy_keys: [{:env, "ADMIN_EMAIL", "0.9"}],
changeset: fn changeset, key ->
changeset
|> FzHttp.Validator.trim_change(key)
|> FzHttp.Validator.validate_email(key)
end
)
@doc """
Default password that will be used for creating or resetting the primary administrator account.
"""
defconfig(:default_admin_password, :string,
default: nil,
sensitive: true,
changeset: fn changeset, key ->
Ecto.Changeset.validate_length(changeset, key, min: 5)
end
)
##############################################
## Secrets
##############################################
@doc """
Secret key used for signing JWTs.
"""
defconfig(:guardian_secret_key, :string,
sensitive: true,
changeset: &FzHttp.Validator.validate_base64/2
)
@doc """
Secret key used for encrypting sensitive data in the database.
"""
defconfig(:database_encryption_key, :string,
sensitive: true,
changeset: &FzHttp.Validator.validate_base64/2
)
@doc """
Primary secret key base for the Phoenix application.
"""
defconfig(:secret_key_base, :string,
sensitive: true,
changeset: &FzHttp.Validator.validate_base64/2
)
@doc """
Signing salt for Phoenix LiveView connection tokens.
"""
defconfig(:live_view_signing_salt, :string,
sensitive: true,
changeset: &FzHttp.Validator.validate_base64/2
)
@doc """
Encryption salt for cookies issued by the Phoenix web application.
"""
defconfig(:cookie_signing_salt, :string,
sensitive: true,
changeset: &FzHttp.Validator.validate_base64/2
)
@doc """
Signing salt for cookies issued by the Phoenix web application.
"""
defconfig(:cookie_encryption_salt, :string,
sensitive: true,
changeset: &FzHttp.Validator.validate_base64/2
)
##############################################
## Devices
##############################################
@doc """
Enable or disable management of devices on unprivileged accounts.
"""
defconfig(:allow_unprivileged_device_management, :boolean, default: true)
@doc """
Enable or disable configuration of device network settings for unprivileged users.
"""
defconfig(:allow_unprivileged_device_configuration, :boolean, default: true)
@doc """
Optionally require users to periodically authenticate to the Firezone web UI in order to keep their VPN sessions active.
"""
defconfig(:vpn_session_duration, :integer,
default: 0,
changeset: fn changeset, key ->
Ecto.Changeset.validate_number(changeset, key,
greater_than_or_equal_to: 0,
less_than_or_equal_to: 2_147_483_647
)
end
)
@doc """
Interval for WireGuard [persistent keepalive](https://www.wireguard.com/quickstart/#nat-and-firewall-traversal-persistence).
If you experience NAT or firewall traversal problems, you can enable this to send a keepalive packet every 25 seconds.
Otherwise, keep it disabled with a 0 default value.
"""
defconfig(:default_client_persistent_keepalive, :integer,
default: 25,
changeset: fn changeset, key ->
Ecto.Changeset.validate_number(changeset, key,
greater_than_or_equal_to: 0,
less_than_or_equal_to: 120
)
end
)
@doc """
WireGuard interface MTU for devices. 1280 is a safe bet for most networks.
Leave this blank to omit this field from generated configs.
"""
defconfig(:default_client_mtu, :integer,
default: 1280,
legacy_keys: [{:env, "WIREGUARD_MTU", "0.8"}],
changeset: fn changeset, key ->
Ecto.Changeset.validate_number(changeset, key,
greater_than_or_equal_to: 576,
less_than_or_equal_to: 1500
)
end
)
@doc """
IPv4, IPv6 address, or FQDN that devices will be configured to connect to. Defaults to this server's FQDN.
"""
defconfig(:default_client_endpoint, {:one_of, [Types.IPPort, :string]},
default: fn ->
external_uri = URI.parse(compile_config!(:external_url))
wireguard_port = compile_config!(:wireguard_port)
"#{external_uri.host}:#{wireguard_port}"
end,
changeset: fn
Types.IPPort, changeset, _key ->
changeset
:string, changeset, key ->
changeset
|> FzHttp.Validator.trim_change(key)
|> FzHttp.Validator.validate_fqdn(key, allow_port: true)
end
)
@doc """
Comma-separated list of DNS servers to use for devices.
It can be either an IP address or a FQDN if you intend to use a DNS-over-TLS server.
Leave this blank to omit the `DNS` section from generated configs.
"""
defconfig(
:default_client_dns,
{:array, ",", {:one_of, [Types.IP, :string]}, validate_unique: true},
default: [],
changeset: fn
Types.IP, changeset, _key ->
changeset
:string, changeset, key ->
changeset
|> FzHttp.Validator.trim_change(key)
|> FzHttp.Validator.validate_fqdn(key)
end
)
@doc """
Configures the default AllowedIPs setting for devices.
AllowedIPs determines which destination IPs get routed through Firezone.
Specify a comma-separated list of IPs or CIDRs here to achieve split tunneling, or use
`0.0.0.0/0, ::/0` to route all device traffic through this Firezone server.
"""
defconfig(
:default_client_allowed_ips,
{:array, ",", {:one_of, [Types.CIDR, Types.IP]}, validate_unique: true},
default: "0.0.0.0/0, ::/0"
)
##############################################
## Limits
##############################################
defconfig(:max_devices_per_user, :integer,
default: 10,
changeset: fn changeset, key ->
Ecto.Changeset.validate_number(changeset, key,
greater_than_or_equal_to: 0,
less_than_or_equal_to: 100
)
end
)
##############################################
## Userpass / SAML / OIDC authentication
##############################################
@doc """
Enable or disable the local authentication method for all users.
"""
# XXX: This should be replaced with auth_methods config which accepts a list
# of enabled methods.
defconfig(:local_auth_enabled, :boolean, default: true)
@doc """
Enable or disable auto disabling VPN connection on OIDC refresh error.
"""
defconfig(:disable_vpn_on_oidc_error, :boolean, default: false)
@doc """
Entity ID for SAML authentication.
"""
defconfig(:saml_entity_id, :string, default: "urn:firezone.dev:firezone-app")
@doc """
Path to the SAML keyfile inside the container. Should be either a PEM or DER-encoded private key,
with file extension `.pem` or `.key`.
"""
defconfig(:saml_keyfile_path, :string,
default: "/var/firezone/saml.key",
changeset: &FzHttp.Validator.validate_file(&1, &2, extensions: ~w[.pem .key])
)
@doc """
Path to the SAML certificate file inside the container. Should be either a PEM or DER-encoded certificate,
with file extension `.crt` or `.pem`.
"""
defconfig(:saml_certfile_path, :string,
default: "/var/firezone/saml.crt",
changeset: &FzHttp.Validator.validate_file(&1, &2, extensions: ~w[.crt .pem])
)
@doc """
List of OpenID Connect identity providers configurations.
For example:
```json
[
{
"auto_create_users": false,
"id": "google",
"label": "google",
"client_id": "test-id",
"client_secret": "test-secret",
"discovery_document_uri": "https://accounts.google.com/.well-known/openid-configuration",
"redirect_uri": "https://invalid",
"response_type": "response-type",
"scope": "oauth email profile"
}
]
```
For more details see https://docs.firezone.dev/authenticate/oidc/.
"""
defconfig(
:openid_connect_providers,
{:json_array, {:embed, Configuration.OpenIDConnectProvider}},
default: [],
changeset: {Configuration.OpenIDConnectProvider, :create_changeset, []}
)
@doc """
List of SAML identity providers configurations.
For example:
```json
[
{
"auto_create_users": false,
"base_url": "https://saml",
"id": "okta",
"label": "okta",
"metadata": "<?xml version="1.0"?>...",
"sign_metadata": false,
"sign_requests": false,
"signed_assertion_in_resp": false,
"signed_envelopes_in_resp": false
}
]
```
For more details see https://docs.firezone.dev/authenticate/saml/.
"""
defconfig(:saml_identity_providers, {:json_array, {:embed, Configuration.SAMLIdentityProvider}},
default: [],
changeset: {Configuration.SAMLIdentityProvider, :create_changeset, []}
)
##############################################
## Telemetry
##############################################
@doc """
Enable or disable the Firezone telemetry collection.
For more details see https://docs.firezone.dev/reference/telemetry/.
"""
defconfig(:telemetry_enabled, :boolean, default: true)
defconfig(:telemetry_id, :string,
default: fn ->
:crypto.hash(:sha256, compile_config!(:external_url))
|> Base.url_encode64(padding: false)
end,
legacy_keys: [{:env, "TID", nil}]
)
##############################################
## Connectivity Checks
##############################################
@doc """
Enable / disable periodic checking for egress connectivity. Determines the instance's public IP to populate `Endpoint` fields.
"""
defconfig(:connectivity_checks_enabled, :boolean, default: true)
@doc """
Periodicity in seconds to check for egress connectivity.
"""
defconfig(:connectivity_checks_interval, :integer,
default: 43_200,
changeset: fn changeset, key ->
Ecto.Changeset.validate_number(changeset, key,
greater_than_or_equal_to: 60,
less_than_or_equal_to: 86_400
)
end
)
##############################################
## WireGuard
##############################################
@doc """
A port on which WireGuard will listen for incoming connections.
"""
defconfig(:wireguard_port, :integer,
default: 51_820,
changeset: fn changeset, key ->
Ecto.Changeset.validate_number(changeset, key,
greater_than: 0,
less_than_or_equal_to: 65_535
)
end
)
@doc """
Enable or disable IPv4 support for WireGuard.
"""
defconfig(:wireguard_ipv4_enabled, :boolean, default: true)
defconfig(:wireguard_ipv4_masquerade, :boolean, default: true)
defconfig(:wireguard_ipv4_network, Types.CIDR,
default: "10.3.2.0/24",
changeset: &FzHttp.Validator.validate_ip_type_inclusion(&1, &2, [:ipv4])
)
defconfig(:wireguard_ipv4_address, Types.IP,
default: "10.3.2.1",
changeset: &FzHttp.Validator.validate_ip_type_inclusion(&1, &2, [:ipv4])
)
@doc """
Enable or disable IPv6 support for WireGuard.
"""
defconfig(:wireguard_ipv6_enabled, :boolean, default: true)
defconfig(:wireguard_ipv6_masquerade, :boolean, default: true)
defconfig(:wireguard_ipv6_network, Types.CIDR,
default: "fd00::3:2:0/120",
changeset: &FzHttp.Validator.validate_ip_type_inclusion(&1, &2, [:ipv6])
)
defconfig(:wireguard_ipv6_address, Types.IP,
default: "fd00::3:2:1",
changeset: &FzHttp.Validator.validate_ip_type_inclusion(&1, &2, [:ipv6])
)
defconfig(:wireguard_private_key_path, :string,
default: "/var/firezone/private_key",
changeset: &FzHttp.Validator.validate_file(&1, &2)
)
defconfig(:wireguard_interface_name, :string, default: "wg-firezone")
defconfig(:gateway_egress_interface, :string,
legacy_keys: [{:env, "EGRESS_INTERFACE", "0.8"}],
default: "eth0"
)
defconfig(:gateway_nft_path, :string, default: "nft")
##############################################
## HTTP Client Settings
##############################################
defconfig(:http_client_ssl_opts, :map,
default: %{},
dump: &Dumper.dump_ssl_opts/1
)
##############################################
## Outbound Email Settings
##############################################
@doc """
From address to use for sending outbound emails. If not set, sending email will be disabled (default).
"""
defconfig(:outbound_email_from, :string,
default: fn ->
external_uri = URI.parse(compile_config!(:external_url))
"firezone@#{external_uri.host}"
end,
sensitive: true,
changeset: fn changeset, key ->
changeset
|> FzHttp.Validator.trim_change(key)
|> FzHttp.Validator.validate_email(key)
end
)
@doc """
Method to use for sending outbound email.
"""
defconfig(
:outbound_email_adapter,
{:parameterized, Ecto.Enum,
Ecto.Enum.init(
values: [
Swoosh.Adapters.AmazonSES,
Swoosh.Adapters.CustomerIO,
Swoosh.Adapters.Dyn,
Swoosh.Adapters.ExAwsAmazonSES,
Swoosh.Adapters.Gmail,
Swoosh.Adapters.MailPace,
Swoosh.Adapters.Mailgun,
Swoosh.Adapters.Mailjet,
Swoosh.Adapters.Mandrill,
Swoosh.Adapters.Postmark,
Swoosh.Adapters.ProtonBridge,
Swoosh.Adapters.SMTP,
Swoosh.Adapters.SMTP2GO,
Swoosh.Adapters.Sendgrid,
Swoosh.Adapters.Sendinblue,
Swoosh.Adapters.Sendmail,
Swoosh.Adapters.SocketLabs,
Swoosh.Adapters.SparkPost,
FzHttpWeb.Mailer.NoopAdapter,
# DEPRECATED: Legacy options should be removed in 0.8
:smtp,
:mailgun,
:mandrill,
:sendgrid,
:post_mark,
:sendmail
]
)},
default: FzHttpWeb.Mailer.NoopAdapter,
legacy_keys: [{:env, "OUTBOUND_EMAIL_PROVIDER", "0.9"}],
dump: fn
:smtp -> Swoosh.Adapters.SMTP
:mailgun -> Swoosh.Adapters.Mailgun
:mandrill -> Swoosh.Adapters.Mandrill
:sendgrid -> Swoosh.Adapters.Sendgrid
:post_mark -> Swoosh.Adapters.Postmark
:sendmail -> Swoosh.Adapters.Sendmail
other -> other
end
)
@doc """
Adapter configuration, for list of options see [Swoosh Adapters](https://github.com/swoosh/swoosh#adapters).
"""
defconfig(:outbound_email_adapter_opts, :map,
default: %{},
sensitive: true,
legacy_keys: [{:env, "OUTBOUND_EMAIL_CONFIGS", "0.9"}],
dump: fn
# DEPRECATED: Legacy options should be removed in 0.8
%{"smtp" => value} -> Dumper.keyword(value)
%{"mailgun" => value} -> Dumper.keyword(value)
%{"mandrill" => value} -> Dumper.keyword(value)
%{"sendgrid" => value} -> Dumper.keyword(value)
%{"post_mark" => value} -> Dumper.keyword(value)
%{"sendmail" => value} -> Dumper.keyword(value)
value -> Dumper.keyword(value)
end
)
##############################################
## Appearance
##############################################
@doc """
The path to a logo image file to replace default Firezone logo.
"""
defconfig(:logo, {:embed, Logo},
default: nil,
changeset: {Logo, :changeset, []}
)
end

View File

@@ -1,8 +1,4 @@
defmodule FzCommon do
@moduledoc """
Documentation for `FzCommon`.
"""
defmodule FzHttp.Config.Dumper do
@doc ~S"""
Maps JSON-decoded ssl opts to pass to Erlang's ssl module. Most users
don't need to override many, if any, SSL opts. Most commonly this is
@@ -10,16 +6,16 @@ defmodule FzCommon do
## Examples:
iex> FzCommon.map_ssl_opts(%{"verify" => "verify_none", "versions" => ["tlsv1.3"]})
iex> FzCommon.dump_ssl_opts(%{"verify" => "verify_none", "versions" => ["tlsv1.3"]})
[verify: :verify_none, versions: ['tlsv1.3']]
iex> FzCommon.map_ssl_opts(%{"keep_secrets" => true})
iex> FzCommon.dump_ssl_opts(%{"keep_secrets" => true})
** (ArgumentError) unsupported key keep_secrets in ssl opts
iex> FzCommon.map_ssl_opts(%{"cacertfile" => "/tmp/cacerts.pem"})
iex> FzCommon.dump_ssl_opts(%{"cacertfile" => "/tmp/cacerts.pem"})
[cacertfile: '/tmp/cacerts.pem']
"""
def map_ssl_opts(decoded_json) do
def dump_ssl_opts(decoded_json) do
Keyword.new(decoded_json, fn {k, v} ->
{String.to_atom(k), map_values(k, v)}
end)
@@ -28,5 +24,13 @@ defmodule FzCommon do
defp map_values("verify", v), do: String.to_atom(v)
defp map_values("versions", v), do: Enum.map(v, &String.to_charlist/1)
defp map_values("cacertfile", v), do: String.to_charlist(v)
defp map_values("server_name_indication", v), do: String.to_charlist(v)
defp map_values(k, _v), do: raise(ArgumentError, message: "unsupported key #{k} in ssl opts")
def keyword(enum) do
Keyword.new(enum, fn
{k, v} when is_binary(k) -> {String.to_atom(k), v}
{k, v} when is_atom(k) -> {k, v}
end)
end
end

View File

@@ -0,0 +1,131 @@
defmodule FzHttp.Config.Errors do
alias FzHttp.Config.Definition
require Logger
@env_doc_url "https://www.firezone.dev/docs/reference/env-vars/#environment-variable-listing"
def raise_error!(errors) do
errors
|> format_error()
|> raise()
end
defp format_error({{nil, ["is required"]}, metadata}) do
module = Keyword.fetch!(metadata, :module)
key = Keyword.fetch!(metadata, :key)
[
"Missing required configuration value for '#{key}'.",
"## How to fix?",
env_example(key),
db_example(key),
format_doc(module, key)
]
|> Enum.reject(&is_nil/1)
|> Enum.join("\n\n")
end
defp format_error({values_and_errors, metadata}) do
source = Keyword.fetch!(metadata, :source)
module = Keyword.fetch!(metadata, :module)
key = Keyword.fetch!(metadata, :key)
[
"Invalid configuration for '#{key}' retrieved from #{source(source)}.",
"Errors:",
format_errors(module, key, values_and_errors),
format_doc(module, key)
]
|> Enum.reject(&is_nil/1)
|> Enum.join("\n\n")
end
defp format_error(errors) do
error_messages = Enum.map(errors, &format_error(&1))
(["Found #{length(errors)} configuration errors:"] ++ error_messages)
|> Enum.join("\n\n\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n")
end
defp source({:app_env, key}), do: "application environment #{key}"
defp source({:env, key}), do: "environment variable #{FzHttp.Config.Resolver.env_key(key)}"
defp source({:db, key}), do: "database configuration #{key}"
defp source(:default), do: "default value"
defp format_errors(module, key, values_and_errors) do
{_type, {_resolve_opts, _validate_opts, _dump_opts, debug_opts}} =
Definition.fetch_spec_and_opts!(module, key)
sensitive? = Keyword.get(debug_opts, :sensitive, false)
values_and_errors
|> List.wrap()
|> Enum.map_join("\n", fn {value, errors} ->
" - `#{format_value(sensitive?, value)}`: #{Enum.join(errors, ", ")}"
end)
end
defp format_value(true, _value), do: "**SENSITIVE-VALUE-REDACTED**"
defp format_value(false, value), do: inspect(value)
defp format_doc(module, key) do
case module.fetch_doc(key) do
{:error, :module_not_found} ->
nil
{:error, :chunk_not_found} ->
nil
:error ->
nil
{:ok, doc} ->
"""
## Documentation
#{doc}
You can find more information on configuration here: #{@env_doc_url}
"""
end
end
def legacy_key_used(key, legacy_key, removed_at) do
Logger.warn(
"A legacy configuration option '#{legacy_key}' is used and it will be removed in v#{removed_at}." <>
"Please use '#{key}' configuration option instead."
)
end
def invalid_spec(key, opts) do
raise "unknown options #{inspect(opts)} for configuration #{inspect(key)}"
end
defp env_example(key) do
"""
### Using environment variables
You can set this configuration via environment variable by adding it to `.env` file:
#{FzHttp.Config.Resolver.env_key(key)}=YOUR_VALUE
"""
end
defp db_example(key) do
if key in FzHttp.Config.Configuration.__schema__(:fields) do
"""
### Using database
Or you can set this configuration in the database by either setting it via the admin panel,
or by running an SQL query:
cd $HOME/.firezone
docker compose exec postgres psql \\
-U postgres \\
-h 127.0.0.1 \\
-d firezone \\
-c "UPDATE configurations SET #{key} = 'YOUR_VALUE'"
"""
end
end
end

View File

@@ -0,0 +1,57 @@
defmodule FzHttp.Config.Fetcher do
alias FzHttp.Config.{Definition, Resolver, Caster, Validator}
@spec fetch_source_and_config(
module(),
key :: atom(),
db_configurations :: map(),
env_configurations :: map()
) ::
{:ok, Resolver.source(), term()} | {:error, {[String.t()], metadata: term()}}
def fetch_source_and_config(module, key, %{} = db_configurations, %{} = env_configurations)
when is_atom(module) and is_atom(key) do
{type, {resolve_opts, validate_opts, dump_opts, _debug_opts}} =
Definition.fetch_spec_and_opts!(module, key)
with {:ok, {source, value}} <-
resolve_value(module, key, env_configurations, db_configurations, resolve_opts),
{:ok, value} <- cast_value(module, key, source, value, type),
{:ok, value} <- validate_value(module, key, source, value, type, validate_opts) do
if dump_cb = Keyword.get(dump_opts, :dump) do
{:ok, source, dump_cb.(value)}
else
{:ok, source, value}
end
end
end
defp resolve_value(module, key, env_configurations, db_configurations, opts) do
with :error <- Resolver.resolve(key, env_configurations, db_configurations, opts) do
{:error, {{nil, ["is required"]}, module: module, key: key, source: :not_found}}
end
end
defp cast_value(module, key, source, value, type) do
case Caster.cast(value, type) do
{:ok, value} ->
{:ok, value}
{:error, %Jason.DecodeError{} = decode_error} ->
reason = Jason.DecodeError.message(decode_error)
{:error, {{value, [reason]}, module: module, key: key, source: source}}
{:error, reason} ->
{:error, {{value, [reason]}, module: module, key: key, source: source}}
end
end
defp validate_value(module, key, source, value, type, opts) do
case Validator.validate(key, value, type, opts) do
{:ok, value} ->
{:ok, value}
{:error, values_and_errors} ->
{:error, {values_and_errors, module: module, key: key, source: source}}
end
end
end

View File

@@ -0,0 +1,48 @@
defmodule FzHttp.Config.Logo do
@moduledoc """
Embedded Schema for logo
"""
use FzHttp, :schema
import FzHttp.Validator
import Ecto.Changeset
@whitelisted_file_extensions ~w[.jpg .jpeg .png .gif .webp .avif .svg .tiff]
# Singleton per configuration
@primary_key false
embedded_schema do
field :url, :string
field :file, :string
field :data, :string
field :type, :string
end
def __types__, do: ~w[Default File URL Upload]
def type(nil), do: "Default"
def type(%{file: path}) when not is_nil(path), do: "File"
def type(%{url: url}) when not is_nil(url), do: "URL"
def type(%{data: data}) when not is_nil(data), do: "Upload"
def changeset(logo \\ %__MODULE__{}, attrs) do
logo
|> cast(attrs, [:url, :data, :file, :type])
|> validate_file(:file, extensions: @whitelisted_file_extensions)
|> move_file_to_static
end
defp move_file_to_static(changeset) do
case fetch_change(changeset, :file) do
{:ok, file} ->
directory = Path.join(Application.app_dir(:fz_http), "priv/static/uploads/logo")
file_name = Path.basename(file)
file_path = Path.join(directory, file_name)
File.mkdir_p!(directory)
File.cp!(file, file_path)
put_change(changeset, :file, file_name)
:error ->
changeset
end
end
end

View File

@@ -0,0 +1,144 @@
defmodule FzHttp.Config.Resolver do
alias FzHttp.Config.Errors
@type source :: {:env, atom()} | {:db, atom()} | :default
@spec resolve(
key :: atom(),
env_configurations :: map(),
db_configurations :: map(),
opts :: [{:legacy_keys, [FzHttp.Config.Definition.legacy_key()]}]
) ::
{:ok, {source :: source(), value :: term()}} | :error
def resolve(key, env_configurations, db_configurations, opts) do
with :error <- resolve_process_env_value(key),
:error <- resolve_env_value(env_configurations, key, opts),
:error <- resolve_db_value(db_configurations, key),
:error <- resolve_default_value(opts) do
:error
end
end
@dialyzer {:nowarn_function, fetch_process_env: 1, resolve_process_env_value: 1}
defp resolve_process_env_value(key) do
pdict_key = {__MODULE__, key}
case fetch_process_env(pdict_key) do
{:ok, {:env, value}} ->
{:ok, {{:env, env_key(key)}, value}}
:error ->
:error
end
end
if Mix.env() == :test do
def fetch_process_env(pdict_key) do
with :error <- fetch_process_value(pdict_key),
:error <- fetch_process_value(get_last_pid_from_pdict_list(:"$ancestors"), pdict_key),
:error <- fetch_process_value(get_last_pid_from_pdict_list(:"$callers"), pdict_key) do
:error
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
with {:dictionary, pdict} <- :erlang.process_info(pid, :dictionary),
{^key, value} <- List.keyfind(pdict, key, 0) do
value
else
_other ->
:error
end
end
defp get_last_pid_from_pdict_list(stack) do
if values = Process.get(stack) do
List.last(values)
end
end
else
def fetch_process_env(_pdict_key), do: :error
end
defp resolve_env_value(env_configurations, key, opts) do
legacy_keys = Keyword.get(opts, :legacy_keys, [])
with :error <- fetch_env(env_configurations, key),
:error <- fetch_legacy_env(env_configurations, key, legacy_keys) do
:error
else
{:ok, {_source, nil}} -> :error
{:ok, source_and_value} -> {:ok, source_and_value}
end
end
defp fetch_env(env_configurations, key) do
key = env_key(key)
case Map.fetch(env_configurations, key) do
{:ok, value} -> {:ok, {{:env, key}, value}}
:error -> :error
end
end
def env_key(key) do
key
|> to_string()
|> String.upcase()
end
defp fetch_legacy_env(env_configurations, key, legacy_keys) do
Enum.find_value(legacy_keys, :error, fn {:env, legacy_key, removed_at} ->
case fetch_env(env_configurations, legacy_key) do
{:ok, value} ->
maybe_warn_on_legacy_key(key, legacy_key, removed_at)
{:ok, value}
:error ->
nil
end
end)
end
defp maybe_warn_on_legacy_key(_key, _legacy_key, nil) do
:ok
end
defp maybe_warn_on_legacy_key(key, legacy_key, removed_at) do
Errors.legacy_key_used(key, legacy_key, removed_at)
end
defp resolve_db_value(db_configurations, key) do
case Map.fetch(db_configurations, key) do
:error -> :error
{:ok, nil} -> :error
{:ok, value} -> {:ok, {{:db, key}, value}}
end
end
defp resolve_default_value(opts) do
with {:ok, value} <- Keyword.fetch(opts, :default) do
{:ok, {:default, maybe_apply_default_value_callback(value)}}
end
end
defp maybe_apply_default_value_callback(cb) when is_function(cb, 0), do: cb.()
defp maybe_apply_default_value_callback(value), do: value
end

View File

@@ -0,0 +1,169 @@
defmodule FzHttp.Config.Validator do
import Ecto.Changeset
def validate(_key, nil, _type, _opts) do
{:ok, nil}
end
def validate(key, values, {:array, _separator, type, array_opts}, opts) do
validate_array(key, values, type, array_opts, opts)
end
def validate(key, values, {:array, separator, type}, opts) do
validate(key, values, {:array, separator, type, []}, opts)
end
def validate(key, values, {:json_array, type, array_opts}, opts) do
validate_array(key, values, type, array_opts, opts)
end
def validate(key, values, {:json_array, type}, opts) do
validate(key, values, {:json_array, type, []}, opts)
end
def validate(key, value, {:one_of, types}, opts) do
types
|> Enum.reduce_while({:error, []}, fn type, {:error, errors} ->
case validate(key, value, type, opts) do
{:ok, value} -> {:halt, {:ok, value}}
{:error, {_value, type_errors}} -> {:cont, {:error, type_errors ++ errors}}
end
end)
|> case do
{:ok, value} ->
{:ok, value}
{:error, errors} ->
errors =
errors
|> Enum.uniq()
|> Enum.map(fn
"is invalid" -> "must be one of: " <> Enum.join(types, ", ")
error -> error
end)
|> Enum.reverse()
{:error, {value, errors}}
end
end
def validate(key, value, {:embed, type}, opts) do
{callback_module, callback_fun, callback_args} =
Keyword.get(opts, :changeset, {type, :changeset, []})
args = [Map.delete(value, :__struct__)] ++ callback_args
changeset = apply(callback_module, callback_fun, args)
if changeset.valid? do
{:ok, apply_changes(changeset)}
else
{:error, {Map.get(changeset.changes, key, value), embedded_errors(changeset)}}
end
end
def validate(key, value, type, opts) do
callback = Keyword.get(opts, :changeset, fn changeset, _key -> changeset end)
changeset =
{%{}, %{key => type}}
|> cast(%{key => value}, [key])
|> apply_validations(callback, type, key)
if changeset.valid? do
{:ok, Map.get(changeset.changes, key)}
else
{:error, {Map.get(changeset.changes, key, value), errors(changeset)}}
end
end
defp validate_array(_key, nil, _type, _array_opts, _opts) do
{:ok, nil}
end
defp validate_array(key, values, type, array_opts, opts) when is_list(values) do
{validate_unique, array_opts} = Keyword.pop(array_opts, :validate_unique, false)
{validate_length, []} = Keyword.pop(array_opts, :validate_length, [])
values
|> Enum.map(&validate(key, &1, type, opts))
|> Enum.reduce({true, [], []}, fn
{:ok, value}, {valid?, values, errors} ->
if validate_unique == true and value in values do
{false, values, [{value, ["should not contain duplicates"]}] ++ errors}
else
{valid?, [value] ++ values, errors}
end
{:error, {value, error}}, {_valid?, values, errors} ->
{false, values, [{value, error}] ++ errors}
end)
|> case do
{true, values, []} ->
min = Keyword.get(validate_length, :min)
max = Keyword.get(validate_length, :max)
is = Keyword.get(validate_length, :is)
values
|> Enum.reverse()
|> validate_array_length(min, max, is)
{false, _values, values_and_errors} ->
{:error, values_and_errors}
end
end
defp validate_array(_key, values, _type, _array_opts, _opts) do
{:error, {values, ["must be an array"]}}
end
defp validate_array_length(values, min, _max, _is)
when not is_nil(min) and length(values) < min do
{:error, {values, ["should be at least #{min} item(s)"]}}
end
defp validate_array_length(values, _min, max, _is)
when not is_nil(max) and length(values) > max do
{:error, {values, ["should be at most #{max} item(s)"]}}
end
defp validate_array_length(values, _min, _max, is)
when not is_nil(is) and length(values) != is do
{:error, {values, ["should be #{is} item(s)"]}}
end
defp validate_array_length(values, _min, _max, _is) do
{:ok, values}
end
defp apply_validations(changeset, callback, _type, key) when is_function(callback, 2) do
callback.(changeset, key)
end
defp apply_validations(changeset, callback, type, key) when is_function(callback, 3) do
callback.(type, changeset, key)
end
defp traverse_errors(changeset) do
changeset
|> Ecto.Changeset.traverse_errors(fn {message, opts} ->
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
end)
end)
end
defp embedded_errors(changeset) do
changeset
|> traverse_errors()
|> Enum.map(fn {key, error} ->
"#{key} #{error}"
end)
end
defp errors(changeset) do
changeset
|> traverse_errors()
|> Map.values()
|> List.flatten()
end
end

View File

@@ -1,95 +0,0 @@
defmodule FzHttp.Configurations do
@moduledoc """
The Conf context for app configurations.
"""
import Ecto.Query, warn: false
alias FzHttp.{Repo, Configurations.Configuration}
def get!(key) do
Map.get(get_configuration!(), key)
end
def fetch_oidc_provider_config(provider_id) do
get!(:openid_connect_providers)
|> Enum.find(&(&1.id == provider_id))
|> case do
nil ->
{:error, :not_found}
provider ->
external_url = FzHttp.Config.fetch_env!(:fz_http, :external_url)
{:ok,
%{
discovery_document_uri: provider.discovery_document_uri,
client_id: provider.client_id,
client_secret: provider.client_secret,
redirect_uri:
provider.redirect_uri || "#{external_url}/auth/oidc/#{provider.id}/callback/",
response_type: provider.response_type,
scope: provider.scope
}}
end
end
def put!(key, val) do
configuration =
get_configuration!()
|> Configuration.changeset(%{key => val})
|> Repo.update!()
FzHttp.SAML.StartProxy.refresh(configuration.saml_identity_providers)
configuration
end
def get_configuration! do
Repo.one!(Configuration)
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
Configuration.changeset(config, %{})
end
def update_configuration(%Configuration{} = config \\ get_configuration!(), attrs) do
case Repo.update(Configuration.changeset(config, attrs)) do
{:ok, configuration} ->
FzHttp.SAML.StartProxy.refresh(configuration.saml_identity_providers)
{:ok, configuration}
error ->
error
end
end
def logo_types, do: ~w(Default URL Upload)
def logo_type(nil), do: "Default"
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

@@ -1,107 +0,0 @@
defmodule FzHttp.Configurations.Configuration do
@moduledoc """
App global configuration, singleton resource
"""
use FzHttp, :schema
import Ecto.Changeset
alias FzHttp.{
Configurations.Logo,
Validator
}
@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, []}
)
|> Validator.trim_change(:default_client_dns)
|> Validator.trim_change(:default_client_allowed_ips)
|> Validator.trim_change(:default_client_endpoint)
|> Validator.validate_no_duplicates(:default_client_dns)
|> Validator.validate_list_of_ips_or_cidrs(:default_client_allowed_ips)
|> Validator.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,24 +0,0 @@
defmodule FzHttp.Configurations.Logo do
@moduledoc """
Embedded Schema for logo
"""
use FzHttp, :schema
import Ecto.Changeset
# Singleton per configuration
@primary_key false
embedded_schema do
field :url, :string
field :data, :string
field :type, :string
end
def changeset(logo, attrs) do
logo
|> cast(attrs, [
:url,
:data,
:type
])
end
end

View File

@@ -1,34 +0,0 @@
defmodule FzHttp.Configurations.Mailer do
@moduledoc """
A non-persisted schema to validate email configs on boot.
XXX: Consider persisting this to make outbound email configurable via API.
"""
use Ecto.Schema
import Ecto.Changeset
embedded_schema do
field :from, :string
field :provider, :string
field :configs, :map
end
def changeset(attrs) do
%__MODULE__{}
|> cast(attrs, [:from, :provider, :configs])
|> validate_required([:from, :provider, :configs])
|> validate_format(:from, ~r/@/)
|> validate_provider_in_configs()
end
defp validate_provider_in_configs(
%Ecto.Changeset{
changes: %{provider: provider, configs: configs}
} = changeset
)
when not is_map_key(configs, provider) do
changeset
|> add_error(:provider, "must exist in configs")
end
defp validate_provider_in_configs(changeset), do: changeset
end

View File

@@ -91,6 +91,6 @@ defmodule FzHttp.ConnectivityCheckService do
end
defp http_client_options do
Application.fetch_env!(:fz_http, :http_client_options)
FzHttp.Config.fetch_env!(:fz_http, :http_client_options)
end
end

View File

@@ -1,10 +1,9 @@
defmodule FzHttp.Devices do
import Ecto.Changeset
import Ecto.Query, warn: false
alias EctoNetwork.INET
alias FzHttp.{
Configurations,
Config,
Devices.Device,
Devices.DeviceSetting,
Repo,
@@ -140,21 +139,33 @@ defmodule FzHttp.Devices do
change_device(%Device{}, attrs)
end
def allowed_ips(device), do: config(device, :allowed_ips)
def endpoint(device), do: config(device, :endpoint)
def dns(device), do: config(device, :dns)
def mtu(device), do: config(device, :mtu)
def persistent_keepalive(device), do: config(device, :persistent_keepalive)
def allowed_ips(device, defaults), do: config(device, defaults, :allowed_ips)
def endpoint(device, defaults), do: config(device, defaults, :endpoint)
def dns(device, defaults), do: config(device, defaults, :dns)
def mtu(device, defaults), do: config(device, defaults, :mtu)
def persistent_keepalive(device, defaults), do: config(device, defaults, :persistent_keepalive)
def config(device, key) do
# XXX: This is an A* query which is executed for every config key,
# we can load all configs in a batch instead
def config(device, defaults, key) do
if Map.get(device, String.to_atom("use_default_#{key}")) do
Map.get(Configurations.get_configuration!(), String.to_atom("default_client_#{key}"))
Map.fetch!(defaults, String.to_atom("default_client_#{key}"))
else
Map.get(device, key)
end
end
def defaults(changeset) do
def defaults do
Config.fetch_configs!([
:default_client_allowed_ips,
:default_client_endpoint,
:default_client_dns,
:default_client_mtu,
:default_client_persistent_keepalive
])
end
def use_default_fields(changeset) do
~w(
use_default_allowed_ips
use_default_dns
@@ -169,6 +180,7 @@ defmodule FzHttp.Devices do
def as_config(device) do
server_public_key = Application.get_env(:fz_vpn, :wireguard_public_key)
defaults = defaults()
if is_nil(server_public_key) do
Logger.error(
@@ -180,21 +192,21 @@ defmodule FzHttp.Devices do
[Interface]
PrivateKey = REPLACE_ME
Address = #{inet(device)}
#{mtu_config(device)}
#{dns_config(device)}
#{mtu_config(device, defaults)}
#{dns_config(device, defaults)}
[Peer]
#{psk_config(device)}
PublicKey = #{server_public_key}
#{allowed_ips_config(device)}
#{endpoint_config(device)}
#{persistent_keepalive_config(device)}
#{allowed_ips_config(device, defaults)}
#{endpoint_config(device, defaults)}
#{persistent_keepalive_config(device, defaults)}
"""
end
def decode(nil), do: nil
def decode(inet) when is_binary(inet), do: inet
def decode(inet), do: INET.decode(inet)
def decode(inet), do: FzHttp.Types.INET.to_string(inet)
@hash_range 2 ** 16
def new_name(name \\ FzCommon.NameGenerator.generate()) do
@@ -219,8 +231,8 @@ defmodule FzHttp.Devices do
end
end
defp mtu_config(device) do
m = mtu(device)
defp mtu_config(device, defaults) do
m = mtu(device, defaults)
if field_empty?(m) do
""
@@ -229,18 +241,18 @@ defmodule FzHttp.Devices do
end
end
defp allowed_ips_config(device) do
a = allowed_ips(device)
defp allowed_ips_config(device, defaults) do
allowed_ips = allowed_ips(device, defaults)
if field_empty?(a) do
if field_empty?(allowed_ips) do
""
else
"AllowedIPs = #{a}"
"AllowedIPs = #{Enum.join(allowed_ips, ",")}"
end
end
defp persistent_keepalive_config(device) do
pk = persistent_keepalive(device)
defp persistent_keepalive_config(device, defaults) do
pk = persistent_keepalive(device, defaults)
if field_empty?(pk) do
""
@@ -249,18 +261,18 @@ defmodule FzHttp.Devices do
end
end
defp dns_config(device) when is_struct(device) do
dns = dns(device)
defp dns_config(device, defaults) when is_struct(device) do
dns = dns(device, defaults)
if field_empty?(dns) do
""
else
"DNS = #{dns}"
"DNS = #{Enum.join(dns, ",")}"
end
end
defp endpoint_config(device) do
ep = endpoint(device)
defp endpoint_config(device, defaults) do
ep = endpoint(device, defaults)
if field_empty?(ep) do
""
@@ -272,7 +284,7 @@ defmodule FzHttp.Devices do
# Finds a port in IPv6-formatted address, e.g. [2001::1]:51820
@capture_port ~r/\[.*]:(?<port>[\d]+)/
defp maybe_add_port(endpoint) do
wireguard_port = Application.fetch_env!(:fz_vpn, :wireguard_port)
wireguard_port = Config.fetch_env!(:fz_vpn, :wireguard_port)
colon_count = endpoint |> String.graphemes() |> Enum.count(&(&1 == ":"))
if colon_count == 1 or !is_nil(Regex.named_captures(@capture_port, endpoint)) do
@@ -284,8 +296,8 @@ defmodule FzHttp.Devices do
end
defp field_empty?(nil), do: true
defp field_empty?(0), do: true
defp field_empty?([]), do: true
defp field_empty?(field) when is_binary(field) do
len =
@@ -299,10 +311,10 @@ defmodule FzHttp.Devices do
defp field_empty?(_), do: false
defp ipv4? do
FzHttp.Config.fetch_env!(:fz_http, :wireguard_ipv4_enabled)
Config.fetch_env!(:fz_http, :wireguard_ipv4_enabled)
end
defp ipv6? do
FzHttp.Config.fetch_env!(:fz_http, :wireguard_ipv6_enabled)
Config.fetch_env!(:fz_http, :wireguard_ipv6_enabled)
end
end

View File

@@ -4,9 +4,9 @@ defmodule FzHttp.Devices.Device do
"""
use FzHttp, :schema
import Ecto.Changeset
import FzHttp.Config, only: [config_changeset: 3]
alias FzHttp.Validator
alias FzHttp.Devices
require Logger
@description_max_length 2048
@@ -28,11 +28,11 @@ defmodule FzHttp.Devices.Device do
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
field :ipv6, EctoNetwork.INET
field :allowed_ips, {:array, FzHttp.Types.INET}, default: []
field :dns, {:array, :string}, default: []
field :remote_ip, FzHttp.Types.IP
field :ipv4, FzHttp.Types.IP
field :ipv6, FzHttp.Types.IP
field :latest_handshake, :utc_datetime_usec
field :key_regenerated_at, :utc_datetime_usec, read_after_writes: true
@@ -88,11 +88,15 @@ defmodule FzHttp.Devices.Device do
defp changeset(changeset) do
changeset
|> Validator.trim_change(:allowed_ips)
|> Validator.trim_change(:dns)
|> Validator.trim_change(:endpoint)
|> Validator.trim_change(:name)
|> Validator.trim_change(:description)
|> config_changeset(:allowed_ips, :default_client_allowed_ips)
|> config_changeset(:dns, :default_client_dns)
|> config_changeset(:endpoint, :default_client_endpoint)
|> config_changeset(:persistent_keepalive, :default_client_persistent_keepalive)
|> config_changeset(:mtu, :default_client_mtu)
|> Validator.validate_base64(:public_key)
|> Validator.validate_base64(:preshared_key)
|> validate_length(:public_key, is: @key_length)
@@ -108,16 +112,6 @@ defmodule FzHttp.Devices.Device do
persistent_keepalive
mtu
]a)
|> Validator.validate_list_of_ips_or_cidrs(:allowed_ips)
|> Validator.validate_no_duplicates(:dns)
|> validate_number(:persistent_keepalive,
greater_than_or_equal_to: 0,
less_than_or_equal_to: 120
)
|> validate_number(:mtu,
greater_than_or_equal_to: 576,
less_than_or_equal_to: 1500
)
|> prepare_changes(fn changeset ->
changeset
|> maybe_put_default_ip(:ipv4)
@@ -146,13 +140,13 @@ defmodule FzHttp.Devices.Device do
defp put_default_ip(changeset, field) do
cidr_string = wireguard_network(field)
{:ok, cidr_inet} = EctoNetwork.INET.cast(cidr_string)
{:ok, cidr_inet} = FzHttp.Types.CIDR.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()
|> FzHttp.Types.IP.cast()
Devices.Device.Query.next_available_address(cidr_inet, offset, [gateway_address])
|> FzHttp.Repo.one()
@@ -174,12 +168,12 @@ defmodule FzHttp.Devices.Device do
defp ipv4_address do
FzHttp.Config.fetch_env!(:fz_http, :wireguard_ipv4_address)
|> EctoNetwork.INET.cast()
|> FzHttp.Types.IP.cast()
end
defp ipv6_address do
FzHttp.Config.fetch_env!(:fz_http, :wireguard_ipv6_address)
|> EctoNetwork.INET.cast()
|> FzHttp.Types.IP.cast()
end
defp validate_max_devices(changeset) do

View File

@@ -72,7 +72,7 @@ defmodule FzHttp.Devices.Device.Query do
# 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
# This way is chosen because IPv6 cannot 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.

View File

@@ -31,20 +31,11 @@ defmodule FzHttp.Devices.StatsUpdater do
end
end
# XXX: Come up with a better way to update devices in Sandbox mode
defp device_to_update(public_key) do
if FzHttp.Config.fetch_env!(:fz_http, :sandbox) do
Repo.one(
from Device,
order_by: fragment("RANDOM()"),
limit: 1
)
else
Repo.one(
from d in Device,
where: d.public_key == ^public_key
)
end
Repo.one(
from d in Device,
where: d.public_key == ^public_key
)
end
defp latest_handshake(epoch) do

View File

@@ -56,10 +56,10 @@ defmodule FzHttp.MFA.Method.Changeset do
{:changes, %{}} ->
changeset
|> add_error(:payload, "is invalid")
|> add_error(:code, "can not be verified")
|> add_error(:code, "cannot be verified")
:error ->
add_error(changeset, :code, "can not be verified")
add_error(changeset, :code, "cannot be verified")
false ->
add_error(changeset, :code, "is invalid")

View File

@@ -4,7 +4,7 @@ defmodule FzHttp.OIDC.Refresher do
"""
use GenServer, restart: :temporary
import Ecto.{Changeset, Query}
alias FzHttp.{Configurations, OIDC, OIDC.Connection, Repo, Users}
alias FzHttp.{Auth, OIDC, OIDC.Connection, Repo, Users}
require Logger
def start_link(init_opts) do
@@ -34,7 +34,7 @@ defmodule FzHttp.OIDC.Refresher do
Logger.info("Refreshing user\##{user_id} @ #{provider_id}...")
refresh_response =
with {:ok, config} <- Configurations.fetch_oidc_provider_config(provider_id),
with {:ok, config} <- Auth.fetch_oidc_provider_config(provider_id),
{:ok, tokens} <-
OpenIDConnect.fetch_tokens(config, %{
grant_type: "refresh_token",
@@ -68,6 +68,6 @@ defmodule FzHttp.OIDC.Refresher do
end
defp enabled? do
FzHttp.Configurations.get!(:disable_vpn_on_oidc_error)
FzHttp.Config.fetch_config!(:disable_vpn_on_oidc_error)
end
end

View File

@@ -1,7 +1,4 @@
defmodule FzHttp.Rules.Rule do
@moduledoc """
Not really sure what to write here. I'll update this later.
"""
use FzHttp, :schema
import Ecto.Changeset
@@ -10,7 +7,7 @@ defmodule FzHttp.Rules.Rule do
@port_type_msg "port_type must be specified with port_range"
schema "rules" do
field :destination, EctoNetwork.INET, read_after_writes: true
field :destination, FzHttp.Types.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

View File

@@ -54,7 +54,7 @@ defmodule FzHttp.SAML.StartProxy do
Keyword.put(samly_configs, :identity_providers, identity_providers)
end
def refresh(providers \\ FzHttp.Configurations.get!(:saml_identity_providers)) do
def refresh(providers \\ FzHttp.Config.fetch_config!(:saml_identity_providers)) do
samly_configs =
FzHttp.Config.fetch_env!(:samly, Samly.Provider)
|> set_service_provider()

View File

@@ -2,16 +2,12 @@ defmodule FzHttp.Server do
@moduledoc """
Functions for other processes to interact with the FzHttp application
"""
use GenServer
alias FzHttp.{Devices, Devices.StatsUpdater, Rules, Users}
@process_opts Application.compile_env(:fz_http, :server_process_opts, [])
def start_link(_) do
# We're not storing state, simply providing an API
GenServer.start_link(__MODULE__, nil, @process_opts)
GenServer.start_link(__MODULE__, nil, name: {:global, :fz_http_server})
end
@impl GenServer

View File

@@ -94,6 +94,25 @@ defmodule FzHttp.Telemetry do
# How far back to count handshakes as an active device
@active_device_window 86_400
def ping_data do
%{
openid_connect_providers: {_, openid_connect_providers},
saml_identity_providers: {_, saml_identity_providers},
allow_unprivileged_device_management: allow_unprivileged_device_management,
allow_unprivileged_device_configuration: {_, allow_unprivileged_device_configuration},
local_auth_enabled: {_, local_auth_enabled},
disable_vpn_on_oidc_error: {_, disable_vpn_on_oidc_error},
logo: {_, logo}
} =
FzHttp.Config.fetch_source_and_configs!([
:openid_connect_providers,
:saml_identity_providers,
:allow_unprivileged_device_management,
:allow_unprivileged_device_configuration,
:local_auth_enabled,
:disable_vpn_on_oidc_error,
:logo
])
common_fields() ++
[
devices_active_within_24h: Devices.count_active_within(@active_device_window),
@@ -104,18 +123,16 @@ defmodule FzHttp.Telemetry do
max_devices_for_users: Devices.max_count_by_user_id(),
users_with_mfa: MFA.count_users_with_method(),
users_with_mfa_totp: MFA.count_users_with_totp_method(),
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),
openid_providers: length(openid_connect_providers),
saml_providers: length(saml_identity_providers),
unprivileged_device_management: allow_unprivileged_device_management,
unprivileged_device_configuration: allow_unprivileged_device_configuration,
local_authentication: local_auth_enabled,
disable_vpn_on_oidc_error: disable_vpn_on_oidc_error,
outbound_email: FzHttpWeb.Mailer.active?(),
external_database:
external_database?(Map.new(FzHttp.Config.fetch_env!(:fz_http, FzHttp.Repo))),
logo_type: FzHttp.Configurations.logo_type(FzHttp.Configurations.get!(:logo))
logo_type: FzHttp.Config.Logo.type(logo)
]
end

View File

@@ -0,0 +1,68 @@
defmodule FzHttp.Types.CIDR do
@moduledoc """
Ecto type implementation for CIDR's based on `Postgrex.INET` type, it required netmask to be always set.
"""
@behaviour Ecto.Type
def type, do: :inet
def embed_as(_), do: :self
def equal?(left, right), do: left == right
def cast(%Postgrex.INET{} = struct), do: {:ok, struct}
def cast(binary) when is_binary(binary) do
with {:ok, {binary_address, binary_netmask}} <- parse_binary(binary),
{:ok, address} <- cast_address(binary_address),
{:ok, netmask} <- cast_netmask(binary_netmask),
:ok <- validate_netmask(address, netmask) do
{:ok, %Postgrex.INET{address: address, netmask: netmask}}
else
_error -> {:error, message: "is invalid"}
end
end
def cast(_), do: :error
defp parse_binary(binary) do
binary = String.trim(binary)
with [binary_address, binary_netmask] <- String.split(binary, "/", parts: 2) do
{:ok, {binary_address, binary_netmask}}
else
_other -> :error
end
end
defp cast_address(address) do
address
|> String.to_charlist()
|> :inet.parse_address()
end
defp cast_netmask(binary) when is_binary(binary) do
case Integer.parse(binary) do
{netmask, ""} -> {:ok, netmask}
_other -> :error
end
end
defp validate_netmask(address, netmask)
when tuple_size(address) == 4 and 0 <= netmask and netmask <= 32,
do: :ok
defp validate_netmask(address, netmask)
when tuple_size(address) == 8 and 0 <= netmask and netmask <= 128,
do: :ok
defp validate_netmask(_address, _netmask), do: :error
def dump(%Postgrex.INET{} = inet), do: {:ok, inet}
def dump(_), do: :error
def load(%Postgrex.INET{} = inet), do: {:ok, inet}
def load(_), do: :error
def to_string(%Postgrex.INET{} = inet), do: FzHttp.Types.INET.to_string(inet)
end

View File

@@ -0,0 +1,71 @@
defmodule FzHttp.Types.INET do
@moduledoc """
INET is an implementation for native PostgreSQL `inet` type which can hold
either a CIDR (IP with a netmask) or just an IP address (with empty netmask).
"""
@behaviour Ecto.Type
def type, do: :inet
def embed_as(_), do: :self
def equal?(left, right), do: left == right
def cast(%Postgrex.INET{} = struct), do: {:ok, struct}
def cast(tuple) when is_tuple(tuple), do: cast(%Postgrex.INET{address: tuple})
def cast(binary) when is_binary(binary) do
with {:ok, {binary_address, binary_netmask}} <- parse_binary(binary),
{:ok, address} <- cast_address(binary_address),
{:ok, netmask} <- cast_netmask(binary_netmask) do
{:ok, %Postgrex.INET{address: address, netmask: netmask}}
else
_error -> {:error, message: "is invalid"}
end
end
def cast(_), do: :error
defp parse_binary(binary) do
binary = String.trim(binary)
case String.split(binary, "/", parts: 2) do
[binary_address, binary_netmask] -> {:ok, {binary_address, binary_netmask}}
[binary_address] -> {:ok, {binary_address, nil}}
_other -> :error
end
end
defp cast_address(address) do
address
|> String.to_charlist()
|> :inet.parse_address()
end
defp cast_netmask(nil), do: {:ok, nil}
defp cast_netmask(binary) when is_binary(binary) do
case Integer.parse(binary) do
{netmask, ""} -> {:ok, netmask}
_other -> :error
end
end
def dump(%Postgrex.INET{} = inet), do: {:ok, inet}
def dump(_), do: :error
def load(%Postgrex.INET{} = inet), do: {:ok, inet}
def load(_), do: :error
def to_string(%Postgrex.INET{address: address, netmask: nil}) do
"#{:inet.ntoa(address)}"
end
def to_string(%Postgrex.INET{address: address, netmask: netmask}) do
"#{:inet.ntoa(address)}/#{netmask}"
end
def to_string(inet) when is_binary(inet) do
inet
end
end

View File

@@ -0,0 +1,34 @@
defmodule FzHttp.Types.IP do
@moduledoc """
Ecto type implementation for IP's based on `Postgrex.INET` type,
it always ignores netmask by setting it to `nil`.
"""
@behaviour Ecto.Type
def type, do: :inet
def embed_as(_), do: :self
def equal?(left, right), do: left == right
def cast(%Postgrex.INET{} = inet), do: {:ok, inet}
def cast(binary) when is_binary(binary) do
with {:ok, address} <- FzHttp.Types.IPPort.cast_address(binary) do
{:ok, %Postgrex.INET{address: address, netmask: nil}}
else
{:error, _reason} -> {:error, message: "is invalid"}
end
end
def cast(_), do: :error
def dump(%Postgrex.INET{} = inet), do: {:ok, inet}
def dump(_), do: :error
def load(%Postgrex.INET{} = inet), do: {:ok, inet}
def load(_), do: :error
def to_string(ip) when is_binary(ip), do: ip
def to_string(%Postgrex.INET{} = inet), do: FzHttp.Types.INET.to_string(inet)
end

View File

@@ -0,0 +1,87 @@
defmodule FzHttp.Types.IPPort do
@behaviour Ecto.Type
defstruct [:type, :address, :port]
def type, do: :string
def embed_as(_), do: :self
def equal?(left, right), do: left == right
def cast(%__MODULE__{} = ip_port), do: ip_port
def cast(binary) when is_binary(binary) do
binary = String.trim(binary)
with {:ok, {binary_address, binary_port}} <- parse_binary(binary),
{:ok, address} <- cast_address(binary_address),
{:ok, port} <- cast_port(binary_port) do
{:ok, %__MODULE__{type: type(address), address: address, port: port}}
else
_error -> {:error, message: "is invalid"}
end
end
def cast(_), do: :error
defp parse_binary("[" <> binary) do
with [binary_address, binary_port] <- String.split(binary, "]:", parts: 2) do
{:ok, {binary_address, binary_port}}
else
[binary_address] -> {:ok, {binary_address, nil}}
end
end
defp parse_binary(binary) do
with [binary_address, binary_port] <- String.split(binary, ":") do
{:ok, {binary_address, binary_port}}
else
_other -> {:ok, {binary, nil}}
end
end
def cast_address(address) do
address
|> String.to_charlist()
|> :inet.parse_address()
end
defp cast_port(nil), do: {:ok, nil}
defp cast_port(binary) when is_binary(binary) do
case Integer.parse(binary) do
{port, ""} when 0 < port and port <= 65_535 -> {:ok, port}
_other -> :error
end
end
defp type(address) when tuple_size(address) == 4, do: :ipv4
defp type(address) when tuple_size(address) == 8, do: :ipv6
def dump(%__MODULE__{} = ip) do
{:ok, __MODULE__.to_string(ip)}
end
def dump(_), do: :error
def load(%__MODULE__{} = ip) do
{:ok, ip}
end
def load(_), do: :error
def to_string(%__MODULE__{address: ip, port: nil}) do
ip |> :inet.ntoa() |> List.to_string()
end
def to_string(%__MODULE__{type: :ipv4, address: ip, port: port}) do
ip = ip |> :inet.ntoa() |> List.to_string()
"#{ip}:#{port}"
end
def to_string(%__MODULE__{type: :ipv6, address: ip, port: port}) do
ip = ip |> :inet.ntoa() |> List.to_string()
"[#{ip}]:#{port}"
end
end

View File

@@ -0,0 +1,7 @@
defimpl String.Chars, for: Postgrex.INET do
def to_string(%Postgrex.INET{} = inet), do: FzHttp.Types.INET.to_string(inet)
end
defimpl Phoenix.HTML.Safe, for: Postgrex.INET do
def to_iodata(%Postgrex.INET{} = inet), do: FzHttp.Types.INET.to_string(inet)
end

View File

@@ -1,5 +1,5 @@
defmodule FzHttp.Users do
alias FzHttp.{Repo, Validator, Configurations}
alias FzHttp.{Repo, Validator, Config}
alias FzHttp.Telemetry
alias FzHttp.Users.User
require Ecto.Query
@@ -162,7 +162,7 @@ defmodule FzHttp.Users do
end
def vpn_session_expires_at(user) do
DateTime.add(user.last_signed_in_at, Configurations.vpn_duration())
DateTime.add(user.last_signed_in_at, Config.fetch_config!(:vpn_session_duration))
end
def vpn_session_expired?(user) do
@@ -170,7 +170,7 @@ defmodule FzHttp.Users do
is_nil(user.last_signed_in_at) ->
false
not Configurations.vpn_sessions_expire?() ->
not Config.vpn_sessions_expire?() ->
false
true ->

View File

@@ -3,7 +3,6 @@ defmodule FzHttp.Validator do
A set of changeset helpers and schema extensions to simplify our changesets and make validation more reliable.
"""
import Ecto.Changeset
alias FzCommon.FzNet
def changed?(changeset, field) do
Map.has_key?(changeset.changes, field)
@@ -17,58 +16,130 @@ defmodule FzHttp.Validator do
validate_format(changeset, field, ~r/@/, message: "is invalid email address")
end
def validate_uri(changeset, fields) when is_list(fields) do
Enum.reduce(fields, changeset, fn field, accumulated_changeset ->
validate_uri(accumulated_changeset, field)
end)
end
def validate_uri(changeset, field, opts \\ []) when is_atom(field) do
valid_schemes = Keyword.get(opts, :schemes, ~w[http https])
def validate_uri(changeset, field) when is_atom(field) do
validate_change(changeset, field, fn _current_field, value ->
case URI.new(value) do
{:ok, %URI{} = uri} ->
cond do
uri.host == nil ->
[{field, "does not contain host"}]
uri.scheme == nil ->
[{field, "does not contain a scheme"}]
uri.scheme not in valid_schemes ->
[{field, "only #{Enum.join(valid_schemes, ", ")} schemes are supported"}]
true ->
[]
end
{:error, part} ->
[{field, "is invalid. Error at #{part}"}]
_ ->
[]
end
end)
end
def normalize_url(changeset, field) do
with {:ok, value} <- fetch_change(changeset, field) do
uri = URI.parse(value)
scheme = uri.scheme || "https"
port = URI.default_port(scheme)
path = uri.path || "/"
put_change(changeset, field, %{uri | scheme: scheme, port: port, path: path})
else
:error ->
changeset
end
end
def validate_no_duplicates(changeset, field) when is_atom(field) do
validate_change(changeset, field, fn _current_field, value ->
values = split_comma_list(value)
dupes = Enum.uniq(values -- Enum.uniq(values))
error_if(
dupes,
&(&1 != []),
&{field, "is invalid: duplicates are not allowed: #{Enum.join(&1, ", ")}"}
)
validate_change(changeset, field, fn _current_field, list when is_list(list) ->
list
|> Enum.reduce_while({true, MapSet.new()}, fn value, {true, acc} ->
if MapSet.member?(acc, value) do
{:halt, {false, acc}}
else
{:cont, {true, MapSet.put(acc, value)}}
end
end)
|> case do
{true, _map_set} -> []
{false, _map_set} -> [{field, "should not contain duplicates"}]
end
end)
end
def validate_list_of_ips(changeset, field) when is_atom(field) do
def validate_fqdn(changeset, field, opts \\ []) do
allow_port = Keyword.get(opts, :allow_port, false)
validate_change(changeset, field, fn _current_field, value ->
value
|> split_comma_list()
|> Enum.find(&(not FzNet.valid_ip?(&1)))
|> error_if(
&(!is_nil(&1)),
&{field, "is invalid: #{&1} is not a valid IPv4 / IPv6 address"}
)
{fqdn, port} = split_port(value)
fqdn_validation_errors = fqdn_validation_errors(field, fqdn)
port_validation_errors = port_validation_errors(field, port, allow_port)
fqdn_validation_errors ++ port_validation_errors
end)
end
def validate_list_of_ips_or_cidrs(changeset, field) when is_atom(field) do
defp fqdn_validation_errors(field, fqdn) do
if Regex.match?(~r/^([a-zA-Z0-9._-])+$/i, fqdn) do
[]
else
[{field, "#{fqdn} is not a valid FQDN"}]
end
end
defp split_port(value) do
case String.split(value, ":", parts: 2) do
[prefix, port] ->
case Integer.parse(port) do
{port, ""} ->
{prefix, port}
_ ->
{value, nil}
end
[value] ->
{value, nil}
end
end
defp port_validation_errors(_field, nil, _allow?),
do: []
defp port_validation_errors(field, _port, false),
do: [{field, "setting port is not allowed"}]
defp port_validation_errors(_field, port, _allow?) when 0 < port and port <= 65_535,
do: []
defp port_validation_errors(field, _port, _allow?),
do: [{field, "port is not a number between 0 and 65535"}]
def validate_ip_type_inclusion(changeset, field, types) do
validate_change(changeset, field, fn _current_field, %{address: address} ->
type = if tuple_size(address) == 4, do: :ipv4, else: :ipv6
if type in types do
[]
else
[{field, "is not a supported IP type"}]
end
end)
end
def validate_cidr(changeset, field, _opts \\ []) do
validate_change(changeset, field, fn _current_field, value ->
value
|> split_comma_list()
|> Enum.find(&(not (FzNet.valid_ip?(&1) or FzNet.valid_cidr?(&1))))
|> error_if(
&(!is_nil(&1)),
&{field, "is invalid: #{&1} is not a valid IPv4 / IPv6 address or CIDR range"}
)
case FzHttp.Types.CIDR.cast(value) do
{:ok, _cidr} ->
[]
{:error, _reason} ->
[{field, "is not a valid CIDR range"}]
end
end)
end
@@ -88,27 +159,32 @@ defmodule FzHttp.Validator do
end
def validate_omitted(changeset, field) when is_atom(field) do
validate_change(changeset, field, fn _current_field, value ->
if is_nil(value) do
[]
else
[{field, "must not be present"}]
end
validate_change(changeset, field, fn
_field, nil -> []
_field, [] -> []
field, _value -> [{field, "must not be present"}]
end)
end
defp split_comma_list(text) do
text
|> String.split(",")
|> Enum.map(&String.trim/1)
end
def validate_file(changeset, field, opts \\ []) do
validate_change(changeset, field, fn _current_field, value ->
extensions = Keyword.get(opts, :extensions, [])
defp error_if(value, is_error, error) do
if is_error.(value) do
[error.(value)]
else
[]
end
cond do
not File.exists?(value) ->
[{field, "file does not exist"}]
extensions != [] and Path.extname(value) not in extensions ->
[
{field,
"file extension is not supported, got #{Path.extname(value)}, " <>
"expected one of #{inspect(extensions)}"}
]
true ->
[]
end
end)
end
@doc """
@@ -137,7 +213,7 @@ defmodule FzHttp.Validator do
end)
else
{:changes, _hash} ->
add_error(changeset, value_field, "can not be verified", validation: :hash)
add_error(changeset, value_field, "can't be verified", validation: :hash)
:error ->
add_error(changeset, value_field, "is already verified", validation: :hash)
@@ -197,7 +273,11 @@ defmodule FzHttp.Validator do
defp maybe_apply(value), do: value
def trim_change(changeset, field) do
update_change(changeset, field, &if(!is_nil(&1), do: String.trim(&1)))
update_change(changeset, field, fn
nil -> nil
changes when is_list(changes) -> Enum.map(changes, &String.trim/1)
change -> String.trim(change)
end)
end
@doc """

View File

@@ -68,7 +68,7 @@ defmodule FzHttpWeb do
def live_view_without_layout do
quote do
use Phoenix.LiveView, layout: nil
use Phoenix.LiveView
import FzHttpWeb.LiveHelpers
alias Phoenix.LiveView.JS
@@ -131,7 +131,7 @@ defmodule FzHttpWeb do
end
end
def static_paths, do: ~w(dist fonts images robots.txt)
def static_paths, do: ~w(dist fonts images uploads robots.txt)
def verified_routes do
quote do

View File

@@ -4,7 +4,7 @@ defmodule FzHttpWeb.Auth.HTML.Authentication do
"""
use Guardian, otp_app: :fz_http
use FzHttpWeb, :controller
alias FzHttp.Configurations
alias FzHttp.Auth
alias FzHttp.Telemetry
alias FzHttp.Users
alias FzHttp.Users.User
@@ -66,7 +66,7 @@ defmodule FzHttpWeb.Auth.HTML.Authentication do
def sign_out(conn) do
with provider_id when not is_nil(provider_id) <- Plug.Conn.get_session(conn, "login_method"),
token when not is_nil(token) <- Plug.Conn.get_session(conn, "id_token"),
{:ok, config} <- Configurations.fetch_oidc_provider_config(provider_id),
{:ok, config} <- Auth.fetch_oidc_provider_config(provider_id),
{:ok, end_session_uri} <-
OpenIDConnect.end_session_uri(config, %{
id_token_hint: token,

View File

@@ -4,7 +4,7 @@ defmodule FzHttpWeb.AuthController do
"""
use FzHttpWeb, :controller
alias FzHttp.Users
alias FzHttp.Configurations
alias FzHttp.Auth
alias FzHttpWeb.Auth.HTML.Authentication
alias FzHttpWeb.OAuth.PKCE
alias FzHttpWeb.OIDC.State
@@ -64,7 +64,7 @@ defmodule FzHttpWeb.AuthController do
token_params = Map.merge(params, PKCE.token_params(conn))
with :ok <- State.verify_state(conn, state),
{:ok, config} <- Configurations.fetch_oidc_provider_config(provider_id),
{:ok, config} <- Auth.fetch_oidc_provider_config(provider_id),
{:ok, tokens} <- OpenIDConnect.fetch_tokens(config, token_params),
{:ok, claims} <- OpenIDConnect.verify(config, tokens["id_token"]) do
case UserFromAuth.find_or_create(provider_id, claims) do
@@ -169,7 +169,7 @@ defmodule FzHttpWeb.AuthController do
code_challenge: PKCE.code_challenge(verifier)
}
with {:ok, config} <- Configurations.fetch_oidc_provider_config(provider_id),
with {:ok, config} <- Auth.fetch_oidc_provider_config(provider_id),
{:ok, uri} <- OpenIDConnect.authorization_uri(config, params) do
conn
|> PKCE.put_cookie(verifier)
@@ -180,7 +180,7 @@ defmodule FzHttpWeb.AuthController do
{:error, :not_found}
{:error, reason} ->
Logger.error("Can not redirect user to OIDC auth uri", reason: inspect(reason))
Logger.error("Cannot redirect user to OIDC auth uri", reason: inspect(reason))
conn
|> put_flash(:error, "Error while processing OpenID request.")

View File

@@ -6,23 +6,22 @@ defmodule FzHttpWeb.JSON.ConfigurationController do
Updates here can be applied at runtime with little to no downtime of affected services.
"""
use FzHttpWeb, :controller
alias FzHttp.{Configurations.Configuration, Configurations}
alias FzHttp.Config
action_fallback(FzHttpWeb.JSON.FallbackController)
@doc api_doc: [summary: "Get Configuration"]
def show(conn, _params) do
configuration = Configurations.get_configuration!()
configuration = Config.fetch_db_config!()
render(conn, "show.json", configuration: configuration)
end
@doc api_doc: [summary: "Update Configuration"]
def update(conn, %{"configuration" => params}) do
configuration = Configurations.get_configuration!()
configuration = Config.fetch_db_config!()
with {:ok, %Configuration{} = configuration} <-
Configurations.update_configuration(configuration, params) do
with {:ok, %Config.Configuration{} = configuration} <-
Config.update_config(configuration, params) do
render(conn, "show.json", configuration: configuration)
end
end

View File

@@ -13,23 +13,27 @@ defmodule FzHttpWeb.JSON.DeviceController do
@doc api_doc: [summary: "List all Devices"]
def index(conn, _params) do
devices = Devices.list_devices()
render(conn, "index.json", devices: devices)
defaults = Devices.defaults()
render(conn, "index.json", devices: devices, defaults: defaults)
end
@doc api_doc: [summary: "Create a Device"]
def create(conn, %{"device" => device_params}) do
with {:ok, device} <- Devices.create_device(device_params) do
defaults = Devices.defaults()
conn
|> put_status(:created)
|> put_resp_header("location", ~p"/v0/devices/#{device}")
|> render("show.json", device: device)
|> render("show.json", device: device, defaults: defaults)
end
end
@doc api_doc: [summary: "Get Device by ID"]
def show(conn, %{"id" => id}) do
device = Devices.get_device!(id)
render(conn, "show.json", device: device)
defaults = Devices.defaults()
render(conn, "show.json", device: device, defaults: defaults)
end
@doc api_doc: [summary: "Update a Device"]
@@ -37,7 +41,8 @@ defmodule FzHttpWeb.JSON.DeviceController do
device = Devices.get_device!(id)
with {:ok, device} <- Devices.update_device(device, device_params) do
render(conn, "show.json", device: device)
defaults = Devices.defaults()
render(conn, "show.json", device: device, defaults: defaults)
end
end

View File

@@ -11,6 +11,11 @@ defmodule FzHttpWeb.JSON.UserController do
If `auto_create_users` is `false`, then you need to provision users with `password` attribute,
otherwise they will have no means to log in.
## Disabling users
Even though API returns `disabled_at` attribute, currently, it's not possible to disable users via API,
since this field is only for internal use by automatic user disabling mechanism on OIDC/SAML errors.
"""
use FzHttpWeb, :controller
alias FzHttp.Users

View File

@@ -5,12 +5,23 @@ defmodule FzHttpWeb.RootController do
use FzHttpWeb, :controller
def index(conn, _params) do
%{
local_auth_enabled: {_, local_auth_enabled},
openid_connect_providers: {_, openid_connect_providers},
saml_identity_providers: {_, saml_identity_providers}
} =
FzHttp.Config.fetch_source_and_configs!([
:local_auth_enabled,
:openid_connect_providers,
:saml_identity_providers
])
conn
|> render(
"auth.html",
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)
local_enabled: local_auth_enabled,
openid_connect_providers: openid_connect_providers,
saml_identity_providers: saml_identity_providers
)
end
end

View File

@@ -7,8 +7,12 @@ defmodule FzHttpWeb.ErrorHelpers do
def aggregated_errors(%Ecto.Changeset{} = changeset) do
traverse_errors(changeset, fn {msg, opts} ->
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
Enum.reduce(opts, msg, fn
{key, {:array, value}}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
{key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end)
|> Enum.reduce("", fn {key, value}, acc ->
@@ -23,12 +27,14 @@ defmodule FzHttpWeb.ErrorHelpers do
def error_tag(form, field) do
values = Keyword.get_values(form.errors, field)
Enum.map(values, fn error ->
values
|> Enum.map(fn error ->
content_tag(:span, translate_error(error),
class: "help-block"
# XXX: data: [phx_error_for: input_id(form, field)]
)
end)
|> Enum.intersperse(", ")
end
@doc """

View File

@@ -44,16 +44,18 @@ defmodule FzHttpWeb.DeviceLive.Admin.Show do
end
defp assigns(device) do
defaults = Devices.defaults()
[
device: device,
user: Users.fetch_user_by_id!(device.user_id),
page_title: device.name,
allowed_ips: Devices.allowed_ips(device),
dns: Devices.dns(device),
endpoint: Devices.endpoint(device),
allowed_ips: Devices.allowed_ips(device, defaults),
dns: Devices.dns(device, defaults),
endpoint: Devices.endpoint(device, defaults),
port: FzHttp.Config.fetch_env!(:fz_vpn, :wireguard_port),
mtu: Devices.mtu(device),
persistent_keepalive: Devices.persistent_keepalive(device),
mtu: Devices.mtu(device, defaults),
persistent_keepalive: Devices.persistent_keepalive(device, defaults),
config: Devices.as_config(device)
]
end

View File

@@ -15,46 +15,50 @@ 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)
config =
FzHttp.Config.fetch_source_and_configs!(~w(
default_client_mtu
default_client_endpoint
default_client_persistent_keepalive
default_client_dns
default_client_allowed_ips
)a)
|> Enum.into(%{}, fn {k, {_s, v}} -> {k, v} end)
{:ok,
socket
|> assign(assigns)
|> assign(:changeset, changeset)
|> assign(Map.take(FzHttp.Configurations.get_configuration!(), @default_fields))
|> assign(Devices.defaults(changeset))}
|> assign(config)
|> assign(Devices.use_default_fields(changeset))}
end
@impl Phoenix.LiveComponent
def handle_event("change", %{"device" => device_params}, socket) do
changeset = Devices.new_device(device_params)
changeset =
device_params
|> Map.update("dns", nil, &binary_to_list/1)
|> Map.update("allowed_ips", nil, &binary_to_list/1)
|> Devices.new_device()
{:noreply,
socket
|> assign(:changeset, changeset)
|> assign(Devices.defaults(changeset))}
|> assign(Devices.use_default_fields(changeset))}
end
@impl Phoenix.LiveComponent
def handle_event("save", %{"device" => device_params}, socket) do
result =
device_params
|> Map.put("user_id", socket.assigns.target_user_id)
|> create_device(socket)
case result do
:not_authorized ->
{:noreply, not_authorized(socket)}
device_params
|> Map.put("user_id", socket.assigns.target_user_id)
|> Map.update("dns", nil, &binary_to_list/1)
|> Map.update("allowed_ips", nil, &binary_to_list/1)
|> create_device(socket)
|> case do
{:ok, device} ->
send_update(FzHttpWeb.ModalComponent, id: :modal, hide_footer_content: true)
@@ -63,6 +67,9 @@ defmodule FzHttpWeb.DeviceLive.NewFormComponent do
|> assign(:device, device)
|> assign(:config, Devices.as_encoded_config(device))}
:not_authorized ->
{:noreply, not_authorized(socket)}
{:error, changeset} ->
{:noreply,
socket
@@ -81,7 +88,7 @@ defmodule FzHttpWeb.DeviceLive.NewFormComponent do
defp authorized_to_create?(socket) do
has_role?(socket, :admin) ||
(FzHttp.Configurations.get!(:allow_unprivileged_device_management) &&
(FzHttp.Config.fetch_config!(:allow_unprivileged_device_management) &&
socket.assigns.current_user.id == socket.assigns.target_user_id)
end
@@ -96,4 +103,10 @@ defmodule FzHttpWeb.DeviceLive.NewFormComponent do
end
|> Devices.new_device()
end
defp binary_to_list(binary) when is_binary(binary),
do: binary |> String.trim() |> String.split(",")
defp binary_to_list(list) when is_list(list),
do: list
end

View File

@@ -105,7 +105,7 @@
</label>
</div>
<p class="help">
Default: <%= @default_client_allowed_ips %>
Default: <%= Enum.join(@default_client_allowed_ips, ", ") %>
</p>
</div>
@@ -114,7 +114,8 @@
<div class="control">
<%= textarea(f, :allowed_ips,
class: "textarea #{input_error_class(f, :allowed_ips)}",
disabled: @use_default_allowed_ips
disabled: @use_default_allowed_ips,
value: list_value(f, :allowed_ips)
) %>
</div>
<p class="help is-danger">
@@ -133,7 +134,7 @@
</label>
</div>
<p class="help">
Default: <%= @default_client_dns %>
Default: <%= Enum.join(@default_client_dns, ", ") %>
</p>
</div>
@@ -142,7 +143,8 @@
<div class="control">
<%= text_input(f, :dns,
class: "input #{input_error_class(f, :dns)}",
disabled: @use_default_dns
disabled: @use_default_dns,
value: list_value(f, :dns)
) %>
</div>
<p class="help is-danger">

View File

@@ -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) ||
FzHttp.Configurations.get!(:allow_unprivileged_device_management)) do
FzHttp.Config.fetch_config!(:allow_unprivileged_device_management)) do
Devices.delete_device(device)
else
{:not_authorized}
@@ -51,16 +51,18 @@ defmodule FzHttpWeb.DeviceLive.Unprivileged.Show do
end
defp assigns(device) do
defaults = Devices.defaults()
[
device: device,
user: Users.fetch_user_by_id!(device.user_id),
page_title: device.name,
allowed_ips: Devices.allowed_ips(device),
allowed_ips: Devices.allowed_ips(device, defaults),
port: FzHttp.Config.fetch_env!(:fz_vpn, :wireguard_port),
dns: Devices.dns(device),
endpoint: Devices.endpoint(device),
mtu: Devices.mtu(device),
persistent_keepalive: Devices.persistent_keepalive(device),
dns: Devices.dns(device, defaults),
endpoint: Devices.endpoint(device, defaults),
mtu: Devices.mtu(device, defaults),
persistent_keepalive: Devices.persistent_keepalive(device, defaults),
config: Devices.as_config(device)
]
end

View File

@@ -24,7 +24,7 @@ defmodule FzHttpWeb.LiveAuth do
%{role: :unprivileged} = user,
%{assigns: %{live_action: :new}, view: FzHttpWeb.DeviceLive.Unprivileged.Index} = socket
) do
if FzHttp.Configurations.get!(:allow_unprivileged_device_management) do
if FzHttp.Config.fetch_config!(:allow_unprivileged_device_management) do
{:cont, assign_new(socket, :current_user, fn -> user end)}
else
{:halt, not_authorized(socket)}

View File

@@ -3,6 +3,7 @@ defmodule FzHttpWeb.LogoComponent do
Logo component displays default, url and data logo
"""
use FzHttpWeb, :live_component
import FzHttpWeb.Endpoint, only: [static_path: 1]
def render(%{url: url} = assigns) when is_binary(url) do
~H"""
@@ -10,6 +11,12 @@ defmodule FzHttpWeb.LogoComponent do
"""
end
def render(%{file: file} = assigns) when is_binary(file) do
~H"""
<img src={static_path("/uploads/logo/" <> @file)} alt="Firezone App Logo" />
"""
end
def render(%{data: data, type: type} = assigns) when is_binary(data) and is_binary(type) do
~H"""
<img src={"data:#{@type};base64," <> @data} alt="Firezone App Logo" />

View File

@@ -3,30 +3,68 @@ defmodule FzHttpWeb.SettingLive.ClientDefaultsFormComponent do
Handles updating client defaults form.
"""
use FzHttpWeb, :live_component
alias FzHttp.Config
alias FzHttp.Configurations
@configs ~w[
default_client_allowed_ips
default_client_dns
default_client_endpoint
default_client_persistent_keepalive
default_client_mtu
]a
@impl Phoenix.LiveComponent
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)}
socket =
socket
|> assign(assigns)
|> assign(:configs, FzHttp.Config.fetch_source_and_configs!(@configs))
{:ok, socket}
end
@impl Phoenix.LiveComponent
def handle_event("save", %{"configuration" => configuration_params}, socket) do
configuration = Configurations.get_configuration!()
configuration_params =
configuration_params
|> Map.update("default_client_dns", nil, &binary_to_list/1)
|> Map.update("default_client_allowed_ips", nil, &binary_to_list/1)
case Configurations.update_configuration(configuration, configuration_params) do
{:ok, configuration} ->
{:noreply,
socket
|> assign(:changeset, Configurations.change_configuration(configuration))}
configuration = Config.fetch_db_config!()
{:error, changeset} ->
{:noreply,
socket
|> assign(:changeset, changeset)}
end
socket =
case Config.update_config(configuration, configuration_params) do
{:ok, configuration} ->
socket
|> assign(:changeset, Config.change_config(configuration))
{:error, changeset} ->
socket
|> assign(:changeset, changeset)
end
{:noreply, socket}
end
defp binary_to_list(binary) when is_binary(binary),
do: binary |> String.trim() |> String.split(",")
defp binary_to_list(list) when is_list(list),
do: list
def config_has_override?({{source, _source_key}, _key}) do
source not in [:db]
end
def config_has_override?({_source, _key}) do
false
end
def config_value({_source, value}) do
value
end
def config_override_source({{:env, source_key}, _value}) do
"environment variable #{source_key}"
end
end

View File

@@ -1,6 +1,7 @@
<div class="block">
<.form :let={f} for={@changeset} id={@id} phx-target={@myself} phx-submit="save">
<div class="field">
<% default_client_allowed_ips = Map.fetch!(@configs, :default_client_allowed_ips) %>
<%= label(f, :default_client_allowed_ips, "Allowed IPs", class: "label") %>
<div class="control">
@@ -8,7 +9,14 @@
f,
:default_client_allowed_ips,
placeholder: "0.0.0.0/0, ::/0",
class: "textarea #{input_error_class(f, :default_client_allowed_ips)}"
class: "textarea #{input_error_class(f, :default_client_allowed_ips)}",
disabled: config_has_override?(default_client_allowed_ips),
value:
if config_has_override?(default_client_allowed_ips) do
"Set in #{config_override_source(default_client_allowed_ips)}: #{Enum.join(config_value(default_client_allowed_ips), ", ")}"
else
list_value(f, :default_client_allowed_ips)
end
) %>
</div>
@@ -25,6 +33,7 @@
</div>
<div class="field">
<% default_client_dns = Map.fetch!(@configs, :default_client_dns) %>
<%= label(f, :default_client_dns, "DNS Servers", class: "label") %>
<div class="control">
@@ -32,7 +41,14 @@
f,
:default_client_dns,
placeholder: "1.1.1.1, 1.0.0.1",
class: "input #{input_error_class(f, :default_client_dns)}"
class: "input #{input_error_class(f, :default_client_dns)}",
disabled: config_has_override?(default_client_dns),
value:
if config_has_override?(default_client_dns) do
"Set in #{config_override_source(default_client_dns)}: #{Enum.join(config_value(default_client_dns), ", ")}"
else
list_value(f, :default_client_dns)
end
) %>
</div>
@@ -47,6 +63,7 @@
</div>
<div class="field">
<% default_client_endpoint = Map.fetch!(@configs, :default_client_endpoint) %>
<%= label(f, :default_client_endpoint, "Endpoint", class: "label") %>
<div class="control">
@@ -54,7 +71,14 @@
f,
:default_client_endpoint,
placeholder: "firezone.example.com",
class: "input #{input_error_class(f, :default_client_endpoint)}"
class: "input #{input_error_class(f, :default_client_endpoint)}",
disabled: config_has_override?(default_client_endpoint),
value:
if config_has_override?(default_client_endpoint) do
"Set in #{config_override_source(default_client_endpoint)}: #{config_value(default_client_endpoint)}"
else
input_value(f, :default_client_endpoint)
end
) %>
</div>
<p class="help is-danger">
@@ -67,6 +91,8 @@
</div>
<div class="field">
<% default_client_persistent_keepalive =
Map.fetch!(@configs, :default_client_persistent_keepalive) %>
<%= label(f, :default_client_persistent_keepalive, "Persistent Keepalive", class: "label") %>
<div class="control">
@@ -74,7 +100,14 @@
f,
:default_client_persistent_keepalive,
placeholder: "25",
class: "input #{input_error_class(f, :default_client_persistent_keepalive)}"
class: "input #{input_error_class(f, :default_client_persistent_keepalive)}",
disabled: config_has_override?(default_client_persistent_keepalive),
value:
if config_has_override?(default_client_persistent_keepalive) do
"Set in #{config_override_source(default_client_persistent_keepalive)}: #{config_value(default_client_persistent_keepalive)}"
else
input_value(f, :default_client_persistent_keepalive)
end
) %>
<p class="help is-danger">
<%= error_tag(f, :default_client_persistent_keepalive) %>
@@ -87,6 +120,7 @@
</div>
<div class="field">
<% default_client_mtu = Map.fetch!(@configs, :default_client_mtu) %>
<%= label(f, :default_client_mtu, "MTU", class: "label") %>
<div class="control">
@@ -94,7 +128,14 @@
f,
:default_client_mtu,
placeholder: "1280",
class: "input #{input_error_class(f, :default_client_mtu)}"
class: "input #{input_error_class(f, :default_client_mtu)}",
disabled: config_has_override?(default_client_mtu),
value:
if config_has_override?(default_client_mtu) do
"Set in #{config_override_source(default_client_mtu)}: #{config_value(default_client_mtu)}"
else
input_value(f, :default_client_mtu)
end
) %>
</div>
<p class="help is-danger">

View File

@@ -3,22 +3,19 @@ defmodule FzHttpWeb.SettingLive.ClientDefaults do
Manages the defaults view.
"""
use FzHttpWeb, :live_view
alias FzHttp.Configurations
alias FzHttp.Config
@page_title "Client Defaults"
@page_subtitle "Configure default values for generating WireGuard client configurations."
@impl Phoenix.LiveView
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:changeset, changeset())
|> assign(:page_subtitle, @page_subtitle)
|> assign(:page_title, @page_title)}
end
socket =
socket
|> assign(:changeset, Config.change_config())
|> assign(:page_subtitle, @page_subtitle)
|> assign(:page_title, @page_title)
defp changeset do
Configurations.get_configuration!() |> Configurations.change_configuration()
{:ok, socket}
end
end

View File

@@ -13,25 +13,33 @@
Use a logo at least 300px wide with a 7:2 ratio for best results. GIF, JPEG, PNG, SVG, TIFF,
WebP and AVIF images are supported.
</p>
<%= if has_override?(@logo_source) do %>
<p>
The logo was overridden by the <code>LOGO</code>
environment variable; you cannot change it.
</p>
<% end %>
</div>
<div class="block">
<div class="field">
<div class="control">
<%= for type <- FzHttp.Configurations.logo_types do %>
<label class="radio">
<input
type="radio"
name="logo"
value={type}
checked={type == @logo_type}
phx-click={JS.push("choose", value: %{type: type})}
/>
<span><%= type %></span>
</label>
<% end %>
<%= unless has_override?(@logo_source) do %>
<div class="field">
<div class="control">
<%= for type <- FzHttp.Config.Logo.__types__() do %>
<label class="radio">
<input
type="radio"
name="logo"
value={type}
checked={type == @logo_type}
phx-click={JS.push("choose", value: %{type: type})}
/>
<span><%= type %></span>
</label>
<% end %>
</div>
</div>
</div>
<% end %>
<div class="column p-0 is-two-thirds-tablet is-half-desktop is-one-third-widescreen">
<%= if @logo_type == "Default" do %>
@@ -41,44 +49,46 @@
<% end %>
</div>
<%= if @logo_type == "Default" do %>
<form id="default-form" phx-submit="save">
<input type="hidden" name="default" value="true" />
<button class="button" type="submit">Save</button>
</form>
<% end %>
<%= unless has_override?(@logo_source) do %>
<%= if @logo_type == "Default" do %>
<form id="default-form" phx-submit="save">
<input type="hidden" name="default" value="true" />
<button class="button" type="submit">Save</button>
</form>
<% end %>
<%= if @logo_type == "URL" do %>
<form id="url-form" phx-submit="save">
<div class="field has-addons">
<div class="control">
<input
class="input"
type="url"
name="url"
placeholder="https://my.logo.com/logo.jpg"
required
/>
<%= if @logo_type == "URL" do %>
<form id="url-form" phx-submit="save">
<div class="field has-addons">
<div class="control">
<input
class="input"
type="url"
name="url"
placeholder="https://my.logo.com/logo.jpg"
required
/>
</div>
<div class="control">
<button class="button" type="submit">Save</button>
</div>
</div>
<div class="control">
<button class="button" type="submit">Save</button>
</div>
</div>
</form>
<% end %>
</form>
<% end %>
<%= if @logo_type == "Upload" do %>
<form id="upload-form" phx-submit="save" phx-change="validate">
<%= for entry <- @uploads.logo.entries do %>
<%= for err <- upload_errors(@uploads.logo, entry) do %>
<p class="notification is-warning"><%= error_to_string(err) %></p>
<%= if @logo_type == "Upload" do %>
<form id="upload-form" phx-submit="save" phx-change="validate">
<%= for entry <- @uploads.logo.entries do %>
<%= for err <- upload_errors(@uploads.logo, entry) do %>
<p class="notification is-warning"><%= error_to_string(err) %></p>
<% end %>
<% end %>
<% end %>
<%= live_file_input(@uploads.logo, class: "button", required: true) %>
<%= live_file_input(@uploads.logo, class: "button", required: true) %>
<button class="button" type="submit">Upload</button>
</form>
<button class="button" type="submit">Upload</button>
</form>
<% end %>
<% end %>
</div>
</section>

View File

@@ -3,6 +3,7 @@ defmodule FzHttpWeb.SettingLive.Customization do
Manages the app customizations.
"""
use FzHttpWeb, :live_view
alias FzHttp.Config
@max_logo_size 1024 ** 2
@page_title "Customization"
@@ -10,21 +11,27 @@ defmodule FzHttpWeb.SettingLive.Customization do
@impl Phoenix.LiveView
def mount(_params, _session, socket) do
logo = FzHttp.Configurations.get!(:logo)
logo_type = FzHttp.Configurations.logo_type(logo)
{source, logo} = FzHttp.Config.fetch_source_and_config!(:logo)
logo_type = FzHttp.Config.Logo.type(logo)
{:ok,
socket
|> assign(:page_title, @page_title)
|> assign(:page_subtitle, @page_subtitle)
|> assign(:logo, logo)
|> assign(:logo_type, logo_type)
|> allow_upload(:logo,
accept: ~w(.jpg .jpeg .png .gif .webp .avif .svg .tiff),
max_file_size: @max_logo_size
)}
socket =
socket
|> assign(:page_title, @page_title)
|> assign(:page_subtitle, @page_subtitle)
|> assign(:logo, logo)
|> assign(:logo_source, source)
|> assign(:logo_type, logo_type)
|> allow_upload(:logo,
accept: ~w(.jpg .jpeg .png .gif .webp .avif .svg .tiff),
max_file_size: @max_logo_size
)
{:ok, socket}
end
def has_override?({source, _source_key}), do: source not in [:db]
def has_override?(_source), do: false
@impl Phoenix.LiveView
def handle_event("choose", %{"type" => type}, socket) do
{:noreply, assign(socket, :logo_type, type)}
@@ -37,15 +44,13 @@ defmodule FzHttpWeb.SettingLive.Customization do
@impl Phoenix.LiveView
def handle_event("save", %{"default" => "true"}, socket) do
{:ok, config} = FzHttp.Configurations.update_configuration(%{logo: nil})
config = Config.put_config!(:logo, nil)
{:noreply, assign(socket, :logo, config.logo)}
end
@impl Phoenix.LiveView
def handle_event("save", %{"url" => url}, socket) do
{:ok, config} = FzHttp.Configurations.update_configuration(%{logo: %{"url" => url}})
config = Config.put_config!(:logo, %{"url" => url})
{:noreply, assign(socket, :logo, config.logo)}
end
@@ -56,13 +61,7 @@ defmodule FzHttpWeb.SettingLive.Customization do
config =
consume_uploaded_entry(socket, entry, fn %{path: path} ->
data = path |> File.read!() |> Base.encode64()
# enforce OK, error from update_configuration instead of consume_uploaded_entry
{:ok, config} =
FzHttp.Configurations.update_configuration(%{
logo: %{"data" => data, "type" => entry.client_type}
})
config = FzHttp.Config.put_config!(:logo, %{"data" => data, "type" => entry.client_type})
{:ok, config}
end)

View File

@@ -3,7 +3,6 @@ defmodule FzHttpWeb.SettingLive.OIDCFormComponent do
Form for OIDC configs
"""
use FzHttpWeb, :live_component
alias FzHttp.Configurations
def render(assigns) do
~H"""
@@ -61,7 +60,9 @@ defmodule FzHttpWeb.SettingLive.OIDCFormComponent do
<%= error_tag(f, :scope) %>
</p>
<p class="help">
Space-delimited list of OpenID scopes.
Space-delimited list of OpenID scopes. <code>openid</code>
and <code>email</code>
are required in order for Firezone to work.
</p>
</div>
@@ -73,6 +74,7 @@ defmodule FzHttpWeb.SettingLive.OIDCFormComponent do
<div class="control">
<%= text_input(f, :response_type,
disabled: true,
placeholder: "code",
class: "input #{input_error_class(f, :response_type)}"
) %>
</div>
@@ -130,7 +132,11 @@ defmodule FzHttpWeb.SettingLive.OIDCFormComponent do
<div class="control">
<%= text_input(f, :redirect_uri,
placeholder: "#{@external_url}/auth/oidc/#{@provider_id || "{CONFIG_ID}"}/callback/",
placeholder:
Path.join(
@external_url,
"auth/oidc/#{input_value(f, :id) || "{CONFIG_ID}"}/callback/"
),
class: "input #{input_error_class(f, :redirect_uri)}"
) %>
</div>
@@ -139,7 +145,14 @@ defmodule FzHttpWeb.SettingLive.OIDCFormComponent do
</p>
<p class="help">
Optionally override the Redirect URI. Must match the redirect URI set in your IdP.
In most cases you shouldn't change this.
In most cases you shouldn't change this. By default
<code>
<%= Path.join(
@external_url,
"auth/oidc/#{input_value(f, :id) || "{CONFIG_ID}"}/callback/"
) %>
</code>
is used.
</p>
</div>
@@ -172,10 +185,9 @@ defmodule FzHttpWeb.SettingLive.OIDCFormComponent do
def update(assigns, socket) do
changeset =
assigns.providers
|> Map.get(assigns.provider_id, %{})
|> Map.put(:id, assigns.provider_id)
|> Configurations.Configuration.OpenIDConnectProvider.create_changeset()
assigns.provider
|> Map.delete(:__struct__)
|> FzHttp.Config.Configuration.OpenIDConnectProvider.create_changeset()
socket =
socket
@@ -187,28 +199,18 @@ defmodule FzHttpWeb.SettingLive.OIDCFormComponent do
end
def handle_event("save", %{"open_id_connect_provider" => params}, socket) do
create_changeset = Configurations.Configuration.OpenIDConnectProvider.create_changeset(params)
changeset = FzHttp.Config.Configuration.OpenIDConnectProvider.create_changeset(params)
if create_changeset.valid? do
new_attrs =
create_changeset
|> Ecto.Changeset.apply_changes()
|> Map.from_struct()
if changeset.valid? do
attrs = Ecto.Changeset.apply_changes(changeset)
new_attrs =
socket.assigns.providers
|> Map.get(socket.assigns.provider_id, %{})
|> Map.merge(new_attrs)
openid_connect_providers =
FzHttp.Config.fetch_config!(:openid_connect_providers)
|> Enum.reject(&(&1.id == socket.assigns.provider.id))
|> Kernel.++([attrs])
|> Enum.map(&Map.from_struct/1)
providers =
socket.assigns.providers
|> Map.delete(socket.assigns.provider_id)
|> Map.values()
{:ok, _changeset} =
FzHttp.Configurations.update_configuration(%{
openid_connect_providers: providers ++ [new_attrs]
})
FzHttp.Config.put_config!(:openid_connect_providers, openid_connect_providers)
socket =
socket
@@ -217,7 +219,7 @@ defmodule FzHttpWeb.SettingLive.OIDCFormComponent do
{:noreply, socket}
else
socket = assign(socket, :changeset, render_changeset_errors(create_changeset))
socket = assign(socket, :changeset, render_changeset_errors(changeset))
{:noreply, socket}
end
end

View File

@@ -3,7 +3,6 @@ defmodule FzHttpWeb.SettingLive.SAMLFormComponent do
Form for SAML configs
"""
use FzHttpWeb, :live_component
alias FzHttp.Configurations
def render(assigns) do
~H"""
@@ -194,10 +193,9 @@ defmodule FzHttpWeb.SettingLive.SAMLFormComponent do
def update(assigns, socket) do
changeset =
assigns.providers
|> Map.get(assigns.provider_id, %{})
|> Map.merge(%{id: assigns.provider_id})
|> Configurations.Configuration.SAMLIdentityProvider.create_changeset()
assigns.provider
|> Map.delete(:__struct__)
|> FzHttp.Config.Configuration.SAMLIdentityProvider.create_changeset()
socket =
socket
@@ -208,28 +206,18 @@ defmodule FzHttpWeb.SettingLive.SAMLFormComponent do
end
def handle_event("save", %{"saml_identity_provider" => params}, socket) do
create_changeset = Configurations.Configuration.SAMLIdentityProvider.create_changeset(params)
changeset = FzHttp.Config.Configuration.SAMLIdentityProvider.create_changeset(params)
if create_changeset.valid? do
new_attrs =
create_changeset
|> Ecto.Changeset.apply_changes()
|> Map.from_struct()
if changeset.valid? do
attrs = Ecto.Changeset.apply_changes(changeset)
new_attrs =
socket.assigns.providers
|> Map.get(socket.assigns.provider_id, %{})
|> Map.merge(new_attrs)
saml_identity_providers =
FzHttp.Config.fetch_config!(:saml_identity_providers)
|> Enum.reject(&(&1.id == socket.assigns.provider.id))
|> Kernel.++([attrs])
|> Enum.map(&Map.from_struct/1)
providers =
socket.assigns.providers
|> Map.delete(socket.assigns.provider_id)
|> Map.values()
{:ok, _changeset} =
FzHttp.Configurations.update_configuration(%{
saml_identity_providers: providers ++ [new_attrs]
})
FzHttp.Config.put_config!(:saml_identity_providers, saml_identity_providers)
socket =
socket
@@ -238,7 +226,10 @@ defmodule FzHttpWeb.SettingLive.SAMLFormComponent do
{:noreply, socket}
else
socket = assign(socket, :changeset, render_changeset_errors(create_changeset))
socket =
socket
|> assign(:changeset, render_changeset_errors(changeset))
{:noreply, socket}
end
end

View File

@@ -1,10 +1,12 @@
<% openid_connect_providers = Map.fetch!(@configs, :openid_connect_providers) %>
<% saml_identity_providers = Map.fetch!(@configs, :saml_identity_providers) %>
<%= if @live_action == :edit_oidc do %>
<%= live_modal(
FzHttpWeb.SettingLive.OIDCFormComponent,
return_to: ~p"/settings/security",
title: "OIDC Configuration",
providers: @oidc_configs,
provider_id: @id,
provider: get_provider(config_value(openid_connect_providers), @id) || %{id: @id},
id: "oidc-form-component",
form: "oidc-form"
) %>
@@ -15,8 +17,7 @@
FzHttpWeb.SettingLive.SAMLFormComponent,
return_to: ~p"/settings/security",
title: "SAML Configuration",
providers: @saml_configs,
provider_id: @id,
provider: get_provider(config_value(saml_identity_providers), @id) || %{id: @id},
id: "saml-form-component",
form: "saml-form"
) %>
@@ -33,6 +34,7 @@
<h4 class="title is-4">Authentication</h4>
<div class="block">
<% vpn_session_duration = Map.fetch!(@configs, :vpn_session_duration) %>
<.form
:let={f}
for={@configuration_changeset}
@@ -46,31 +48,48 @@
<div class="field has-addons">
<p class="control">
<span class="select">
<%= select(f, :vpn_session_duration, @session_duration_options, class: "input") %>
<%= select(f, :vpn_session_duration, session_duration_options(vpn_session_duration),
class: "input",
disabled: config_has_override?(vpn_session_duration),
selected: config_value(vpn_session_duration)
) %>
</span>
</p>
<p class="control">
<%= submit("Save",
disabled: !@form_changed,
disabled: !(@form_changed and !config_has_override?(vpn_session_duration)),
phx_disable_with: "Saving...",
class: "button is-primary"
) %>
</p>
</div>
<p class="help">
Optionally require users to periodically authenticate to the Firezone
web UI in order to keep their VPN sessions active.
<%= if config_has_override?(vpn_session_duration) do %>
This field was overridden using <%= config_override_source(vpn_session_duration) %>; you cannot change it.
<% else %>
Optionally require users to periodically authenticate to the Firezone
web UI in order to keep their VPN sessions active.
<% end %>
</p>
</div>
</.form>
</div>
<div class="block" title={@field_titles.local_auth_enabled}>
<div class="block">
<% local_auth_enabled = Map.fetch!(@configs, :local_auth_enabled) %>
<strong>Local Auth</strong>
<div class="level">
<div class="level-left">
<p>Enable or disable authentication with email and password.</p>
<p>
Enable or disable authentication with email and password.
<%= if config_has_override?(local_auth_enabled) do %>
<br />
<i>
This value is overridden using <%= config_override_source(local_auth_enabled) %>; you cannot change it.
</i>
<% end %>
</p>
</div>
<div class="level-right">
<label class="switch is-medium">
@@ -79,8 +98,9 @@
phx-click="toggle"
name="local_auth_enabled"
phx-value-config="local_auth_enabled"
checked={FzHttp.Configurations.get!(:local_auth_enabled)}
value={if(!FzHttp.Configurations.get!(:local_auth_enabled), do: "on")}
disabled={config_has_override?(local_auth_enabled)}
checked={config_value(local_auth_enabled)}
value={config_toggle_status(local_auth_enabled)}
/>
<span class="check"></span>
</label>
@@ -88,12 +108,24 @@
</div>
</div>
<div class="block" title={@field_titles.allow_unprivileged_device_management}>
<div class="block">
<% allow_unprivileged_device_management =
Map.fetch!(@configs, :allow_unprivileged_device_management) %>
<strong>Allow unprivileged device management</strong>
<div class="level">
<div class="level-left">
<p>Enable or disable management of devices on unprivileged accounts.</p>
<p>
Enable or disable management of devices on unprivileged accounts.
<%= if config_has_override?(allow_unprivileged_device_management) do %>
<br />
<i>
This value is overridden using <%= config_override_source(
allow_unprivileged_device_management
) %>; you cannot change it.
</i>
<% end %>
</p>
</div>
<div class="level-right">
<label class="switch is-medium">
@@ -102,10 +134,9 @@
phx-click="toggle"
name="allow_unprivileged_device_management"
phx-value-config="allow_unprivileged_device_management"
checked={FzHttp.Configurations.get!(:allow_unprivileged_device_management)}
value={
if(!FzHttp.Configurations.get!(:allow_unprivileged_device_management), do: "on")
}
disabled={config_has_override?(allow_unprivileged_device_management)}
checked={config_value(allow_unprivileged_device_management)}
value={config_toggle_status(allow_unprivileged_device_management)}
/>
<span class="check"></span>
</label>
@@ -113,13 +144,23 @@
</div>
</div>
<div class="block" title={@field_titles.allow_unprivileged_device_configuration}>
<div class="block">
<% allow_unprivileged_device_configuration =
Map.fetch!(@configs, :allow_unprivileged_device_configuration) %>
<strong>Allow unprivileged device configuration</strong>
<div class="level">
<div class="level-left">
<p>
Enable or disable configuration of device network settings for unprivileged users.
<%= if config_has_override?(allow_unprivileged_device_configuration) do %>
<br />
<i>
This value is overridden using <%= config_override_source(
allow_unprivileged_device_configuration
) %>; you cannot change it.
</i>
<% end %>
</p>
</div>
<div class="level-right">
@@ -129,10 +170,9 @@
phx-click="toggle"
name="allow_unprivileged_device_configuration"
phx-value-config="allow_unprivileged_device_configuration"
checked={FzHttp.Configurations.get!(:allow_unprivileged_device_configuration)}
value={
if(!FzHttp.Configurations.get!(:allow_unprivileged_device_configuration), do: "on")
}
disabled={config_has_override?(allow_unprivileged_device_configuration)}
checked={config_value(allow_unprivileged_device_configuration)}
value={config_toggle_status(allow_unprivileged_device_configuration)}
/>
<span class="check"></span>
</label>
@@ -150,12 +190,19 @@
</p>
</div>
<div class="block" title={@field_titles.disable_vpn_on_oidc_error}>
<div class="block">
<% disable_vpn_on_oidc_error = Map.fetch!(@configs, :disable_vpn_on_oidc_error) %>
<strong>Auto disable VPN</strong>
<div class="level">
<div class="level-left">
<p>Enable or disable auto disabling VPN connection on OIDC refresh error.</p>
<%= if config_has_override?(disable_vpn_on_oidc_error) do %>
<br />
<i>
This value is overridden using <%= config_override_source(disable_vpn_on_oidc_error) %>; you cannot change it.
</i>
<% end %>
</div>
<div class="level-right">
<label class="switch is-medium">
@@ -164,8 +211,9 @@
phx-click="toggle"
name="disable_vpn_on_oidc_error"
phx-value-config="disable_vpn_on_oidc_error"
checked={FzHttp.Configurations.get!(:disable_vpn_on_oidc_error)}
value={if(!FzHttp.Configurations.get!(:disable_vpn_on_oidc_error), do: "on")}
disabled={config_has_override?(disable_vpn_on_oidc_error)}
checked={config_value(disable_vpn_on_oidc_error)}
value={config_toggle_status(disable_vpn_on_oidc_error)}
/>
<span class="check"></span>
</label>
@@ -175,6 +223,16 @@
<label class="label">OpenID Connect providers configuration</label>
<%= if config_has_override?(openid_connect_providers) do %>
<i>
<p>
You cannot add new change providers because this value is overridden using <%= config_override_source(
openid_connect_providers
) %>.
</p>
</i>
<% end %>
<table class="table is-bordered is-hoverable is-striped is-fullwidth">
<thead>
<tr>
@@ -183,85 +241,109 @@
<th>Client ID</th>
<th>Discovery URI</th>
<th>Scope</th>
<th></th>
<%= unless config_has_override?(openid_connect_providers) do %>
<th></th>
<% end %>
</tr>
</thead>
<tbody>
<%= for {k, v} <- @oidc_configs do %>
<%= for provider <- config_value(openid_connect_providers) 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>
<%= live_patch(to: ~p"/settings/security/oidc/#{k}/edit",
<td><%= provider.id %></td>
<td><%= provider.label %></td>
<td><%= provider.client_id %></td>
<td><%= provider.discovery_document_uri %></td>
<td><%= provider.scope %></td>
<%= unless config_has_override?(openid_connect_providers) do %>
<td>
<%= live_patch(to: ~p"/settings/security/oidc/#{provider.id}/edit",
class: "button") do %>
Edit
<% end %>
<button
class="button is-danger"
phx-click="delete"
phx-value-key={k}
phx-value-type="oidc"
data-confirm={"Are you sure about deleting OIDC config #{k}?"}
>
Delete
</button>
</td>
Edit
<% end %>
<button
class="button is-danger"
phx-click="delete"
phx-value-key={provider.id}
phx-value-type="openid_connect_providers"
disabled={config_has_override?(openid_connect_providers)}
data-confirm={"Are you sure about deleting OIDC config #{provider.label}?"}
>
Delete
</button>
</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
<%= live_patch(
<%= unless config_has_override?(openid_connect_providers) do %>
<%= live_patch(
to: ~p"/settings/security/oidc/#{rand_string(8)}/edit",
class: "button mb-4") do %>
Add OpenID Connect Provider
Add OpenID Connect Provider
<% end %>
<% end %>
<label class="label">SAML identity providers configuration</label>
<%= if config_has_override?(saml_identity_providers) do %>
<p>
<i>
You cannot add new change providers because this value is overridden using <%= config_override_source(
saml_identity_providers
) %>.
</i>
</p>
<% end %>
<table class="table is-bordered is-hoverable is-striped is-fullwidth">
<thead>
<tr>
<th>Config ID</th>
<th>label</th>
<th>Metadata</th>
<th></th>
<%= unless config_has_override?(saml_identity_providers) do %>
<th></th>
<% end %>
</tr>
</thead>
<tbody>
<%= for {k, v} <- @saml_configs do %>
<%= for provider <- config_value(saml_identity_providers) do %>
<tr>
<td><%= k %></td>
<td><%= v.label %></td>
<td><%= provider.id %></td>
<td><%= provider.label %></td>
<td>
<div class="line-clamp"><%= v.metadata %></div>
<div class="line-clamp"><%= provider.metadata %></div>
</td>
<td>
<%= live_patch(to: ~p"/settings/security/saml/#{k}/edit",
<%= unless config_has_override?(saml_identity_providers) do %>
<td>
<%= live_patch(to: ~p"/settings/security/saml/#{provider.id}/edit",
class: "button") do %>
Edit
<% end %>
<button
class="button is-danger"
phx-click="delete"
phx-value-key={k}
phx-value-type="saml"
data-confirm={"Are you sure about deleting SAML config #{k}?"}
>
Delete
</button>
</td>
Edit
<% end %>
<button
class="button is-danger"
phx-click="delete"
phx-value-key={provider.id}
phx-value-type="saml_identity_providers"
disabled={config_has_override?(saml_identity_providers)}
data-confirm={"Are you sure about deleting SAML config #{provider.label}?"}
>
Delete
</button>
</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
<%= live_patch(
<%= unless config_has_override?(saml_identity_providers) do %>
<%= live_patch(
to: ~p"/settings/security/saml/#{rand_string(8)}/edit",
class: "button mb-4") do %>
Add SAML Identity Provider
Add SAML Identity Provider
<% end %>
<% end %>
</section>

View File

@@ -3,38 +3,36 @@ defmodule FzHttpWeb.SettingLive.Security do
Manages security LiveView
"""
use FzHttpWeb, :live_view
import Ecto.Changeset
import FzCommon.FzCrypto, only: [rand_string: 1]
alias FzHttp.Configurations
alias FzHttp.Config
@page_title "Security Settings"
@page_subtitle "Configure security-related settings."
@hour 3_600
@day 24 * @hour
@configs ~w[
local_auth_enabled
disable_vpn_on_oidc_error
allow_unprivileged_device_management
allow_unprivileged_device_configuration
vpn_session_duration
openid_connect_providers
saml_identity_providers
]a
@impl Phoenix.LiveView
def mount(_params, _session, socket) do
config_changeset = Configurations.change_configuration()
socket =
socket
|> assign(:page_title, @page_title)
|> assign(:page_subtitle, @page_subtitle)
|> assign(:form_changed, false)
|> assign(:configuration_changeset, configuration_changeset())
|> assign(:configs, FzHttp.Config.fetch_source_and_configs!(@configs))
{:ok,
socket
|> assign(:form_changed, false)
|> assign(:session_duration_options, session_duration_options())
|> assign(:configuration_changeset, configuration_changeset())
|> assign(:config_changeset, config_changeset)
|> 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)}
{:ok, socket}
end
@impl Phoenix.LiveView
@@ -43,104 +41,98 @@ defmodule FzHttpWeb.SettingLive.Security do
end
@impl Phoenix.LiveView
def handle_event("change", _params, socket) do
{:noreply,
socket
|> assign(:form_changed, true)}
def handle_event("change", %{"configuration" => attrs}, socket) do
changeset = configuration_changeset(attrs)
{:noreply, assign(socket, :form_changed, changeset.changes != %{})}
end
@impl Phoenix.LiveView
def handle_event(
"save_configuration",
%{"configuration" => %{"vpn_session_duration" => vpn_session_duration}},
socket
) do
configuration = Configurations.get_configuration!()
def handle_event("save_configuration", %{"configuration" => attrs}, socket) do
configuration = Config.fetch_db_config!()
case Configurations.update_configuration(configuration, %{
vpn_session_duration: vpn_session_duration
}) do
{:ok, configuration} ->
{:noreply,
socket
|> assign(:form_changed, false)
|> assign(:configuration_changeset, Configurations.change_configuration(configuration))}
socket =
case Config.update_config(configuration, attrs) do
{:ok, configuration} ->
socket
|> assign(:form_changed, false)
|> assign(:configuration_changeset, Config.change_config(configuration))
{:error, configuration_changeset} ->
{:noreply,
socket
|> assign(:configuration_changeset, configuration_changeset)}
end
end
{:error, configuration_changeset} ->
socket
|> assign(:configuration_changeset, configuration_changeset)
end
@impl Phoenix.LiveView
def handle_event("toggle", %{"config" => config} = params, socket) do
toggle_value = !!params["value"]
{:ok, _conf} = Configurations.update_configuration(%{config => toggle_value})
{:noreply, socket}
end
@types %{"oidc" => :openid_connect_providers, "saml" => :saml_identity_providers}
@impl Phoenix.LiveView
def handle_event("toggle", %{"config" => key} = params, socket) do
Config.put_config!(key, !!params["value"])
configs = FzHttp.Config.fetch_source_and_configs!(@configs)
{:noreply, assign(socket, :configs, configs)}
end
@impl Phoenix.LiveView
def handle_event("delete", %{"type" => type, "key" => key}, socket) do
field_key = Map.fetch!(@types, type)
field_key = String.to_existing_atom(type)
providers =
socket.assigns.config_changeset.data
Config.fetch_db_config!()
|> Map.fetch!(field_key)
|> Enum.reject(&(&1.id == key))
|> Enum.map(&Map.from_struct/1)
{:ok, configuration} = Configurations.update_configuration(%{field_key => providers})
Config.put_config!(field_key, providers)
configs = FzHttp.Config.fetch_source_and_configs!(@configs)
socket =
socket
|> assign(:config_changeset, Configurations.change_configuration(configuration))
|> assign(:oidc_configs, map_providers(configuration.openid_connect_providers))
|> assign(:saml_configs, map_providers(configuration.saml_identity_providers))
{:noreply, socket}
{:noreply, assign(socket, :configs, configs)}
end
@hour 3_600
@day 24 * @hour
def config_has_override?({{source, _source_key}, _key}), do: source not in [:db]
def config_has_override?({_source, _key}), do: false
def session_duration_options do
[
Never: 0,
Once: FzHttp.Configurations.Configuration.max_vpn_session_duration(),
"Every Hour": @hour,
"Every Day": @day,
"Every Week": 7 * @day,
"Every 30 Days": 30 * @day,
"Every 90 Days": 90 * @day
def config_value({_source, value}) do
value
end
def get_provider(providers, id) do
Enum.find(providers, &(&1.id == id))
end
def config_toggle_status({_source, value}) do
if(!value, do: "on")
end
def config_override_source({{:env, source_key}, _value}) do
"environment variable #{source_key}"
end
def session_duration_options(vpn_session_duration) do
options = [
{"Never", 0},
{"Once", FzHttp.Config.Configuration.Changeset.max_vpn_session_duration()},
{"Every Hour", @hour},
{"Every Day", @day},
{"Every Week", 7 * @day},
{"Every 30 Days", 30 * @day},
{"Every 90 Days", 90 * @day}
]
values = Enum.map(options, fn {_, value} -> value end)
if config_value(vpn_session_duration) in values do
options
else
options ++
[
{"Every #{config_value(vpn_session_duration)} seconds",
config_value(vpn_session_duration)}
]
end
end
defp configuration_changeset do
Configurations.get_configuration!()
|> Configurations.change_configuration()
end
@fields ~w(
local_auth_enabled
disable_vpn_on_oidc_error
allow_unprivileged_device_management
allow_unprivileged_device_configuration
openid_connect_providers
)a
@override_title """
This value is currently overriding the value set in your configuration file.
"""
defp field_titles(changeset) do
@fields
|> Map.new(fn key ->
if is_nil(get_field(changeset, key)) do
{key, ""}
else
{key, @override_title}
end
end)
defp configuration_changeset(attrs \\ %{}) do
Config.fetch_db_config!()
|> Config.change_config(attrs)
end
end

View File

@@ -49,7 +49,7 @@ defmodule FzHttpWeb.SettingLive.ShowApiTokenComponent do
<pre><code id="api-usage-example"><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>
<%= FzHttp.Config.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">

View File

@@ -23,7 +23,7 @@ defmodule FzHttpWeb.SettingLive.Unprivileged.Account do
socket =
socket
|> assign(:local_auth_enabled, FzHttp.Configurations.get!(:local_auth_enabled))
|> assign(:local_auth_enabled, FzHttp.Config.fetch_config!(:local_auth_enabled))
|> assign(:changeset, Users.change_user(socket.assigns.current_user))
|> assign(:methods, methods)
|> assign(:page_title, @page_title)

View File

@@ -5,7 +5,7 @@ defmodule FzHttpWeb.LiveHelpers do
https://bernheisel.com/blog/phoenix-liveview-and-views
"""
use Phoenix.Component
alias FzHttp.{Configurations, Users}
alias FzHttp.{Config, Users}
def live_modal(component, opts) do
path = Keyword.fetch!(opts, :return_to)
@@ -40,7 +40,7 @@ defmodule FzHttpWeb.LiveHelpers do
end
def vpn_sessions_expire? do
Configurations.vpn_sessions_expire?()
Config.vpn_sessions_expire?()
end
def vpn_expires_at(user) do
@@ -71,4 +71,11 @@ defmodule FzHttpWeb.LiveHelpers do
def render_changeset_errors(%Ecto.Changeset{} = changeset) do
%{changeset | action: :validate}
end
def list_value(form, field) do
case Phoenix.HTML.Form.input_value(form, field) do
value when is_list(value) -> Enum.join(value, ", ")
value -> value
end
end
end

View File

@@ -2,19 +2,8 @@ defmodule FzHttpWeb.Mailer do
@moduledoc """
Outbound Email Sender.
"""
use Swoosh.Mailer, otp_app: :fz_http
alias Swoosh.{Adapters, Email}
@provider_mapping %{
"smtp" => Adapters.SMTP,
"mailgun" => Adapters.Mailgun,
"mandrill" => Adapters.Mandrill,
"sendgrid" => Adapters.Sendgrid,
"post_mark" => Adapters.Postmark,
"sendmail" => Adapters.Sendmail
}
alias Swoosh.Email
def active? do
mailer_config = FzHttp.Config.fetch_env!(:fz_http, FzHttpWeb.Mailer)
@@ -30,15 +19,4 @@ defmodule FzHttpWeb.Mailer do
Email.new()
|> Email.from(from_email)
end
def from_configuration(%FzHttp.Configurations.Mailer{} = mailer) do
from_email = mailer.from
config = Map.fetch!(mailer.configs, mailer.provider)
adapter = Map.fetch!(@provider_mapping, mailer.provider)
[
from_email: from_email,
adapter: adapter
] ++ Enum.map(config, fn {k, v} -> {String.to_atom(k), v} end)
end
end

View File

@@ -4,7 +4,7 @@ defmodule FzHttpWeb.Plug.RequireLocalAuthentication do
def init(opts), do: opts
def call(conn, _opts) do
if FzHttp.Configurations.get!(:local_auth_enabled) do
if FzHttp.Config.fetch_config!(:local_auth_enabled) do
conn
else
conn

View File

@@ -3,9 +3,8 @@ defmodule FzHttpWeb.ProxyHeaders do
Loads proxy-related headers when it corresponds using runtime config
"""
alias FzHttpWeb.HeaderHelpers
@behaviour Plug
require Logger
@behaviour Plug
def init(opts), do: opts

View File

@@ -20,9 +20,7 @@
<div class="column is-two-thirds-tablet is-half-desktop is-one-third-widescreen">
<div class="block">
<div class="has-text-centered">
<%= FzHttpWeb.LogoComponent.render(
FzHttp.Configurations.get_configuration!().logo
) %>
<%= FzHttpWeb.LogoComponent.render(FzHttp.Config.fetch_config!(:logo)) %>
</div>
</div>
<%= @inner_content %>

View File

@@ -26,9 +26,7 @@
<div class="column">
<div class="block">
<div class="has-text-centered">
<%= FzHttpWeb.LogoComponent.render(
FzHttp.Configurations.get_configuration!().logo
) %>
<%= FzHttpWeb.LogoComponent.render(FzHttp.Config.fetch_config!(:logo)) %>
</div>
</div>

View File

@@ -51,22 +51,22 @@
<tr>
<td><strong>Received</strong></td>
<td><%= FzCommon.FzInteger.to_human_bytes(@device.rx_bytes) %></td>
<td><%= to_human_bytes(@device.rx_bytes) %></td>
</tr>
<tr>
<td><strong>Sent</strong></td>
<td><%= FzCommon.FzInteger.to_human_bytes(@device.tx_bytes) %></td>
<td><%= to_human_bytes(@device.tx_bytes) %></td>
</tr>
<tr>
<td><strong>Allowed IPs</strong></td>
<td><%= @allowed_ips || "None" %></td>
<td><%= list_to_string(@allowed_ips) || "None" %></td>
</tr>
<tr>
<td><strong>DNS Servers</strong></td>
<td><%= @dns || "None" %></td>
<td><%= list_to_string(@dns) || "None" %></td>
</tr>
<tr>

View File

@@ -43,8 +43,8 @@
</td>
<td class="code">
<%= FzCommon.FzInteger.to_human_bytes(device.rx_bytes) %> received <br />
<%= FzCommon.FzInteger.to_human_bytes(device.tx_bytes) %> sent
<%= to_human_bytes(device.rx_bytes) %> received <br />
<%= to_human_bytes(device.tx_bytes) %> sent
</td>
<td class="code"><%= device.public_key %></td>
<td

View File

@@ -2,8 +2,7 @@ defmodule FzHttpWeb.UserFromAuth do
@moduledoc """
Authenticates users.
"""
alias FzHttp.Users
alias FzHttp.{Auth, Users}
alias FzHttpWeb.Auth.HTML.Authentication
# Local auth
@@ -38,7 +37,7 @@ defmodule FzHttpWeb.UserFromAuth do
end
defp maybe_create_user(idp_field, provider_id, email) do
if FzHttp.Configurations.auto_create_users?(idp_field, provider_id) do
if Auth.auto_create_users?(idp_field, provider_id) do
Users.create_unprivileged_user(%{email: email})
else
{:error, "user not found and auto_create_users disabled"}

View File

@@ -1,12 +1,12 @@
defmodule FzHttpWeb.DeviceView do
use FzHttpWeb, :view
alias FzHttp.Config
def can_manage_devices?(user) do
has_role?(user, :admin) || FzHttp.Configurations.get!(:allow_unprivileged_device_management)
has_role?(user, :admin) || Config.fetch_config!(:allow_unprivileged_device_management)
end
def can_configure_devices?(user) do
has_role?(user, :admin) ||
FzHttp.Configurations.get!(:allow_unprivileged_device_configuration)
has_role?(user, :admin) || Config.fetch_config!(:allow_unprivileged_device_configuration)
end
end

View File

@@ -6,12 +6,12 @@ defmodule FzHttpWeb.JSON.DeviceView do
alias FzHttp.Devices
def render("index.json", %{devices: devices}) do
%{data: render_many(devices, __MODULE__, "device.json")}
def render("index.json", %{devices: devices, defaults: defaults}) do
%{data: render_many(devices, __MODULE__, "device.json", defaults: defaults)}
end
def render("show.json", %{device: device}) do
%{data: render_one(device, __MODULE__, "device.json")}
def render("show.json", %{device: device, defaults: defaults}) do
%{data: render_one(device, __MODULE__, "device.json", defaults: defaults)}
end
@keys_to_render ~w[
@@ -40,16 +40,16 @@ defmodule FzHttpWeb.JSON.DeviceView do
inserted_at
user_id
]a
def render("device.json", %{device: device}) do
def render("device.json", %{device: device, defaults: defaults}) do
Map.merge(
Map.take(device, @keys_to_render),
%{
server_public_key: Application.get_env(:fz_vpn, :wireguard_public_key),
endpoint: Devices.config(device, :endpoint),
allowed_ips: Devices.config(device, :allowed_ips),
dns: Devices.config(device, :dns),
persistent_keepalive: Devices.config(device, :persistent_keepalive),
mtu: Devices.config(device, :mtu)
endpoint: Devices.endpoint(device, defaults),
allowed_ips: Devices.allowed_ips(device, defaults),
dns: Devices.dns(device, defaults),
persistent_keepalive: Devices.persistent_keepalive(device, defaults),
mtu: Devices.mtu(device, defaults)
}
)
end

View File

@@ -1,4 +1,23 @@
defmodule FzHttpWeb.SharedView do
use FzHttpWeb, :view
import FzHttpWeb.Endpoint, only: [static_path: 1]
@byte_size_opts [
precision: 2,
delimiter: ""
]
def list_to_string(list, separator \\ ", ") do
case Enum.join(list, separator) do
"" -> nil
binary -> binary
end
end
def to_human_bytes(nil), do: to_human_bytes(0)
def to_human_bytes(bytes) when is_integer(bytes) do
FileSize.from_bytes(bytes, scale: :iec)
|> FileSize.format(@byte_size_opts)
end
end

View File

@@ -36,13 +36,12 @@ defmodule FzHttp.MixProject do
extra_applications: [
:logger,
:runtime_tools
],
registered: [:fz_http_server]
]
]
end
# Specifies which paths to compile per environment.
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(:test), do: ["test/support", "lib"]
defp elixirc_paths(_), do: ["lib"]
defp deps do
@@ -62,13 +61,12 @@ defmodule FzHttp.MixProject do
{:phoenix_live_reload, "~> 1.3", only: :dev},
{:phoenix_swoosh, "~> 1.0"},
{:gettext, "~> 0.18"},
{:file_size, "~> 3.0.1"},
# Ecto-related deps
{:postgrex, "~> 0.16"},
{:decimal, "~> 2.0"},
{:ecto_sql, "~> 3.7"},
{:ecto_network,
github: "firezone/ecto_network", ref: "7dfe65bcb6506fb0ed6050871b433f3f8b1c10cb"},
{:cloak, "~> 1.1"},
{:cloak_ecto, "~> 1.2"},
@@ -104,6 +102,7 @@ defmodule FzHttp.MixProject do
defp aliases do
[
"assets.build": ["cmd cd assets && yarn install --frozen-lockfile && node esbuild.js prod"],
"ecto.seed": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.setup": ["ecto.create", "ecto.migrate"],
"ecto.reset": ["ecto.drop", "ecto.setup"],

View File

@@ -1,19 +1,16 @@
defmodule FzHttp.Repo.Migrations.MoveCacheFallbacksToConfigurations do
use Ecto.Migration
alias FzCommon.FzString
def change do
local_auth_enabled = FzString.to_boolean(System.get_env("LOCAL_AUTH_ENABLED", "true"))
local_auth_enabled = to_boolean(System.get_env("LOCAL_AUTH_ENABLED", "true"))
disable_vpn_on_oidc_error =
FzString.to_boolean(System.get_env("DISABLE_VPN_ON_OIDC_ERROR", "false"))
disable_vpn_on_oidc_error = to_boolean(System.get_env("DISABLE_VPN_ON_OIDC_ERROR", "false"))
allow_unprivileged_device_management =
FzString.to_boolean(System.get_env("ALLOW_UNPRIVILEGED_DEVICE_MANAGEMENT", "true"))
to_boolean(System.get_env("ALLOW_UNPRIVILEGED_DEVICE_MANAGEMENT", "true"))
allow_unprivileged_device_configuration =
FzString.to_boolean(System.get_env("ALLOW_UNPRIVILEGED_DEVICE_CONFIGURATION", "true"))
to_boolean(System.get_env("ALLOW_UNPRIVILEGED_DEVICE_CONFIGURATION", "true"))
execute("""
UPDATE configurations
@@ -53,4 +50,20 @@ defmodule FzHttp.Repo.Migrations.MoveCacheFallbacksToConfigurations do
modify(:saml_identity_providers, :map, default: %{}, null: false)
end
end
def to_boolean(str) when is_binary(str) do
as_bool(String.downcase(str))
end
defp as_bool("true") do
true
end
defp as_bool("false") do
false
end
defp as_bool(unknown) do
raise "Unknown boolean: string #{unknown} not one of ['true', 'false']."
end
end

View File

@@ -0,0 +1,46 @@
defmodule FzHttp.Repo.Migrations.ChangeDnsAndAllowedIpsToInetArray do
use Ecto.Migration
def change do
rename(table(:configurations), :default_client_dns, to: :default_client_dns_string)
rename(table(:configurations), :default_client_allowed_ips,
to: :default_client_allowed_ips_string
)
rename(table(:devices), :dns, to: :dns_string)
rename(table(:devices), :allowed_ips, to: :allowed_ips_string)
alter table(:configurations) do
add(:default_client_dns, {:array, :string}, default: [])
add(:default_client_allowed_ips, {:array, :inet}, default: [])
end
alter table(:devices) do
add(:dns, {:array, :string}, default: [])
add(:allowed_ips, {:array, :inet}, default: [])
end
execute("""
UPDATE configurations
SET default_client_dns = string_to_array(default_client_dns_string, ','),
default_client_allowed_ips = string_to_array(default_client_allowed_ips_string, ',')::inet[]
""")
execute("""
UPDATE devices
SET dns = string_to_array(dns_string, ','),
allowed_ips = string_to_array(allowed_ips_string, ',')::inet[]
""")
alter table(:configurations) do
remove(:default_client_dns_string, :string)
remove(:default_client_allowed_ips_string, :string)
end
alter table(:devices) do
remove(:dns_string, :string)
remove(:allowed_ips_string, :string)
end
end
end

View File

@@ -32,8 +32,12 @@ alias FzHttp.{
preshared_key: "27eCDMVRVFfMVS5Rfnn9n7as4M6MemGY/oghmdrwX2E=",
public_key: "4Fo+SBnDJ6hi8qzPt3nWLwgjCVwvpjHL35qJeatKwEc=",
remote_ip: %Postgrex.INET{address: {127, 5, 0, 1}},
dns: "8.8.8.8,8.8.4.4",
allowed_ips: "0.0.0.0/0,::/0,1.1.1.1",
dns: ["8.8.8.8", "8.8.4.4"],
allowed_ips: [
%Postgrex.INET{address: {0, 0, 0, 0}, netmask: 0},
%Postgrex.INET{address: {0, 0, 0, 0, 0, 0, 0, 0}, netmask: 0},
%Postgrex.INET{address: {1, 1, 1, 1}}
],
use_default_allowed_ips: false,
use_default_dns: false,
rx_bytes: 123_917_823,
@@ -81,9 +85,12 @@ MFA.create_method(
public_key: "pSLWbPiQ2mKh26IG1dMFQQWuAstFJXV91dNk+olzEjA=",
mtu: 1280,
persistent_keepalive: 25,
allowed_ips: "0.0.0.0,::/0",
allowed_ips: [
%Postgrex.INET{address: {0, 0, 0, 0}, netmask: 0},
%Postgrex.INET{address: {0, 0, 0, 0, 0, 0, 0, 0}, netmask: 0}
],
endpoint: "elixir",
dns: "127.0.0.11",
dns: ["127.0.0.11"],
use_default_allowed_ips: false,
use_default_dns: false,
use_default_endpoint: false,
@@ -208,10 +215,18 @@ Rules.create_rule(%{
destination: "1.2.3.4"
})
FzHttp.Configurations.put!(:default_client_dns, "4.3.2.1,1.2.3.4")
FzHttp.Configurations.put!(:default_client_allowed_ips, "10.0.0.1/20,::/0,1.1.1.1")
FzHttp.Config.put_config!(:default_client_dns, ["4.3.2.1", "1.2.3.4"])
FzHttp.Configurations.put!(
FzHttp.Config.put_config!(
:default_client_allowed_ips,
[
%Postgrex.INET{address: {10, 0, 0, 1}, netmask: 20},
%Postgrex.INET{address: {0, 0, 0, 0, 0, 0, 0, 0}, netmask: 0},
%Postgrex.INET{address: {1, 1, 1, 1}}
]
)
FzHttp.Config.put_config!(
:openid_connect_providers,
[
%{

View File

@@ -1,5 +1,5 @@
defmodule FzHttp.ApiTokensTest do
use FzHttp.DataCase
use FzHttp.DataCase, async: true
alias FzHttp.ApiTokensFixtures
alias FzHttp.UsersFixtures
alias FzHttp.ApiTokens

View File

@@ -0,0 +1,83 @@
defmodule FzHttp.AuthTest do
use FzHttp.DataCase
import FzHttp.Auth
alias FzHttp.ConfigFixtures
describe "fetch_oidc_provider_config/1" do
test "returns error when provider does not exist" do
assert fetch_oidc_provider_config(Ecto.UUID.generate()) == {:error, :not_found}
assert fetch_oidc_provider_config("foo") == {:error, :not_found}
end
test "returns openid connect provider" do
{_bypass, [attrs]} = ConfigFixtures.start_openid_providers(["google"])
assert fetch_oidc_provider_config(attrs["id"]) ==
{:ok,
%{
client_id: attrs["client_id"],
client_secret: attrs["client_secret"],
discovery_document_uri: attrs["discovery_document_uri"],
redirect_uri: attrs["redirect_uri"],
response_type: attrs["response_type"],
scope: attrs["scope"]
}}
end
test "puts default redirect_uri" do
FzHttp.Config.put_env_override(:external_url, "http://foo.bar.com/")
{_bypass, [attrs]} =
ConfigFixtures.start_openid_providers(["google"], %{"redirect_uri" => nil})
assert fetch_oidc_provider_config(attrs["id"]) ==
{:ok,
%{
client_id: attrs["client_id"],
client_secret: attrs["client_secret"],
discovery_document_uri: attrs["discovery_document_uri"],
redirect_uri: "http://foo.bar.com//auth/oidc/google/callback/",
response_type: attrs["response_type"],
scope: attrs["scope"]
}}
end
end
describe "auto_create_users?/2" do
test "raises if provider_id not found" do
assert_raise(RuntimeError, "Unknown provider foobar", fn ->
auto_create_users?(:openid_connect_providers, "foobar")
end)
end
test "returns true if auto_create_users is true" do
ConfigFixtures.configuration(%{
saml_identity_providers: [
%{
"id" => "test",
"metadata" => ConfigFixtures.saml_metadata(),
"auto_create_users" => true,
"label" => "SAML"
}
]
})
assert auto_create_users?(:saml_identity_providers, "test")
end
test "returns false if auto_create_users is false" do
ConfigFixtures.configuration(%{
saml_identity_providers: [
%{
"id" => "test",
"metadata" => ConfigFixtures.saml_metadata(),
"auto_create_users" => false,
"label" => "SAML"
}
]
})
refute auto_create_users?(:saml_identity_providers, "test")
end
end
end

View File

@@ -0,0 +1,59 @@
defmodule FzHttp.Config.CasterTest do
use ExUnit.Case, async: true
import FzHttp.Config.Caster
describe "cast/2" do
test "casts a binary to an array of integers" do
assert cast("1,2,3", {:array, ",", :integer}) == {:ok, [1, 2, 3]}
end
test "casts a binary to an embed" do
assert cast(~s|{"foo": "bar"}|, :embed) == {:ok, %{"foo" => "bar"}}
end
test "casts a binary to an array of embeds" do
assert cast(~s|[{"foo": "bar"}]|, {:json_array, :embed}) == {:ok, [%{"foo" => "bar"}]}
end
test "casts a binary to a map" do
assert cast(~s|{"foo": "bar"}|, :map) == {:ok, %{"foo" => "bar"}}
end
test "casts a binary to boolean" do
assert cast("true", :boolean) == {:ok, true}
assert cast("false", :boolean) == {:ok, false}
assert cast("", :boolean) == {:ok, nil}
end
test "casts a binary to integer" do
assert cast("1", :integer) == {:ok, 1}
assert cast("12345", :integer) == {:ok, 12_345}
end
test "keeps original non-binary value even if doesn't match the type" do
assert cast(1, :integer) == {:ok, 1}
assert cast(1, :boolean) == {:ok, 1}
assert cast(1, {:array, ",", :integer}) == {:ok, 1}
assert cast(1, :embed) == {:ok, 1}
assert cast(1, {:json_array, :embed}) == {:ok, 1}
assert cast(1, :map) == {:ok, 1}
end
test "raises when integer is not valid" do
assert cast("invalid integer", :integer) == {:error, "cannot be cast to an integer"}
assert cast("123invalid integer", :integer) ==
{:error,
"cannot be cast to an integer, " <>
"got a reminder invalid integer after an integer value 123"}
end
test "raises when JSON is not valid" do
assert cast("invalid json", :embed) ==
{:error, %Jason.DecodeError{position: 0, token: nil, data: "invalid json"}}
assert cast("invalid json", {:json_array, :embed}) ==
{:error, %Jason.DecodeError{position: 0, token: nil, data: "invalid json"}}
end
end
end

View File

@@ -0,0 +1,93 @@
defmodule FzHttp.Config.DefinitionTest do
use ExUnit.Case, async: true
import FzHttp.Config.Definition
defmodule InvalidDefinitions do
use FzHttp.Config.Definition
defconfig(:required, Types.IP, foo: :bar)
end
defmodule Definitions do
use FzHttp.Config.Definition
defconfig(:required, Types.IP)
defconfig(:optional, Types.IP, default: "0.0.0.0")
defconfig(:with_legacy_key, :string, legacy_keys: [{:env, "FOO", "100.0"}])
defconfig(:with_validation, :integer,
changeset: fn changeset, key ->
Ecto.Changeset.validate_number(changeset, key,
greater_than_or_equal_to: 0,
less_than_or_equal_to: 2
)
end
)
defconfig(:sensitive, :string, sensitive: true)
defconfig(:with_dump, :map,
dump: fn value ->
for {k, v} <- value, do: {k, v}
end
)
end
describe "__using__/1" do
test "inserts a function which returns list of defined configs" do
assert Definitions.configs() == [
{Definitions, :with_dump},
{Definitions, :sensitive},
{Definitions, :with_validation},
{Definitions, :with_legacy_key},
{Definitions, :optional},
{Definitions, :required}
]
end
test "inserts a function which returns spec of a given config definition" do
assert Definitions.required() == {Types.IP, []}
assert Definitions.optional() == {Types.IP, default: "0.0.0.0"}
assert Definitions.with_legacy_key() == {:string, legacy_keys: [{:env, "FOO", "100.0"}]}
assert {:integer, changeset: _cb} = Definitions.with_validation()
assert InvalidDefinitions.required() == {Types.IP, [foo: :bar]}
end
test "inserts a function which returns definition doc" do
assert fetch_doc(FzHttp.Config.Definitions, :default_admin_email) ==
{:ok, "Primary administrator email.\n"}
assert fetch_doc(Foo, :bar) ==
{:error, :module_not_found}
end
end
describe "fetch_spec_and_opts!/2" do
test "returns spec and opts for a given config definition" do
assert fetch_spec_and_opts!(Definitions, :required) == {Types.IP, {[], [], [], []}}
assert fetch_spec_and_opts!(Definitions, :optional) ==
{Types.IP, {[default: "0.0.0.0"], [], [], []}}
assert fetch_spec_and_opts!(Definitions, :with_legacy_key) ==
{:string, {[legacy_keys: [{:env, "FOO", "100.0"}]], [], [], []}}
assert {:integer, {[], [{:changeset, _cb}], [], []}} =
fetch_spec_and_opts!(Definitions, :with_validation)
assert {:map, {[], [], [{:dump, _cb}], []}} = fetch_spec_and_opts!(Definitions, :with_dump)
assert {:string, {[], [], [], [sensitive: true]}} =
fetch_spec_and_opts!(Definitions, :sensitive)
end
test "raises on invalid opts" do
assert_raise RuntimeError, "unknown options [foo: :bar] for configuration :required", fn ->
fetch_spec_and_opts!(InvalidDefinitions, :required)
end
end
end
end

View File

@@ -0,0 +1,217 @@
defmodule FzHttp.Config.FetcherTest do
use ExUnit.Case, async: true
import FzHttp.Config.Fetcher
defmodule Test do
use FzHttp.Config.Definition
alias FzHttp.Types
defconfig(:required, Types.IP)
defconfig(:optional, Types.IP, default: "0.0.0.0")
defconfig(:optional_generated, Types.IP,
legacy_keys: [{:env, "OGID", "1.0"}],
default: fn -> "1.1.1.1" end
)
defconfig(:one_of, {:one_of, [:string, :integer]},
changeset: fn
:integer, changeset, key ->
Ecto.Changeset.validate_number(changeset, key,
greater_than_or_equal_to: 0,
less_than_or_equal_to: 2
)
:string, changeset, key ->
Ecto.Changeset.validate_inclusion(changeset, key, ~w[a b])
end
)
defconfig(:integer, :integer)
defconfig(:invalid_with_validation, :integer,
default: -1,
changeset: fn changeset, key ->
Ecto.Changeset.validate_number(changeset, key,
greater_than_or_equal_to: 0,
less_than_or_equal_to: 2
)
end
)
defconfig(:array, {:array, ",", :integer},
default: [1, 2, 3],
changeset: fn changeset, key ->
Ecto.Changeset.validate_number(changeset, key,
greater_than_or_equal_to: 0,
less_than_or_equal_to: 2
)
end
)
defconfig(:json_array, {:json_array, :map})
defconfig(:json, :map,
dump: fn value ->
for {k, v} <- value, do: {String.to_atom(k), v}
end
)
defconfig(:boolean, :boolean)
defconfig(:sensitive, :map, default: %{}, sensitive: true)
end
describe "fetch_source_and_config/4" do
test "returns error when required config is not set" do
assert fetch_source_and_config(Test, :required, %{}, %{}) ==
{:error,
{{nil, ["is required"]}, [module: Test, key: :required, source: :not_found]}}
end
test "does not allow to explicitly set required config to nil" do
assert fetch_source_and_config(Test, :required, %{required: nil}, %{}) ==
{:error,
{{nil, ["is required"]}, [module: Test, key: :required, source: :not_found]}}
assert fetch_source_and_config(Test, :required, %{}, %{"REQUIRED" => nil}) ==
{:error,
{{nil, ["is required"]}, [module: Test, key: :required, source: :not_found]}}
end
test "returns default value when config is not set" do
assert fetch_source_and_config(Test, :optional, %{}, %{}) ==
{:ok, :default, %Postgrex.INET{address: {0, 0, 0, 0}, netmask: nil}}
assert fetch_source_and_config(Test, :optional_generated, %{}, %{}) ==
{:ok, :default, %Postgrex.INET{address: {1, 1, 1, 1}, netmask: nil}}
end
test "returns error when resolved value is invalid" do
assert fetch_source_and_config(Test, :invalid_with_validation, %{}, %{}) ==
{:error,
{{-1, ["must be greater than or equal to 0"]},
[
module: __MODULE__.Test,
key: :invalid_with_validation,
source: :default
]}}
assert fetch_source_and_config(Test, :required, %{required: "a.b.c.d"}, %{}) ==
{:error,
{{"a.b.c.d", ["is invalid"]},
[
module: __MODULE__.Test,
key: :required,
source: {:db, :required}
]}}
assert fetch_source_and_config(Test, :one_of, %{one_of: :atom}, %{}) ==
{:error,
{{:atom, ["must be one of: string, integer"]},
[
module: __MODULE__.Test,
key: :one_of,
source: {:db, :one_of}
]}}
assert fetch_source_and_config(Test, :array, %{}, %{}) ==
{:error,
{[{3, ["must be less than or equal to 2"]}],
[module: __MODULE__.Test, key: :array, source: :default]}}
end
test "casts binary to appropriate data type" do
assert fetch_source_and_config(Test, :array, %{}, %{"ARRAY" => "0,1,2"}) ==
{:ok, {:env, "ARRAY"}, [0, 1, 2]}
json = Jason.encode!(%{foo: :bar})
assert fetch_source_and_config(Test, :json, %{}, %{"JSON" => json}) ==
{:ok, {:env, "JSON"}, foo: "bar"}
json = Jason.encode!([%{foo: :bar}])
assert fetch_source_and_config(Test, :json_array, %{}, %{"JSON_ARRAY" => json}) ==
{:ok, {:env, "JSON_ARRAY"}, [%{"foo" => "bar"}]}
assert fetch_source_and_config(Test, :optional, %{}, %{"OPTIONAL" => "127.0.0.1"}) ==
{:ok, {:env, "OPTIONAL"}, %Postgrex.INET{address: {127, 0, 0, 1}, netmask: nil}}
assert fetch_source_and_config(Test, :boolean, %{}, %{"BOOLEAN" => "true"}) ==
{:ok, {:env, "BOOLEAN"}, true}
end
test "applies dump function" do
json = Jason.encode!(%{foo: :bar})
assert fetch_source_and_config(Test, :json, %{}, %{"JSON" => json}) ==
{:ok, {:env, "JSON"}, foo: "bar"}
end
test "does not apply dump function on invalid values" do
assert fetch_source_and_config(Test, :json, %{}, %{"JSON" => "foo"}) ==
{:error,
{{"foo", ["unexpected byte at position 0: 0x66 (\"f\")"]},
[module: __MODULE__.Test, key: :json, source: {:env, "JSON"}]}}
end
test "returns error when type can't be casted" do
assert fetch_source_and_config(Test, :integer, %{}, %{"INTEGER" => "X"}) ==
{:error,
{{"X", ["cannot be cast to an integer"]},
[
module: __MODULE__.Test,
key: :integer,
source: {:env, "INTEGER"}
]}}
assert fetch_source_and_config(Test, :integer, %{}, %{"INTEGER" => "123a"}) ==
{:error,
{{"123a",
["cannot be cast to an integer, got a reminder a after an integer value 123"]},
[
module: __MODULE__.Test,
key: :integer,
source: {:env, "INTEGER"}
]}}
json = Jason.encode!(%{foo: :bar})
assert fetch_source_and_config(Test, :json, %{}, %{"JSON" => json}) ==
{:ok, {:env, "JSON"}, foo: "bar"}
json = Jason.encode!([%{foo: :bar}])
assert fetch_source_and_config(Test, :json_array, %{}, %{"JSON_ARRAY" => json}) ==
{:ok, {:env, "JSON_ARRAY"}, [%{"foo" => "bar"}]}
end
test "returns value for a given config using resolver precedence" do
key = :optional_generated
# Generated default value
assert fetch_source_and_config(Test, key, %{}, %{}) ==
{:ok, :default, %Postgrex.INET{address: {1, 1, 1, 1}}}
# DB value overrides default
db = %{optional_generated: "2.2.2.2"}
assert fetch_source_and_config(Test, key, db, %{}) ==
{:ok, {:db, key}, %Postgrex.INET{address: {2, 2, 2, 2}}}
# Legacy env overrides DB
env = %{"OGID" => "3.3.3.3"}
assert fetch_source_and_config(Test, key, db, env) ==
{:ok, {:env, "OGID"}, %Postgrex.INET{address: {3, 3, 3, 3}}}
# Env overrides legacy env
env = Map.merge(env, %{"OPTIONAL_GENERATED" => "4.4.4.4"})
assert fetch_source_and_config(Test, key, db, env) ==
{:ok, {:env, "OPTIONAL_GENERATED"}, %Postgrex.INET{address: {4, 4, 4, 4}}}
end
end
end

View File

@@ -0,0 +1,72 @@
defmodule FzHttp.Config.ResolverTest do
use ExUnit.Case, async: true
import FzHttp.Config.Resolver
describe "resolve/4" do
test "returns nil when variable is not found" do
env_configurations = %{}
db_configurations = %{}
assert resolve(:foo, env_configurations, db_configurations, []) == :error
end
test "returns default value when variable is not found" do
env_configurations = %{}
db_configurations = %{}
opts = [default: :foo]
assert resolve(:foo, env_configurations, db_configurations, opts) == {:ok, {:default, :foo}}
end
test "returns variable from system environment" do
env_configurations = %{"FOO" => "bar"}
db_configurations = %{}
assert resolve(:foo, env_configurations, db_configurations, []) ==
{:ok, {{:env, "FOO"}, "bar"}}
end
test "returns variable from system environment with legacy key" do
env_configurations = %{"FOO" => "bar"}
db_configurations = %{}
opts = [legacy_keys: [{:env, "FOO", "1.0"}]]
assert resolve(:bar, env_configurations, db_configurations, opts) ==
{:ok, {{:env, "FOO"}, "bar"}}
end
test "returns variable from database" do
env_configurations = %{}
db_configurations = %FzHttp.Config.Configuration{default_client_dns: "1.2.3.4"}
assert resolve(:default_client_dns, env_configurations, db_configurations, []) ==
{:ok, {{:db, :default_client_dns}, "1.2.3.4"}}
end
test "precedence" do
key = :my_key
env = %{"FOO" => "3.3.2.2"}
db = %{}
# `nil` by default
opts = []
assert resolve(key, env, db, opts) == :error
# `default` opt overrides `nil`
opts = [default: "8.8.4.4"]
assert resolve(key, env, db, opts) == {:ok, {:default, "8.8.4.4"}}
# DB value overrides default
db = %{my_key: "1.2.3.4"}
assert resolve(key, env, db, opts) == {:ok, {{:db, key}, "1.2.3.4"}}
# Legacy env overrides DB
opts = [legacy_keys: [{:env, "FOO", "1.0"}]]
assert resolve(key, env, db, opts) == {:ok, {{:env, "FOO"}, "3.3.2.2"}}
# Env overrides legacy env
env = Map.merge(env, %{"MY_KEY" => "2.7.2.8"})
assert resolve(key, env, db, opts) == {:ok, {{:env, "MY_KEY"}, "2.7.2.8"}}
end
end
end

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