elektrine/config/runtime.exs
maxfield da169a3869
Some checks failed
Deploy Docker Images / Build, push, and deploy (push) Failing after 23m0s
feat(privacy): harden session logs, admin access, and docker isolation
Disable durable VPN session logs and never store client IPs. Drop admin
body decrypt and impersonation. Default mail privacy (no raw copy, no
inbound MTA IP, trash/spam retention). Tighten compose caps and split
edge/db networks; gate Tor/VPN packages by release modules. Update legal
copy and operator hardening docs.
2026-08-02 22:55:08 -04:00

1263 lines
39 KiB
Elixir

import Config
alias Elektrine.Platform.Modules
alias Elektrine.RuntimeSecrets
# config/runtime.exs is executed for all environments, including
# during releases. It is executed after compilation and before the
# system starts, so it is typically used to load production configuration
# and secrets from environment variables or elsewhere. Do not define
# any compile-time configuration in here, as it won't be applied.
# The block below contains prod specific runtime configuration.
# ## Using releases
#
# If you use `mix release`, you need to explicitly enable the server
# by passing the PHX_SERVER=true when you start it:
#
# PHX_SERVER=true bin/elektrine start
#
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
# script that automatically sets the env var above.
if System.get_env("PHX_SERVER") do
config :elektrine, ElektrineWeb.Endpoint, server: true
end
runtime_env = System.get_env()
iftas_blocklist_enabled =
case System.get_env("IFTAS_BLOCKLIST_ENABLED", "true") do
value when value in ["1", "true", "TRUE", "yes", "YES"] -> true
_ -> false
end
iftas_blocklist_threshold =
case Integer.parse(System.get_env("IFTAS_BLOCKLIST_THRESHOLD", "66")) do
{threshold, ""} when threshold in [51, 66, 80] -> threshold
_ -> 66
end
iftas_blocklist_url =
case System.get_env("IFTAS_BLOCKLIST_URL") do
nil -> nil
"" -> nil
value -> value
end
iftas_blocklist_api_key =
case System.get_env("IFTAS_BLOCKLIST_API_KEY") do
nil -> nil
"" -> nil
value -> value
end
config :elektrine_social, :iftas_blocklist,
enabled: iftas_blocklist_enabled,
threshold: iftas_blocklist_threshold,
url: iftas_blocklist_url,
api_key: iftas_blocklist_api_key
parse_int_env = fn env_name, default ->
case System.get_env(env_name) do
nil ->
default
"" ->
default
value ->
case Integer.parse(value) do
{int, ""} when int > 0 -> int
_ -> default
end
end
end
parse_bool_env = fn env_name, default ->
case System.get_env(env_name) do
nil ->
default
"" ->
default
value when value in ["1", "true", "TRUE", "yes", "YES", "on", "ON"] ->
true
value when value in ["0", "false", "FALSE", "no", "NO", "off", "OFF"] ->
false
_ ->
default
end
end
analytics_retention_config = Application.get_env(:elektrine, :analytics_retention, [])
config :elektrine, :analytics_retention,
site_retention_days:
parse_int_env.(
"ANALYTICS_SITE_RETENTION_DAYS",
Keyword.get(analytics_retention_config, :site_retention_days, 30)
),
profile_retention_days:
parse_int_env.(
"ANALYTICS_PROFILE_RETENTION_DAYS",
Keyword.get(analytics_retention_config, :profile_retention_days, 90)
),
batch_size:
parse_int_env.(
"ANALYTICS_RETENTION_BATCH_SIZE",
Keyword.get(analytics_retention_config, :batch_size, 5_000)
),
max_batches:
parse_int_env.(
"ANALYTICS_RETENTION_MAX_BATCHES",
Keyword.get(analytics_retention_config, :max_batches, 100)
)
present_env = fn names ->
Enum.find_value(List.wrap(names), fn env_name ->
case System.get_env(env_name) do
value when is_binary(value) ->
value = String.trim(value)
if value == "", do: nil, else: value
_ ->
nil
end
end)
end
# Keep Oban concurrency proportional to the DB pool available to the role
# that actually executes jobs. Web-only nodes run enqueue-only Oban.
oban_db_pool_size = parse_int_env.("POOL_SIZE", 10)
oban_queue_override = fn env_name, default ->
parse_int_env.(env_name, default)
end
config :atomine, :credits,
dm_gate_enabled: parse_bool_env.("ATOMINE_DM_CREDIT_GATE_ENABLED", false),
email_gate_enabled: parse_bool_env.("ATOMINE_EMAIL_CREDIT_GATE_ENABLED", true)
config :elektrine, :atomine_pow,
difficulty:
parse_int_env.(
"ATOMINE_POW_DIFFICULTY",
Application.get_env(:elektrine, :atomine_pow, []) |> Keyword.get(:difficulty, 20)
),
skip_verification:
parse_bool_env.(
"ATOMINE_POW_SKIP_VERIFICATION",
Application.get_env(:elektrine, :atomine_pow, []) |> Keyword.get(:skip_verification, false)
)
config :elektrine, :atomine_gate,
enabled:
parse_bool_env.(
"ATOMINE_GATE_ENABLED",
Application.get_env(:elektrine, :atomine_gate, []) |> Keyword.get(:enabled, false)
),
difficulty:
parse_int_env.(
"ATOMINE_GATE_DIFFICULTY",
Application.get_env(:elektrine, :atomine_gate, []) |> Keyword.get(:difficulty, 20)
),
clearance_ttl_seconds:
parse_int_env.(
"ATOMINE_GATE_CLEARANCE_TTL_SECONDS",
Application.get_env(:elektrine, :atomine_gate, [])
|> Keyword.get(:clearance_ttl_seconds, 12 * 60 * 60)
)
paige_brave_api_key =
present_env.(["PAIGE_BRAVE_API_KEY", "BRAVE_SEARCH_API_KEY", "BRAVE_API_KEY"])
paige_github_token = present_env.(["PAIGE_GITHUB_TOKEN"])
paige_index_enabled = config_env() != :test and parse_bool_env.("PAIGE_INDEX_ENABLED", true)
paige_index_seeds =
System.get_env("PAIGE_INDEX_SEEDS", "")
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> Enum.uniq()
config :elektrine, :web_index,
enabled: paige_index_enabled,
seeds: paige_index_seeds,
max_depth: parse_int_env.("PAIGE_INDEX_MAX_DEPTH", 2),
recrawl_seconds: parse_int_env.("PAIGE_INDEX_RECRAWL_SECONDS", 7 * 24 * 60 * 60),
schedule_batch_size: parse_int_env.("PAIGE_INDEX_BATCH_SIZE", 100)
paige_scraper_names =
if config_env() == :test do
[]
else
System.get_env("PAIGE_SCRAPERS", "wiby")
|> String.split(",", trim: true)
|> Enum.map(&(&1 |> String.trim() |> String.downcase()))
|> Enum.uniq()
end
paige_scraper_providers =
Enum.flat_map(paige_scraper_names, fn
"wiby" ->
[
{Paige.Providers.Wiby,
[
kinds: [:web],
scoring: :rank,
score_offset: -3,
max_results: 12,
page_size: 12,
paginated: true
]}
]
"duckduckgo" ->
[
{Paige.Providers.DuckDuckGo,
[
kinds: [:web],
scoring: :rank,
score_offset: -2,
max_results: 20,
page_size: 20,
paginated: true
]}
]
_unknown ->
[]
end)
# Supplementary sources blend a handful of results into web searches below the
# top Brave hits. They are rank-scored so native scores (stars, points, page
# sizes) can't dominate the blend. GitHub search is heavily rate-limited
# without a token, so it stays off unless one is configured.
paige_web_blend = fn extra ->
Keyword.merge([kinds: [:web], scoring: :rank, max_results: 3, paginated: true], extra)
end
paige_supplementary_providers =
if config_env() == :test do
[]
else
[
{Paige.Providers.Wikipedia, paige_web_blend.(score_offset: -5, max_results: 2)},
{Paige.Providers.HackerNews, paige_web_blend.(score_offset: -7)},
paige_github_token &&
{Paige.Providers.GitHub, paige_web_blend.(score_offset: -8, token: paige_github_token)}
]
end
paige_providers =
[
paige_index_enabled &&
{Elektrine.WebIndex.Provider,
[
kinds: [:web],
scoring: :rank,
score_offset: 2,
max_results: 10,
page_size: 10,
paginated: true
]},
paige_brave_api_key &&
{Paige.Providers.Brave,
[api_key: paige_brave_api_key, paginated_kinds: [:web, :videos, :news]]}
]
|> Enum.concat(paige_scraper_providers)
|> Enum.concat(paige_supplementary_providers)
|> Enum.filter(& &1)
config :paige,
providers: paige_providers,
brave_api_key: paige_brave_api_key
oban_queues =
cond do
oban_db_pool_size <= 5 ->
[
default: oban_queue_override.("OBAN_QUEUE_DEFAULT", 1),
activitypub: oban_queue_override.("OBAN_QUEUE_ACTIVITYPUB", 1),
activitypub_delivery: oban_queue_override.("OBAN_QUEUE_ACTIVITYPUB_DELIVERY", 1),
email: oban_queue_override.("OBAN_QUEUE_EMAIL", 1),
email_inbound: oban_queue_override.("OBAN_QUEUE_EMAIL_INBOUND", 1),
rss: oban_queue_override.("OBAN_QUEUE_RSS", 1),
exports: oban_queue_override.("OBAN_QUEUE_EXPORTS", 1),
webhooks: oban_queue_override.("OBAN_QUEUE_WEBHOOKS", 1),
federation_metadata: oban_queue_override.("OBAN_QUEUE_FEDERATION_METADATA", 1),
federation: oban_queue_override.("OBAN_QUEUE_FEDERATION", 1),
messaging_federation: oban_queue_override.("OBAN_QUEUE_MESSAGING_FEDERATION", 1),
uptime: oban_queue_override.("OBAN_QUEUE_UPTIME", 1),
kairo: oban_queue_override.("OBAN_QUEUE_KAIRO", 1),
crawler: oban_queue_override.("OBAN_QUEUE_CRAWLER", 1)
]
oban_db_pool_size <= 10 ->
[
default: oban_queue_override.("OBAN_QUEUE_DEFAULT", 2),
activitypub: oban_queue_override.("OBAN_QUEUE_ACTIVITYPUB", 2),
activitypub_delivery: oban_queue_override.("OBAN_QUEUE_ACTIVITYPUB_DELIVERY", 1),
email: oban_queue_override.("OBAN_QUEUE_EMAIL", 1),
email_inbound: oban_queue_override.("OBAN_QUEUE_EMAIL_INBOUND", 1),
rss: oban_queue_override.("OBAN_QUEUE_RSS", 1),
exports: oban_queue_override.("OBAN_QUEUE_EXPORTS", 1),
webhooks: oban_queue_override.("OBAN_QUEUE_WEBHOOKS", 1),
federation_metadata: oban_queue_override.("OBAN_QUEUE_FEDERATION_METADATA", 1),
federation: oban_queue_override.("OBAN_QUEUE_FEDERATION", 2),
messaging_federation: oban_queue_override.("OBAN_QUEUE_MESSAGING_FEDERATION", 2),
uptime: oban_queue_override.("OBAN_QUEUE_UPTIME", 2),
kairo: oban_queue_override.("OBAN_QUEUE_KAIRO", 1),
crawler: oban_queue_override.("OBAN_QUEUE_CRAWLER", 1)
]
true ->
[
default: oban_queue_override.("OBAN_QUEUE_DEFAULT", 3),
activitypub: oban_queue_override.("OBAN_QUEUE_ACTIVITYPUB", 3),
activitypub_delivery: oban_queue_override.("OBAN_QUEUE_ACTIVITYPUB_DELIVERY", 2),
email: oban_queue_override.("OBAN_QUEUE_EMAIL", 2),
email_inbound: oban_queue_override.("OBAN_QUEUE_EMAIL_INBOUND", 2),
rss: oban_queue_override.("OBAN_QUEUE_RSS", 2),
exports: oban_queue_override.("OBAN_QUEUE_EXPORTS", 2),
webhooks: oban_queue_override.("OBAN_QUEUE_WEBHOOKS", 2),
federation_metadata: oban_queue_override.("OBAN_QUEUE_FEDERATION_METADATA", 2),
federation: oban_queue_override.("OBAN_QUEUE_FEDERATION", 2),
messaging_federation: oban_queue_override.("OBAN_QUEUE_MESSAGING_FEDERATION", 4),
uptime: oban_queue_override.("OBAN_QUEUE_UPTIME", 4),
kairo: oban_queue_override.("OBAN_QUEUE_KAIRO", 2),
crawler: oban_queue_override.("OBAN_QUEUE_CRAWLER", 2)
]
end
config :elektrine, Oban, queues: oban_queues
# Drop cron entries whose worker module isn't bundled in this release build.
# Module-specific workers (uptime, email, social, ...) are only present when
# their platform module is compiled in. Oban validates the whole crontab on
# boot, so a single missing worker would crash the app - fatal for partial
# builds (e.g. ELEKTRINE_RELEASE_MODULES=chat). Filtering by module
# loadability keeps the scheduler running with whatever modules are present.
oban_existing_plugins = Application.get_env(:elektrine, Oban, []) |> Keyword.get(:plugins)
if is_list(oban_existing_plugins) do
filtered_oban_plugins =
Enum.map(oban_existing_plugins, fn
{Oban.Plugins.Cron, cron_opts} ->
crontab =
cron_opts
|> Keyword.get(:crontab, [])
|> Enum.filter(fn entry -> Code.ensure_loaded?(elem(entry, 1)) end)
{Oban.Plugins.Cron, Keyword.put(cron_opts, :crontab, crontab)}
other ->
other
end)
config :elektrine, Oban, plugins: filtered_oban_plugins
end
first_present_env = fn env_names ->
Enum.find_value(env_names, fn env_name ->
case System.get_env(env_name) do
nil -> nil
"" -> nil
value -> value
end
end)
end
config :elektrine, :runtime_components,
web: parse_bool_env.("ELEKTRINE_ENABLE_WEB", true),
jobs: parse_bool_env.("ELEKTRINE_ENABLE_JOBS", true),
mail: parse_bool_env.("ELEKTRINE_ENABLE_MAIL", true)
enabled_platform_modules =
case System.get_env("ELEKTRINE_ENABLED_MODULES") do
nil ->
Application.get_env(:elektrine, :platform_modules, [])
|> Keyword.get(:enabled, Modules.default_enabled())
value ->
value
end
|> Modules.normalize_enabled_modules()
config :elektrine, :platform_modules, enabled: enabled_platform_modules
Code.eval_file(Path.expand("runtime/bluesky.exs", __DIR__))
recommendations_enabled =
case System.get_env("RECOMMENDATIONS_ENABLED", "true") do
value when value in ["0", "false", "FALSE", "no", "NO", "off", "OFF"] -> false
_ -> true
end
config :elektrine, :recommendations_enabled, recommendations_enabled
haraka_async_ingest_enabled =
case System.get_env("HARAKA_ASYNC_INGEST", "true") do
value when value in ["1", "true", "TRUE", "yes", "YES", "on", "ON"] -> true
_ -> false
end
config :elektrine, :haraka_async_ingest, haraka_async_ingest_enabled
email_auto_suppression_enabled =
case System.get_env("EMAIL_AUTO_SUPPRESSION", "true") do
value when value in ["1", "true", "TRUE", "yes", "YES", "on", "ON"] -> true
_ -> false
end
config :elektrine, :email_auto_suppression, email_auto_suppression_enabled
# Native push notifications (APNs/FCM) and browser Web Push (VAPID).
# Generate VAPID keys with: Elektrine.Push.WebPushClient.generate_vapid_keys/0
config :elektrine, :push,
enabled: parse_bool_env.("PUSH_ENABLED", false),
apns_topic: System.get_env("PUSH_APNS_TOPIC") || "com.elektrine.app",
web_push_public_key: System.get_env("WEB_PUSH_PUBLIC_KEY"),
web_push_private_key: System.get_env("WEB_PUSH_PRIVATE_KEY"),
web_push_subject: System.get_env("WEB_PUSH_SUBJECT")
# Configure the Haraka mail adapter whenever the email module is enabled.
runtime_primary_domain =
System.get_env("PRIMARY_DOMAIN") ||
Application.get_env(:elektrine, :primary_domain, "example.com")
runtime_email_domain =
System.get_env("EMAIL_DOMAIN") ||
Application.get_env(:elektrine, :email, []) |> Keyword.get(:domain, runtime_primary_domain)
derived_internal_api_key = RuntimeSecrets.internal_api_key(runtime_env)
derived_haraka_signing_secret = RuntimeSecrets.haraka_internal_signing_secret(runtime_env)
derived_receiver_webhook_secret = RuntimeSecrets.email_receiver_webhook_secret(runtime_env)
Code.eval_file(Path.expand("runtime/webrtc.exs", __DIR__))
config :elektrine,
internal_api_key: derived_internal_api_key,
session_signing_salt: RuntimeSecrets.session_signing_salt(runtime_env),
session_encryption_salt: RuntimeSecrets.session_encryption_salt(runtime_env)
if config_env() != :test and :email in enabled_platform_modules do
config :elektrine, Elektrine.Mailer,
adapter: Elektrine.Email.HarakaAdapter,
api_key:
first_present_env.([
"HARAKA_HTTP_API_KEY",
"HARAKA_OUTBOUND_API_KEY",
"HARAKA_API_KEY"
]) || derived_internal_api_key,
base_url: first_present_env.(["HARAKA_BASE_URL"]) || "https://mail.#{runtime_email_domain}",
timeout: 30_000
# Enable API client for Haraka
config :swoosh, :api_client, Elektrine.HTTP.SwooshClient
end
# Configure encryption.
# In production, missing encryption secrets fail validation unless unencrypted data
# has been explicitly allowed with ELEKTRINE_ALLOW_UNENCRYPTED_PROD_DATA=true.
encryption_master_secret = RuntimeSecrets.encryption_master_secret(runtime_env)
encryption_key_salt = RuntimeSecrets.encryption_key_salt(runtime_env)
encryption_search_salt = RuntimeSecrets.encryption_search_salt(runtime_env)
non_prod_encryption_master_secret =
:crypto.hash(:sha256, "elektrine:nonprod:encryption_master_secret")
|> Base.encode64()
non_prod_encryption_key_salt =
:crypto.hash(:sha256, "elektrine:nonprod:encryption_key_salt")
|> binary_part(0, 16)
|> Base.encode64()
non_prod_encryption_search_salt =
:crypto.hash(:sha256, "elektrine:nonprod:encryption_search_salt")
|> binary_part(0, 16)
|> Base.encode64()
encryption_configured =
Enum.all?(
[encryption_master_secret, encryption_key_salt, encryption_search_salt],
&(is_binary(&1) and String.trim(&1) != "")
)
allow_unencrypted_prod_data =
config_env() == :prod and
parse_bool_env.("ELEKTRINE_ALLOW_UNENCRYPTED_PROD_DATA", false) and
not encryption_configured
if config_env() == :prod do
config :elektrine,
encryption_enabled: encryption_configured and not allow_unencrypted_prod_data,
encryption_master_secret: encryption_master_secret,
encryption_key_salt: encryption_key_salt,
encryption_search_salt: encryption_search_salt
else
# Keep encryption on by default outside prod, but use stable fallback secrets so
# separate BEAM processes (for example a running dev server plus `mix run` seeds)
# can still decrypt each other's rows.
config :elektrine,
encryption_enabled: true,
encryption_master_secret: encryption_master_secret || non_prod_encryption_master_secret,
encryption_key_salt: encryption_key_salt || non_prod_encryption_key_salt,
encryption_search_salt: encryption_search_salt || non_prod_encryption_search_salt
end
if config_env() == :prod do
config :elektrine, :environment, :prod
config :elektrine, :enforce_https, parse_bool_env.("FORCE_SSL", true)
config :elektrine, :allow_insecure_dav_jmap_auth, false
config :elektrine, :allow_insecure_mail_auth, false
Elektrine.Platform.RuntimeConfigValidator.validate!(
env: System.get_env(),
environment: :prod,
compiled_modules: Application.get_env(:elektrine, :compiled_platform_modules, []),
enabled_modules: enabled_platform_modules
)
trusted_proxy_cidrs =
System.get_env("TRUSTED_PROXY_CIDRS", "")
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
config :elektrine, :trusted_proxy_cidrs, trusted_proxy_cidrs
proxy_protocol_trusted_cidrs =
System.get_env("PROXY_PROTOCOL_TRUSTED_CIDRS", System.get_env("TRUSTED_PROXY_CIDRS", ""))
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
config :elektrine, :proxy_protocol_trusted_cidrs, proxy_protocol_trusted_cidrs
netbird_allowed_cidrs =
System.get_env("NETBIRD_ALLOWED_CIDRS", "")
|> String.split(~r/[\s,]+/, trim: true)
|> Enum.map(&String.trim/1)
netbird_api_token =
case System.get_env("NETBIRD_API_TOKEN") do
nil -> nil
"" -> nil
token -> String.trim(token)
end
netbird_admin_group =
case System.get_env("NETBIRD_ADMIN_GROUP") do
nil -> "elektrine-admins"
"" -> "elektrine-admins"
group -> String.trim(group)
end
netbird_management_url =
case System.get_env("NETBIRD_MANAGEMENT_URL") do
nil -> "https://api.netbird.io"
"" -> "https://api.netbird.io"
url -> String.trim(url) |> String.trim_trailing("/")
end
netbird_env_file =
case System.get_env("NETBIRD_ENV_FILE") do
nil -> System.get_env("ELEKTRINE_ENV_FILE")
"" -> System.get_env("ELEKTRINE_ENV_FILE")
path -> path
end
config :elektrine, :netbird,
enabled: parse_bool_env.("NETBIRD_ENABLED", false),
allowed_cidrs: netbird_allowed_cidrs,
management_url: netbird_management_url,
api_token: netbird_api_token,
admin_group: netbird_admin_group,
env_file: netbird_env_file
# Production paths - use persistent /data volume
config :elektrine, :export_dir, "/data/exports"
database_url =
System.get_env("DATABASE_URL") ||
raise """
environment variable DATABASE_URL is missing.
For example: ecto://USER:PASS@HOST/DATABASE
"""
database_uri = URI.parse(database_url)
database_query_params =
if database_uri.query, do: URI.decode_query(database_uri.query), else: %{}
ecto_ipv6 =
case System.get_env("ECTO_IPV6") do
nil -> false
value -> value in ~w(true 1)
end
maybe_ipv6 = if ecto_ipv6, do: [:inet6], else: []
db_ssl_server_name =
case System.get_env("DATABASE_SSL_SERVER_NAME") do
nil -> database_uri.host
"" -> database_uri.host
hostname -> hostname
end
db_ssl_enabled = parse_bool_env.("DATABASE_SSL_ENABLED", true)
db_ssl_verify =
case System.get_env("DATABASE_SSL_VERIFY") do
nil ->
case Map.get(database_query_params, "sslmode") do
"disable" -> "none"
_ -> "peer"
end
value ->
String.downcase(value)
end
db_ssl_opts =
if db_ssl_enabled do
db_ssl_opts =
case db_ssl_verify do
"none" ->
[verify: :verify_none]
"peer" ->
if is_nil(db_ssl_server_name) do
raise """
DATABASE_SSL_VERIFY=peer requires a database hostname.
Set DATABASE_SSL_SERVER_NAME or include a host in DATABASE_URL.
"""
end
[
verify: :verify_peer,
server_name_indication: String.to_charlist(db_ssl_server_name),
customize_hostname_check: [
match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
]
]
value ->
raise """
invalid DATABASE_SSL_VERIFY value: #{value}
Expected one of: peer, none
"""
end
case System.get_env("DATABASE_SSL_CACERTFILE") do
nil ->
if db_ssl_verify == "peer" do
default_cacertfile =
[
"/etc/ssl/certs/ca-certificates.crt",
"/etc/pki/tls/certs/ca-bundle.crt",
"/etc/ssl/cert.pem"
]
|> Enum.find(&File.exists?/1)
case default_cacertfile do
nil -> db_ssl_opts
path -> Keyword.put(db_ssl_opts, :cacertfile, path)
end
else
db_ssl_opts
end
"" ->
db_ssl_opts
cacertfile ->
Keyword.put(db_ssl_opts, :cacertfile, cacertfile)
end
else
false
end
db_prepare =
case System.get_env("DB_PREPARE", "named") |> String.downcase() do
"named" ->
:named
"unnamed" ->
:unnamed
value ->
raise """
invalid DB_PREPARE value: #{value}
Expected one of: named, unnamed
"""
end
db_application_name =
case System.get_env("DB_APPLICATION_NAME") do
nil -> "elektrine"
"" -> "elektrine"
value -> value
end
env_int = fn name, default ->
case System.get_env(name) do
nil ->
default
"" ->
default
value ->
case Integer.parse(value) do
{int, ""} when int > 0 -> int
_ -> default
end
end
end
pool_size = env_int.("POOL_SIZE", 10)
queue_target_ms = env_int.("DB_QUEUE_TARGET_MS", 2_000)
queue_interval_ms = env_int.("DB_QUEUE_INTERVAL_MS", 5_000)
query_timeout_ms = env_int.("DB_TIMEOUT_MS", 30_000)
pool_timeout_ms = env_int.("DB_POOL_TIMEOUT_MS", 15_000)
connect_timeout_ms = env_int.("DB_CONNECT_TIMEOUT_MS", 15_000)
ap_inbox_max_per_ip_per_minute = env_int.("AP_INBOX_MAX_PER_IP_PER_MINUTE", 10)
ap_inbox_max_per_domain_per_minute = env_int.("AP_INBOX_MAX_PER_DOMAIN_PER_MINUTE", 20)
ap_inbox_max_global_per_second = env_int.("AP_INBOX_MAX_GLOBAL_PER_SECOND", 4)
# SSL configuration for PostgreSQL.
# Defaults to certificate verification (DATABASE_SSL_VERIFY=peer).
# To disable verification for private-network deployments, set DATABASE_SSL_VERIFY=none.
# To disable TLS entirely for internal-only databases, set DATABASE_SSL_ENABLED=false.
config :elektrine, Elektrine.Repo,
ssl: db_ssl_opts,
url: database_url,
pool_size: pool_size,
queue_target: queue_target_ms,
queue_interval: queue_interval_ms,
timeout: query_timeout_ms,
pool_timeout: pool_timeout_ms,
connect_timeout: connect_timeout_ms,
socket_options: maybe_ipv6 ++ [keepalive: true],
prepare: db_prepare,
parameters: [application_name: db_application_name]
config :elektrine, Elektrine.ActivityPub.InboxRateLimiter,
max_per_minute: ap_inbox_max_per_ip_per_minute,
max_per_domain_per_minute: ap_inbox_max_per_domain_per_minute,
max_global_per_second: ap_inbox_max_global_per_second
# The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you
# want to use a different value for prod and you most likely don't want
# to check this value into version control, so we use an environment
# variable instead.
secret_key_base =
RuntimeSecrets.secret_key_base(runtime_env) ||
raise """
one of SECRET_KEY_BASE or ELEKTRINE_MASTER_SECRET must be set.
ELEKTRINE_MASTER_SECRET is required when deriving internal runtime secrets.
"""
session_signing_salt =
RuntimeSecrets.session_signing_salt(runtime_env) ||
raise """
one of SESSION_SIGNING_SALT or ELEKTRINE_MASTER_SECRET must be set.
ELEKTRINE_MASTER_SECRET is required when deriving internal runtime secrets.
"""
RuntimeSecrets.session_encryption_salt(runtime_env) ||
raise """
one of SESSION_ENCRYPTION_SALT or ELEKTRINE_MASTER_SECRET must be set.
Without a session encryption salt the session cookie would be signed but
not encrypted in production.
"""
normalize_domain = fn domain ->
domain
|> String.trim()
|> String.downcase()
|> String.trim_leading("www.")
end
parse_domain_list = fn value, default ->
domains =
case value do
nil -> default
"" -> default
raw -> String.split(raw, ",", trim: true)
end
domains
|> Enum.map(normalize_domain)
|> Enum.reject(&(&1 == ""))
|> Enum.uniq()
end
parse_origin_list = fn value ->
case value do
nil ->
[]
"" ->
[]
raw ->
raw
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> Enum.uniq()
end
end
primary_domain_env = System.get_env("PRIMARY_DOMAIN")
primary_domain =
case primary_domain_env do
nil ->
raise """
environment variable PRIMARY_DOMAIN is missing.
Set PRIMARY_DOMAIN to the main public domain for this instance, for example: example.com
"""
value ->
normalize_domain.(value)
end
email_domain =
case System.get_env("EMAIL_DOMAIN") do
nil -> primary_domain
"" -> primary_domain
value -> normalize_domain.(value)
end
official_elektrine_domains = [
"elektrine.com",
"elektrine.net",
"elektrine.org",
"maidcorps.net"
]
default_supported_domains =
if primary_domain in official_elektrine_domains or email_domain in official_elektrine_domains do
official_elektrine_domains
else
[email_domain]
end
supported_domains_env =
System.get_env("SUPPORTED_DOMAINS") || System.get_env("EMAIL_SUPPORTED_DOMAINS")
configured_supported_domains =
parse_domain_list.(supported_domains_env, default_supported_domains)
receive_only_email_domains =
configured_supported_domains
|> Enum.reject(&(&1 in [primary_domain, email_domain] or &1 in official_elektrine_domains))
|> Enum.uniq()
supported_email_domains =
([primary_domain] ++ configured_supported_domains)
|> Enum.reject(&(&1 in receive_only_email_domains))
|> Enum.uniq()
profile_domains_env = System.get_env("PROFILE_BASE_DOMAINS")
profile_base_domains =
parse_domain_list.(profile_domains_env, [primary_domain])
|> Enum.reject(&(&1 in receive_only_email_domains))
public_base_url_env =
first_present_env.(["PUBLIC_BASE_URL", "APP_BASE_URL", "PHX_PUBLIC_URL", "NGROK_URL"])
public_base_uri =
case public_base_url_env do
value when is_binary(value) and value != "" -> URI.parse(String.trim(value))
_ -> nil
end
host =
(public_base_uri && public_base_uri.host) ||
System.get_env("PHX_HOST") ||
primary_domain
public_scheme =
case public_base_uri do
%URI{scheme: scheme} when is_binary(scheme) and scheme != "" -> scheme
_ -> "https"
end
public_port =
case public_base_uri do
%URI{port: port} when is_integer(port) -> port
_ -> 443
end
host_domain = normalize_domain.(host)
admin_host_domain =
case System.get_env("CADDY_ADMIN_HOST") do
value when is_binary(value) and value != "" -> normalize_domain.(value)
_ -> nil
end
all_public_domains =
([host_domain, admin_host_domain] ++ supported_email_domains ++ profile_base_domains)
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
custom_domain_mx_host =
case System.get_env("CUSTOM_DOMAIN_MX_HOST") do
nil -> normalize_domain.("mail.#{email_domain}")
"" -> normalize_domain.("mail.#{email_domain}")
value -> normalize_domain.(value)
end
custom_domain_mx_priority = parse_int_env.("CUSTOM_DOMAIN_MX_PRIORITY", 10)
custom_domain_spf_include =
case System.get_env("CUSTOM_DOMAIN_SPF_INCLUDE") do
nil -> nil
"" -> nil
value -> normalize_domain.(value)
end
custom_domain_dkim_selector =
case System.get_env("CUSTOM_DOMAIN_DKIM_SELECTOR") do
nil -> "default"
"" -> "default"
value -> value
end
custom_domain_dkim_sync_enabled = parse_bool_env.("CUSTOM_DOMAIN_DKIM_SYNC_ENABLED", true)
custom_domain_haraka_base_url =
case first_present_env.(["CUSTOM_DOMAIN_HARAKA_BASE_URL", "HARAKA_BASE_URL"]) do
nil -> "https://mail.#{email_domain}"
value -> String.trim_trailing(value, "/")
end
custom_domain_haraka_api_key =
case first_present_env.([
"CUSTOM_DOMAIN_HARAKA_API_KEY",
"HARAKA_HTTP_API_KEY",
"HARAKA_OUTBOUND_API_KEY",
"HARAKA_API_KEY"
]) do
nil -> derived_internal_api_key
value -> value
end
custom_domain_haraka_timeout = parse_int_env.("CUSTOM_DOMAIN_HARAKA_TIMEOUT_MS", 10_000)
max_retained_raw_source_bytes =
case System.get_env("EMAIL_RAW_SOURCE_MAX_BYTES") do
nil ->
Keyword.get(
Application.get_env(:elektrine, :email, []),
:max_retained_raw_source_bytes,
0
)
"" ->
0
value ->
case Integer.parse(String.trim(value)) do
{n, ""} when n >= 0 -> n
_ -> 0
end
end
store_inbound_remote_ip =
case System.get_env("EMAIL_STORE_INBOUND_REMOTE_IP") do
nil ->
Keyword.get(Application.get_env(:elektrine, :email, []), :store_inbound_remote_ip, false)
"" ->
false
value when value in ["1", "true", "TRUE", "yes", "YES", "on", "ON"] ->
true
_ ->
false
end
parse_non_neg_int = fn env_name, default ->
case System.get_env(env_name) do
nil ->
default
"" ->
default
value ->
case Integer.parse(String.trim(value)) do
{n, ""} when n >= 0 -> n
_ -> default
end
end
end
email_cfg = Application.get_env(:elektrine, :email, [])
trash_retention_days =
parse_non_neg_int.(
"EMAIL_TRASH_RETENTION_DAYS",
Keyword.get(email_cfg, :trash_retention_days, 30)
)
spam_retention_days =
parse_non_neg_int.(
"EMAIL_SPAM_RETENTION_DAYS",
Keyword.get(email_cfg, :spam_retention_days, 30)
)
inbox_retention_days =
parse_non_neg_int.(
"EMAIL_INBOX_RETENTION_DAYS",
Keyword.get(email_cfg, :inbox_retention_days, 0)
)
retention_batch_size =
parse_non_neg_int.(
"EMAIL_RETENTION_BATCH_SIZE",
Keyword.get(email_cfg, :retention_batch_size, 500)
)
|> then(fn
0 -> 500
n -> n
end)
custom_domain_haraka_dkim_path =
case System.get_env("CUSTOM_DOMAIN_HARAKA_DKIM_PATH") do
nil -> "/api/v1/dkim/domains"
"" -> "/api/v1/dkim/domains"
value -> value
end
custom_domain_dmarc_policy =
case System.get_env("CUSTOM_DOMAIN_DMARC_POLICY") do
nil -> "quarantine"
"" -> "quarantine"
value -> String.downcase(value)
end
custom_domain_dmarc_rua =
case System.get_env("CUSTOM_DOMAIN_DMARC_RUA") do
nil -> nil
"" -> nil
value -> value
end
custom_domain_dmarc_adkim =
case System.get_env("CUSTOM_DOMAIN_DMARC_ADKIM") do
nil -> "s"
"" -> "s"
value -> String.downcase(value)
end
custom_domain_dmarc_aspf =
case System.get_env("CUSTOM_DOMAIN_DMARC_ASPF") do
nil -> "s"
"" -> "s"
value -> String.downcase(value)
end
config :elektrine, :email,
domain: email_domain,
max_retained_raw_source_bytes: max_retained_raw_source_bytes,
store_inbound_remote_ip: store_inbound_remote_ip,
trash_retention_days: trash_retention_days,
spam_retention_days: spam_retention_days,
inbox_retention_days: inbox_retention_days,
retention_batch_size: retention_batch_size,
allow_insecure_receiver_webhook: false,
receiver_webhook_secret: derived_receiver_webhook_secret,
internal_signing_secret: derived_haraka_signing_secret,
supported_domains: supported_email_domains,
receive_only_domains: receive_only_email_domains,
custom_domain_mx_host: custom_domain_mx_host,
custom_domain_mx_priority: custom_domain_mx_priority,
custom_domain_spf_include: custom_domain_spf_include,
custom_domain_dkim_selector: custom_domain_dkim_selector,
custom_domain_dkim_sync_enabled: custom_domain_dkim_sync_enabled,
custom_domain_haraka_base_url: custom_domain_haraka_base_url,
custom_domain_haraka_api_key: custom_domain_haraka_api_key,
custom_domain_haraka_timeout: custom_domain_haraka_timeout,
custom_domain_haraka_dkim_path: custom_domain_haraka_dkim_path,
custom_domain_dmarc_policy: custom_domain_dmarc_policy,
custom_domain_dmarc_rua: custom_domain_dmarc_rua,
custom_domain_dmarc_adkim: custom_domain_dmarc_adkim,
custom_domain_dmarc_aspf: custom_domain_dmarc_aspf
config :elektrine, :profile_base_domains, profile_base_domains
config :elektrine, :primary_domain, primary_domain
port = String.to_integer(System.get_env("PORT") || "4000")
http_ip_env = System.get_env("PHX_HTTP_IP") || "0.0.0.0"
http_ip =
case :inet.parse_address(String.to_charlist(String.trim(http_ip_env))) do
{:ok, ip} -> ip
_ -> {0, 0, 0, 0}
end
onion_tls_port = String.to_integer(System.get_env("ONION_TLS_PORT") || "8443")
onion_tls_certfile = System.get_env("ONION_TLS_CERTFILE") || "/data/certs/live/onion-cert.pem"
onion_tls_keyfile = System.get_env("ONION_TLS_KEYFILE") || "/data/certs/live/onion-key.pem"
onion_tls_enabled =
case System.get_env("ONION_TLS_ENABLED", "true") do
value when value in ["1", "true", "TRUE", "yes", "YES", "on", "ON"] -> true
_ -> false
end
config :elektrine, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
endpoint_http = [
ip: http_ip,
port: port,
http_1_options: [
max_header_count: 50,
max_header_length: 32_768
]
]
onion_https =
if onion_tls_enabled and File.regular?(onion_tls_certfile) and
File.regular?(onion_tls_keyfile) do
[
ip: {0, 0, 0, 0},
port: onion_tls_port,
cipher_suite: :strong,
certfile: onion_tls_certfile,
keyfile: onion_tls_keyfile,
http_1_options: [
max_header_count: 50,
max_header_length: 32_768
]
]
else
nil
end
# The Tor onion mirror is reached over http:// (and https:// when ONION_TLS is
# on). Its origin must be allow-listed for the LiveView WebSocket; without it
# the socket handshake is rejected and the client falls back to longpoll. We
# only trust the operator's own ONION_HOST, never every .onion, so a hostile
# onion site can't hijack the socket (CSWSH).
onion_origins =
case System.get_env("ONION_HOST") do
value when is_binary(value) and value != "" ->
host = normalize_domain.(value)
["http://#{host}", "https://#{host}"]
_ ->
[]
end
# Allowed origins for WebSocket connections. Keep defaults to exact app hosts;
# add any required profile/onion/custom origins through EXTRA_CHECK_ORIGINS.
allowed_origins =
all_public_domains
|> Enum.flat_map(fn domain ->
[
"https://#{domain}",
"https://www.#{domain}"
]
end)
|> Kernel.++(onion_origins)
|> Kernel.++(parse_origin_list.(System.get_env("EXTRA_CHECK_ORIGINS")))
|> Enum.uniq()
endpoint_config = [
url: [host: host, port: public_port, scheme: public_scheme],
http: endpoint_http,
secret_key_base: secret_key_base,
live_view: [signing_salt: session_signing_salt],
check_origin: allowed_origins
]
endpoint_config =
if onion_https do
Keyword.put(endpoint_config, :https, onion_https)
else
endpoint_config
end
# Clearnet traffic usually terminates TLS at the reverse proxy.
# Onion traffic can terminate TLS in-app on :https when cert/key files are present.
config :elektrine, ElektrineWeb.Endpoint, endpoint_config
# WebAuthn/Passkey configuration for production
# Uses the PHX_HOST environment variable for RP ID
config :elektrine,
passkey_rp_id: host,
passkey_origin: "https://#{host}"
config :elektrine, :admin_security,
require_passkey: parse_bool_env.("ADMIN_REQUIRE_PASSKEY", true),
access_ttl_seconds: parse_int_env.("ADMIN_ACCESS_TTL_SECONDS", 15 * 60),
elevation_ttl_seconds: parse_int_env.("ADMIN_ELEVATION_TTL_SECONDS", 5 * 60),
action_grant_ttl_seconds: parse_int_env.("ADMIN_ACTION_GRANT_TTL_SECONDS", 90),
intent_ttl_seconds: parse_int_env.("ADMIN_INTENT_TTL_SECONDS", 3 * 60),
replay_ttl_seconds: parse_int_env.("ADMIN_ACTION_REPLAY_TTL_SECONDS", 10 * 60)
config :elektrine, :atomine_pow,
difficulty: parse_int_env.("ATOMINE_POW_DIFFICULTY", 20),
skip_verification: parse_bool_env.("ATOMINE_POW_SKIP_VERIFICATION", false)
config :elektrine, :atomine_gate,
enabled: parse_bool_env.("ATOMINE_GATE_ENABLED", false),
difficulty: parse_int_env.("ATOMINE_GATE_DIFFICULTY", 20),
clearance_ttl_seconds: parse_int_env.("ATOMINE_GATE_CLEARANCE_TTL_SECONDS", 12 * 60 * 60)
# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
# to your endpoint configuration:
#
# config :elektrine, ElektrineWeb.Endpoint,
# https: [
# ...,
# port: 443,
# cipher_suite: :strong,
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
# ]
#
# The `cipher_suite` is set to `:strong` to support only the
# latest and more secure SSL ciphers. This means old browsers
# and clients may not be supported. You can set it to
# `:compatible` TLS mode for broader client support.
#
# `:keyfile` and `:certfile` expect an absolute path to the key
# and cert in disk or a relative path inside priv, for example
# "priv/ssl/server.key". For all supported SSL configuration
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
#
# We also recommend setting `force_ssl` in your config/prod.exs,
# ensuring no data is ever sent via http, always redirecting to https:
#
# config :elektrine, ElektrineWeb.Endpoint,
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
Code.eval_file(Path.expand("runtime/uploads.exs", __DIR__))
end
Code.eval_file(Path.expand("runtime/mail_protocols.exs", __DIR__))
Code.eval_file(Path.expand("runtime/dns.exs", __DIR__))
Code.eval_file(Path.expand("runtime/vpn.exs", __DIR__))
Code.eval_file(Path.expand("runtime/messaging_federation.exs", __DIR__))
Code.eval_file(Path.expand("runtime/stripe.exs", __DIR__))
Code.eval_file(Path.expand("runtime/monero.exs", __DIR__))