mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
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:
@@ -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
|
||||
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -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
4
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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+$}, "")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
defmodule FzCommonTest do
|
||||
use ExUnit.Case
|
||||
doctest FzCommon
|
||||
|
||||
# XXX: Ensure command injection is NOT POSSIBLE
|
||||
end
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
43
apps/fz_http/lib/fz_http/auth.ex
Normal file
43
apps/fz_http/lib/fz_http/auth.ex
Normal 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
|
||||
@@ -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
|
||||
|
||||
56
apps/fz_http/lib/fz_http/config/caster.ex
Normal file
56
apps/fz_http/lib/fz_http/config/caster.ex
Normal 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
|
||||
45
apps/fz_http/lib/fz_http/config/configuration.ex
Normal file
45
apps/fz_http/lib/fz_http/config/configuration.ex
Normal 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
|
||||
70
apps/fz_http/lib/fz_http/config/configuration/changeset.ex
Normal file
70
apps/fz_http/lib/fz_http/config/configuration/changeset.ex
Normal 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
|
||||
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
defmodule FzHttp.Configurations.Configuration.SAMLIdentityProvider do
|
||||
defmodule FzHttp.Config.Configuration.SAMLIdentityProvider do
|
||||
@moduledoc """
|
||||
SAML Config virtual schema
|
||||
"""
|
||||
143
apps/fz_http/lib/fz_http/config/definition.ex
Normal file
143
apps/fz_http/lib/fz_http/config/definition.ex
Normal 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
|
||||
807
apps/fz_http/lib/fz_http/config/definitions.ex
Normal file
807
apps/fz_http/lib/fz_http/config/definitions.ex
Normal 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
|
||||
@@ -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
|
||||
131
apps/fz_http/lib/fz_http/config/errors.ex
Normal file
131
apps/fz_http/lib/fz_http/config/errors.ex
Normal 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
|
||||
57
apps/fz_http/lib/fz_http/config/fetcher.ex
Normal file
57
apps/fz_http/lib/fz_http/config/fetcher.ex
Normal 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
|
||||
48
apps/fz_http/lib/fz_http/config/logo.ex
Normal file
48
apps/fz_http/lib/fz_http/config/logo.ex
Normal 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
|
||||
144
apps/fz_http/lib/fz_http/config/resolver.ex
Normal file
144
apps/fz_http/lib/fz_http/config/resolver.ex
Normal 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
|
||||
169
apps/fz_http/lib/fz_http/config/validator.ex
Normal file
169
apps/fz_http/lib/fz_http/config/validator.ex
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
68
apps/fz_http/lib/fz_http/types/cidr.ex
Normal file
68
apps/fz_http/lib/fz_http/types/cidr.ex
Normal 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
|
||||
71
apps/fz_http/lib/fz_http/types/inet.ex
Normal file
71
apps/fz_http/lib/fz_http/types/inet.ex
Normal 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
|
||||
34
apps/fz_http/lib/fz_http/types/ip.ex
Normal file
34
apps/fz_http/lib/fz_http/types/ip.ex
Normal 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
|
||||
87
apps/fz_http/lib/fz_http/types/ip_port.ex
Normal file
87
apps/fz_http/lib/fz_http/types/ip_port.ex
Normal 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
|
||||
7
apps/fz_http/lib/fz_http/types/protocols.ex
Normal file
7
apps/fz_http/lib/fz_http/types/protocols.ex
Normal 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
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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 """
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 """
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
[
|
||||
%{
|
||||
|
||||
@@ -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
|
||||
|
||||
83
apps/fz_http/test/fz_http/auth_test.exs
Normal file
83
apps/fz_http/test/fz_http/auth_test.exs
Normal 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
|
||||
59
apps/fz_http/test/fz_http/config/caster_test.exs
Normal file
59
apps/fz_http/test/fz_http/config/caster_test.exs
Normal 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
|
||||
93
apps/fz_http/test/fz_http/config/definition_test.exs
Normal file
93
apps/fz_http/test/fz_http/config/definition_test.exs
Normal 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
|
||||
217
apps/fz_http/test/fz_http/config/fetcher_test.exs
Normal file
217
apps/fz_http/test/fz_http/config/fetcher_test.exs
Normal 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
|
||||
72
apps/fz_http/test/fz_http/config/resolver_test.exs
Normal file
72
apps/fz_http/test/fz_http/config/resolver_test.exs
Normal 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
Reference in New Issue
Block a user