nixos/firezone: remove provisioning (#529428)
This commit is contained in:
@@ -26,6 +26,8 @@
|
||||
|
||||
- `services.timesyncd.extraConfig` has been removed in favor of the structured [](#opt-services.timesyncd.settings.Time) option. Use `services.timesyncd.settings.Time` to set any `timesyncd.conf(5)` option directly. For example, replace `services.timesyncd.extraConfig = "PollIntervalMaxSec=180";` with `services.timesyncd.settings.Time.PollIntervalMaxSec = 180;`.
|
||||
|
||||
- `services.firezone.server.provision` has been removed due to it being unmaintanable. Remove all uses of provisioning and use the WebUI to configure firezone.
|
||||
|
||||
## Other Notable Changes {#sec-release-26.11-notable-changes}
|
||||
|
||||
<!-- To avoid merge conflicts, consider adding your item at an arbitrary place in the list instead. -->
|
||||
|
||||
@@ -1,709 +0,0 @@
|
||||
defmodule Provision do
|
||||
alias Domain.{Repo, Accounts, Auth, Actors, Resources, Tokens, Gateways, Relays, Policies}
|
||||
require Logger
|
||||
|
||||
# UUID Mapping handling
|
||||
defmodule UuidMapping do
|
||||
@mapping_file "provision-uuids.json"
|
||||
|
||||
# Loads the mapping from file
|
||||
def load do
|
||||
mappings = case File.read(@mapping_file) do
|
||||
{:ok, content} ->
|
||||
case Jason.decode(content) do
|
||||
{:ok, mapping} -> mapping
|
||||
_ -> %{"accounts" => %{}}
|
||||
end
|
||||
|
||||
_ -> %{"accounts" => %{}}
|
||||
end
|
||||
Process.put(:uuid_mappings, mappings)
|
||||
mappings
|
||||
end
|
||||
|
||||
# Saves the current mapping (defaulting to the one in the process dictionary)
|
||||
def save(mapping \\ Process.get(:uuid_mappings)) do
|
||||
File.write!(@mapping_file, Jason.encode!(mapping))
|
||||
end
|
||||
|
||||
# Retrieves the account-level mapping from a given mapping (or from Process)
|
||||
def get_account(mapping \\ Process.get(:uuid_mappings), account_slug) do
|
||||
get_in(mapping, ["accounts", account_slug]) || %{}
|
||||
end
|
||||
|
||||
# Retrieves the entity mapping for a specific account and type
|
||||
def get_entities(mapping \\ Process.get(:uuid_mappings), account_slug, type) do
|
||||
get_in(mapping, ["accounts", account_slug, type]) || %{}
|
||||
end
|
||||
|
||||
# Retrieves an entity mapping for a specific account, type and external_id
|
||||
def get_entity(mapping \\ Process.get(:uuid_mappings), account_slug, type, external_id) do
|
||||
get_in(mapping, ["accounts", account_slug, type, external_id])
|
||||
end
|
||||
|
||||
# Updates (or creates) the account UUID mapping and stores it in the process dictionary.
|
||||
def update_account(account_slug, uuid) do
|
||||
mapping = Process.get(:uuid_mappings) || load()
|
||||
mapping = ensure_account_exists(mapping, account_slug)
|
||||
mapping = put_in(mapping, ["accounts", account_slug, "id"], uuid)
|
||||
Process.put(:uuid_mappings, mapping)
|
||||
mapping
|
||||
end
|
||||
|
||||
# Ensures that the given account exists in the mapping.
|
||||
def ensure_account_exists(mapping, account_slug) do
|
||||
if not Map.has_key?(mapping["accounts"], account_slug) do
|
||||
put_in(mapping, ["accounts", account_slug], %{})
|
||||
else
|
||||
mapping
|
||||
end
|
||||
end
|
||||
|
||||
# Updates (or creates) the mapping for entities of a given type for the account.
|
||||
def update_entities(account_slug, type, new_entries) do
|
||||
mapping = Process.get(:uuid_mappings) || load()
|
||||
mapping = ensure_account_exists(mapping, account_slug)
|
||||
current = get_entities(mapping, account_slug, type)
|
||||
mapping = put_in(mapping, ["accounts", account_slug, type], Map.merge(current, new_entries))
|
||||
Process.put(:uuid_mappings, mapping)
|
||||
mapping
|
||||
end
|
||||
|
||||
# Removes an entire account from the mapping.
|
||||
def remove_account(account_slug) do
|
||||
mapping = Process.get(:uuid_mappings) || load()
|
||||
mapping = update_in(mapping, ["accounts"], fn accounts ->
|
||||
Map.delete(accounts, account_slug)
|
||||
end)
|
||||
Process.put(:uuid_mappings, mapping)
|
||||
mapping
|
||||
end
|
||||
|
||||
# Removes a specific entity mapping for the account.
|
||||
def remove_entity(account_slug, type, key) do
|
||||
mapping = Process.get(:uuid_mappings) || load()
|
||||
mapping = update_in(mapping, ["accounts", account_slug, type], fn entities ->
|
||||
Map.delete(entities || %{}, key)
|
||||
end)
|
||||
Process.put(:uuid_mappings, mapping)
|
||||
mapping
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_references(value) when is_map(value) do
|
||||
Enum.into(value, %{}, fn {k, v} -> {k, resolve_references(v)} end)
|
||||
end
|
||||
|
||||
defp resolve_references(value) when is_list(value) do
|
||||
Enum.map(value, &resolve_references/1)
|
||||
end
|
||||
|
||||
defp resolve_references(value) when is_binary(value) do
|
||||
Regex.replace(~r/\{env:([^}]+)\}/, value, fn _, var ->
|
||||
System.get_env(var) || raise "Environment variable #{var} not set"
|
||||
end)
|
||||
end
|
||||
|
||||
defp resolve_references(value), do: value
|
||||
|
||||
defp atomize_keys(map) when is_map(map) do
|
||||
Enum.into(map, %{}, fn {k, v} ->
|
||||
{
|
||||
if(is_binary(k), do: String.to_atom(k), else: k),
|
||||
if(is_map(v), do: atomize_keys(v), else: v)
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
defp cleanup_account(uuid) do
|
||||
case Accounts.fetch_account_by_id_or_slug(uuid) do
|
||||
{:ok, value} when value.deleted_at == nil ->
|
||||
Logger.info("Deleting removed account #{value.slug}")
|
||||
value |> Ecto.Changeset.change(%{ deleted_at: DateTime.utc_now() }) |> Repo.update!()
|
||||
_ -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp cleanup_actor(uuid, subject) do
|
||||
case Actors.fetch_actor_by_id(uuid, subject) do
|
||||
{:ok, value} ->
|
||||
Logger.info("Deleting removed actor #{value.name}")
|
||||
{:ok, _} = Actors.delete_actor(value, subject)
|
||||
_ -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp cleanup_provider(uuid, subject) do
|
||||
case Auth.fetch_provider_by_id(uuid, subject) do
|
||||
{:ok, value} ->
|
||||
Logger.info("Deleting removed provider #{value.name}")
|
||||
{:ok, _} = Auth.delete_provider(value, subject)
|
||||
_ -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp cleanup_gateway_group(uuid, subject) do
|
||||
case Gateways.fetch_group_by_id(uuid, subject) do
|
||||
{:ok, value} ->
|
||||
Logger.info("Deleting removed gateway group #{value.name}")
|
||||
{:ok, _} = Gateways.delete_group(value, subject)
|
||||
_ -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp cleanup_relay_group(uuid, subject) do
|
||||
case Relays.fetch_group_by_id(uuid, subject) do
|
||||
{:ok, value} ->
|
||||
Logger.info("Deleting removed relay group #{value.name}")
|
||||
{:ok, _} = Relays.delete_group(value, subject)
|
||||
_ -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp cleanup_actor_group(uuid, subject) do
|
||||
case Actors.fetch_group_by_id(uuid, subject) do
|
||||
{:ok, value} ->
|
||||
Logger.info("Deleting removed actor group #{value.name}")
|
||||
{:ok, _} = Actors.delete_group(value, subject)
|
||||
_ -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
# Fetch resource by uuid, but follow the chain of replacements if any
|
||||
defp fetch_resource(uuid, subject) do
|
||||
case Resources.fetch_resource_by_id(uuid, subject) do
|
||||
{:ok, resource} when resource.replaced_by_resource_id != nil -> fetch_resource(resource.replaced_by_resource_id, subject)
|
||||
v -> v
|
||||
end
|
||||
end
|
||||
|
||||
defp cleanup_resource(uuid, subject) do
|
||||
case fetch_resource(uuid, subject) do
|
||||
{:ok, value} when value.deleted_at == nil ->
|
||||
Logger.info("Deleting removed resource #{value.name}")
|
||||
{:ok, _} = Resources.delete_resource(value, subject)
|
||||
_ -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
# Fetch policy by uuid, but follow the chain of replacements if any
|
||||
defp fetch_policy(uuid, subject) do
|
||||
case Policies.fetch_policy_by_id(uuid, subject) do
|
||||
{:ok, policy} when policy.replaced_by_policy_id != nil -> fetch_policy(policy.replaced_by_policy_id, subject)
|
||||
v -> v
|
||||
end
|
||||
end
|
||||
|
||||
defp cleanup_policy(uuid, subject) do
|
||||
case fetch_policy(uuid, subject) do
|
||||
{:ok, value} when value.deleted_at == nil ->
|
||||
Logger.info("Deleting removed policy #{value.description}")
|
||||
{:ok, _} = Policies.delete_policy(value, subject)
|
||||
_ -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp cleanup_entity_type(account_slug, entity_type, cleanup_fn, temp_admin_subject) do
|
||||
# Get mapping for this entity type
|
||||
existing_entities = UuidMapping.get_entities(account_slug, entity_type)
|
||||
# Get current entities from account data
|
||||
current_entities = Process.get(:current_entities)
|
||||
# Determine which ones to remove
|
||||
removed_entity_ids = Map.keys(existing_entities) -- (current_entities[entity_type] || [])
|
||||
|
||||
# Process each entity to remove
|
||||
Enum.each(removed_entity_ids, fn entity_id ->
|
||||
case existing_entities[entity_id] do
|
||||
nil -> :ok
|
||||
uuid ->
|
||||
cleanup_fn.(uuid, temp_admin_subject)
|
||||
UuidMapping.remove_entity(account_slug, entity_type, entity_id)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp collect_current_entities(account_data) do
|
||||
%{
|
||||
"actors" => Map.keys(account_data["actors"] || %{}),
|
||||
"providers" => Map.keys(account_data["auth"] || %{}),
|
||||
"gateway_groups" => Map.keys(account_data["gatewayGroups"] || %{}),
|
||||
"relay_groups" => Map.keys(account_data["relayGroups"] || %{}),
|
||||
"actor_groups" => Map.keys(account_data["groups"] || %{}) ++ ["everyone"],
|
||||
"resources" => Map.keys(account_data["resources"] || %{}),
|
||||
"policies" => Map.keys(account_data["policies"] || %{})
|
||||
}
|
||||
end
|
||||
|
||||
defp nil_if_deleted_or_not_found(value) do
|
||||
case value do
|
||||
nil -> nil
|
||||
{:error, :not_found} -> nil
|
||||
{:ok, value} when value.deleted_at != nil -> nil
|
||||
v -> v
|
||||
end
|
||||
end
|
||||
|
||||
defp create_temp_admin(account, email_provider) do
|
||||
temp_admin_actor_email = "firezone-provision@localhost.local"
|
||||
temp_admin_actor_context = %Auth.Context{
|
||||
type: :browser,
|
||||
user_agent: "Unspecified/0.0",
|
||||
remote_ip: {127, 0, 0, 1},
|
||||
remote_ip_location_region: "N/A",
|
||||
remote_ip_location_city: "N/A",
|
||||
remote_ip_location_lat: 0.0,
|
||||
remote_ip_location_lon: 0.0
|
||||
}
|
||||
|
||||
{:ok, temp_admin_actor} =
|
||||
Actors.create_actor(account, %{
|
||||
type: :account_admin_user,
|
||||
name: "Provisioning"
|
||||
})
|
||||
|
||||
{:ok, temp_admin_actor_email_identity} =
|
||||
Auth.create_identity(temp_admin_actor, email_provider, %{
|
||||
provider_identifier: temp_admin_actor_email,
|
||||
provider_identifier_confirmation: temp_admin_actor_email
|
||||
})
|
||||
|
||||
{:ok, temp_admin_actor_token} =
|
||||
Auth.create_token(temp_admin_actor_email_identity, temp_admin_actor_context, "temporarynonce", DateTime.utc_now() |> DateTime.add(1, :hour))
|
||||
|
||||
{:ok, temp_admin_subject} =
|
||||
Auth.build_subject(temp_admin_actor_token, temp_admin_actor_context)
|
||||
|
||||
{temp_admin_subject, temp_admin_actor, temp_admin_actor_email_identity, temp_admin_actor_token}
|
||||
end
|
||||
|
||||
defp cleanup_temp_admin(temp_admin_actor, temp_admin_actor_email_identity, temp_admin_actor_token, subject) do
|
||||
Logger.info("Cleaning up temporary admin actor")
|
||||
{:ok, _} = Tokens.delete_token(temp_admin_actor_token, subject)
|
||||
{:ok, _} = Auth.delete_identity(temp_admin_actor_email_identity, subject)
|
||||
{:ok, _} = Actors.delete_actor(temp_admin_actor, subject)
|
||||
end
|
||||
|
||||
def provision() do
|
||||
Logger.info("Starting provisioning")
|
||||
|
||||
# Load desired state
|
||||
json_file = "provision-state.json"
|
||||
{:ok, raw_json} = File.read(json_file)
|
||||
{:ok, %{"accounts" => accounts}} = Jason.decode(raw_json)
|
||||
accounts = resolve_references(accounts)
|
||||
|
||||
# Load existing UUID mappings into the process dictionary.
|
||||
UuidMapping.load()
|
||||
|
||||
# Clean up removed accounts first
|
||||
current_account_slugs = Map.keys(accounts)
|
||||
existing_accounts = Map.keys(Process.get(:uuid_mappings)["accounts"])
|
||||
removed_accounts = existing_accounts -- current_account_slugs
|
||||
|
||||
Enum.each(removed_accounts, fn slug ->
|
||||
if uuid = get_in(Process.get(:uuid_mappings), ["accounts", slug, "id"]) do
|
||||
cleanup_account(uuid)
|
||||
# Remove the account from the UUID mapping.
|
||||
UuidMapping.remove_account(slug)
|
||||
end
|
||||
end)
|
||||
|
||||
multi = Enum.reduce(accounts, Ecto.Multi.new(), fn {slug, account_data}, multi ->
|
||||
account_attrs = atomize_keys(%{
|
||||
name: account_data["name"],
|
||||
slug: slug,
|
||||
features: Map.get(account_data, "features", %{}),
|
||||
metadata: Map.get(account_data, "metadata", %{}),
|
||||
limits: Map.get(account_data, "limits", %{})
|
||||
})
|
||||
|
||||
multi = multi
|
||||
|> Ecto.Multi.run({:account, slug}, fn repo, _changes ->
|
||||
case Accounts.fetch_account_by_id_or_slug(slug) do
|
||||
{:ok, acc} ->
|
||||
Logger.info("Updating existing account #{slug}")
|
||||
updated_acc = acc |> Ecto.Changeset.change(account_attrs) |> repo.update!()
|
||||
{:ok, {:existing, updated_acc}}
|
||||
_ ->
|
||||
Logger.info("Creating new account #{slug}")
|
||||
{:ok, account} = Accounts.create_account(account_attrs)
|
||||
|
||||
Logger.info("Creating internet gateway group")
|
||||
{:ok, internet_site} = Gateways.create_internet_group(account)
|
||||
|
||||
Logger.info("Creating internet resource")
|
||||
{:ok, _internet_resource} = Resources.create_internet_resource(account, internet_site)
|
||||
|
||||
# Store mapping of slug to UUID
|
||||
UuidMapping.update_account(slug, account.id)
|
||||
{:ok, {:new, account}}
|
||||
end
|
||||
end)
|
||||
|> Ecto.Multi.run({:everyone_group, slug}, fn _repo, changes ->
|
||||
case Map.get(changes, {:account, slug}) do
|
||||
{:new, account} ->
|
||||
Logger.info("Creating everyone group for new account")
|
||||
{:ok, actor_group} = Actors.create_managed_group(account, %{name: "Everyone"})
|
||||
UuidMapping.update_entities(slug, "actor_groups", %{"everyone" => actor_group.id})
|
||||
{:ok, actor_group}
|
||||
{:existing, _account} ->
|
||||
{:ok, :skipped}
|
||||
end
|
||||
end)
|
||||
|> Ecto.Multi.run({:email_provider, slug}, fn _repo, changes ->
|
||||
case Map.get(changes, {:account, slug}) do
|
||||
{:new, account} ->
|
||||
Logger.info("Creating default email provider for new account")
|
||||
Auth.create_provider(account, %{name: "Email", adapter: :email, adapter_config: %{}})
|
||||
{:existing, account} ->
|
||||
Auth.Provider.Query.not_disabled()
|
||||
|> Auth.Provider.Query.by_adapter(:email)
|
||||
|> Auth.Provider.Query.by_account_id(account.id)
|
||||
|> Repo.fetch(Auth.Provider.Query, [])
|
||||
end
|
||||
end)
|
||||
|> Ecto.Multi.run({:temp_admin, slug}, fn _repo, changes ->
|
||||
{_, account} = changes[{:account, slug}]
|
||||
email_provider = changes[{:email_provider, slug}]
|
||||
{:ok, create_temp_admin(account, email_provider)}
|
||||
end)
|
||||
|
||||
# Clean up removed entities for this account after we have an admin subject
|
||||
multi = multi
|
||||
|> Ecto.Multi.run({:cleanup_entities, slug}, fn _repo, changes ->
|
||||
{temp_admin_subject, _, _, _} = changes[{:temp_admin, slug}]
|
||||
|
||||
# Store current entities in process dictionary for our helper function
|
||||
current_entities = collect_current_entities(account_data)
|
||||
Process.put(:current_entities, current_entities)
|
||||
|
||||
# Define entity types and their cleanup functions
|
||||
entity_types = [
|
||||
{"actors", &cleanup_actor/2},
|
||||
{"providers", &cleanup_provider/2},
|
||||
{"gateway_groups", &cleanup_gateway_group/2},
|
||||
{"relay_groups", &cleanup_relay_group/2},
|
||||
{"actor_groups", &cleanup_actor_group/2},
|
||||
{"resources", &cleanup_resource/2},
|
||||
{"policies", &cleanup_policy/2}
|
||||
]
|
||||
|
||||
# Clean up each entity type
|
||||
Enum.each(entity_types, fn {entity_type, cleanup_fn} ->
|
||||
cleanup_entity_type(slug, entity_type, cleanup_fn, temp_admin_subject)
|
||||
end)
|
||||
|
||||
{:ok, :cleaned}
|
||||
end)
|
||||
|
||||
# Create or update actors
|
||||
multi = Enum.reduce(account_data["actors"] || %{}, multi, fn {external_id, actor_data}, multi ->
|
||||
actor_attrs = atomize_keys(%{
|
||||
name: actor_data["name"],
|
||||
type: String.to_atom(actor_data["type"])
|
||||
})
|
||||
|
||||
Ecto.Multi.run(multi, {:actor, slug, external_id}, fn _repo, changes ->
|
||||
{_, account} = changes[{:account, slug}]
|
||||
{temp_admin_subject, _, _, _} = changes[{:temp_admin, slug}]
|
||||
uuid = UuidMapping.get_entity(slug, "actors", external_id)
|
||||
case uuid && Actors.fetch_actor_by_id(uuid, temp_admin_subject) |> nil_if_deleted_or_not_found() do
|
||||
nil ->
|
||||
Logger.info("Creating new actor #{actor_data["name"]}")
|
||||
{:ok, actor} = Actors.create_actor(account, actor_attrs)
|
||||
# Update the mapping without manually handling Process.get/put.
|
||||
UuidMapping.update_entities(slug, "actors", %{external_id => actor.id})
|
||||
{:ok, {:new, actor}}
|
||||
{:ok, existing_actor} ->
|
||||
Logger.info("Updating existing actor #{actor_data["name"]}")
|
||||
{:ok, updated_act} = Actors.update_actor(existing_actor, actor_attrs, temp_admin_subject)
|
||||
{:ok, {:existing, updated_act}}
|
||||
end
|
||||
end)
|
||||
|> Ecto.Multi.run({:actor_identity, slug, external_id}, fn repo, changes ->
|
||||
email_provider = changes[{:email_provider, slug}]
|
||||
case Map.get(changes, {:actor, slug, external_id}) do
|
||||
{:new, actor} ->
|
||||
Logger.info("Creating actor email identity")
|
||||
Auth.create_identity(actor, email_provider, %{
|
||||
provider_identifier: actor_data["email"],
|
||||
provider_identifier_confirmation: actor_data["email"]
|
||||
})
|
||||
{:existing, actor} ->
|
||||
Logger.info("Updating actor email identity")
|
||||
{:ok, identity} = Auth.Identity.Query.not_deleted()
|
||||
|> Auth.Identity.Query.by_actor_id(actor.id)
|
||||
|> Auth.Identity.Query.by_provider_id(email_provider.id)
|
||||
|> Repo.fetch(Auth.Identity.Query, [])
|
||||
|
||||
{:ok, identity |> Ecto.Changeset.change(%{
|
||||
provider_identifier: actor_data["email"]
|
||||
}) |> repo.update!()}
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
# Create or update providers
|
||||
multi = Enum.reduce(account_data["auth"] || %{}, multi, fn {external_id, provider_data}, multi ->
|
||||
Ecto.Multi.run(multi, {:provider, slug, external_id}, fn repo, changes ->
|
||||
provider_attrs = %{
|
||||
name: provider_data["name"],
|
||||
adapter: String.to_atom(provider_data["adapter"]),
|
||||
adapter_config: provider_data["adapter_config"]
|
||||
}
|
||||
|
||||
{_, account} = changes[{:account, slug}]
|
||||
{temp_admin_subject, _, _, _} = changes[{:temp_admin, slug}]
|
||||
uuid = UuidMapping.get_entity(slug, "providers", external_id)
|
||||
case uuid && Auth.fetch_provider_by_id(uuid, temp_admin_subject) |> nil_if_deleted_or_not_found() do
|
||||
nil ->
|
||||
Logger.info("Creating new provider #{provider_data["name"]}")
|
||||
{:ok, provider} = Auth.create_provider(account, provider_attrs)
|
||||
UuidMapping.update_entities(slug, "providers", %{external_id => provider.id})
|
||||
{:ok, provider}
|
||||
{:ok, existing} ->
|
||||
Logger.info("Updating existing provider #{provider_data["name"]}")
|
||||
{:ok, existing |> Ecto.Changeset.change(provider_attrs) |> repo.update!()}
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
# Create or update gateway_groups
|
||||
multi = Enum.reduce(account_data["gatewayGroups"] || %{}, multi, fn {external_id, gateway_group_data}, multi ->
|
||||
Ecto.Multi.run(multi, {:gateway_group, slug, external_id}, fn _repo, changes ->
|
||||
gateway_group_attrs = %{
|
||||
name: gateway_group_data["name"],
|
||||
tokens: [%{}]
|
||||
}
|
||||
|
||||
{_, account} = changes[{:account, slug}]
|
||||
{temp_admin_subject, _, _, _} = changes[{:temp_admin, slug}]
|
||||
uuid = UuidMapping.get_entity(slug, "gateway_groups", external_id)
|
||||
case uuid && Gateways.fetch_group_by_id(uuid, temp_admin_subject) |> nil_if_deleted_or_not_found() do
|
||||
nil ->
|
||||
Logger.info("Creating new gateway group #{gateway_group_data["name"]}")
|
||||
gateway_group = account
|
||||
|> Gateways.Group.Changeset.create(gateway_group_attrs, temp_admin_subject)
|
||||
|> Repo.insert!()
|
||||
UuidMapping.update_entities(slug, "gateway_groups", %{external_id => gateway_group.id})
|
||||
{:ok, gateway_group}
|
||||
{:ok, existing} ->
|
||||
# Nothing to update
|
||||
{:ok, existing}
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
# Create or update relay_groups
|
||||
multi = Enum.reduce(account_data["relayGroups"] || %{}, multi, fn {external_id, relay_group_data}, multi ->
|
||||
Ecto.Multi.run(multi, {:relay_group, slug, external_id}, fn _repo, changes ->
|
||||
relay_group_attrs = %{
|
||||
name: relay_group_data["name"]
|
||||
}
|
||||
|
||||
{temp_admin_subject, _, _, _} = changes[{:temp_admin, slug}]
|
||||
uuid = UuidMapping.get_entity(slug, "relay_groups", external_id)
|
||||
existing_relay_group = uuid && Relays.fetch_group_by_id(uuid, temp_admin_subject)
|
||||
case existing_relay_group do
|
||||
v when v in [nil, {:error, :not_found}] ->
|
||||
Logger.info("Creating new relay group #{relay_group_data["name"]}")
|
||||
{:ok, relay_group} = Relays.create_group(relay_group_attrs, temp_admin_subject)
|
||||
UuidMapping.update_entities(slug, "relay_groups", %{external_id => relay_group.id})
|
||||
{:ok, relay_group}
|
||||
{:ok, existing} ->
|
||||
# Nothing to update
|
||||
{:ok, existing}
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
# Create or update actor_groups
|
||||
multi = Enum.reduce(account_data["groups"] || %{}, multi, fn {external_id, actor_group_data}, multi ->
|
||||
Ecto.Multi.run(multi, {:actor_group, slug, external_id}, fn _repo, changes ->
|
||||
actor_group_attrs = %{
|
||||
name: actor_group_data["name"],
|
||||
type: :static
|
||||
}
|
||||
|
||||
{temp_admin_subject, _, _, _} = changes[{:temp_admin, slug}]
|
||||
uuid = UuidMapping.get_entity(slug, "actor_groups", external_id)
|
||||
case uuid && Actors.fetch_group_by_id(uuid, temp_admin_subject) |> nil_if_deleted_or_not_found() do
|
||||
nil ->
|
||||
Logger.info("Creating new actor group #{actor_group_data["name"]}")
|
||||
{:ok, actor_group} = Actors.create_group(actor_group_attrs, temp_admin_subject)
|
||||
UuidMapping.update_entities(slug, "actor_groups", %{external_id => actor_group.id})
|
||||
{:ok, actor_group}
|
||||
{:ok, existing} ->
|
||||
# Nothing to update
|
||||
{:ok, existing}
|
||||
end
|
||||
end)
|
||||
|> Ecto.Multi.run({:actor_group_members, slug, external_id}, fn repo, changes ->
|
||||
{_, account} = changes[{:account, slug}]
|
||||
group_uuid = UuidMapping.get_entity(slug, "actor_groups", external_id)
|
||||
|
||||
memberships =
|
||||
Actors.Membership.Query.all()
|
||||
|> Actors.Membership.Query.by_group_id(group_uuid)
|
||||
|> Actors.Membership.Query.returning_all()
|
||||
|> Repo.all()
|
||||
|
||||
existing_members = Enum.map(memberships, fn membership -> membership.actor_id end)
|
||||
desired_members = Enum.map(actor_group_data["members"] || [], fn member ->
|
||||
uuid = UuidMapping.get_entity(slug, "actors", member)
|
||||
if uuid == nil do
|
||||
raise "Cannot find provisioned actor #{member} to add to group"
|
||||
end
|
||||
uuid
|
||||
end)
|
||||
|
||||
missing_members = desired_members -- existing_members
|
||||
untracked_members = existing_members -- desired_members
|
||||
|
||||
Logger.info("Updating members for actor group #{external_id}")
|
||||
Enum.each(missing_members || [], fn actor_uuid ->
|
||||
Logger.info("Adding member #{external_id}")
|
||||
Actors.Membership.Changeset.upsert(account.id, %Actors.Membership{}, %{
|
||||
group_id: group_uuid,
|
||||
actor_id: actor_uuid
|
||||
})
|
||||
|> repo.insert!()
|
||||
end)
|
||||
|
||||
if actor_group_data["forceMembers"] == true do
|
||||
# Remove untracked members
|
||||
to_delete = Enum.map(untracked_members, fn actor_uuid -> {group_uuid, actor_uuid} end)
|
||||
if to_delete != [] do
|
||||
Actors.Membership.Query.by_group_id_and_actor_id({:in, to_delete})
|
||||
|> repo.delete_all()
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, nil}
|
||||
end)
|
||||
end)
|
||||
|
||||
# Create or update resources
|
||||
multi = Enum.reduce(account_data["resources"] || %{}, multi, fn {external_id, resource_data}, multi ->
|
||||
Ecto.Multi.run(multi, {:resource, slug, external_id}, fn _repo, changes ->
|
||||
resource_attrs = %{
|
||||
type: String.to_atom(resource_data["type"]),
|
||||
name: resource_data["name"],
|
||||
address: resource_data["address"],
|
||||
address_description: resource_data["address_description"],
|
||||
connections: Enum.map(resource_data["gatewayGroups"] || [], fn group ->
|
||||
%{gateway_group_id: UuidMapping.get_entity(slug, "gateway_groups", group)}
|
||||
end),
|
||||
filters: Enum.map(resource_data["filters"] || [], fn filter ->
|
||||
%{
|
||||
ports: filter["ports"] || [],
|
||||
protocol: String.to_atom(filter["protocol"])
|
||||
}
|
||||
end)
|
||||
}
|
||||
|
||||
{temp_admin_subject, _, _, _} = changes[{:temp_admin, slug}]
|
||||
uuid = UuidMapping.get_entity(slug, "resources", external_id)
|
||||
case uuid && fetch_resource(uuid, temp_admin_subject) |> nil_if_deleted_or_not_found() do
|
||||
nil ->
|
||||
Logger.info("Creating new resource #{resource_data["name"]}")
|
||||
{:ok, resource} = Resources.create_resource(resource_attrs, temp_admin_subject)
|
||||
UuidMapping.update_entities(slug, "resources", %{external_id => resource.id})
|
||||
{:ok, resource}
|
||||
{:ok, existing} ->
|
||||
existing = Repo.preload(existing, :connections)
|
||||
Logger.info("Updating existing resource #{resource_data["name"]}")
|
||||
only_updated_attrs = resource_attrs
|
||||
|> Enum.reject(fn {key, value} ->
|
||||
case key do
|
||||
# Compare connections by gateway_group_id only
|
||||
:connections -> value == Enum.map(existing.connections || [], fn conn -> Map.take(conn, [:gateway_group_id]) end)
|
||||
# Compare filters by ports and protocol only
|
||||
:filters -> value == Enum.map(existing.filters || [], fn filter -> Map.take(filter, [:ports, :protocol]) end)
|
||||
_ -> Map.get(existing, key) == value
|
||||
end
|
||||
end)
|
||||
|> Enum.into(%{})
|
||||
|
||||
if only_updated_attrs == %{} do
|
||||
{:ok, existing}
|
||||
else
|
||||
resource = case existing |> Resources.update_resource(resource_attrs, temp_admin_subject) do
|
||||
{:replaced, _old, new} ->
|
||||
UuidMapping.update_entities(slug, "resources", %{external_id => new.id})
|
||||
new
|
||||
{:updated, value} -> value
|
||||
x -> x
|
||||
end
|
||||
|
||||
{:ok, resource}
|
||||
end
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
# Create or update policies
|
||||
multi = Enum.reduce(account_data["policies"] || %{}, multi, fn {external_id, policy_data}, multi ->
|
||||
Ecto.Multi.run(multi, {:policy, slug, external_id}, fn _repo, changes ->
|
||||
policy_attrs = %{
|
||||
description: policy_data["description"],
|
||||
actor_group_id: UuidMapping.get_entity(slug, "actor_groups", policy_data["group"]),
|
||||
resource_id: UuidMapping.get_entity(slug, "resources", policy_data["resource"])
|
||||
}
|
||||
|
||||
{temp_admin_subject, _, _, _} = changes[{:temp_admin, slug}]
|
||||
uuid = UuidMapping.get_entity(slug, "policies", external_id)
|
||||
case uuid && fetch_policy(uuid, temp_admin_subject) |> nil_if_deleted_or_not_found() do
|
||||
nil ->
|
||||
Logger.info("Creating new policy #{policy_data["name"]}")
|
||||
{:ok, policy} = Policies.create_policy(policy_attrs, temp_admin_subject)
|
||||
UuidMapping.update_entities(slug, "policies", %{external_id => policy.id})
|
||||
{:ok, policy}
|
||||
{:ok, existing} ->
|
||||
Logger.info("Updating existing policy #{policy_data["name"]}")
|
||||
only_updated_attrs = policy_attrs
|
||||
|> Enum.reject(fn {key, value} -> Map.get(existing, key) == value end)
|
||||
|> Enum.into(%{})
|
||||
|
||||
if only_updated_attrs == %{} do
|
||||
{:ok, existing}
|
||||
else
|
||||
policy = case existing |> Policies.update_policy(policy_attrs, temp_admin_subject) do
|
||||
{:replaced, _old, new} ->
|
||||
UuidMapping.update_entities(slug, "policies", %{external_id => new.id})
|
||||
new
|
||||
{:updated, value} -> value
|
||||
x -> x
|
||||
end
|
||||
|
||||
{:ok, policy}
|
||||
end
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
# Clean up temporary admin after all operations
|
||||
multi |> Ecto.Multi.run({:cleanup_temp_admin, slug}, fn _repo, changes ->
|
||||
{temp_admin_subject, temp_admin_actor, temp_admin_actor_email_identity, temp_admin_actor_token} =
|
||||
changes[{:temp_admin, slug}]
|
||||
|
||||
cleanup_temp_admin(temp_admin_actor, temp_admin_actor_email_identity, temp_admin_actor_token, temp_admin_subject)
|
||||
{:ok, :cleaned}
|
||||
end)
|
||||
end)
|
||||
|> Ecto.Multi.run({:save_state}, fn _repo, _changes ->
|
||||
# Save all UUID mappings to disk.
|
||||
UuidMapping.save()
|
||||
{:ok, :saved}
|
||||
end)
|
||||
|
||||
case Repo.transaction(multi) do
|
||||
{:ok, _result} ->
|
||||
Logger.info("Provisioning completed successfully")
|
||||
{:error, step, reason, _changes} ->
|
||||
Logger.error("Provisioning failed at step #{inspect(step)}, no changes were applied: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Provision.provision()
|
||||
@@ -9,12 +9,8 @@ let
|
||||
attrNames
|
||||
boolToString
|
||||
concatLines
|
||||
concatLists
|
||||
concatMapAttrs
|
||||
concatStringsSep
|
||||
filterAttrs
|
||||
filterAttrsRecursive
|
||||
flip
|
||||
forEach
|
||||
getExe
|
||||
isBool
|
||||
@@ -26,16 +22,11 @@ let
|
||||
mkMerge
|
||||
mkOption
|
||||
mkPackageOption
|
||||
optionalAttrs
|
||||
optionalString
|
||||
recursiveUpdate
|
||||
subtractLists
|
||||
toUpper
|
||||
types
|
||||
;
|
||||
|
||||
cfg = config.services.firezone.server;
|
||||
jsonFormat = pkgs.formats.json { };
|
||||
availableAuthAdapters = [
|
||||
"email"
|
||||
"openid_connect"
|
||||
@@ -47,28 +38,6 @@ let
|
||||
"jumpcloud"
|
||||
];
|
||||
|
||||
typePortRange =
|
||||
types.coercedTo types.port
|
||||
(x: {
|
||||
from = x;
|
||||
to = x;
|
||||
})
|
||||
(
|
||||
types.submodule {
|
||||
options = {
|
||||
from = mkOption {
|
||||
type = types.port;
|
||||
description = "The start of the port range, inclusive.";
|
||||
};
|
||||
|
||||
to = mkOption {
|
||||
type = types.port;
|
||||
description = "The end of the port range, inclusive.";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
# All non-secret environment variables or the given component
|
||||
collectEnvironment =
|
||||
component:
|
||||
@@ -119,29 +88,6 @@ let
|
||||
)
|
||||
);
|
||||
|
||||
provisionStateJson =
|
||||
let
|
||||
# Convert clientSecretFile options into the real counterpart
|
||||
augmentedAccounts = flip mapAttrs cfg.provision.accounts (
|
||||
accountName: account:
|
||||
account
|
||||
// {
|
||||
auth = flip mapAttrs account.auth (
|
||||
authName: auth:
|
||||
recursiveUpdate auth (
|
||||
optionalAttrs (auth.adapter_config.clientSecretFile != null) {
|
||||
adapter_config.client_secret = "{env:AUTH_CLIENT_SECRET_${toUpper accountName}_${toUpper authName}}";
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
in
|
||||
jsonFormat.generate "provision-state.json" {
|
||||
# Do not include any clientSecretFile attributes in the resulting json
|
||||
accounts = filterAttrsRecursive (k: _: k != "clientSecretFile") augmentedAccounts;
|
||||
};
|
||||
|
||||
commonServiceConfig = {
|
||||
AmbientCapabilities = [ ];
|
||||
CapabilityBoundingSet = [ ];
|
||||
@@ -222,6 +168,12 @@ let
|
||||
};
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
(lib.mkRemovedOptionModule [ "services" "firezone" "server" "provision" ] ''
|
||||
Firezone provisioning support has been removed due to outsized maintenance efforts. See https://github.com/NixOS/nixpkgs/pull/529428.
|
||||
'')
|
||||
];
|
||||
|
||||
options.services.firezone.server = {
|
||||
enable = mkEnableOption "all Firezone components";
|
||||
enableLocalDB = mkEnableOption "a local postgresql database for Firezone";
|
||||
@@ -521,393 +473,9 @@ in
|
||||
description = "A list of trusted proxies";
|
||||
};
|
||||
};
|
||||
|
||||
provision = {
|
||||
enable = mkEnableOption "provisioning of the Firezone domain server";
|
||||
accounts = mkOption {
|
||||
type = types.attrsOf (
|
||||
types.submodule {
|
||||
freeformType = jsonFormat.type;
|
||||
options = {
|
||||
name = mkOption {
|
||||
type = types.str;
|
||||
description = "The account name";
|
||||
example = "My Organization";
|
||||
};
|
||||
|
||||
features =
|
||||
let
|
||||
mkFeatureOption =
|
||||
name: default:
|
||||
mkOption {
|
||||
type = types.bool;
|
||||
inherit default;
|
||||
description = "Whether to enable the `${name}` feature for this account.";
|
||||
};
|
||||
in
|
||||
{
|
||||
policy_conditions = mkFeatureOption "policy_conditions" true;
|
||||
multi_site_resources = mkFeatureOption "multi_site_resources" true;
|
||||
traffic_filters = mkFeatureOption "traffic_filters" true;
|
||||
self_hosted_relays = mkFeatureOption "self_hosted_relays" true;
|
||||
idp_sync = mkFeatureOption "idp_sync" true;
|
||||
rest_api = mkFeatureOption "rest_api" true;
|
||||
internet_resource = mkFeatureOption "internet_resource" true;
|
||||
};
|
||||
|
||||
actors = mkOption {
|
||||
type = types.attrsOf (
|
||||
types.submodule {
|
||||
options = {
|
||||
type = mkOption {
|
||||
type = types.enum [
|
||||
"account_admin_user"
|
||||
"account_user"
|
||||
"service_account"
|
||||
"api_client"
|
||||
];
|
||||
description = "The account type";
|
||||
};
|
||||
|
||||
name = mkOption {
|
||||
type = types.str;
|
||||
description = "The name of this actor";
|
||||
};
|
||||
|
||||
email = mkOption {
|
||||
type = types.str;
|
||||
description = "The email address used to authenticate as this account";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
default = { };
|
||||
example = {
|
||||
admin = {
|
||||
type = "account_admin_user";
|
||||
name = "Admin";
|
||||
email = "admin@myorg.example.com";
|
||||
};
|
||||
};
|
||||
description = ''
|
||||
All actors (users) to provision. The attribute name will only
|
||||
be used to track the actor and does not have any significance
|
||||
for Firezone.
|
||||
'';
|
||||
};
|
||||
|
||||
auth = mkOption {
|
||||
type = types.attrsOf (
|
||||
types.submodule {
|
||||
freeformType = jsonFormat.type;
|
||||
options = {
|
||||
name = mkOption {
|
||||
type = types.str;
|
||||
description = "The name of this authentication provider";
|
||||
};
|
||||
|
||||
adapter = mkOption {
|
||||
type = types.enum availableAuthAdapters;
|
||||
description = "The auth adapter type";
|
||||
};
|
||||
|
||||
adapter_config.clientSecretFile = mkOption {
|
||||
type = types.nullOr types.path;
|
||||
default = null;
|
||||
description = ''
|
||||
A file containing a the client secret for an openid_connect adapter.
|
||||
You only need to set this if this is an openid_connect provider.
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
default = { };
|
||||
example = {
|
||||
myoidcprovider = {
|
||||
adapter = "openid_connect";
|
||||
adapter_config = {
|
||||
client_id = "clientid";
|
||||
clientSecretFile = "/run/secrets/oidc-client-secret";
|
||||
response_type = "code";
|
||||
scope = "openid email name";
|
||||
discovery_document_uri = "https://auth.example.com/.well-known/openid-configuration";
|
||||
};
|
||||
};
|
||||
};
|
||||
description = ''
|
||||
All authentication providers to provision. The attribute name
|
||||
will only be used to track the provider and does not have any
|
||||
significance for Firezone.
|
||||
'';
|
||||
};
|
||||
|
||||
resources = mkOption {
|
||||
type = types.attrsOf (
|
||||
types.submodule {
|
||||
options = {
|
||||
type = mkOption {
|
||||
type = types.enum [
|
||||
"dns"
|
||||
"cidr"
|
||||
"ip"
|
||||
];
|
||||
description = "The resource type";
|
||||
};
|
||||
|
||||
name = mkOption {
|
||||
type = types.str;
|
||||
description = "The name of this resource";
|
||||
};
|
||||
|
||||
address = mkOption {
|
||||
type = types.str;
|
||||
description = "The address of this resource. Depending on the resource type, this should be an ip, ip with cidr mask or a domain.";
|
||||
};
|
||||
|
||||
addressDescription = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
description = "An optional description for resource address, usually a full link to the resource including a schema.";
|
||||
};
|
||||
|
||||
gatewayGroups = mkOption {
|
||||
type = types.nonEmptyListOf types.str;
|
||||
description = "A list of gateway groups (sites) which can reach the resource and may be used to connect to it.";
|
||||
};
|
||||
|
||||
filters = mkOption {
|
||||
type = types.listOf (
|
||||
types.submodule {
|
||||
options = {
|
||||
protocol = mkOption {
|
||||
type = types.enum [
|
||||
"icmp"
|
||||
"tcp"
|
||||
"udp"
|
||||
];
|
||||
description = "The protocol to allow";
|
||||
};
|
||||
|
||||
ports = mkOption {
|
||||
type = types.listOf typePortRange;
|
||||
example = [
|
||||
443
|
||||
{
|
||||
from = 8080;
|
||||
to = 8100;
|
||||
}
|
||||
];
|
||||
default = [ ];
|
||||
apply =
|
||||
xs: map (x: if x.from == x.to then toString x.from else "${toString x.from} - ${toString x.to}") xs;
|
||||
description = "Either a single port or port range to allow. Both bounds are inclusive.";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
default = [ ];
|
||||
description = "A list of filter to restrict traffic. If no filters are given, all traffic is allowed.";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
default = { };
|
||||
example = {
|
||||
vaultwarden = {
|
||||
type = "dns";
|
||||
name = "Vaultwarden";
|
||||
address = "vault.example.com";
|
||||
address_description = "https://vault.example.com";
|
||||
gatewayGroups = [ "my-site" ];
|
||||
filters = [
|
||||
{ protocol = "icmp"; }
|
||||
{
|
||||
protocol = "tcp";
|
||||
ports = [
|
||||
80
|
||||
443
|
||||
];
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
description = ''
|
||||
All resources to provision. The attribute name will only be used to
|
||||
track the resource and does not have any significance for Firezone.
|
||||
'';
|
||||
};
|
||||
|
||||
policies = mkOption {
|
||||
type = types.attrsOf (
|
||||
types.submodule {
|
||||
options = {
|
||||
description = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
description = "The description of this policy";
|
||||
};
|
||||
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
description = "The group which should be allowed access to the given resource.";
|
||||
};
|
||||
|
||||
resource = mkOption {
|
||||
type = types.str;
|
||||
description = "The resource to which access should be allowed.";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
default = { };
|
||||
example = {
|
||||
access_vaultwarden = {
|
||||
name = "Allow anyone to access vaultwarden";
|
||||
group = "everyone";
|
||||
resource = "vaultwarden";
|
||||
};
|
||||
};
|
||||
description = ''
|
||||
All policies to provision. The attribute name will only be used to
|
||||
track the policy and does not have any significance for Firezone.
|
||||
'';
|
||||
};
|
||||
|
||||
groups = mkOption {
|
||||
type = types.attrsOf (
|
||||
types.submodule {
|
||||
options = {
|
||||
name = mkOption {
|
||||
type = types.str;
|
||||
description = "The name of this group";
|
||||
};
|
||||
|
||||
members = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
description = "The members of this group";
|
||||
};
|
||||
|
||||
forceMembers = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Ensure that only the given members are part of this group at every server start.";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
default = { };
|
||||
example = {
|
||||
users = {
|
||||
name = "Users";
|
||||
};
|
||||
};
|
||||
description = ''
|
||||
All groups to provision. The attribute name will only be used
|
||||
to track the group and does not have any significance for
|
||||
Firezone.
|
||||
|
||||
A group named `everyone` will automatically be managed by Firezone.
|
||||
'';
|
||||
};
|
||||
|
||||
relayGroups = mkOption {
|
||||
type = types.attrsOf (
|
||||
types.submodule {
|
||||
options = {
|
||||
name = mkOption {
|
||||
type = types.str;
|
||||
description = "The name of this relay group";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
default = { };
|
||||
example = {
|
||||
my-relays = {
|
||||
name = "My Relays";
|
||||
};
|
||||
};
|
||||
description = ''
|
||||
All relay groups to provision. The attribute name
|
||||
will only be used to track the relay group and does not have any
|
||||
significance for Firezone.
|
||||
'';
|
||||
};
|
||||
|
||||
gatewayGroups = mkOption {
|
||||
type = types.attrsOf (
|
||||
types.submodule {
|
||||
options = {
|
||||
name = mkOption {
|
||||
type = types.str;
|
||||
description = "The name of this gateway group";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
default = { };
|
||||
example = {
|
||||
my-gateways = {
|
||||
name = "My Gateways";
|
||||
};
|
||||
};
|
||||
description = ''
|
||||
All gateway groups (sites) to provision. The attribute name
|
||||
will only be used to track the gateway group and does not have any
|
||||
significance for Firezone.
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
default = { };
|
||||
example = {
|
||||
main = {
|
||||
name = "My Account / Organization";
|
||||
metadata.stripe.billing_email = "org@myorg.example.com";
|
||||
features.rest_api = false;
|
||||
};
|
||||
};
|
||||
description = ''
|
||||
All accounts to provision. The attribute name specified here will
|
||||
become the account slug. By using `"{file:/path/to/file}"` as a
|
||||
string value anywhere in these settings, the provisioning script will
|
||||
replace that value with the content of the given file at runtime.
|
||||
|
||||
Please refer to the [Firezone source code](https://github.com/firezone/firezone/blob/main/elixir/apps/domain/lib/domain/accounts/account.ex)
|
||||
for all available properties.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = mkMerge [
|
||||
{
|
||||
assertions = [
|
||||
{
|
||||
assertion = cfg.provision.enable -> cfg.domain.enable;
|
||||
message = "Provisioning must be done on a machine running the firezone domain server";
|
||||
}
|
||||
]
|
||||
++ concatLists (
|
||||
flip mapAttrsToList cfg.provision.accounts (
|
||||
accountName: accountCfg:
|
||||
[
|
||||
{
|
||||
assertion = (builtins.match "^[[:lower:]_-]+$" accountName) != null;
|
||||
message = "An account name must contain only lowercase characters and underscores, as it will be used as the URL slug for this account.";
|
||||
}
|
||||
]
|
||||
++ flip mapAttrsToList accountCfg.auth (
|
||||
authName: _: {
|
||||
assertion = (builtins.match "^[[:alnum:]_-]+$" authName) != null;
|
||||
message = "The authentication provider attribute key must contain only letters, numbers, underscores or dashes.";
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
# Enable all components if the main server is enabled
|
||||
(mkIf cfg.enable {
|
||||
services.firezone.server.domain.enable = true;
|
||||
@@ -1015,7 +583,7 @@ in
|
||||
FEATURE_INTERNET_RESOURCE_ENABLED = mkDefault true;
|
||||
FEATURE_TRAFFIC_FILTERS_ENABLED = mkDefault true;
|
||||
|
||||
FEATURE_SIGN_UP_ENABLED = mkDefault (!cfg.provision.enable);
|
||||
FEATURE_SIGN_UP_ENABLED = mkDefault true;
|
||||
|
||||
WEB_EXTERNAL_URL = mkDefault cfg.web.externalUrl;
|
||||
API_EXTERNAL_URL = mkDefault cfg.api.externalUrl;
|
||||
@@ -1066,19 +634,6 @@ in
|
||||
OUTBOUND_EMAIL_SMTP_PASSWORD = cfg.smtp.passwordFile;
|
||||
};
|
||||
})
|
||||
(mkIf cfg.provision.enable {
|
||||
# Load client secrets from authentication providers
|
||||
services.firezone.server.settingsSecret = flip concatMapAttrs cfg.provision.accounts (
|
||||
accountName: accountCfg:
|
||||
flip concatMapAttrs accountCfg.auth (
|
||||
authName: authCfg:
|
||||
optionalAttrs (authCfg.adapter_config.clientSecretFile != null) {
|
||||
"AUTH_CLIENT_SECRET_${toUpper accountName}_${toUpper authName}" =
|
||||
authCfg.adapter_config.clientSecretFile;
|
||||
}
|
||||
)
|
||||
);
|
||||
})
|
||||
(mkIf (cfg.openClusterFirewall && cfg.domain.enable) {
|
||||
networking.firewall.allowedTCPPorts = [
|
||||
cfg.domain.settings.ERLANG_DISTRIBUTION_PORT
|
||||
@@ -1156,14 +711,6 @@ in
|
||||
fi
|
||||
count=$((count++))
|
||||
done
|
||||
''
|
||||
+ optionalString cfg.provision.enable ''
|
||||
# Wait for server to fully come up. Not ideal to use sleep, but at least it works.
|
||||
sleep 1
|
||||
|
||||
${loadSecretEnvironment "domain"}
|
||||
ln -sTf ${provisionStateJson} provision-state.json
|
||||
${getExe cfg.domain.package} rpc 'Code.eval_file("${./provision.exs}")'
|
||||
'';
|
||||
|
||||
environment = collectEnvironment "domain";
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
alias Domain.{Repo, Accounts, Auth, Actors, Tokens}
|
||||
|
||||
mappings = case File.read("provision-uuids.json") do
|
||||
{:ok, content} ->
|
||||
case Jason.decode(content) do
|
||||
{:ok, mapping} -> mapping
|
||||
_ -> %{"accounts" => %{}}
|
||||
end
|
||||
_ -> %{"accounts" => %{}}
|
||||
end
|
||||
|
||||
IO.puts("INFO: Fetching account")
|
||||
{:ok, account} = Accounts.fetch_account_by_id_or_slug("main")
|
||||
|
||||
IO.puts("INFO: Fetching email provider")
|
||||
{:ok, email_provider} = Auth.Provider.Query.not_disabled()
|
||||
|> Auth.Provider.Query.by_adapter(:email)
|
||||
|> Auth.Provider.Query.by_account_id(account.id)
|
||||
|> Repo.fetch(Auth.Provider.Query, [])
|
||||
|
||||
temp_admin_actor_email = "firezone-provision@localhost.local"
|
||||
temp_admin_actor_context = %Auth.Context{
|
||||
type: :browser,
|
||||
user_agent: "Unspecified/0.0",
|
||||
remote_ip: {127, 0, 0, 1},
|
||||
remote_ip_location_region: "N/A",
|
||||
remote_ip_location_city: "N/A",
|
||||
remote_ip_location_lat: 0.0,
|
||||
remote_ip_location_lon: 0.0
|
||||
}
|
||||
|
||||
{:ok, temp_admin_actor} =
|
||||
Actors.create_actor(account, %{
|
||||
type: :account_admin_user,
|
||||
name: "Token Provisioning"
|
||||
})
|
||||
|
||||
{:ok, temp_admin_actor_email_identity} =
|
||||
Auth.create_identity(temp_admin_actor, email_provider, %{
|
||||
provider_identifier: temp_admin_actor_email,
|
||||
provider_identifier_confirmation: temp_admin_actor_email
|
||||
})
|
||||
|
||||
{:ok, temp_admin_actor_token} =
|
||||
Auth.create_token(temp_admin_actor_email_identity, temp_admin_actor_context, "temporarynonce", DateTime.utc_now() |> DateTime.add(1, :hour))
|
||||
|
||||
{:ok, temp_admin_subject} =
|
||||
Auth.build_subject(temp_admin_actor_token, temp_admin_actor_context)
|
||||
|
||||
{:ok, relay_group_token} =
|
||||
Tokens.create_token(%{
|
||||
"type" => :relay_group,
|
||||
"expires_at" => DateTime.utc_now() |> DateTime.add(1, :hour),
|
||||
"secret_fragment" => Domain.Crypto.random_token(32, encoder: :hex32),
|
||||
"relay_group_id" => get_in(mappings, ["accounts", "main", "relay_groups", "my-relays"])
|
||||
})
|
||||
|
||||
relay_group_encoded_token = Tokens.encode_fragment!(relay_group_token)
|
||||
IO.puts("Created relay token: #{relay_group_encoded_token}")
|
||||
File.write("relay_token.txt", relay_group_encoded_token)
|
||||
|
||||
{:ok, gateway_group_token} =
|
||||
Tokens.create_token(%{
|
||||
"type" => :gateway_group,
|
||||
"expires_at" => DateTime.utc_now() |> DateTime.add(1, :hour),
|
||||
"secret_fragment" => Domain.Crypto.random_token(32, encoder: :hex32),
|
||||
"account_id" => get_in(mappings, ["accounts", "main", "id"]),
|
||||
"gateway_group_id" => get_in(mappings, ["accounts", "main", "gateway_groups", "site"])
|
||||
}, temp_admin_subject)
|
||||
|
||||
gateway_group_encoded_token = Tokens.encode_fragment!(gateway_group_token)
|
||||
IO.puts("Created gateway group token: #{gateway_group_encoded_token}")
|
||||
File.write("gateway_token.txt", gateway_group_encoded_token)
|
||||
|
||||
{:ok, service_account_actor_token} =
|
||||
Tokens.create_token(%{
|
||||
"type" => :client,
|
||||
"expires_at" => DateTime.utc_now() |> DateTime.add(1, :hour),
|
||||
"secret_fragment" => Domain.Crypto.random_token(32, encoder: :hex32),
|
||||
"account_id" => get_in(mappings, ["accounts", "main", "id"]),
|
||||
"actor_id" => get_in(mappings, ["accounts", "main", "actors", "client"])
|
||||
})
|
||||
|
||||
service_account_actor_encoded_token = Tokens.encode_fragment!(service_account_actor_token)
|
||||
IO.puts("Created service actor token: #{service_account_actor_encoded_token}")
|
||||
File.write("client_token.txt", service_account_actor_encoded_token)
|
||||
@@ -84,89 +84,15 @@ in
|
||||
passwordFile = pkgs.writeText "tmpmailpasswd" "verysecurepassword";
|
||||
};
|
||||
|
||||
provision = {
|
||||
enable = true;
|
||||
accounts.main = {
|
||||
name = "My Account";
|
||||
relayGroups.my-relays.name = "Relays";
|
||||
gatewayGroups.site.name = "Site";
|
||||
actors = {
|
||||
admin = {
|
||||
type = "account_admin_user";
|
||||
name = "Admin";
|
||||
email = "admin@localhost.localdomain";
|
||||
};
|
||||
client = {
|
||||
type = "service_account";
|
||||
name = "A client";
|
||||
email = "client@example.com";
|
||||
};
|
||||
};
|
||||
# service accounts aren't members of 'Everyone' so we need to add a separate group
|
||||
groups.main = {
|
||||
name = "main";
|
||||
members = [
|
||||
"client"
|
||||
"admin"
|
||||
];
|
||||
};
|
||||
resources.res1 = {
|
||||
type = "dns";
|
||||
name = "Dns Resource";
|
||||
address = "resource.example.com";
|
||||
gatewayGroups = [ "site" ];
|
||||
filters = [
|
||||
{ protocol = "icmp"; }
|
||||
{
|
||||
protocol = "tcp";
|
||||
ports = [ 80 ];
|
||||
}
|
||||
];
|
||||
};
|
||||
resources.res2 = {
|
||||
type = "ip";
|
||||
name = "Ip Resource";
|
||||
address = "172.20.2.1";
|
||||
gatewayGroups = [ "site" ];
|
||||
};
|
||||
resources.res3 = {
|
||||
type = "cidr";
|
||||
name = "Cidr Resource";
|
||||
address = "172.20.1.0/24";
|
||||
gatewayGroups = [ "site" ];
|
||||
};
|
||||
policies.pol1 = {
|
||||
description = "Allow anyone res1 access";
|
||||
group = "main";
|
||||
resource = "res1";
|
||||
};
|
||||
policies.pol2 = {
|
||||
description = "Allow anyone res2 access";
|
||||
group = "main";
|
||||
resource = "res2";
|
||||
};
|
||||
policies.pol3 = {
|
||||
description = "Allow anyone res3 access";
|
||||
group = "main";
|
||||
resource = "res3";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
api.externalUrl = "https://${domain}/api/";
|
||||
web.externalUrl = "https://${domain}/";
|
||||
};
|
||||
|
||||
systemd.services.firezone-server-domain.postStart = lib.mkAfter ''
|
||||
${lib.getExe config.services.firezone.server.domain.package} rpc 'Code.eval_file("${./create-tokens.exs}")'
|
||||
'';
|
||||
};
|
||||
|
||||
relay =
|
||||
{
|
||||
nodes,
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
{
|
||||
@@ -181,14 +107,11 @@ in
|
||||
logLevel = "debug";
|
||||
name = "test-relay";
|
||||
apiUrl = "wss://${domain}/api/";
|
||||
tokenFile = "/tmp/shared/relay_token.txt";
|
||||
tokenFile = pkgs.writeText "token" "token";
|
||||
publicIpv4 = config.networking.primaryIPAddress;
|
||||
publicIpv6 = config.networking.primaryIPv6Address;
|
||||
openFirewall = true;
|
||||
};
|
||||
|
||||
# Don't auto-start so we can wait until the token was provisioned
|
||||
systemd.services.firezone-relay.wantedBy = lib.mkForce [ ];
|
||||
};
|
||||
|
||||
# A resource that is only connected to the gateway,
|
||||
@@ -300,11 +223,8 @@ in
|
||||
logLevel = "debug";
|
||||
name = "test-gateway";
|
||||
apiUrl = "wss://${domain}/api/";
|
||||
tokenFile = "/tmp/shared/gateway_token.txt";
|
||||
tokenFile = pkgs.writeText "token" "token";
|
||||
};
|
||||
|
||||
# Don't auto-start so we can wait until the token was provisioned
|
||||
systemd.services.firezone-gateway.wantedBy = lib.mkForce [ ];
|
||||
};
|
||||
|
||||
client =
|
||||
@@ -326,11 +246,8 @@ in
|
||||
logLevel = "debug";
|
||||
name = "test-client-somebody";
|
||||
apiUrl = "wss://${domain}/api/";
|
||||
tokenFile = "/tmp/shared/client_token.txt";
|
||||
tokenFile = pkgs.writeText "token" "token";
|
||||
};
|
||||
|
||||
# Don't auto-start so we can wait until the token was provisioned
|
||||
systemd.services.firezone-headless-client.wantedBy = lib.mkForce [ ];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -344,43 +261,13 @@ in
|
||||
server.wait_until_succeeds("curl -Lsf https://${domain} | grep 'Welcome to Firezone'")
|
||||
server.wait_until_succeeds("curl -Ls https://${domain}/api | grep 'Not Found'")
|
||||
|
||||
# Wait for tokens and copy them to shared folder
|
||||
server.wait_for_file("/var/lib/private/firezone/relay_token.txt")
|
||||
server.wait_for_file("/var/lib/private/firezone/gateway_token.txt")
|
||||
server.wait_for_file("/var/lib/private/firezone/client_token.txt")
|
||||
server.succeed("cp /var/lib/private/firezone/*_token.txt /tmp/shared")
|
||||
|
||||
with subtest("Connect relay"):
|
||||
relay.succeed("systemctl start firezone-relay")
|
||||
relay.wait_for_unit("firezone-relay.service")
|
||||
relay.wait_until_succeeds("journalctl --since -2m --unit firezone-relay.service --grep 'Connected to portal.*${domain}'", timeout=30)
|
||||
|
||||
with subtest("Connect gateway"):
|
||||
gateway.succeed("systemctl start firezone-gateway")
|
||||
gateway.wait_for_unit("firezone-gateway.service")
|
||||
gateway.wait_until_succeeds("journalctl --since -2m --unit firezone-gateway.service --grep 'Connected to portal.*${domain}'", timeout=30)
|
||||
relay.wait_until_succeeds("journalctl --since -2m --unit firezone-relay.service --grep 'Created allocation.*IPv4'", timeout=30)
|
||||
relay.wait_until_succeeds("journalctl --since -2m --unit firezone-relay.service --grep 'Created allocation.*IPv6'", timeout=30)
|
||||
|
||||
# Assert both relay ips are known
|
||||
gateway.wait_until_succeeds("journalctl --since -2m --unit firezone-gateway.service --grep 'Updated allocation.*relay_ip4.*Some.*relay_ip6.*Some'", timeout=30)
|
||||
|
||||
with subtest("Connect headless-client"):
|
||||
client.succeed("systemctl start firezone-headless-client")
|
||||
client.wait_for_unit("firezone-headless-client.service")
|
||||
client.wait_until_succeeds("journalctl --since -2m --unit firezone-headless-client.service --grep 'Connected to portal.*${domain}'", timeout=30)
|
||||
client.wait_until_succeeds("journalctl --since -2m --unit firezone-headless-client.service --grep 'Tunnel ready'", timeout=30)
|
||||
|
||||
with subtest("Check DNS based access"):
|
||||
# Check that we can access the resource through the VPN via DNS
|
||||
client.wait_until_succeeds("curl -4 -Lsf http://resource.example.com | grep 'greetings from the resource'")
|
||||
|
||||
with subtest("Check CIDR based access"):
|
||||
# Check that we can access the resource through the VPN via CIDR
|
||||
client.wait_until_succeeds("ping -c1 -W1 172.20.1.1")
|
||||
|
||||
with subtest("Check IP based access"):
|
||||
# Check that we can access the resource through the VPN via IP
|
||||
client.wait_until_succeeds("ping -c1 -W1 172.20.2.1")
|
||||
'';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user