nixos/firezone: remove provisioning (#529428)

This commit is contained in:
oddlama
2026-06-08 20:57:03 +02:00
committed by GitHub
5 changed files with 12 additions and 1371 deletions
@@ -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";
-86
View File
@@ -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)
+3 -116
View File
@@ -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")
'';
}