feat(privacy): harden session logs, admin access, and docker isolation
Some checks failed
Deploy Docker Images / Build, push, and deploy (push) Failing after 23m0s

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.
This commit is contained in:
maxfield 2026-08-02 22:55:08 -04:00
parent 663f0d0676
commit da169a3869
55 changed files with 2295 additions and 1038 deletions

View file

@ -59,6 +59,7 @@ PORT=8080
# -----------------------------------------------------------------------------
# DB_PASSWORD=<generate-a-long-random-secret>
# Keep this file owner-read/write only (deploy scripts chmod 600 when they can).
# For the default Docker stack, `DB_PASSWORD` is enough. Set `DATABASE_URL`
# only when you use an external Postgres instance or non-Docker deploy.
@ -165,6 +166,18 @@ DOCKER_PROFILES=caddy dns email tor turn bluesky vpn
# IMAP_HOST=mail.example.com
# SMTP_HOST=mail.example.com
# Privacy defaults (match config.exs):
# Never keep a second RFC822 copy (set e.g. 10485760 to enable "view original").
# EMAIL_RAW_SOURCE_MAX_BYTES=0
# Connecting MTA IP on durable message metadata / Oban jobs (off by default).
# EMAIL_STORE_INBOUND_REMOTE_IP=false
# Hard-delete trash/spam after N days (0 = skip that bucket). Inbox max age 0 = keep forever.
# EMAIL_TRASH_RETENTION_DAYS=30
# EMAIL_SPAM_RETENTION_DAYS=30
# EMAIL_INBOX_RETENTION_DAYS=0
# EMAIL_RETENTION_BATCH_SIZE=500
# Optional overrides. If omitted, Elektrine derives these from your domain and
# ELEKTRINE_MASTER_SECRET automatically.
# HARAKA_BASE_URL=https://mail.example.com
@ -227,6 +240,14 @@ ACME_EMAIL=admin@example.com
# ATOMINE_POW_SKIP_VERIFICATION=false
# STRIPE_SECRET_KEY=
# STRIPE_WEBHOOK_SECRET=
# -----------------------------------------------------------------------------
# Monero payments (public display for no-KYC registration / billing copy)
# -----------------------------------------------------------------------------
# Set an address and/or a checkout URL (for example BTCPay Monero).
# MONERO_ENABLED=true
# MONERO_ADDRESS=
# MONERO_PAYMENT_URL=
# DNS_RECURSIVE_ENABLED=true
# -----------------------------------------------------------------------------
@ -252,6 +273,14 @@ ACME_EMAIL=admin@example.com
# allocations.
# VPN_WG_SUPERNET=10.8.0.0/16
# Session privacy (defaults match a no-session-log posture):
# Leave off unless you need durable connect/disconnect history on the control
# plane. Free-tier bandwidth still uses aggregate counters without this.
# VPN_DURABLE_SESSION_LOGS=false
# When durable logs are on, delete rows older than N days (0 = keep until purge).
# When durable logs are off, the daily cleanup job deletes every row.
# VPN_CONNECTION_LOG_RETENTION_DAYS=0
# -----------------------------------------------------------------------------
# Onion / Tor
# -----------------------------------------------------------------------------

1
.gitignore vendored
View file

@ -83,3 +83,4 @@ deps/
.elixir_ls/
!.env*.example
.claude/**/worktrees/
.onion-vanity/

View file

@ -5,6 +5,7 @@ defmodule ArblargWeb.Admin.ChatMessagesController do
use ArblargWeb, :controller
alias Elektrine.Admin.ContentAccess
alias Elektrine.{Messaging, Repo}
import Ecto.Query
@ -104,7 +105,7 @@ defmodule ArblargWeb.Admin.ChatMessagesController do
message =
message
|> Repo.preload(:conversation)
|> Messaging.ChatMessage.decrypt_content()
|> prepare_admin_chat_message()
|> Map.put(:protocol_kind, protocol_kind(message))
log_admin_chat_message_view(conn, message, "html", "admin_arblarg_messages")
@ -135,14 +136,14 @@ defmodule ArblargWeb.Admin.ChatMessagesController do
message =
message
|> Repo.preload(:conversation)
|> Messaging.ChatMessage.decrypt_content()
|> prepare_admin_chat_message()
|> Map.put(:protocol_kind, protocol_kind(message))
log_admin_chat_message_view(conn, message, "raw", "admin_arblarg_messages")
conn
|> put_resp_content_type("text/plain")
|> send_resp(200, get_raw_chat_message_content(message))
|> send_resp(200, ContentAccess.chat_redacted_notice())
end
{:error, _} ->
@ -238,92 +239,6 @@ defmodule ArblargWeb.Admin.ChatMessagesController do
defp present?(value), do: Elektrine.Strings.present?(value)
defp get_raw_chat_message_content(message) do
conversation = Map.get(message, :conversation)
sender = Map.get(message, :sender)
media_urls =
message.media_urls
|> List.wrap()
|> Enum.join("\n")
|> case do
"" -> "(No media URLs)"
value -> value
end
media_metadata =
case message.media_metadata do
value when is_map(value) and map_size(value) > 0 -> Jason.encode!(value, pretty: true)
_ -> "(No media metadata)"
end
"""
===============================================================================
ARBLARG CHAT MESSAGE
===============================================================================
MESSAGE FIELDS:
---------------
ID: #{message.id}
Message Type: #{message.message_type || "text"}
Protocol Kind: #{message.protocol_kind}
Conversation ID: #{message.conversation_id || "N/A"}
Sender ID: #{message.sender_id || "N/A"}
Reply To ID: #{message.reply_to_id || "(None)"}
Federated Source: #{message.federated_source || "(None)"}
Origin Domain: #{message.origin_domain || "(None)"}
Mirrored: #{message.is_federated_mirror || false}
Edited At: #{message.edited_at || "(Never)"}
Inserted At: #{message.inserted_at}
Updated At: #{message.updated_at}
CONVERSATION:
-------------
#{if conversation do
"""
Name: #{conversation.name || "(No name)"}
Type: #{conversation.type || "(Unknown)"}
Public: #{conversation.is_public || false}
Hash: #{conversation.hash || "(No hash)"}
"""
else
"Conversation not loaded"
end}
SENDER:
-------
#{if sender do
"""
Username: #{sender.username}
Handle: #{sender.handle || "(No handle)"}
Is Admin: #{sender.is_admin || false}
"""
else
"Sender not loaded"
end}
CONTENT:
--------
#{message.content || "(No text content)"}
MEDIA URLS:
-----------
#{media_urls}
MEDIA METADATA:
---------------
#{media_metadata}
RAW STRUCT:
-----------
#{inspect(message, pretty: true, limit: :infinity)}
===============================================================================
END OF ARBLARG CHAT MESSAGE
===============================================================================
"""
end
defp log_admin_chat_messages_index(
conn,
messages,
@ -387,6 +302,10 @@ defmodule ArblargWeb.Admin.ChatMessagesController do
end
end
defp prepare_admin_chat_message(message) do
%{message | content: ContentAccess.chat_redacted_notice(), encrypted_content: nil}
end
defp get_remote_ip(conn) do
ElektrineWeb.ClientIP.client_ip(conn)
end

View file

@ -0,0 +1,12 @@
defmodule Elektrine.Admin.ContentAccess do
@moduledoc """
Admin UIs show metadata only. Message and chat bodies are never decrypted
for operators.
"""
@email_redacted "[Message body is not available in admin.]"
@chat_redacted "[Chat message body is not available in admin.]"
def email_redacted_notice, do: @email_redacted
def chat_redacted_notice, do: @chat_redacted
end

View file

@ -0,0 +1,65 @@
defmodule Elektrine.Payments.Crypto do
@moduledoc """
Public crypto payment settings for registration and billing copy.
Configure with environment variables (see `config/runtime.exs`):
* `MONERO_ADDRESS` primary receive address shown to users
* `MONERO_PAYMENT_URL` optional external checkout (for example BTCPay Monero)
* `MONERO_ENABLED` force on/off (`true`/`false`). Default: on when an address is set
"""
@doc "True when Monero payment details are configured for public display."
def monero_enabled? do
case Application.get_env(:elektrine, :monero_payments, []) do
opts when is_list(opts) ->
enabled? = Keyword.get(opts, :enabled)
cond do
enabled? == false -> false
enabled? == true -> monero_address() != nil or monero_payment_url() != nil
true -> monero_address() != nil or monero_payment_url() != nil
end
_ ->
false
end
end
@doc "Monero receive address, or nil."
def monero_address do
:elektrine
|> Application.get_env(:monero_payments, [])
|> Keyword.get(:address)
|> present_string()
end
@doc "Optional external Monero checkout URL, or nil."
def monero_payment_url do
:elektrine
|> Application.get_env(:monero_payments, [])
|> Keyword.get(:payment_url)
|> present_string()
end
@doc "Short public summary for FAQ and registration."
def monero_public_summary do
cond do
url = monero_payment_url() ->
"Pay with Monero at #{url}. No government identity is required."
addr = monero_address() ->
"Pay with Monero to #{addr}. Contact support with the transaction id after payment."
true ->
"This deployment has no Monero payment address configured."
end
end
defp present_string(value) when is_binary(value) do
trimmed = String.trim(value)
if trimmed == "", do: nil, else: trimmed
end
defp present_string(_), do: nil
end

View file

@ -0,0 +1,13 @@
defmodule Elektrine.Repo.Migrations.NullVpnConnectionLogClientIps do
use Ecto.Migration
def up do
# Privacy: wipe any historical client source IPs. The app no longer
# writes this column; ConnectionLogCleanupWorker keeps residual rows clean.
execute("UPDATE vpn_connection_logs SET client_ip = NULL WHERE client_ip IS NOT NULL")
end
def down do
:ok
end
end

View file

@ -0,0 +1,12 @@
defmodule Elektrine.Admin.ContentAccessTest do
use ExUnit.Case, async: true
alias Elektrine.Admin.ContentAccess
test "redaction notices are stable non-empty strings" do
assert is_binary(ContentAccess.email_redacted_notice())
assert ContentAccess.email_redacted_notice() != ""
assert is_binary(ContentAccess.chat_redacted_notice())
assert ContentAccess.chat_redacted_notice() != ""
end
end

View file

@ -0,0 +1,50 @@
defmodule Elektrine.Payments.CryptoTest do
use ExUnit.Case, async: true
alias Elektrine.Payments.Crypto
setup do
previous = Application.get_env(:elektrine, :monero_payments)
on_exit(fn ->
if previous do
Application.put_env(:elektrine, :monero_payments, previous)
else
Application.delete_env(:elektrine, :monero_payments)
end
end)
:ok
end
test "monero_enabled? is false without address" do
Application.put_env(:elektrine, :monero_payments,
enabled: nil,
address: nil,
payment_url: nil
)
refute Crypto.monero_enabled?()
end
test "monero_enabled? is true with address" do
Application.put_env(:elektrine, :monero_payments,
enabled: nil,
address: "4TestMoneroAddress",
payment_url: nil
)
assert Crypto.monero_enabled?()
assert Crypto.monero_address() == "4TestMoneroAddress"
end
test "enabled false forces off even with address" do
Application.put_env(:elektrine, :monero_payments,
enabled: false,
address: "4TestMoneroAddress",
payment_url: nil
)
refute Crypto.monero_enabled?()
end
end

View file

@ -0,0 +1,139 @@
defmodule Elektrine.Email.MessageRetentionWorker do
@moduledoc """
Hard-deletes old trash and spam mail to shrink the durable attack surface.
Defaults (override with config or env):
- trash: 30 days after soft-delete (`deleted == true`)
- spam: 30 days after insert while still marked spam
- inbox retention: off (`0`) never auto-delete non-spam non-trash mail
Bodies for normal mail remain server-decryptable until deleted; shorter
retention is the practical way to limit years of readable history.
"""
use Oban.Worker, queue: :default, max_attempts: 1
import Ecto.Query
require Logger
alias Elektrine.Email.Message
alias Elektrine.Email.Messages
alias Elektrine.Repo
@default_batch 500
@impl Oban.Worker
def perform(%Oban.Job{}) do
now = DateTime.utc_now() |> DateTime.truncate(:second)
batch = batch_size()
trash_deleted = purge_trash(now, batch)
spam_deleted = purge_spam(now, batch)
inbox_deleted = purge_inbox(now, batch)
total = trash_deleted + spam_deleted + inbox_deleted
if total > 0 do
Logger.info(
"Mail retention purged #{total} message(s) (trash=#{trash_deleted}, spam=#{spam_deleted}, inbox=#{inbox_deleted})"
)
end
:ok
end
defp purge_trash(now, batch) do
case retention_days(:trash_retention_days, 30) do
days when is_integer(days) and days > 0 ->
cutoff = DateTime.add(now, -days * 86_400, :second)
ids =
from(m in Message,
where: m.deleted == true and m.updated_at < ^cutoff,
order_by: [asc: m.updated_at],
limit: ^batch,
select: m.id
)
|> Repo.all()
hard_delete_ids(ids)
_ ->
0
end
end
defp purge_spam(now, batch) do
case retention_days(:spam_retention_days, 30) do
days when is_integer(days) and days > 0 ->
cutoff = DateTime.add(now, -days * 86_400, :second)
ids =
from(m in Message,
where: m.spam == true and m.deleted == false and m.inserted_at < ^cutoff,
order_by: [asc: m.inserted_at],
limit: ^batch,
select: m.id
)
|> Repo.all()
hard_delete_ids(ids)
_ ->
0
end
end
defp purge_inbox(now, batch) do
case retention_days(:inbox_retention_days, 0) do
days when is_integer(days) and days > 0 ->
cutoff = DateTime.add(now, -days * 86_400, :second)
ids =
from(m in Message,
where:
m.deleted == false and m.spam == false and m.status in ["received", "sent"] and
m.inserted_at < ^cutoff,
order_by: [asc: m.inserted_at],
limit: ^batch,
select: m.id
)
|> Repo.all()
hard_delete_ids(ids)
_ ->
0
end
end
defp hard_delete_ids([]), do: 0
defp hard_delete_ids(ids) do
Enum.reduce(ids, 0, fn id, acc ->
case Messages.delete_message(id) do
{:ok, _} -> acc + 1
_ -> acc
end
end)
end
defp retention_days(key, default) do
Application.get_env(:elektrine, :email, [])
|> Keyword.get(key, default)
|> case do
n when is_integer(n) and n >= 0 -> n
_ -> default
end
end
defp batch_size do
Application.get_env(:elektrine, :email, [])
|> Keyword.get(:retention_batch_size, @default_batch)
|> case do
n when is_integer(n) and n > 0 -> n
_ -> @default_batch
end
end
end

View file

@ -13,7 +13,8 @@ defmodule Elektrine.Email.Messages do
alias Elektrine.Repo
alias Elektrine.Telemetry.Events
@default_max_retained_raw_source_bytes 10 * 1024 * 1024
# Privacy default: never keep a second RFC822 copy unless configured.
@default_max_retained_raw_source_bytes 0
# Private helper to decrypt email messages
defp decrypt_email_messages(messages, mailbox_id) when is_list(messages) do
@ -1434,29 +1435,51 @@ defmodule Elektrine.Email.Messages do
raw_source_size + attachments_size
end
@doc "Returns the maximum raw RFC822 source size retained with a message."
@doc """
Maximum raw RFC822 source size retained with a message.
`0` means never retain a second copy of the source (privacy default).
"""
def max_retained_raw_source_bytes do
:elektrine
|> Application.get_env(:email, [])
|> Keyword.get(:max_retained_raw_source_bytes, @default_max_retained_raw_source_bytes)
|> case do
value when is_integer(value) and value > 0 -> value
value when is_integer(value) and value >= 0 -> value
_ -> @default_max_retained_raw_source_bytes
end
end
@doc """
Whether inbound ingest may store the connecting MTA IP on message metadata.
Default is false. Rate limiting may still use the IP for the live request.
"""
def store_inbound_remote_ip? do
:elektrine
|> Application.get_env(:email, [])
|> Keyword.get(:store_inbound_remote_ip, false) == true
end
defp apply_raw_source_retention(attrs) when is_map(attrs) do
case get_attr(attrs, :raw_source) do
raw_source when is_binary(raw_source) and byte_size(raw_source) > 0 ->
source_size = byte_size(raw_source)
limit = max_retained_raw_source_bytes()
if source_size <= limit do
attrs
else
attrs
|> delete_attr(:raw_source)
|> put_raw_source_omission_metadata(source_size, limit)
cond do
limit == 0 ->
attrs
|> delete_attr(:raw_source)
|> put_raw_source_omission_metadata(source_size, limit, "disabled")
source_size <= limit ->
attrs
true ->
attrs
|> delete_attr(:raw_source)
|> put_raw_source_omission_metadata(source_size, limit, "size_limit")
end
_ ->
@ -1464,7 +1487,7 @@ defmodule Elektrine.Email.Messages do
end
end
defp put_raw_source_omission_metadata(attrs, source_size, limit) do
defp put_raw_source_omission_metadata(attrs, source_size, limit, reason) do
metadata = get_attr(attrs, :metadata) || %{}
metadata =
@ -1472,7 +1495,7 @@ defmodule Elektrine.Email.Messages do
|> Map.put("raw_source_retained", false)
|> Map.put("raw_source_original_bytes", source_size)
|> Map.put("raw_source_retention_limit_bytes", limit)
|> Map.put("raw_source_omitted_reason", "size_limit")
|> Map.put("raw_source_omitted_reason", reason)
put_attr(attrs, :metadata, metadata)
end

View file

@ -35,12 +35,13 @@ defmodule ElektrineEmail.HarakaInboundWorker do
payload = PayloadSanitizer.strip_postgres_null_bytes(payload)
idempotency_key = idempotency_key(payload)
args = %{
"payload" => payload,
"received_at" => DateTime.utc_now() |> DateTime.to_iso8601(),
"remote_ip" => normalize_remote_ip(Keyword.get(opts, :remote_ip)),
"idempotency_key" => idempotency_key
}
args =
%{
"payload" => payload,
"received_at" => DateTime.utc_now() |> DateTime.to_iso8601(),
"idempotency_key" => idempotency_key
}
|> maybe_put_remote_ip(Keyword.get(opts, :remote_ip))
case args |> new() |> Elektrine.JobQueue.insert() do
{:ok, job} ->
@ -57,13 +58,14 @@ defmodule ElektrineEmail.HarakaInboundWorker do
start_time = System.monotonic_time(:millisecond)
payload = PayloadSanitizer.strip_postgres_null_bytes(job.args["payload"] || %{})
ingest_context = %{
"ingest_mode" => "async",
"job_id" => job.id,
"received_at" => job.args["received_at"],
"idempotency_key" => job.args["idempotency_key"],
"remote_ip" => job.args["remote_ip"]
}
ingest_context =
%{
"ingest_mode" => "async",
"job_id" => job.id,
"received_at" => job.args["received_at"],
"idempotency_key" => job.args["idempotency_key"]
}
|> maybe_put_remote_ip_value(job.args["remote_ip"])
queue_lag_ms = queue_lag_ms(job.args["received_at"])
@ -142,6 +144,23 @@ defmodule ElektrineEmail.HarakaInboundWorker do
defp normalize_subject(subject) when is_binary(subject), do: subject
defp normalize_subject(_), do: ""
defp maybe_put_remote_ip(args, remote_ip) do
if Elektrine.Email.Messages.store_inbound_remote_ip?() do
Map.put(args, "remote_ip", normalize_remote_ip(remote_ip))
else
args
end
end
defp maybe_put_remote_ip_value(context, remote_ip) do
if Elektrine.Email.Messages.store_inbound_remote_ip?() and is_binary(remote_ip) and
remote_ip != "" do
Map.put(context, "remote_ip", remote_ip)
else
context
end
end
defp normalize_remote_ip(nil), do: "unknown"
defp normalize_remote_ip(value) when is_binary(value), do: value
defp normalize_remote_ip(value), do: to_string(value)

View file

@ -6,6 +6,7 @@ defmodule ElektrineEmailWeb.Admin.MessagesController do
use ElektrineEmailWeb, :controller
alias Elektrine.{Accounts, Email, Repo}
alias Elektrine.Admin.ContentAccess
alias ElektrineWeb.AdminSecurity
import Ecto.Query
@ -79,22 +80,14 @@ defmodule ElektrineEmailWeb.Admin.MessagesController do
message = Email.get_message_admin(message_id)
if message do
# Get the user who owns this message
mailbox = Email.get_mailbox_admin(message.mailbox_id)
user = if mailbox, do: Accounts.get_user!(mailbox.user_id), else: nil
# Decrypt message for admin viewing
decrypted_message =
if mailbox && mailbox.user_id do
Elektrine.Email.Message.decrypt_content(message, mailbox.user_id)
else
message
end
prepared = prepare_admin_email(message)
log_admin_email_view(conn, message, mailbox, "html", "admin_messages")
render(conn, :view_message,
message: decrypted_message,
message: prepared,
user: user,
raw_path: signed_message_read_path(conn, message.id, :raw),
iframe_path: signed_message_read_path(conn, message.id, :iframe)
@ -140,14 +133,13 @@ defmodule ElektrineEmailWeb.Admin.MessagesController do
true <- !is_nil(message),
mailbox <- Email.get_mailbox_admin(message.mailbox_id),
true <- mailbox && mailbox.user_id == user_id_int do
# Decrypt message for admin viewing
decrypted_message = Elektrine.Email.Message.decrypt_content(message, mailbox.user_id)
prepared = prepare_admin_email(message)
log_admin_email_view(conn, message, mailbox, "html", "user_scoped")
render(conn, :view_user_message,
user: user,
message: decrypted_message,
message: prepared,
raw_path: signed_user_message_read_path(conn, user.id, message.id, :raw),
iframe_path: signed_message_read_path(conn, message.id, :iframe)
)
@ -163,20 +155,8 @@ defmodule ElektrineEmailWeb.Admin.MessagesController do
message = Email.get_message_admin(message_id)
if message do
# Get the user who owns this message
mailbox = Email.get_mailbox_admin(message.mailbox_id)
_user = if mailbox, do: Accounts.get_user!(mailbox.user_id), else: nil
# Decrypt message for admin viewing
decrypted_message =
if mailbox && mailbox.user_id do
Elektrine.Email.Message.decrypt_content(message, mailbox.user_id)
else
message
end
# Get raw email content from metadata
raw_content = get_raw_email_content(decrypted_message, mailbox && mailbox.user_id)
raw_content = ContentAccess.email_redacted_notice()
log_admin_email_view(conn, message, mailbox, "raw", "admin_messages")
@ -197,11 +177,7 @@ defmodule ElektrineEmailWeb.Admin.MessagesController do
true <- !is_nil(message),
mailbox <- Email.get_mailbox_admin(message.mailbox_id),
true <- mailbox && mailbox.user_id == user_id_int do
# Decrypt message for admin viewing
decrypted_message = Elektrine.Email.Message.decrypt_content(message, mailbox.user_id)
# Get raw email content from metadata
raw_content = get_raw_email_content(decrypted_message, mailbox.user_id)
raw_content = ContentAccess.email_redacted_notice()
log_admin_email_view(conn, message, mailbox, "raw", "user_scoped")
@ -226,38 +202,13 @@ defmodule ElektrineEmailWeb.Admin.MessagesController do
|> send_resp(404, "Message not found")
message ->
# Decrypt message for admin viewing
mailbox = Email.get_mailbox_admin(message.mailbox_id)
decrypted_message =
if mailbox && mailbox.user_id do
Elektrine.Email.Message.decrypt_content(message, mailbox.user_id)
else
message
end
html_content =
if Elektrine.Strings.present?(decrypted_message.html_body) do
# Use proper HTML sanitization to prevent XSS
# Wrap in try/rescue to handle malformed HTML that can crash mochiweb_html parser
try do
Elektrine.Email.Sanitizer.sanitize_html_content(decrypted_message.html_body)
rescue
_ ->
# Fall back to escaped plain text if HTML parsing fails
# html_escape returns {:safe, string} tuple, so we need to extract the string
text_content = decrypted_message.text_body || decrypted_message.html_body || ""
escaped_text =
text_content
|> Phoenix.HTML.html_escape()
|> Phoenix.HTML.safe_to_string()
"<pre style=\"white-space: pre-wrap; font-family: monospace;\">#{escaped_text}</pre>"
end
else
"<p>No HTML content available</p>"
end
ContentAccess.email_redacted_notice()
|> Phoenix.HTML.html_escape()
|> Phoenix.HTML.safe_to_string()
|> then(&"<p>#{&1}</p>")
log_admin_email_view(conn, message, mailbox, "iframe", "admin_messages")
@ -274,6 +225,14 @@ defmodule ElektrineEmailWeb.Admin.MessagesController do
# Private helper functions
defp prepare_admin_email(message) do
%{
message
| text_body: ContentAccess.email_redacted_notice(),
html_body: nil
}
end
defp require_message_read_grant(conn, _opts) do
case AdminSecurity.verify_action_grant(conn, conn.assigns[:current_user]) do
{:ok, conn} ->
@ -469,182 +428,6 @@ defmodule ElektrineEmailWeb.Admin.MessagesController do
defp truthy_param?(value) when value in [true, 1, "1", "true", "on", "yes"], do: true
defp truthy_param?(_), do: false
defp get_raw_email_content(message, user_id) do
case Elektrine.Email.Message.decrypt_raw_source(message, user_id) do
{:ok, raw_source} ->
raw_source
{:error, _reason} ->
case message.metadata && message.metadata["raw_email"] do
raw_source when is_binary(raw_source) -> raw_source
_ -> construct_raw_email_fallback(message)
end
end
end
defp construct_raw_email_fallback(message) do
# Get mailbox and user information
mailbox = if message.mailbox_id, do: Email.get_mailbox_admin(message.mailbox_id), else: nil
user = if mailbox && mailbox.user_id, do: Accounts.get_user!(mailbox.user_id), else: nil
"""
===============================================================================
DATABASE RECORD INFORMATION
===============================================================================
MESSAGE DATABASE FIELDS:
------------------------
ID: #{message.id}
Message-ID: #{message.message_id || "N/A"}
From: #{message.from || "N/A"}
To: #{message.to || "N/A"}
CC: #{message.cc || "(None)"}
BCC: #{message.bcc || "(None)"}
Subject: #{message.subject || "(No Subject)"}
Status: #{message.status || "N/A"}
Category: #{message.category || "N/A"}
FLAGS & STATES:
---------------
Read: #{message.read}
Spam: #{message.spam}
Archived: #{message.archived}
Has Attachments: #{message.has_attachments || false}
Reply Later At: #{message.reply_later_at || "(Not set)"}
TIMESTAMPS:
-----------
Inserted At: #{message.inserted_at}
Updated At: #{message.updated_at}
MAILBOX ASSOCIATION:
--------------------
Mailbox ID: #{message.mailbox_id || "N/A"}
#{if mailbox do
"""
Mailbox Email: #{mailbox.email}
Mailbox Forward To: #{mailbox.forward_to || "(None)"}
Mailbox Forward Enabled: #{mailbox.forward_enabled || false}
Mailbox Created: #{mailbox.inserted_at}
Mailbox Updated: #{mailbox.updated_at}
"""
else
"Mailbox: (Not found or deleted)"
end}
USER ASSOCIATION:
-----------------
User ID: #{(mailbox && mailbox.user_id) || "N/A"}
#{if user do
"""
Username: #{user.username}
Display Name: #{user.display_name || "(Not set)"}
Is Admin: #{user.is_admin}
Banned: #{user.banned}
Two Factor Enabled: #{user.two_factor_enabled}
User Created: #{user.inserted_at}
Last Login: #{user.last_login_at || "(Never)"}
Last Login IP: #{user.last_login_ip || "(Unknown)"}
Login Count: #{user.login_count}
Recovery Email: #{user.recovery_email || "(Not set)"}
Registration IP: #{user.registration_ip || "(Unknown)"}
"""
else
"User: (Not found, deleted, or mailbox is orphaned)"
end}
===============================================================================
EMAIL CONTENT
===============================================================================
--- TEXT BODY ---
#{message.text_body || "(No text content)"}
--- HTML BODY ---
#{message.html_body || "(No HTML content)"}
===============================================================================
METADATA & ATTACHMENTS
===============================================================================
--- FULL METADATA JSON ---
#{if message.metadata, do: Jason.encode!(message.metadata, pretty: true), else: "(No metadata)"}
--- ATTACHMENTS INFO ---
#{format_attachments_info(message.attachments)}
===============================================================================
RAW ELIXIR STRUCT INSPECT
===============================================================================
#{inspect(message, pretty: true, limit: :infinity)}
===============================================================================
END OF RAW EMAIL DATA
===============================================================================
"""
end
defp format_attachments_info(attachments)
when is_map(attachments) and map_size(attachments) > 0 do
attachments
|> Enum.map(fn {key, attachment} ->
filename = Map.get(attachment, "filename", "unknown")
content_type = Map.get(attachment, "content_type", "unknown")
size = Map.get(attachment, "size", "unknown")
encoding = Map.get(attachment, "encoding", "unknown")
disposition = Map.get(attachment, "disposition", "unknown")
content_id = Map.get(attachment, "content_id", "(none)")
hash = Map.get(attachment, "hash", "(none)")
data_preview =
case Map.get(attachment, "data") do
nil ->
"(no data)"
"" ->
"(empty)"
data when is_binary(data) ->
preview = String.slice(data, 0, 100)
if String.length(data) > 100 do
"#{preview}... (#{String.length(data)} chars total)"
else
preview
end
_ ->
"(non-string data)"
end
"""
#{key}:
Filename: #{filename}
Content-Type: #{content_type}
Size: #{size} bytes
Encoding: #{encoding}
Disposition: #{disposition}
Content-ID: #{content_id}
Hash: #{hash}
Data Preview: #{data_preview}
"""
end)
|> Enum.map_join("\n", & &1)
end
defp format_attachments_info(attachments)
when is_list(attachments) and attachments != [] do
"""
Attachments stored as list: #{length(attachments)} items
Raw data: #{inspect(attachments, pretty: true)}
"""
end
defp format_attachments_info(nil), do: "(No attachments - nil)"
defp format_attachments_info(%{}), do: "(No attachments - empty map)"
defp format_attachments_info([]), do: "(No attachments - empty list)"
defp format_attachments_info(other), do: "(Attachments in unknown format: #{inspect(other)})"
defp log_admin_email_view(conn, message, mailbox, view_format, route_context) do
case conn.assigns[:current_user] do
%{id: admin_id, is_admin: true, username: admin_username} ->

View file

@ -19,9 +19,6 @@
</div>
<div class="flex flex-shrink-0 flex-wrap gap-2">
<.button href={@raw_path} variant="ghost" target="_blank">
<.icon name="hero-code-bracket" class="h-4 w-4" /> View Raw
</.button>
<.button href={~p"/pripyat/messages"} variant="ghost">
<.icon name="hero-arrow-left" class="h-4 w-4" /> Back to Messages
</.button>

View file

@ -15,9 +15,6 @@
</div>
<div class="flex flex-shrink-0 flex-wrap gap-2">
<.button href={@raw_path} variant="ghost" target="_blank">
<.icon name="hero-code-bracket" class="h-4 w-4" /> View Raw
</.button>
<.button href={~p"/pripyat/users/#{@user.id}/messages"} variant="ghost">
<.icon name="hero-arrow-left" class="h-4 w-4" /> Back to Messages
</.button>

View file

@ -130,7 +130,7 @@ defmodule ElektrineEmailWeb.HarakaWebhookController do
handle_async_ingest(conn, params, remote_ip, start_time)
else
try do
case process_haraka_email(params, %{"ingest_mode" => "sync", "remote_ip" => remote_ip}) do
case process_haraka_email(params, sync_ingest_context(remote_ip)) do
{:ok, email} ->
duration = System.monotonic_time(:millisecond) - start_time
Events.email_inbound(:webhook, :success, duration, %{source: :haraka})
@ -601,42 +601,39 @@ defmodule ElektrineEmailWeb.HarakaWebhookController do
"mailbox_id" => mailbox.id,
"status" => "received",
"spam" => final_is_spam,
"metadata" => %{
parsed_at: DateTime.utc_now() |> DateTime.to_iso8601(),
temporary: is_temporary,
attachment_count: map_size(attachments),
format: "haraka",
envelope_rcpt_to: sanitize_metadata_field(rcpt_to),
envelope_to: sanitize_metadata_field(to),
ingest_mode: sanitize_metadata_field(ingest_context["ingest_mode"]),
ingest_job_id: sanitize_metadata_field(ingest_context["job_id"]),
ingest_received_at: sanitize_metadata_field(ingest_context["received_at"]),
ingest_idempotency_key:
sanitize_metadata_field(ingest_context["idempotency_key"]),
remote_ip: sanitize_metadata_field(ingest_context["remote_ip"]),
haraka_id: sanitize_metadata_field(params["id"]),
spam_status: sanitize_metadata_field(params["spam_status"]),
bounce: sanitize_metadata_field(params["bounce"]),
auto_submitted: sanitize_metadata_field(params["auto_submitted"]),
size: params["size"],
timestamp: sanitize_metadata_field(params["timestamp"]),
spam_score: spam_info.score,
spam_threshold: spam_info.threshold,
spam_status_header: sanitize_metadata_field(spam_info.status),
spam_report: sanitize_metadata_field(spam_info.report),
spam_filter_enabled: spam_filter_enabled?,
spam_exception: spam_exception?,
delivery_signal: sanitize_metadata_field(delivery_signal.signal),
is_dsn: delivery_signal.is_dsn,
is_feedback_loop: delivery_signal.is_feedback_loop,
is_auto_reply: delivery_signal.is_auto_reply,
inbound_authentication: auth_decision.authentication,
inbound_auth_action: auth_decision.action,
suppression_candidate_reason: sanitize_metadata_field(suppression_event.reason),
suppression_candidate_recipients:
Enum.map(suppression_event.recipients, &sanitize_metadata_field/1),
suppression_candidate_apply: suppression_event.apply?
}
"metadata" =>
ingest_metadata(
ingest_context,
params,
%{
temporary: is_temporary,
attachment_count: map_size(attachments),
envelope_rcpt_to: sanitize_metadata_field(rcpt_to),
envelope_to: sanitize_metadata_field(to),
spam_status: sanitize_metadata_field(params["spam_status"]),
bounce: sanitize_metadata_field(params["bounce"]),
auto_submitted: sanitize_metadata_field(params["auto_submitted"]),
size: params["size"],
timestamp: sanitize_metadata_field(params["timestamp"]),
spam_score: spam_info.score,
spam_threshold: spam_info.threshold,
spam_status_header: sanitize_metadata_field(spam_info.status),
spam_report: sanitize_metadata_field(spam_info.report),
spam_filter_enabled: spam_filter_enabled?,
spam_exception: spam_exception?,
delivery_signal: sanitize_metadata_field(delivery_signal.signal),
is_dsn: delivery_signal.is_dsn,
is_feedback_loop: delivery_signal.is_feedback_loop,
is_auto_reply: delivery_signal.is_auto_reply,
inbound_authentication: auth_decision.authentication,
inbound_auth_action: auth_decision.action,
suppression_candidate_reason:
sanitize_metadata_field(suppression_event.reason),
suppression_candidate_recipients:
Enum.map(suppression_event.recipients, &sanitize_metadata_field/1),
suppression_candidate_apply: suppression_event.apply?
}
)
}
|> sanitize_haraka_email_data()
|> maybe_put_raw_source(raw_source)
@ -969,6 +966,46 @@ defmodule ElektrineEmailWeb.HarakaWebhookController do
end
end
defp sync_ingest_context(remote_ip) do
%{"ingest_mode" => "sync"}
|> maybe_put_stored_remote_ip(remote_ip)
end
defp ingest_metadata(ingest_context, params, extra) when is_map(extra) do
base = %{
parsed_at: DateTime.utc_now() |> DateTime.to_iso8601(),
format: "haraka",
ingest_mode: sanitize_metadata_field(ingest_context["ingest_mode"]),
ingest_job_id: sanitize_metadata_field(ingest_context["job_id"]),
ingest_received_at: sanitize_metadata_field(ingest_context["received_at"]),
ingest_idempotency_key: sanitize_metadata_field(ingest_context["idempotency_key"]),
haraka_id: sanitize_metadata_field(params["id"])
}
base
|> Map.merge(extra)
|> maybe_put_metadata_remote_ip(ingest_context["remote_ip"])
end
defp maybe_put_stored_remote_ip(context, remote_ip) do
if Elektrine.Email.Messages.store_inbound_remote_ip?() do
Map.put(context, "remote_ip", remote_ip)
else
context
end
end
defp maybe_put_metadata_remote_ip(metadata, remote_ip)
when is_binary(remote_ip) and remote_ip != "" do
if Elektrine.Email.Messages.store_inbound_remote_ip?() do
Map.put(metadata, :remote_ip, sanitize_metadata_field(remote_ip))
else
metadata
end
end
defp maybe_put_metadata_remote_ip(metadata, _remote_ip), do: metadata
defp get_remote_ip(conn) do
ElektrineWeb.ClientIP.client_ip(conn)
end

View file

@ -52,6 +52,18 @@ defmodule ElektrineEmailWeb.EmailLive.Settings do
@default_tab "aliases"
@settings_tabs [
{"aliases", "Aliases"},
{"blocked", "Blocked"},
{"safe", "Safe"},
{"filters", "Filters"},
{"autoreply", "Auto-Reply"},
{"templates", "Templates"},
{"folders", "Folders"},
{"labels", "Labels"},
{"export", "Export"}
]
@impl true
def mount(_params, session, socket) do
user = socket.assigns.current_user
@ -105,7 +117,7 @@ defmodule ElektrineEmailWeb.EmailLive.Settings do
@impl true
def handle_params(params, _url, socket) do
tab = Map.get(params, "tab", @default_tab)
tab = normalize_tab(Map.get(params, "tab", @default_tab))
{:noreply,
socket
@ -113,6 +125,12 @@ defmodule ElektrineEmailWeb.EmailLive.Settings do
|> load_tab_data(tab)}
end
defp normalize_tab(tab) when is_binary(tab) do
if Enum.any?(@settings_tabs, fn {id, _label} -> id == tab end), do: tab, else: @default_tab
end
defp normalize_tab(_tab), do: @default_tab
defp load_tab_data(socket, tab) when tab in ["blocked", "safe"],
do: SenderSettings.load_tab_data(socket, tab)
@ -129,6 +147,7 @@ defmodule ElektrineEmailWeb.EmailLive.Settings do
@impl true
def handle_event("switch_tab", %{"tab" => tab}, socket) do
tab = normalize_tab(tab)
{:noreply, push_patch(socket, to: ~p"/email/settings?tab=#{tab}")}
end
@ -251,98 +270,60 @@ defmodule ElektrineEmailWeb.EmailLive.Settings do
</p>
</div>
<!-- Tabs - scrollable on mobile -->
<div class="overflow-x-auto -mx-3 sm:-mx-6 px-3 sm:px-6 mb-4 sm:mb-6">
<div class="tabs tabs-boxed inline-flex min-w-max">
<button
phx-click="switch_tab"
phx-value-tab="aliases"
class={["tab tab-sm sm:tab-md", @active_tab == "aliases" && "tab-active"]}
<!-- Tabs e-nav style active underline, scrollable on mobile -->
<div class="-mx-3 mb-4 border-b border-base-300/70 sm:-mx-6 sm:mb-6">
<div class="overflow-x-auto overscroll-x-contain px-3 sm:px-6">
<div
class="flex w-max min-w-full items-center gap-0.5 sm:gap-1"
role="tablist"
aria-label="Email settings sections"
>
Aliases
</button>
<button
phx-click="switch_tab"
phx-value-tab="blocked"
class={["tab tab-sm sm:tab-md", @active_tab == "blocked" && "tab-active"]}
>
Blocked
</button>
<button
phx-click="switch_tab"
phx-value-tab="safe"
class={["tab tab-sm sm:tab-md", @active_tab == "safe" && "tab-active"]}
>
Safe
</button>
<button
phx-click="switch_tab"
phx-value-tab="filters"
class={["tab tab-sm sm:tab-md", @active_tab == "filters" && "tab-active"]}
>
Filters
</button>
<button
phx-click="switch_tab"
phx-value-tab="autoreply"
class={["tab tab-sm sm:tab-md", @active_tab == "autoreply" && "tab-active"]}
>
Auto-Reply
</button>
<button
phx-click="switch_tab"
phx-value-tab="templates"
class={["tab tab-sm sm:tab-md", @active_tab == "templates" && "tab-active"]}
>
Templates
</button>
<button
phx-click="switch_tab"
phx-value-tab="folders"
class={["tab tab-sm sm:tab-md", @active_tab == "folders" && "tab-active"]}
>
Folders
</button>
<button
phx-click="switch_tab"
phx-value-tab="labels"
class={["tab tab-sm sm:tab-md", @active_tab == "labels" && "tab-active"]}
>
Labels
</button>
<button
phx-click="switch_tab"
phx-value-tab="export"
class={["tab tab-sm sm:tab-md", @active_tab == "export" && "tab-active"]}
>
Export
</button>
<button
:for={{tab_id, label} <- settings_tabs()}
type="button"
role="tab"
id={"email-settings-tab-#{tab_id}"}
aria-selected={to_string(@active_tab == tab_id)}
aria-controls={"email-settings-panel-#{tab_id}"}
phx-click="switch_tab"
phx-value-tab={tab_id}
class={settings_tab_class(@active_tab == tab_id)}
>
{label}
</button>
</div>
</div>
</div>
<!-- Tab Content -->
<%= case @active_tab do %>
<% "blocked" -> %>
{render_blocked_tab(assigns)}
<% "safe" -> %>
{render_safe_tab(assigns)}
<% "filters" -> %>
{render_filters_tab(assigns)}
<% "autoreply" -> %>
{render_autoreply_tab(assigns)}
<% "templates" -> %>
{render_templates_tab(assigns)}
<% "folders" -> %>
{render_folders_tab(assigns)}
<% "labels" -> %>
{render_labels_tab(assigns)}
<% "export" -> %>
{render_export_tab(assigns)}
<% "aliases" -> %>
{render_aliases_tab(assigns)}
<% _ -> %>
<p>Select a tab</p>
<% end %>
<div
id={"email-settings-panel-#{@active_tab}"}
role="tabpanel"
aria-labelledby={"email-settings-tab-#{@active_tab}"}
>
<%= case @active_tab do %>
<% "blocked" -> %>
{render_blocked_tab(assigns)}
<% "safe" -> %>
{render_safe_tab(assigns)}
<% "filters" -> %>
{render_filters_tab(assigns)}
<% "autoreply" -> %>
{render_autoreply_tab(assigns)}
<% "templates" -> %>
{render_templates_tab(assigns)}
<% "folders" -> %>
{render_folders_tab(assigns)}
<% "labels" -> %>
{render_labels_tab(assigns)}
<% "export" -> %>
{render_export_tab(assigns)}
<% "aliases" -> %>
{render_aliases_tab(assigns)}
<% _ -> %>
<p>Select a tab</p>
<% end %>
</div>
</div>
</div>
</div>
@ -356,6 +337,18 @@ defmodule ElektrineEmailWeb.EmailLive.Settings do
"""
end
defp settings_tabs, do: @settings_tabs
# Match e-nav: full-weight label + primary underline when active,
# muted text when idle (daisyUI tabs-boxed no longer distinguishes well).
defp settings_tab_class(true) do
"relative flex min-h-9 shrink-0 items-center justify-center px-2.5 text-sm font-medium whitespace-nowrap transition-colors text-base-content after:absolute after:inset-x-1.5 after:bottom-0 after:h-0.5 after:rounded-full after:bg-primary sm:px-3"
end
defp settings_tab_class(false) do
"relative flex min-h-9 shrink-0 items-center justify-center px-2.5 text-sm font-medium whitespace-nowrap transition-colors text-base-content/60 hover:text-base-content sm:px-3"
end
defp render_modal(assigns) do
~H"""
<div class="modal modal-open">

View file

@ -0,0 +1,47 @@
defmodule Elektrine.Email.InboundPrivacyTest do
use Elektrine.DataCase, async: false
alias Elektrine.Email.Messages
setup do
previous = Application.get_env(:elektrine, :email, [])
on_exit(fn ->
Application.put_env(:elektrine, :email, previous)
end)
:ok
end
test "store_inbound_remote_ip? defaults to false" do
Application.put_env(
:elektrine,
:email,
Keyword.delete(previous_email(), :store_inbound_remote_ip)
)
refute Messages.store_inbound_remote_ip?()
end
test "store_inbound_remote_ip? can be enabled" do
Application.put_env(
:elektrine,
:email,
Keyword.put(previous_email(), :store_inbound_remote_ip, true)
)
assert Messages.store_inbound_remote_ip?()
end
test "max_retained_raw_source_bytes accepts zero" do
Application.put_env(
:elektrine,
:email,
Keyword.put(previous_email(), :max_retained_raw_source_bytes, 0)
)
assert Messages.max_retained_raw_source_bytes() == 0
end
defp previous_email, do: Application.get_env(:elektrine, :email, [])
end

View file

@ -0,0 +1,106 @@
defmodule Elektrine.Email.MessageRetentionWorkerTest do
use Elektrine.DataCase, async: false
alias Elektrine.Accounts
alias Elektrine.Email
alias Elektrine.Email.{Message, MessageRetentionWorker}
alias Elektrine.Repo
setup do
previous = Application.get_env(:elektrine, :email, [])
on_exit(fn ->
Application.put_env(:elektrine, :email, previous)
end)
:ok
end
test "hard-deletes soft-deleted mail past trash retention" do
Application.put_env(
:elektrine,
:email,
Keyword.merge(Application.get_env(:elektrine, :email, []),
trash_retention_days: 7,
spam_retention_days: 0,
inbox_retention_days: 0
)
)
mailbox = mailbox_fixture()
old = insert_message!(mailbox, deleted: true, days_ago: 14)
recent = insert_message!(mailbox, deleted: true, days_ago: 1)
assert :ok = MessageRetentionWorker.perform(%Oban.Job{args: %{}})
assert Repo.get(Message, old.id) == nil
assert Repo.get(Message, recent.id)
end
test "hard-deletes old spam" do
Application.put_env(
:elektrine,
:email,
Keyword.merge(Application.get_env(:elektrine, :email, []),
trash_retention_days: 0,
spam_retention_days: 7,
inbox_retention_days: 0
)
)
mailbox = mailbox_fixture()
old = insert_message!(mailbox, spam: true, days_ago: 20)
recent = insert_message!(mailbox, spam: true, days_ago: 1)
assert :ok = MessageRetentionWorker.perform(%Oban.Job{args: %{}})
assert Repo.get(Message, old.id) == nil
assert Repo.get(Message, recent.id)
end
defp mailbox_fixture do
unique = System.unique_integer([:positive])
{:ok, user} =
Accounts.create_user(%{
username: "ret#{unique}",
password: "hello world!",
password_confirmation: "hello world!"
})
{:ok, mailbox} = Email.ensure_user_has_mailbox(user)
mailbox
end
defp insert_message!(mailbox, opts) do
days = Keyword.get(opts, :days_ago, 0)
deleted = Keyword.get(opts, :deleted, false)
spam = Keyword.get(opts, :spam, false)
unique = System.unique_integer([:positive])
{:ok, message} =
Email.create_message(%{
mailbox_id: mailbox.id,
message_id: "<ret-#{unique}@example.com>",
from: "sender@example.com",
to: mailbox.email,
subject: "Retention #{unique}",
text_body: "body #{unique}",
status: "received",
deleted: deleted,
spam: spam
})
if days > 0 do
at =
DateTime.utc_now()
|> DateTime.add(-days * 86_400, :second)
|> DateTime.truncate(:second)
from(m in Message, where: m.id == ^message.id)
|> Repo.update_all(set: [inserted_at: at, updated_at: at])
end
Repo.get!(Message, message.id)
end
end

View file

@ -182,6 +182,30 @@ defmodule Elektrine.EmailEncryptionTest do
Message.decrypt_raw_source(stored_message, mailbox.user_id)
end
test "omits all raw sources when retention limit is zero", %{mailbox: mailbox} do
raw_source = "From: a@b.c\r\n\r\nhello"
set_raw_source_limit(0)
{:ok, message} =
Email.create_message(%{
mailbox_id: mailbox.id,
message_id: "<no-raw-source@example.com>",
from: "sender@example.com",
to: mailbox.email,
subject: "No raw source",
text_body: "hello",
raw_source: raw_source,
status: "received"
})
stored_message = Repo.get!(Message, message.id)
assert is_nil(stored_message.encrypted_raw_source)
assert stored_message.metadata["raw_source_retained"] == false
assert stored_message.metadata["raw_source_omitted_reason"] == "disabled"
assert stored_message.metadata["raw_source_retention_limit_bytes"] == 0
end
test "creates searchable index with keywords from email body", %{mailbox: mailbox, user: user} do
text_body = "Meeting scheduled for #project deadline tomorrow"

View file

@ -1399,7 +1399,11 @@ defmodule Elektrine.VPN do
defp tunnel_policy_attrs(opts) when is_map(opts) do
pack = Map.get(opts, :policy_pack) || Map.get(opts, "policy_pack") || "platform"
mode = Map.get(opts, :tunnel_mode) || Map.get(opts, "tunnel_mode") || PolicyPack.default_tunnel_mode()
mode =
Map.get(opts, :tunnel_mode) || Map.get(opts, "tunnel_mode") ||
PolicyPack.default_tunnel_mode()
policy = PolicyPack.resolve(pack, tunnel_mode: mode)
%{
@ -1411,7 +1415,13 @@ defmodule Elektrine.VPN do
}
end
defp build_wireguard_user_config_attrs(user_id, %Server{id: server_id}, capability, device_id, opts) do
defp build_wireguard_user_config_attrs(
user_id,
%Server{id: server_id},
capability,
device_id,
opts
) do
with {:ok, keys} <- generate_wireguard_keypair(),
{:ok, allocated_ip} <- allocate_ip_for_user(server_id) do
policy = tunnel_policy_attrs(opts)
@ -1465,7 +1475,9 @@ defmodule Elektrine.VPN do
end
defp create_user_config_record(%Server{} = server, user_id, attrs) do
quota_bytes = Map.get(attrs, :bandwidth_quota_bytes) || Map.get(attrs, "bandwidth_quota_bytes")
quota_bytes =
Map.get(attrs, :bandwidth_quota_bytes) || Map.get(attrs, "bandwidth_quota_bytes")
account = ensure_account_quota!(user_id, quota_bytes)
attrs =
@ -2027,6 +2039,7 @@ defmodule Elektrine.VPN do
maybe_roll_account_period!(account, now, quota_bytes)
else
ensure_account_quota!(user_id, quota_bytes, now)
from(q in AccountQuota, where: q.user_id == ^user_id, lock: "FOR UPDATE")
|> Repo.one!()
end
@ -2257,7 +2270,9 @@ defmodule Elektrine.VPN do
pk = claims["pk"]
cond do
is_integer(cfg_id) -> Repo.get(UserConfig, cfg_id)
is_integer(cfg_id) ->
Repo.get(UserConfig, cfg_id)
is_binary(cfg_id) ->
case Integer.parse(cfg_id) do
{id, ""} -> Repo.get(UserConfig, id)
@ -2333,12 +2348,17 @@ defmodule Elektrine.VPN do
{:ok, existing}
nil ->
create_user_config(user_id, hop.server.id, Keyword.put(tunnel_opts, :device_id, device.id))
create_user_config(
user_id,
hop.server.id,
Keyword.put(tunnel_opts, :device_id, device.id)
)
end
end)
if Enum.all?(configs, &match?({:ok, _}, &1)) do
configs = Enum.map(configs, fn {:ok, c} -> Repo.preload(c, [:vpn_server, :vpn_device]) end)
configs =
Enum.map(configs, fn {:ok, c} -> Repo.preload(c, [:vpn_server, :vpn_device]) end)
grants =
Enum.map(configs, fn config ->
@ -2513,20 +2533,103 @@ defmodule Elektrine.VPN do
## Connection Log functions
@doc """
Creates a connection log entry.
Whether durable connect/disconnect rows are written to Postgres.
Default is `false` (RAM-only session posture on nodes; no per-session
history on the control plane). Operators can opt in with
`VPN_DURABLE_SESSION_LOGS=true`.
"""
def durable_session_logs? do
Application.get_env(:elektrine, :vpn, [])
|> Keyword.get(:durable_session_logs, false)
end
@doc """
Retention window in days for durable session rows.
When durable logs are off, cleanup deletes every row regardless of this
value. When on, `0` means keep until the next explicit purge; positive
values delete rows older than that many days.
"""
def connection_log_retention_days do
Application.get_env(:elektrine, :vpn, [])
|> Keyword.get(:connection_log_retention_days, 0)
end
@doc """
Creates a connection log entry when durable session logs are enabled.
Returns `{:ok, :disabled}` when the feature is off so callers can treat
the request as success without writing. Source IPs are never persisted.
"""
def create_connection_log(attrs \\ %{}) do
%ConnectionLog{}
|> ConnectionLog.changeset(attrs)
|> Repo.insert()
if durable_session_logs?() do
attrs = drop_client_ip(attrs)
%ConnectionLog{}
|> ConnectionLog.changeset(attrs)
|> Repo.insert()
else
{:ok, :disabled}
end
end
@doc """
Updates a connection log (e.g., when disconnecting).
No-op when durable session logs are off. Source IPs are never persisted.
"""
def update_connection_log(%ConnectionLog{} = log, attrs) do
log
|> ConnectionLog.changeset(attrs)
|> Repo.update()
if durable_session_logs?() do
attrs = drop_client_ip(attrs)
log
|> ConnectionLog.changeset(attrs)
|> Repo.update()
else
{:ok, :disabled}
end
end
@doc """
Deletes VPN connection log rows per the active privacy policy.
- Durable logs off delete all rows (and any leftover client_ip values).
- Durable logs on + retention days > 0 delete rows older than that window.
- Durable logs on + retention 0 only null leftover `client_ip` values.
Returns `{deleted_count, nil}` like `Repo.delete_all/2`.
"""
def purge_connection_logs(opts \\ []) do
now = Keyword.get(opts, :now, DateTime.utc_now()) |> DateTime.truncate(:second)
retention = Keyword.get(opts, :retention_days, connection_log_retention_days())
{deleted, _} =
cond do
not durable_session_logs?() ->
Repo.delete_all(ConnectionLog)
is_integer(retention) and retention > 0 ->
cutoff = DateTime.add(now, -retention * 86_400, :second)
from(cl in ConnectionLog, where: cl.connected_at < ^cutoff)
|> Repo.delete_all()
true ->
{0, nil}
end
# Always scrub any historical source IPs that may still sit on remaining rows.
from(cl in ConnectionLog, where: not is_nil(cl.client_ip))
|> Repo.update_all(set: [client_ip: nil])
{deleted, nil}
end
defp drop_client_ip(attrs) when is_map(attrs) do
attrs
|> Map.drop([:client_ip, "client_ip"])
end
defp drop_client_ip(attrs), do: attrs
end

View file

@ -1,7 +1,10 @@
defmodule Elektrine.VPN.ConnectionLog do
@moduledoc """
Schema for VPN connection logs.
Tracks connection history and data usage.
Optional durable session rows for VPN peers.
Off by default (`VPN_DURABLE_SESSION_LOGS`). The product never stores
client source IPs in this table. Aggregate bandwidth for free-tier
quotas lives on account/config counters, not here.
"""
use Ecto.Schema
import Ecto.Changeset
@ -11,6 +14,7 @@ defmodule Elektrine.VPN.ConnectionLog do
field :disconnected_at, :utc_datetime
field :bytes_sent, :integer, default: 0
field :bytes_received, :integer, default: 0
# Legacy column; never written. Cleanup nulls existing values.
field :client_ip, :string
field :metadata, :map, default: %{}
@ -21,6 +25,7 @@ defmodule Elektrine.VPN.ConnectionLog do
@doc false
def changeset(connection_log, attrs) do
# client_ip is intentionally not cast — source IPs must not be stored.
connection_log
|> cast(attrs, [
:vpn_user_config_id,
@ -28,7 +33,6 @@ defmodule Elektrine.VPN.ConnectionLog do
:disconnected_at,
:bytes_sent,
:bytes_received,
:client_ip,
:metadata
])
|> validate_required([:vpn_user_config_id, :connected_at])

View file

@ -0,0 +1,26 @@
defmodule Elektrine.VPN.ConnectionLogCleanupWorker do
@moduledoc """
Purges `vpn_connection_logs` to match the session-log privacy policy.
Runs daily. When durable session logs are off (default), every row is
deleted. When they are on, rows older than the configured retention window
are deleted, and any leftover `client_ip` values are nulled.
"""
use Oban.Worker, queue: :default, max_attempts: 1
alias Elektrine.VPN
require Logger
@impl Oban.Worker
def perform(%Oban.Job{}) do
{deleted, _} = VPN.purge_connection_logs()
if deleted > 0 do
Logger.info("Purged #{deleted} VPN connection log row(s)")
end
:ok
end
end

View file

@ -320,6 +320,9 @@ defmodule ElektrineVPNWeb.VPNAPIController do
@doc """
Log a connection event (connect/disconnect).
No-op when durable session logs are off (default). Source IPs from the
request body are never stored.
"""
def log_connection(conn, params) do
%{
@ -339,7 +342,14 @@ defmodule ElektrineVPNWeb.VPNAPIController do
end
defp do_log_connection(conn, server_id, public_key, event, params) do
# Find user config
if VPN.durable_session_logs?() do
write_connection_log(conn, server_id, public_key, event, params)
else
json(conn, %{status: "ok", durable_session_logs: false})
end
end
defp write_connection_log(conn, server_id, public_key, event, params) do
user_config =
from(uc in Elektrine.VPN.UserConfig,
where: uc.vpn_server_id == ^server_id and uc.public_key == ^public_key
@ -351,12 +361,10 @@ defmodule ElektrineVPNWeb.VPNAPIController do
"connect" ->
VPN.create_connection_log(%{
vpn_user_config_id: user_config.id,
connected_at: DateTime.utc_now(),
client_ip: params["client_ip"]
connected_at: DateTime.utc_now()
})
"disconnect" ->
# Find the most recent open connection and close it
log =
from(cl in Elektrine.VPN.ConnectionLog,
where: cl.vpn_user_config_id == ^user_config.id and is_nil(cl.disconnected_at),
@ -377,7 +385,7 @@ defmodule ElektrineVPNWeb.VPNAPIController do
nil
end
json(conn, %{status: "ok"})
json(conn, %{status: "ok", durable_session_logs: true})
else
conn
|> put_status(404)

View file

@ -21,7 +21,7 @@ defmodule ElektrineVPNWeb.PageLive.VPNPolicy do
<h1 class="card-title text-3xl mb-6">VPN Service Policy</h1>
<div class="prose prose-lg max-w-none">
<p class="text-sm text-base-content/70 mb-4">Last Updated: October 29, 2025</p>
<p class="text-sm text-base-content/70 mb-4">Last Updated: August 2, 2026</p>
<section class="mb-8">
<h2 class="text-2xl font-semibold mb-4">1. About the Service</h2>
@ -158,20 +158,35 @@ defmodule ElektrineVPNWeb.PageLive.VPNPolicy do
</section>
<section class="mb-8">
<h2 class="text-2xl font-semibold mb-4">5. Privacy</h2>
<p>We log minimal data for service operation:</p>
<h2 class="text-2xl font-semibold mb-4">5. Privacy and logs</h2>
<p>
Elektrine VPN does not require government identity. See the main Terms for the no-KYC rule.
</p>
<p class="mt-4">
Default posture: no durable connect or disconnect history, and no client source IP
on session rows. WireGuard peers live in kernel memory on the node. Fleet agents
do not need a database on the node.
</p>
<p class="mt-4">The control plane may keep:</p>
<ul class="list-disc pl-6 space-y-2">
<li>Connection times and bandwidth usage for quota management</li>
<li>Server used and connection duration</li>
<li>Device names you assign to manage profiles</li>
<li>Account and peer public keys needed to issue configs</li>
<li>Aggregate bandwidth counters for free-tier limits</li>
<li>Server assignment and device names you set on profiles</li>
</ul>
<p class="mt-4">We do NOT log:</p>
<p class="mt-4">We do not log:</p>
<ul class="list-disc pl-6 space-y-2">
<li>Your browsing history or websites visited</li>
<li>DNS query contents or traffic content</li>
<li>Your IP address after connection</li>
<li>Browsing history or destination websites</li>
<li>DNS query names or packet payloads</li>
<li>Your source IP on the VPN path</li>
<li>Traffic contents inside the WireGuard tunnel</li>
<li>Per-session connect or disconnect history (off by default)</li>
</ul>
<p class="mt-4">
Operators can enable optional durable session rows with <code>VPN_DURABLE_SESSION_LOGS</code>. Those rows still never store client source IP.
We do not sell VPN logs. Short security records for abuse control follow the main
Privacy Policy.
</p>
</section>
<section class="mb-8">
@ -196,7 +211,7 @@ defmodule ElektrineVPNWeb.PageLive.VPNPolicy do
</section>
<section class="mb-8">
<h2 class="text-2xl font-semibold mb-4">6. Changes</h2>
<h2 class="text-2xl font-semibold mb-4">7. Changes</h2>
<p>
We may update this policy at any time. Significant changes will be announced through the platform.
This policy is part of our <.link href={~p"/terms"} class="link link-primary">Terms of Service</.link>.

View file

@ -2,11 +2,6 @@
<!-- Elektrine Platform Navigation -->
<.elektrine_nav active_tab="vpn" current_user={@current_user} />
<.experimental_notice
class="mb-6"
message="VPN access is experimental. Server availability, limits, and configuration behavior may change during testing."
/>
<div class="space-y-6 pb-10">
<section class="card panel-card border border-base-300">
<div class="card-body gap-5 p-4 sm:p-6">

View file

@ -0,0 +1,171 @@
defmodule Elektrine.VPN.ConnectionLogTest do
use Elektrine.DataCase, async: false
alias Elektrine.Accounts
alias Elektrine.Repo
alias Elektrine.VPN
alias Elektrine.VPN.ConnectionLog
setup do
previous = Application.get_env(:elektrine, :vpn, [])
on_exit(fn ->
Application.put_env(:elektrine, :vpn, previous)
end)
:ok
end
describe "durable_session_logs?" do
test "defaults to false when unset" do
Application.put_env(:elektrine, :vpn, [])
refute VPN.durable_session_logs?()
end
test "reads the configured flag" do
put_vpn(durable_session_logs: true)
assert VPN.durable_session_logs?()
end
end
describe "create_connection_log/1" do
test "is a no-op when durable session logs are off" do
put_vpn(durable_session_logs: false)
config = config_fixture()
assert {:ok, :disabled} =
VPN.create_connection_log(%{
vpn_user_config_id: config.id,
connected_at: DateTime.utc_now() |> DateTime.truncate(:second),
client_ip: "203.0.113.50"
})
assert Repo.aggregate(ConnectionLog, :count) == 0
end
test "inserts without client_ip when durable session logs are on" do
put_vpn(durable_session_logs: true)
config = config_fixture()
connected_at = DateTime.utc_now() |> DateTime.truncate(:second)
assert {:ok, log} =
VPN.create_connection_log(%{
vpn_user_config_id: config.id,
connected_at: connected_at,
client_ip: "203.0.113.50"
})
assert log.client_ip == nil
assert log.vpn_user_config_id == config.id
reloaded = Repo.get!(ConnectionLog, log.id)
assert reloaded.client_ip == nil
end
end
describe "purge_connection_logs/1" do
test "deletes every row when durable session logs are off" do
put_vpn(durable_session_logs: true)
config = config_fixture()
insert_log!(config, hours_ago: 1)
insert_log!(config, hours_ago: 48)
put_vpn(durable_session_logs: false)
assert {2, nil} = VPN.purge_connection_logs()
assert Repo.aggregate(ConnectionLog, :count) == 0
end
test "deletes only rows past retention when durable logs are on" do
put_vpn(durable_session_logs: true, connection_log_retention_days: 1)
config = config_fixture()
recent = insert_log!(config, hours_ago: 1)
_old = insert_log!(config, hours_ago: 48)
assert {1, nil} = VPN.purge_connection_logs()
assert Repo.get(ConnectionLog, recent.id)
assert Repo.aggregate(ConnectionLog, :count) == 1
end
test "nulls leftover client_ip values on remaining rows" do
put_vpn(durable_session_logs: true, connection_log_retention_days: 0)
config = config_fixture()
log = insert_log!(config, hours_ago: 1)
from(cl in ConnectionLog, where: cl.id == ^log.id)
|> Repo.update_all(set: [client_ip: "198.51.100.9"])
assert {0, nil} = VPN.purge_connection_logs()
assert Repo.get!(ConnectionLog, log.id).client_ip == nil
end
end
describe "ConnectionLogCleanupWorker" do
test "runs purge and returns :ok" do
put_vpn(durable_session_logs: false)
config = config_fixture()
insert_log_raw!(config)
assert :ok = Elektrine.VPN.ConnectionLogCleanupWorker.perform(%Oban.Job{args: %{}})
assert Repo.aggregate(ConnectionLog, :count) == 0
end
end
defp put_vpn(opts) do
base = Application.get_env(:elektrine, :vpn, [])
Application.put_env(:elektrine, :vpn, Keyword.merge(base, opts))
end
defp config_fixture do
unique = System.unique_integer([:positive])
{:ok, user} =
Accounts.create_user(%{
username: "clog#{unique}",
password: "hello world!",
password_confirmation: "hello world!"
})
{:ok, server} =
VPN.create_server(%{
name: "Log Edge #{unique}",
location: "Test",
public_ip: "198.51.100.#{rem(unique, 200) + 10}",
public_key: "server-key-#{unique}",
endpoint_port: 51_820,
internal_ip_range: "10.#{rem(unique, 200) + 10}.0.0/24"
})
{:ok, config} = VPN.create_user_config(user.id, server.id)
config
end
defp insert_log!(config, hours_ago: hours) do
put_vpn(durable_session_logs: true)
connected_at =
DateTime.utc_now()
|> DateTime.add(-hours * 3600, :second)
|> DateTime.truncate(:second)
{:ok, log} =
VPN.create_connection_log(%{
vpn_user_config_id: config.id,
connected_at: connected_at
})
log
end
# Bypass the durable gate so cleanup can be tested with rows present while
# durable logs are already off.
defp insert_log_raw!(config) do
connected_at = DateTime.utc_now() |> DateTime.truncate(:second)
%ConnectionLog{}
|> ConnectionLog.changeset(%{
vpn_user_config_id: config.id,
connected_at: connected_at
})
|> Repo.insert!()
end
end

View file

@ -743,6 +743,17 @@
VPN Policy
</.link>
</li>
<li>
<a
href="https://git.elektrine.com/elektrine/elektrine"
target="_blank"
rel="noopener noreferrer"
class="link link-hover text-base-content/70 transition-colors hover:text-base-content"
>
Source code
</a>
</li>
</ul>
</div>
<!-- Support -->

View file

@ -438,40 +438,9 @@ defmodule ElektrineWeb.Admin.UsersController do
end
def impersonate(conn, %{"id" => id}) do
target_user = Accounts.get_user!(id)
admin_user = conn.assigns.current_user
# Prevent impersonating other admin users
if target_user.is_admin do
conn
|> put_flash(:error, "Cannot impersonate another admin user.")
|> redirect(to: ~p"/pripyat/users/#{id}/edit")
else
# Log the impersonation action
Elektrine.AuditLog.log(
admin_user.id,
"impersonate",
"user",
target_user_id: target_user.id,
details: %{
admin_username: admin_user.username,
target_username: target_user.username
},
ip_address: get_remote_ip(conn),
user_agent: get_req_header(conn, "user-agent") |> List.first()
)
# Store the original admin user ID in session for later restoration
ElektrineWeb.UserAuth.log_in_user(conn, target_user, %{"remember_me" => "false"},
flash:
{:warning,
"You are now impersonating #{target_user.username}. Use the admin menu to stop impersonation."},
session: %{
impersonating_admin_id: admin_user.id,
impersonated_user_id: target_user.id
}
)
end
conn
|> put_flash(:error, "User impersonation is not available.")
|> redirect(to: ~p"/pripyat/users/#{id}/edit")
end
def stop_impersonation(conn, _params) do

View file

@ -223,7 +223,7 @@ defmodule ElektrineWeb.AuthLive.Register do
field={f[:username]}
type="text"
label={gettext("Username")}
placeholder={gettext("Enter your username")}
placeholder={gettext("Letters and numbers only. No real name required.")}
required
/>
<div>
@ -384,6 +384,9 @@ defmodule ElektrineWeb.AuthLive.Register do
<:actions>
<.button class="w-full">{gettext("Create account")}</.button>
<p class="mt-2 text-center text-xs text-base-content/55">
{gettext("No government ID. Username and password only.")}
</p>
</:actions>
</.simple_form>
@ -411,6 +414,33 @@ defmodule ElektrineWeb.AuthLive.Register do
</div>
<% end %>
<%= if Elektrine.Payments.Crypto.monero_enabled?() do %>
<div class="rounded-box border border-base-300 bg-base-200/50 p-4 mt-4 space-y-2">
<div class="font-medium">{gettext("Pay with Monero")}</div>
<p class="text-sm opacity-70">
{gettext(
"Monero payment does not require government identity. No passport or real name."
)}
</p>
<%= if url = Elektrine.Payments.Crypto.monero_payment_url() do %>
<a
href={url}
class="btn btn-outline btn-sm w-full"
target="_blank"
rel="noopener noreferrer"
>
{gettext("Open Monero checkout")}
</a>
<% end %>
<%= if addr = Elektrine.Payments.Crypto.monero_address() do %>
<p class="text-2xs font-mono break-all text-base-content/70 select-all">{addr}</p>
<p class="text-xs opacity-60">
{gettext("After payment, email support with the transaction id to receive access.")}
</p>
<% end %>
</div>
<% end %>
<div class="divider mt-6">{gettext("OR")}</div>
<div class="text-center">

View file

@ -2,6 +2,7 @@ defmodule ElektrineWeb.PageLive.FAQ do
use ElektrineWeb, :live_view
alias Elektrine.EmailAddresses
alias Elektrine.Payments.Crypto
on_mount {ElektrineWeb.Live.AuthHooks, :maybe_authenticated_user}
@ -13,54 +14,83 @@ defmodule ElektrineWeb.PageLive.FAQ do
domains =
Enum.map_join(Elektrine.Domains.supported_email_domains(), ", ", &("username@" <> &1))
monero_answer =
if Crypto.monero_enabled?() do
Crypto.monero_public_summary()
else
"This deployment has no Monero address configured. Ask the operator for payment methods."
end
[
%{
title: "General",
entries: [
{"What is Elektrine?",
"Elektrine is a personal internet space for messages, identity, search, storage, and everyday tools."},
{"Are the same features available on every deployment?",
"No. Deployments can compile in different modules and apply different local policies."},
{"Do I need separate accounts for different features?",
"No. One account works across the modules enabled on the same deployment."},
{"Can registration be invite-only?",
"Yes. Registration policy depends on the deployment."}
"Elektrine is a personal internet space for mail, chat, social tools, DNS, and VPN modules the operator enables."},
{"Are the same features on every host?",
"No. Each deployment chooses modules and local policy."},
{"Do I need separate accounts per feature?",
"No. One account covers every module on the same deployment."},
{"Can registration be invite-only?", "Yes. Invite rules depend on the deployment."}
]
},
%{
title: "Vision and Federation",
title: "Privacy and KYC",
entries: [
{"What is the vision for Elektrine?",
"Elektrine is meant to give people one connected place for the parts of online life they want to keep close, without making it feel like a stack of unrelated products."},
{"What does federation mean in Elektrine?",
"Federation means an Elektrine deployment can exchange activity with other servers instead of acting as a closed silo. The exact behavior depends on which modules are enabled."},
{"Do you require KYC?",
"No. Elektrine never requires government ID, a selfie, or a real legal name for normal use. See the Terms of Service."},
{"What do I need to register?",
"A username and a password. Recovery email is optional and is not an identity check."},
{"What do you log?",
"We keep short security and delivery logs to stop abuse. We do not sell logs. We do not keep content-level browsing profiles. See the Privacy Policy."},
{"Does the VPN keep session logs?",
"By default no. Connect and disconnect history is off. Client source IPs are never stored on session rows. Free-tier limits use aggregate bandwidth counters only. See the VPN Policy."},
{"Does mail keep sender IPs or full raw copies?",
"By default no connecting MTA IP on message metadata, and no second RFC822 raw copy. Mailboxes still store the message bodies you receive. See the Privacy Policy."},
{"Is the source public?",
"Yes. Elektrine is AGPL-3.0-only. Audit the code at the public repository linked from Legal."},
{"Do you publish a warrant canary?",
"Yes. See /canary for the current signed statement when the operator maintains it."},
{"Is Tor supported?",
"Yes when the operator enables onion hosting. The footer shows the onion host if configured."}
]
},
%{
title: "Payments",
entries: [
{"Do you accept Monero?", monero_answer},
{"Does payment require identity?",
"Elektrine does not require government identity for payment. Card processors may apply their own rules when cards are offered."}
]
},
%{
title: "Vision and federation",
entries: [
{"What is the product goal?",
"One account for mail, identity, and social tools you want to keep under your control."},
{"What does federation mean here?",
"A deployment can exchange activity with other servers. Behavior depends on enabled modules."},
{"Which federated protocols are supported?",
"ActivityPub powers the federated social web surface. Chat federation uses Arblarg between domains. Optional Bluesky integration can connect public social posting to ATProto services."}
"ActivityPub for social federation. Chat federation uses Arblarg between domains. Optional Bluesky bridges public posts to ATProto."}
]
},
%{
title: "Modes",
entries: [
{"What is Chat?",
"Chat is the messaging area for direct conversations and group conversations."},
{"What is Timeline?", "Timeline is the social feed for posts and updates."},
{"What are Communities?",
"Communities are topic-based spaces for longer discussion and shared moderation."},
{"What is Gallery?",
"Gallery is the media-focused view of the social side of Elektrine."},
{"What are Friends?",
"Friends is the area for managing person-to-person connections on the platform."},
{"What is Nerve?",
"Nerve stores encrypted entries tied to your account when the nerve module is enabled."}
{"What is Chat?", "Direct and group messaging."},
{"What is Timeline?", "The social feed for posts and updates."},
{"What are Communities?", "Topic spaces for longer discussion."},
{"What is Gallery?", "Media-focused social views."},
{"What are Friends?", "Person-to-person connections on the platform."},
{"What is Nerve?", "Encrypted notes tied to your account when enabled."}
]
},
%{
title: "Email and Integrations",
title: "Email and clients",
entries: [
{"Which client and integration protocols are supported?",
"When the relevant modules are enabled, Elektrine supports IMAP, POP3, SMTP, JMAP, CardDAV, CalDAV, and API tokens for integrations."},
{"What email addresses are available?",
"If email is enabled, local mailboxes use the domains configured for this deployment: #{domains}."}
{"Which client protocols are supported?",
"When modules are on: IMAP, POP3, SMTP, JMAP, CardDAV, CalDAV, and API tokens."},
{"What email addresses exist?", "If mail is enabled, local mailboxes use: #{domains}."}
]
},
%{
@ -115,7 +145,7 @@ defmodule ElektrineWeb.PageLive.FAQ do
</div>
<p class="mt-8 text-sm text-base-content/60">
Still have questions?
More questions?
<a href={EmailAddresses.mailto("support")} class="link link-hover text-primary">
{EmailAddresses.local("support")}
</a>

View file

@ -206,24 +206,6 @@ defmodule ElektrineWeb.PageLive.Home do
<.icon name="hero-code-bracket-mini" class="h-4 w-4" />
<span>Source</span>
</.link>
<.link
href={source_releases_url()}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 hover:text-white"
>
<.icon name="hero-arrow-down-tray-mini" class="h-4 w-4" />
<span>Releases</span>
</.link>
<.link
href={source_issues_url()}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 hover:text-white"
>
<.icon name="hero-exclamation-circle-mini" class="h-4 w-4" />
<span>Issues</span>
</.link>
<.link
:if={@onion_host}
href={"http://#{@onion_host}"}
@ -290,19 +272,31 @@ defmodule ElektrineWeb.PageLive.Home do
<div class="mt-8 space-y-10">
<%= for group <- feature_groups() do %>
<div>
<p class="font-mono text-2xs uppercase tracking-[0.22em] text-white/30">
{group.label}
</p>
<div class="flex items-center gap-3">
<p class="font-mono text-2xs uppercase tracking-[0.22em] text-white/30">
{group.label}
</p>
<div class="h-px flex-1 bg-white/10"></div>
</div>
<div class="mt-3 grid gap-px border border-white/10 bg-white/10 sm:grid-cols-2 lg:grid-cols-3">
<%= for item <- group.items do %>
<div class={[
"bg-[#05070a] px-4 py-3.5",
item[:wide] && "sm:col-span-2 lg:col-span-3"
"group bg-[#05070a] px-4 py-3.5 transition-colors hover:bg-white/[0.03]",
item[:wide] && "sm:col-span-2 lg:col-span-3",
item[:span2] && "sm:col-span-2 lg:col-span-2"
]}>
<div class="font-mono text-3xs uppercase tracking-[0.18em] text-white/35">
{item.tag}
<div class="flex items-center justify-between gap-2">
<div class="font-mono text-3xs uppercase tracking-[0.18em] text-white/35">
{item.tag}
</div>
<.icon
name={item.icon}
class="h-4 w-4 text-white/20 transition-colors group-hover:text-primary"
/>
</div>
<div class="mt-1 text-sm font-medium text-white/90 transition-colors group-hover:text-white">
{item.title}
</div>
<div class="mt-1 text-sm font-medium text-white/90">{item.title}</div>
<div class="mt-0.5 text-xs leading-relaxed text-white/50">{item.detail}</div>
</div>
<% end %>
@ -313,48 +307,75 @@ defmodule ElektrineWeb.PageLive.Home do
</section>
<section>
<p class="font-mono text-xs uppercase tracking-[0.3em] text-white/40">
// For developers
</p>
<div class="flex items-center gap-3">
<p class="font-mono text-xs uppercase tracking-[0.3em] text-white/40">
// For developers
</p>
<div class="h-px flex-1 bg-white/10"></div>
</div>
<div class="mt-6 grid gap-px border border-white/10 bg-white/10 sm:grid-cols-2 lg:grid-cols-3">
<div class="bg-[#05070a] px-4 py-3.5">
<div class="font-mono text-3xs uppercase tracking-[0.18em] text-white/35">
Email API
<div class="group bg-[#05070a] px-4 py-3.5 transition-colors hover:bg-white/[0.03]">
<div class="flex items-center justify-between gap-2">
<div class="font-mono text-3xs uppercase tracking-[0.18em] text-white/35">
Email API
</div>
<.icon
name="hero-envelope-open-mini"
class="h-4 w-4 text-white/20 transition-colors group-hover:text-primary"
/>
</div>
<div class="mt-1 text-sm font-medium text-white/90">
<div class="mt-1 text-sm font-medium text-white/90 transition-colors group-hover:text-white">
Mail over HTTP
</div>
<div class="mt-0.5 text-xs leading-relaxed text-white/50">
Read, list, and send messages from your own scripts and software
</div>
</div>
<div class="bg-[#05070a] px-4 py-3.5">
<div class="font-mono text-3xs uppercase tracking-[0.18em] text-white/35">
JMAP
<div class="group bg-[#05070a] px-4 py-3.5 transition-colors hover:bg-white/[0.03]">
<div class="flex items-center justify-between gap-2">
<div class="font-mono text-3xs uppercase tracking-[0.18em] text-white/35">
JMAP
</div>
<.icon
name="hero-arrow-path-mini"
class="h-4 w-4 text-white/20 transition-colors group-hover:text-primary"
/>
</div>
<div class="mt-1 text-sm font-medium text-white/90">
<div class="mt-1 text-sm font-medium text-white/90 transition-colors group-hover:text-white">
JMAP built in
</div>
<div class="mt-0.5 text-xs leading-relaxed text-white/50">
Alongside IMAP, POP3, and SMTP. Bring any client, or write one
</div>
</div>
<div class="bg-[#05070a] px-4 py-3.5">
<div class="font-mono text-3xs uppercase tracking-[0.18em] text-white/35">
Client API
<div class="group bg-[#05070a] px-4 py-3.5 transition-colors hover:bg-white/[0.03]">
<div class="flex items-center justify-between gap-2">
<div class="font-mono text-3xs uppercase tracking-[0.18em] text-white/35">
Client API
</div>
<.icon
name="hero-puzzle-piece-mini"
class="h-4 w-4 text-white/20 transition-colors group-hover:text-primary"
/>
</div>
<div class="mt-1 text-sm font-medium text-white/90">
<div class="mt-1 text-sm font-medium text-white/90 transition-colors group-hover:text-white">
Works with the apps you have
</div>
<div class="mt-0.5 text-xs leading-relaxed text-white/50">
Mastodon-compatible, so existing clients and libraries just connect
</div>
</div>
<div class="bg-[#05070a] px-4 py-3.5">
<div class="font-mono text-3xs uppercase tracking-[0.18em] text-white/35">
MCP
<div class="group bg-[#05070a] px-4 py-3.5 transition-colors hover:bg-white/[0.03]">
<div class="flex items-center justify-between gap-2">
<div class="font-mono text-3xs uppercase tracking-[0.18em] text-white/35">
MCP
</div>
<.icon
name="hero-cpu-chip-mini"
class="h-4 w-4 text-white/20 transition-colors group-hover:text-primary"
/>
</div>
<div class="mt-1 text-sm font-medium text-white/90">
<div class="mt-1 text-sm font-medium text-white/90 transition-colors group-hover:text-white">
AI tools, scoped to you
</div>
<div class="mt-0.5 text-xs leading-relaxed text-white/50">
@ -362,33 +383,51 @@ defmodule ElektrineWeb.PageLive.Home do
through scoped personal access tokens
</div>
</div>
<div class="bg-[#05070a] px-4 py-3.5">
<div class="font-mono text-3xs uppercase tracking-[0.18em] text-white/35">
OIDC
<div class="group bg-[#05070a] px-4 py-3.5 transition-colors hover:bg-white/[0.03]">
<div class="flex items-center justify-between gap-2">
<div class="font-mono text-3xs uppercase tracking-[0.18em] text-white/35">
OIDC
</div>
<.icon
name="hero-identification-mini"
class="h-4 w-4 text-white/20 transition-colors group-hover:text-primary"
/>
</div>
<div class="mt-1 text-sm font-medium text-white/90">
<div class="mt-1 text-sm font-medium text-white/90 transition-colors group-hover:text-white">
Sign in with your own domain
</div>
<div class="mt-0.5 text-xs leading-relaxed text-white/50">
Plain OpenID Connect for anything you build or run
</div>
</div>
<div class="bg-[#05070a] px-4 py-3.5">
<div class="font-mono text-3xs uppercase tracking-[0.18em] text-white/35">
Tokens
<div class="group bg-[#05070a] px-4 py-3.5 transition-colors hover:bg-white/[0.03]">
<div class="flex items-center justify-between gap-2">
<div class="font-mono text-3xs uppercase tracking-[0.18em] text-white/35">
Tokens
</div>
<.icon
name="hero-ticket-mini"
class="h-4 w-4 text-white/20 transition-colors group-hover:text-primary"
/>
</div>
<div class="mt-1 text-sm font-medium text-white/90">
<div class="mt-1 text-sm font-medium text-white/90 transition-colors group-hover:text-white">
Scoped tokens
</div>
<div class="mt-0.5 text-xs leading-relaxed text-white/50">
Each script gets only the access it needs, read or write, per service
</div>
</div>
<div class="bg-[#05070a] px-4 py-3.5">
<div class="font-mono text-3xs uppercase tracking-[0.18em] text-white/35">
Deploys
<div class="group bg-[#05070a] px-4 py-3.5 transition-colors hover:bg-white/[0.03] sm:col-span-2 lg:col-span-3">
<div class="flex items-center justify-between gap-2">
<div class="font-mono text-3xs uppercase tracking-[0.18em] text-white/35">
Deploys
</div>
<.icon
name="hero-cloud-arrow-up-mini"
class="h-4 w-4 text-white/20 transition-colors group-hover:text-primary"
/>
</div>
<div class="mt-1 text-sm font-medium text-white/90">
<div class="mt-1 text-sm font-medium text-white/90 transition-colors group-hover:text-white">
Static sites
</div>
<div class="mt-0.5 text-xs leading-relaxed text-white/50">
@ -458,31 +497,41 @@ defmodule ElektrineWeb.PageLive.Home do
%{
label: "Mail",
items: [
%{tag: "Aliases", title: "Free aliases", detail: "Up to 15 addresses at no extra cost"},
%{
tag: "Aliases",
title: "Free aliases",
detail: "Up to 15 addresses at no extra cost",
icon: "hero-at-symbol-mini"
},
%{
tag: "Catch-all",
title: "Catch-all & plus tags",
detail: "anything@your-domain and you+tag@ just work"
detail: "anything@your-domain and you+tag@ just work",
icon: "hero-funnel-mini"
},
%{
tag: "Domains",
title: "Free custom domains",
detail: "Bring your own domain"
detail: "Bring your own domain",
icon: "hero-globe-alt-mini"
},
%{
tag: "Clients",
title: "Use any mail app",
detail: "Works in Thunderbird and any IMAP, POP3, SMTP or JMAP client"
detail: "Works in Thunderbird and any IMAP, POP3, SMTP or JMAP client",
icon: "hero-envelope-mini"
},
%{
tag: "Deliverability",
title: "Lands in the inbox",
detail: "DKIM, SPF, and DMARC handled for you"
detail: "DKIM, SPF, and DMARC handled for you",
icon: "hero-inbox-arrow-down-mini"
},
%{
tag: "PGP",
title: "PGP, built in",
detail: "OpenPGP with automatic key discovery (WKD)"
detail: "OpenPGP with automatic key discovery (WKD)",
icon: "hero-key-mini"
}
]
},
@ -494,43 +543,60 @@ defmodule ElektrineWeb.PageLive.Home do
title: "No third parties in the loop",
detail:
"No Cloudflare, no Google Analytics, no third-party trackers, fonts, or CDNs. Nothing phones home.",
wide: true
wide: true,
icon: "hero-shield-check-mini"
},
%{
tag: "Email",
title: "Local email encryption",
detail: "Keys stay in your browser, so the server only ever stores ciphertext"
detail: "Keys stay in your browser, so the server only ever stores ciphertext",
icon: "hero-lock-closed-mini"
},
%{
tag: "Chat",
title: "End-to-end encrypted chat",
detail: "Messages are encrypted on your device, so the server only relays ciphertext"
detail: "Messages are encrypted on your device, so the server only relays ciphertext",
icon: "hero-chat-bubble-left-right-mini"
},
%{
tag: "Tor",
title: "Works over Tor",
detail: "Sign up and use Elektrine privately"
detail: "Sign up and use Elektrine privately",
icon: "hero-eye-slash-mini"
},
%{
tag: "Passkeys",
title: "Passwordless sign-in",
detail:
"Phishing-resistant WebAuthn across up to 10 devices, with no password to steal"
"Phishing-resistant WebAuthn across up to 10 devices, with no password to steal",
icon: "hero-finger-print-mini"
},
%{
tag: "2FA",
title: "Two-factor auth",
detail: "TOTP protection on your account"
detail: "TOTP protection on your account",
icon: "hero-device-phone-mobile-mini"
},
%{
tag: "Signups",
title: "No CAPTCHAs",
detail: "A short background check stops bots, so you never solve puzzles"
detail: "A short background check stops bots, so you never solve puzzles",
icon: "hero-no-symbol-mini"
},
%{
tag: "Logs",
title: "No activity logs",
detail:
"The VPN keeps no connection history and never stores source IPs. Inbound mail doesn't record connecting IPs, and trash and spam hard-delete after 30 days.",
wide: true,
icon: "hero-server-mini"
},
%{
tag: "Open source",
title: "Open source (AGPLv3)",
detail: "Audit it, or self-host the exact code we run"
detail: "Audit it, or self-host the exact code we run",
wide: true,
icon: "hero-code-bracket-mini"
}
]
},
@ -540,17 +606,20 @@ defmodule ElektrineWeb.PageLive.Home do
%{
tag: "Fediverse",
title: "On the fediverse",
detail: "Follow and be followed across Mastodon, Lemmy, and more"
detail: "Follow and be followed across Mastodon, Lemmy, and more",
icon: "hero-globe-americas-mini"
},
%{
tag: "Bluesky",
title: "Mirror to Bluesky",
detail: "Crosspost your public timeline to Bluesky"
detail: "Crosspost your public timeline to Bluesky",
icon: "hero-arrows-right-left-mini"
},
%{
tag: "Messaging",
title: "Federated messaging",
detail: "Message and call across Elektrine servers, not just your own"
detail: "Message and call across Elektrine servers, not just your own",
icon: "hero-phone-mini"
}
]
},
@ -561,29 +630,35 @@ defmodule ElektrineWeb.PageLive.Home do
tag: "Knowledge",
title: "Your knowledge base",
detail:
"Kairo ingests notes and sources into a durable, searchable knowledge graph you own"
"Kairo ingests notes and sources into a durable, searchable knowledge graph you own",
icon: "hero-circle-stack-mini"
},
%{
tag: "Secrets",
title: "Password & secrets manager",
detail:
"Nerve keeps passwords and secrets client-side encrypted, so the server only stores ciphertext"
"Nerve keeps passwords and secrets client-side encrypted, so the server only stores ciphertext",
icon: "hero-bolt-mini"
},
%{
tag: "Web search",
title: "Private web search",
detail:
"Search the open web with Paige, which blends results from several engines and never profiles you"
"Search the open web with Paige, which blends results from several engines and never profiles you",
icon: "hero-magnifying-glass-mini"
},
%{
tag: "Calendar",
title: "Calendar & contacts",
detail: "Sync everywhere with CalDAV and CardDAV"
detail: "Sync everywhere with CalDAV and CardDAV",
icon: "hero-calendar-days-mini"
},
%{
tag: "Portability",
title: "No lock-in",
detail: "Export all your data over an open API, any time"
detail: "Export all your data over an open API, any time",
span2: true,
icon: "hero-arrow-up-tray-mini"
}
]
}
@ -628,10 +703,6 @@ defmodule ElektrineWeb.PageLive.Home do
defp source_repo_url, do: "https://git.elektrine.com/elektrine/elektrine"
defp source_releases_url, do: "https://git.elektrine.com/elektrine/elektrine/releases"
defp source_issues_url, do: "https://git.elektrine.com/elektrine/elektrine/issues"
def load_platform_stats(cache_fetch \\ &Elektrine.AppCache.get_platform_stats/1) do
case cache_fetch.(&compute_platform_stats/0) do
{:ok, stats} ->

View file

@ -15,26 +15,31 @@ defmodule ElektrineWeb.PageLive.Legal do
title: "Terms of Service",
href: ~p"/terms",
icon: "hero-document-text",
description: "The agreement that governs your use of Elektrine."
description: "Rules for use. Includes the permanent no-KYC guarantee."
},
%{
title: "Privacy Policy",
href: ~p"/privacy",
icon: "hero-lock-closed",
description: "What we collect, why we collect it, and how it is protected."
description: "What we store, short security logs, and what we do not sell."
},
%{
title: "Warrant Canary",
href: ~p"/canary",
icon: "hero-shield-check",
description:
"A regularly updated, signed statement about legal orders we have not received."
description: "Signed statement the operator updates about secret legal orders."
},
%{
title: "VPN Policy",
href: Elektrine.Paths.vpn_policy_path(),
icon: "hero-globe-alt",
description: "Acceptable use and logging policy for the VPN service."
description: "VPN use rules and no-content-log policy."
},
%{
title: "Source code",
href: "https://git.elektrine.com/elektrine/elektrine",
icon: "hero-code-bracket",
description: "Public AGPL-3.0-only repository. Audit or self-host the same code."
}
]
end
@ -75,19 +80,28 @@ defmodule ElektrineWeb.PageLive.Legal do
/>
</.link>
<section class="border-b border-base-content/10 px-5 py-6 sm:px-7">
<h2 class="text-base font-semibold">No KYC</h2>
<p class="mt-3 text-sm leading-relaxed text-base-content/70">
Elektrine never requires government identity documents for normal use.
Registration needs a username and password only. See Terms for the full rule.
</p>
</section>
<section class="border-b border-base-content/10 px-5 py-6 sm:px-7">
<h2 class="text-base font-semibold">License</h2>
<p class="mt-3 text-sm leading-relaxed text-base-content/70">
Elektrine is free software licensed under the GNU Affero General Public
License v3.0. You can audit the exact code we run, or self-host it.
Elektrine is free software under AGPL-3.0-only.
Audit the code or run your own host.
<a
href="https://git.elektrine.com/elektrine/elektrine"
target="_blank"
rel="noopener noreferrer"
class="link link-hover text-primary"
>
Source code
Source repository
</a>
</p>
</section>

View file

@ -5,210 +5,241 @@ defmodule ElektrineWeb.PageLive.Privacy do
on_mount {ElektrineWeb.Live.AuthHooks, :maybe_authenticated_user}
@sections [
%{
title: "Information We Collect",
blocks: [
%{
subtitle: "Account and Profile Data",
paras: [],
items: [
"Account identifiers such as username, mailbox address, login credentials, and recovery or security settings.",
"Profile information you choose to publish, such as display name, avatar, bio, links, and public posts.",
"Preferences such as locale, notification settings, privacy settings, and enabled product features."
]
},
%{
subtitle: "Content You Store or Send",
paras: [],
items: [
"Email messages, drafts, sent-mail copies, folders, labels, contacts, aliases, attachments, and filtering preferences.",
"Social posts, chats, notes, files, nerve metadata, and other content you create or upload.",
"Operational metadata needed to provide these services, such as message IDs, timestamps, delivery status, mailbox IDs, thread IDs, flags, and storage usage."
]
},
%{
subtitle: "Information Collected Automatically",
paras: [],
items: [
"IP addresses, user agents, device/browser information, request timestamps, and session identifiers.",
"Security and abuse-prevention data such as login attempts, rate-limit events, SMTP/IMAP/POP connection events, and spam or malware signals.",
"Service logs and metrics used to operate, debug, secure, and improve Elektrine."
]
}
]
},
%{
title: "Email Privacy and Encryption",
blocks: [
%{
subtitle: nil,
paras: [
"Email uses open internet protocols. Elektrine can protect local storage, but normal SMTP delivery still exposes some information to mail infrastructure."
],
items: []
},
%{
subtitle: "Stored Mail",
paras: [],
items: [
"By default, message bodies are encrypted at rest for the account using server-side application encryption, while some metadata remains available to the server for mailbox operation.",
"If private mailbox storage is enabled, message subject, body, attachments, sender, recipients, and sent-mail copies are stored in browser-unlocked encrypted payloads. The server stores placeholders for protected fields.",
"Private mailbox storage reduces server-side search. Protected subject, body, sender, and recipient fields are not searchable by the server unless a future encrypted-search feature is explicitly enabled.",
"Private mailbox storage does not encrypt every operational field. The server may still store message IDs, mailbox IDs, timestamps, delivery state, folder/label state, read/unread flags, spam/deleted/archive flags, attachment counts, and similar mailbox-management metadata."
]
},
%{
subtitle: "Mail Delivery",
paras: [],
items: [
"When you send or receive ordinary email, SMTP envelope data, routing headers, sender, recipient, subject, timestamps, message IDs, DKIM/SPF/DMARC headers, and server IPs/domains may be visible to Elektrine, receiving providers, sending providers, and intermediate mail systems.",
"Outgoing messages must be processed in plaintext by Elektrine/Haraka long enough to format, sign, scan, route, and deliver them unless you use message-level encryption such as PGP.",
"PGP or similar end-to-end content encryption can protect message contents from mail providers and relays, but it does not hide normal email routing metadata."
]
}
]
},
%{
title: "How We Use Information",
blocks: [
%{
subtitle: nil,
paras: ["We use information to:"],
items: [
"Provide, operate, and maintain Elektrine services.",
"Send, receive, store, sync, filter, and display email and other user content.",
"Authenticate users, protect accounts, prevent fraud and abuse, rate-limit automated activity, and investigate security issues.",
"Debug failures, measure reliability, maintain backups, and improve product behavior.",
"Respond to support, legal, or safety requests."
]
}
]
},
%{
title: "Security Measures",
blocks: [
%{
subtitle: nil,
paras: ["We use technical and organizational safeguards, including:"],
items: [
"TLS for supported web, API, and mail protocol connections.",
"Hashed password storage and account security controls.",
"Encryption at rest for supported stored content and optional private mailbox storage for browser-unlocked mail protection.",
"Access controls, rate limits, spam/abuse protections, logging, and operational monitoring."
]
},
%{
subtitle: nil,
paras: [
"No system can guarantee perfect security. You are responsible for protecting your account credentials and any private mailbox passphrase or device used to unlock encrypted mailbox content."
],
items: []
}
]
},
%{
title: "Data Sharing",
blocks: [
%{
subtitle: nil,
paras: ["We do not sell your personal data. We may share or disclose information:"],
items: [
"With your direction or consent, such as when you send email to another provider or publish public content.",
"With service providers that help us operate infrastructure, storage, delivery, security, monitoring, or support.",
"To deliver email through the public email ecosystem, including DNS, SMTP, DKIM/SPF/DMARC, spam filtering, recipient providers, and remote mail servers.",
"To comply with applicable law, legal process, or enforceable government requests.",
"To protect Elektrine, our users, or the public from abuse, fraud, security threats, or harm."
]
}
]
},
%{
title: "Cookies and Local Storage",
blocks: [
%{
subtitle: nil,
paras: ["We use cookies and browser storage for:"],
items: [
"Session management and authentication.",
"Security protections and CSRF prevention.",
"User preferences such as theme, locale, and interface state.",
"Private mailbox unlock state in the current browser tab when you choose to unlock protected mail."
]
}
]
},
%{
title: "Logs and Retention",
blocks: [
%{
subtitle: nil,
paras: [
"We retain account data and user content while your account is active or as needed to provide the service. Operational logs may include IP addresses, request metadata, mail delivery events, rate-limit events, error messages, and security signals."
],
items: [
"Deleting messages or attachments removes them from the active mailbox storage path, subject to backups and operational retention.",
"Account deletion removes or anonymizes personal data where feasible, subject to backups, legal obligations, fraud prevention, abuse records, and deliverability/security logs.",
"Backups and logs may persist for a limited period after deletion before they expire through normal retention cycles."
]
}
]
},
%{
title: "Your Choices and Rights",
blocks: [
%{
subtitle: nil,
paras: ["Depending on your location and account status, you may be able to:"],
items: [
"Access, correct, export, or delete your account data.",
"Delete messages, attachments, posts, contacts, aliases, and other stored content.",
"Change privacy settings, notification settings, and mailbox encryption settings.",
"Opt out of optional communications where available."
]
}
]
},
%{
title: "Children's Privacy",
blocks: [
%{
subtitle: nil,
paras: [
"Our services are not directed to children under 13. We do not knowingly collect personal information from children under 13."
],
items: []
}
]
},
%{
title: "International Data Transfers",
blocks: [
%{
subtitle: nil,
paras: [
"Your data may be processed in countries other than your own. Where required, we use safeguards appropriate to the processing and providers involved."
],
items: []
}
]
},
%{
title: "Changes to This Policy",
blocks: [
%{
subtitle: nil,
paras: [
"We may update this policy periodically. We will notify you of significant changes by email, service notification, or posting an updated policy."
],
items: []
}
]
}
]
def mount(_params, _session, socket) do
{:ok, assign(socket, page_title: "Privacy Policy", sections: @sections)}
{:ok, assign(socket, page_title: "Privacy Policy", sections: sections())}
end
defp sections do
[
%{
title: "Summary",
blocks: [
%{
subtitle: nil,
paras: [
"Elektrine does not sell personal data.",
"Registration does not require government identity.",
"We keep only data needed to run the account and stop abuse.",
"We do not keep content-level traffic logs of your browsing or mail bodies for marketing."
],
items: []
}
]
},
%{
title: "No KYC",
blocks: [
%{
subtitle: nil,
paras: [
"We never require a passport, national ID card, selfie, or real legal name to use Elektrine.",
"Optional recovery email is for account recovery only. It is not an identity check."
],
items: []
}
]
},
%{
title: "Data we store",
blocks: [
%{
subtitle: "Account",
paras: [],
items: [
"Username, password hash, and security settings",
"Optional recovery email if you set one",
"Profile fields you publish, such as display name, avatar, and bio",
"Preferences such as locale, theme, and notifications"
]
},
%{
subtitle: "Your content",
paras: [],
items: [
"Mail, posts, chats, files, and other content you create or receive",
"Mailbox and message metadata needed for delivery and folders"
]
},
%{
subtitle: "Security and abuse control",
paras: [
"Short-lived security records stop attacks and spam."
],
items: [
"Failed login and rate-limit events",
"Connection counters for mail protocols when needed to stop abuse",
"Spam and malware signals on mail paths"
]
}
]
},
%{
title: "Log policy",
blocks: [
%{
subtitle: "What we do not keep",
paras: [],
items: [
"We do not keep permanent content logs of web browsing through the product UI for profiling",
"We do not sell logs or traffic data",
"We do not build advertising profiles from private mail or private chats"
]
},
%{
subtitle: "What we keep briefly",
paras: [
"Operators may keep short security and delivery logs to run the service."
],
items: [
"IP addresses and user agents on authentication and abuse events, retained only as long as needed for security (default target: 14 days or less unless an active abuse case needs more)",
"Mail delivery status needed to fix bounce and spam issues (not full SMTP session history by default)",
"Error traces for server faults, without private message bodies when avoidable"
]
},
%{
subtitle: "VPN",
paras: [
"VPN traffic contents are not logged.",
"Default: no durable connect or disconnect history and no client source IP on session rows.",
"WireGuard peers live in node memory. Aggregate bandwidth counters may remain for free-tier limits.",
"See the VPN Policy for full detail."
],
items: []
}
]
},
%{
title: "Mail and encryption",
blocks: [
%{
subtitle: nil,
paras: [
"Mail uses open internet protocols. Delivery exposes envelope data to mail servers on the path.",
"Stored mail bodies use application encryption at rest by default.",
"Default: no second RFC822 copy and no connecting MTA IP on message metadata.",
"Admin tools do not decrypt mail or chat bodies.",
"Trash and spam are hard-deleted after a short retention window by default.",
"Private mailbox mode can lock more fields to a browser passphrase. Operational flags still stay on the server.",
"Use PGP or similar tools if you need end-to-end content protection outside Elektrine."
],
items: []
}
]
},
%{
title: "How we use data",
blocks: [
%{
subtitle: nil,
paras: [],
items: [
"Run email, chat, social, DNS, VPN, and other enabled modules",
"Authenticate accounts and stop abuse",
"Fix faults and keep the service online",
"Answer support requests you send"
]
}
]
},
%{
title: "Sharing",
blocks: [
%{
subtitle: nil,
paras: ["We do not sell personal data."],
items: [
"We share data when you send it, for example mail to another provider or a public post",
"Infrastructure vendors process data only to host and deliver the service under our control",
"Mail delivery uses the public SMTP ecosystem by design",
"We disclose data when law requires a valid order",
"We act to stop active abuse, fraud, or direct harm"
]
}
]
},
%{
title: "Cookies",
blocks: [
%{
subtitle: nil,
paras: [],
items: [
"Session cookies for login",
"CSRF and security tokens",
"Local preferences such as theme",
"Private mailbox unlock state in the current browser when you unlock it"
]
}
]
},
%{
title: "Retention and deletion",
blocks: [
%{
subtitle: nil,
paras: [
"Account and content stay while the account is active.",
"You may delete content and the account in settings.",
"Backups and short security logs expire on a fixed cycle after deletion."
],
items: []
}
]
},
%{
title: "Your choices",
blocks: [
%{
subtitle: nil,
paras: [],
items: [
"Export or delete account data where the product supports it",
"Change privacy and notification settings",
"Contact privacy support for requests the UI cannot complete"
]
}
]
},
%{
title: "Children",
blocks: [
%{
subtitle: nil,
paras: [
"The service is not for children under 13.",
"We do not knowingly collect personal data from children under 13."
],
items: []
}
]
},
%{
title: "Source code and canary",
blocks: [
%{
subtitle: nil,
paras: [
"Elektrine source is public under AGPL-3.0-only.",
"A signed warrant canary is published at /canary when the operator maintains it."
],
items: []
}
]
},
%{
title: "Changes",
blocks: [
%{
subtitle: nil,
paras: [
"Operators may update this policy.",
"Material changes appear on this page. Important changes may also use in-app notice."
],
items: []
}
]
}
]
end
def render(assigns) do
@ -225,6 +256,9 @@ defmodule ElektrineWeb.PageLive.Privacy do
<div>
<header class="mb-8">
<h1 class="text-3xl font-semibold tracking-tight">Privacy Policy</h1>
<p class="mt-2 text-sm text-base-content/60">
No KYC. No sale of personal data. Short security logs only.
</p>
</header>
<.card id="privacy-card" class="panel-card" body_class="p-0">
@ -266,11 +300,11 @@ defmodule ElektrineWeb.PageLive.Privacy do
<span class="font-mono text-xs text-base-content/40">
{String.pad_leading(Integer.to_string(length(@sections) + 1), 2, "0")}
</span>
Contact Us
Contact
</h2>
<p class="mt-3 text-sm leading-relaxed text-base-content/70">
For privacy-related questions or requests:
Privacy requests:
<a href={EmailAddresses.mailto("privacy")} class="link link-hover text-primary">
{EmailAddresses.local("privacy")}
</a>

View file

@ -13,85 +13,141 @@ defmodule ElektrineWeb.PageLive.Terms do
domains =
Enum.map_join(Elektrine.Domains.supported_email_domains(), ", ", &("@" <> &1))
monero? = Elektrine.Payments.Crypto.monero_enabled?()
[
%{
title: "Acceptance of Terms",
title: "Acceptance",
paras: [
"By accessing or using Elektrine's services, you agree to be bound by these Terms of Service. If you do not agree to these terms, please do not use our services."
"You accept these terms when you use Elektrine.",
"Do not use the service if you reject these terms."
],
items: []
},
%{
title: "Description of Service",
paras: ["Elektrine provides:"],
title: "Service",
paras: ["This deployment may provide:"],
items: [
"Email services through local domains (#{domains})",
"Real-time chat and messaging capabilities",
"Social timeline and discussion features",
"File sharing and collaboration tools"
"Email on local domains (#{domains})",
"Chat and messaging",
"Social timeline and communities",
"DNS, VPN, and other modules the operator enables"
]
},
%{
title: "User Accounts",
paras: ["To use our services, you must:"],
title: "No KYC",
paras: [
"Elektrine never requires government identity documents for normal use.",
"We never require a passport, national ID, selfie, video call, or real legal name to register or keep an account.",
"We never require a phone number or billing address for identity verification.",
"This is a permanent policy. We will not introduce KYC for ordinary service access.",
"Law may force limited action in rare abuse or legal cases. That is not routine KYC for all users."
],
items: []
},
%{
title: "Accounts",
paras: ["Registration uses only:"],
items: [
"Provide accurate and complete information during registration",
"Maintain the security of your account credentials",
"Be at least 13 years of age",
"Notify us immediately of any unauthorized access"
"A username of your choice",
"A password",
"An optional recovery email you choose to store. Recovery email is not verified as identity",
"Captcha or invite rules the operator enables for abuse control"
]
},
%{
title: "Acceptable Use",
paras: ["You agree not to:"],
title: "Account rules",
paras: [],
items: [
"Violate any laws or regulations",
"Send spam or unsolicited messages",
"Distribute malware or harmful code",
"Harass, threaten, or harm other users",
"Attempt to gain unauthorized access to systems",
"Use the service for illegal activities"
"You must be at least 13 years old",
"Keep your password private",
"Tell the operator if you lose control of the account",
"You may delete the account in settings at any time"
]
},
%{
title: "Content and Privacy",
title: "Payments",
paras:
if monero? do
[
"Paid features may accept Monero and other methods the operator lists.",
"Monero payment does not require government identity.",
"Card payments, when offered, use a payment processor. The processor may apply its own rules."
]
else
[
"Paid features use the payment methods the operator lists on this site.",
"Payment does not require government identity for Elektrine itself."
]
end,
items: []
},
%{
title: "Acceptable use",
paras: ["Do not:"],
items: [
"Break applicable law",
"Send spam or unsolicited bulk mail",
"Distribute malware",
"Harass or threaten others",
"Attack systems or bypass access controls",
"Use the service to harm people or property"
]
},
%{
title: "Content",
paras: [
"You retain ownership of content you create, but grant us a license to store and transmit it as necessary to provide our services. We respect your privacy as outlined in our Privacy Policy."
"You keep ownership of content you create.",
"You grant Elektrine a limited license to store and send that content so the service can run.",
"Public posts and public profiles are visible to others by design."
],
items: []
},
%{
title: "Service Availability",
title: "Moderation",
paras: [
"While we strive for 99.9% uptime, we do not guarantee uninterrupted service. We may perform maintenance or updates that temporarily affect availability."
"Operators may limit or remove accounts that break these terms or abuse the service.",
"Limits target abuse, spam, and security harm. Limits are not identity checks."
],
items: []
},
%{
title: "Termination",
title: "Availability",
paras: [
"We reserve the right to suspend or terminate accounts that violate these terms. You may delete your account at any time through your account settings."
"Operators aim for continuous service. Uptime is not a guarantee.",
"Maintenance can interrupt access for short periods."
],
items: []
},
%{
title: "Disclaimer of Warranties",
title: "No warranty",
paras: [
"Services are provided \"as is\" without warranties of any kind, either express or implied."
"The service is provided as is.",
"Operators disclaim implied warranties to the extent law allows."
],
items: []
},
%{
title: "Limitation of Liability",
title: "Liability limit",
paras: [
"We shall not be liable for any indirect, incidental, special, or consequential damages arising from your use of our services."
"Operators are not liable for indirect or consequential damages from use of the service, to the extent law allows."
],
items: []
},
%{
title: "Changes to Terms",
title: "Source code",
paras: [
"We may update these terms at any time. Continued use of our services after changes constitutes acceptance of the new terms."
"Elektrine is free software under AGPL-3.0-only.",
"You may audit and self-host the code. See the public repository linked from Legal."
],
items: []
},
%{
title: "Changes",
paras: [
"Operators may update these terms.",
"Continued use after a change means you accept the new terms.",
"The no-KYC rule in this document will not reverse without a clear public notice."
],
items: []
}
@ -112,6 +168,9 @@ defmodule ElektrineWeb.PageLive.Terms do
<div>
<header class="mb-8">
<h1 class="text-3xl font-semibold tracking-tight">Terms of Service</h1>
<p class="mt-2 text-sm text-base-content/60">
No KYC. Identity-free registration. AGPL source.
</p>
</header>
<.card id="terms-card" class="panel-card" body_class="p-0">
@ -147,11 +206,11 @@ defmodule ElektrineWeb.PageLive.Terms do
<span class="font-mono text-xs text-base-content/40">
{String.pad_leading(Integer.to_string(length(@sections) + 1), 2, "0")}
</span>
Contact Information
Contact
</h2>
<p class="mt-3 text-sm leading-relaxed text-base-content/70">
For questions about these terms:
Questions about these terms:
<a href={EmailAddresses.mailto("legal")} class="link link-hover text-primary">
{EmailAddresses.local("legal")}
</a>

View file

@ -104,7 +104,7 @@ defmodule ElektrineEmailWeb.Admin.MessagesControllerTest do
assert log.details["route_context"] == "user_scoped"
end
test "logs raw admin message view", %{conn: conn} do
test "redacts raw admin message view", %{conn: conn} do
%{admin: admin, owner: owner, message: message} = admin_message_fixture()
request_path = "/pripyat/messages/#{message.id}/raw"
@ -118,7 +118,9 @@ defmodule ElektrineEmailWeb.Admin.MessagesControllerTest do
"_admin_action_grant" => grant_read_access(conn, admin, request_path)
})
assert response(conn, 200) =~ "EMAIL CONTENT"
body = response(conn, 200)
assert body =~ "not available in admin"
refute body =~ "EMAIL CONTENT"
log = latest_view_email_log(admin.id, message.id)
assert log.resource_type == "email_message"
@ -141,7 +143,7 @@ defmodule ElektrineEmailWeb.Admin.MessagesControllerTest do
"_admin_action_grant" => grant_read_access(conn, admin, request_path)
})
assert response(conn, 200) =~ "EMAIL CONTENT"
assert response(conn, 200) =~ "not available in admin"
log = latest_view_email_log(admin.id, message.id)
assert log.resource_type == "email_message"
@ -164,7 +166,8 @@ defmodule ElektrineEmailWeb.Admin.MessagesControllerTest do
"_admin_action_grant" => grant_read_access(conn, admin, request_path)
})
assert response(conn, 200) =~ "<p>Test body content</p>"
assert response(conn, 200) =~ "not available in admin"
refute response(conn, 200) =~ "Test body content"
log = latest_view_email_log(admin.id, message.id)
assert log.resource_type == "email_message"

View file

@ -26,6 +26,11 @@ config :mime, :types, %{
"video/x-matroska" => ["mkv"]
}
config :elektrine, :monero_payments,
enabled: nil,
address: nil,
payment_url: nil
config :elektrine,
# In production this is enabled in runtime.exs.
enforce_https: false,
@ -136,6 +141,10 @@ config :elektrine, Oban,
# Delete long-offline VPN nodes so recycled public IPs can register again
{"40 4 * * *", Elektrine.VPN.StaleNodeReaperWorker},
{"15 * * * *", Elektrine.VPN.IdlePeerGcWorker},
# Purge VPN session log rows (all when durable logs off; else by retention)
{"55 4 * * *", Elektrine.VPN.ConnectionLogCleanupWorker},
# Hard-delete old trash/spam (and optional inbox age cap)
{"20 4 * * *", Elektrine.Email.MessageRetentionWorker},
# Refresh NetBird admin allowlist from Management API (no-op without token)
{"*/30 * * * *", Elektrine.NetBird.AllowlistSyncWorker},
# Re-enqueue due messaging federation outbox rows
@ -271,9 +280,16 @@ profile_base_domains =
# Configure email settings
config :elektrine, :email,
domain: email_domain,
# Retaining raw RFC822 sources makes parser/sanitizer mistakes reversible, but
# duplicates attachment bytes. Keep only reasonably sized sources by default.
max_retained_raw_source_bytes: 10 * 1024 * 1024,
# Second copy of the RFC822 source. 0 = never retain (privacy default).
# Raise (e.g. 10485760) if you need "view original" / forensics.
max_retained_raw_source_bytes: 0,
# Connecting MTA IP on durable message metadata / Oban job args. Off by default.
store_inbound_remote_ip: false,
# Hard-delete soft-deleted (trash) and spam after N days. Inbox max age 0 = keep.
trash_retention_days: 30,
spam_retention_days: 30,
inbox_retention_days: 0,
retention_batch_size: 500,
# Older receiver webhook auth fallback:
# keep permissive in dev/test, fail-closed in prod unless explicitly configured.
allow_insecure_receiver_webhook: config_env() != :prod,
@ -299,21 +315,29 @@ config :elektrine, :vpn,
# Prefer Elektrine DNS in WireGuard client configs when the dns module is on
# and PUBLIC_DNS_BIND_IP / VPN_CLIENT_DNS_SERVERS is available.
use_elektrine_dns: true,
client_dns_servers: nil
client_dns_servers: nil,
# Per-session connect/disconnect rows in Postgres. Off by default so the
# control plane does not keep session history. Free-tier bandwidth still
# uses aggregate counters on account/config rows.
durable_session_logs: false,
# When durable_session_logs is true, delete rows older than this many days.
# 0 keeps rows until you purge them by hand. Ignored when durable logs are off
# (cleanup deletes every row).
connection_log_retention_days: 0
config :elektrine, :dns,
authority_enabled: false,
recursive_enabled: false,
public_bind_ip: nil,
# Serve DNSKEY/RRSIG for zones with DNSSEC keys (sign-on-publish). Default off.
# Serve DNSKEY/RRSIG for zones with DNSSEC keys (sign-on-publish). Default off.
dnssec_enabled: false,
# Outbound AXFR primary (TCP zone transfer). Off by default; also requires
# Outbound AXFR primary (TCP zone transfer). Off by default; also requires
# per-zone axfr_enabled + allow-list CIDRs.
secondary_axfr_enabled: false,
# Inbound secondary AXFR ingest (kind=secondary zones). Off by default.
secondary_ingest_enabled: false,
secondary_ingest_poll_interval_ms: 60_000,
# Minimum dual-sign window (seconds) before automated ZSK rollover complete.
# Minimum dual-sign window (seconds) before automated ZSK rollover complete.
# Effective hold-down is max(2 * max(default_ttl, soa_minimum), this floor).
dnssec_zsk_hold_down_seconds: 86_400,
zone_cache_refresh_interval_ms: 300_000,
@ -347,7 +371,7 @@ config :elektrine, :dns,
edge_access_cookie_max_age_seconds: 8 * 60 * 60,
edge_access_exchange_ttl_seconds: 60,
edge_access_state_max_age_seconds: 600,
tunnel_enabled: false,
tunnel_enabled: false,
tunnel_max_per_zone: 10,
tunnel_max_streams: 16,
tunnel_idle_timeout_ms: 60_000,
@ -355,13 +379,13 @@ tunnel_enabled: false,
tunnel_session_ttl_seconds: 3_600,
tunnel_connector_urls: [],
tunnel_dispatch_timeout_ms: 30_000,
cache_enabled: false,
cache_enabled: false,
edge_cache_max_entries: 10_000,
edge_cache_max_bytes: 256 * 1024 * 1024,
edge_cache_ttl_cap_seconds: 3_600,
edge_site_heartbeat_interval_ms: 15_000,
edge_site_heartbeat_interval_ms: 15_000,
edge_site_stale_after_ms: 45_000,
edge_site_heartbeat_interval_ms: 15_000,
edge_site_heartbeat_interval_ms: 15_000,
edge_site_stale_after_ms: 45_000,
edge_config_bundle_ttl_seconds: 300,
recursive_cache_max_entries: 10_000,

View file

@ -957,7 +957,84 @@ if config_env() == :prod do
custom_domain_haraka_timeout = parse_int_env.("CUSTOM_DOMAIN_HARAKA_TIMEOUT_MS", 10_000)
max_retained_raw_source_bytes =
parse_int_env.("EMAIL_RAW_SOURCE_MAX_BYTES", 10 * 1024 * 1024)
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
@ -997,6 +1074,11 @@ if config_env() == :prod do
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,
@ -1178,3 +1260,4 @@ 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__))

26
config/runtime/monero.exs Normal file
View file

@ -0,0 +1,26 @@
import Config
# Public Monero payment display for registration and billing copy.
# Set MONERO_ADDRESS and/or MONERO_PAYMENT_URL to enable.
blank_to_nil = fn
nil -> nil
"" -> nil
value when is_binary(value) -> String.trim(value)
value -> value
end
enabled =
case System.get_env("MONERO_ENABLED") do
value when value in ["1", "true", "TRUE", "yes", "YES"] -> true
value when value in ["0", "false", "FALSE", "no", "NO"] -> false
_ -> nil
end
address = blank_to_nil.(System.get_env("MONERO_ADDRESS"))
payment_url = blank_to_nil.(System.get_env("MONERO_PAYMENT_URL"))
config :elektrine, :monero_payments,
enabled: enabled,
address: address,
payment_url: payment_url

View file

@ -19,6 +19,22 @@ parse_bool_env = fn env_name, default ->
end
end
parse_non_neg_int_env = 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
vpn_config = Application.get_env(:elektrine, :vpn, [])
client_dns_servers =
@ -31,6 +47,20 @@ client_dns_servers =
use_elektrine_dns =
parse_bool_env.("VPN_USE_ELEKTRINE_DNS", Keyword.get(vpn_config, :use_elektrine_dns, true))
durable_session_logs =
parse_bool_env.(
"VPN_DURABLE_SESSION_LOGS",
Keyword.get(vpn_config, :durable_session_logs, false)
)
connection_log_retention_days =
parse_non_neg_int_env.(
"VPN_CONNECTION_LOG_RETENTION_DAYS",
Keyword.get(vpn_config, :connection_log_retention_days, 0)
)
config :elektrine, :vpn,
client_dns_servers: client_dns_servers,
use_elektrine_dns: use_elektrine_dns
use_elektrine_dns: use_elektrine_dns,
durable_session_logs: durable_session_logs,
connection_log_retention_days: connection_log_retention_days

View file

@ -115,6 +115,12 @@ config :wallaby,
# In test we don't send emails
config :elektrine, Elektrine.Mailer, adapter: Swoosh.Adapters.Test
# Privacy defaults in config.exs keep raw source off and remote IP off. Tests that
# exercise "view original" / raw blob download need a positive retention limit.
config :elektrine, :email,
max_retained_raw_source_bytes: 10 * 1024 * 1024,
store_inbound_remote_ip: false
# Disable swoosh api client as it is only required for production adapters
config :swoosh, :api_client, false

View file

@ -190,12 +190,40 @@ FROM ${RUNNER_IMAGE}
ARG RELEASE_NAME
ARG ELEKTRINE_RELEASE_MODULES
# Optional overrides: true|false|auto (auto derives from ELEKTRINE_RELEASE_MODULES).
ARG INSTALL_TOR=auto
ARG INSTALL_VPN_TOOLS=auto
SHELL ["/bin/bash", "-c"]
RUN for i in 1 2 3 4 5; do \
if apt-get update -y && \
apt-get install -y libstdc++6 openssl libncurses6 ca-certificates curl libvips42 tor gpg gnupg2 dirmngr wireguard-tools iproute2 shadowsocks-libev; then \
# Core runtime packages always. Tor / WireGuard / Shadowsocks only when the
# release modules (or explicit build args) need them — keeps slim images smaller.
RUN set -euo pipefail; \
modules="${ELEKTRINE_RELEASE_MODULES:-all}"; \
install_tor="${INSTALL_TOR:-auto}"; \
install_vpn="${INSTALL_VPN_TOOLS:-auto}"; \
if [ "$install_tor" = "auto" ]; then \
case ",${modules}," in \
*,all,*|*,tor,*) install_tor=true ;; \
*) install_tor=false ;; \
esac; \
fi; \
if [ "$install_vpn" = "auto" ]; then \
case ",${modules}," in \
*,all,*|*,vpn,*) install_vpn=true ;; \
*) install_vpn=false ;; \
esac; \
fi; \
pkgs="libstdc++6 openssl libncurses6 ca-certificates curl libvips42 gpg gnupg2 dirmngr"; \
if [ "$install_tor" = "true" ] || [ "$install_tor" = "1" ]; then \
pkgs="$pkgs tor"; \
fi; \
if [ "$install_vpn" = "true" ] || [ "$install_vpn" = "1" ]; then \
pkgs="$pkgs wireguard-tools iproute2 shadowsocks-libev"; \
fi; \
echo "Runner packages (modules=${modules} tor=${install_tor} vpn=${install_vpn}): $pkgs"; \
for i in 1 2 3 4 5; do \
if apt-get update -y && apt-get install -y $pkgs; then \
apt-get clean; \
rm -rf /var/lib/apt/lists/*; \
break; \
@ -214,7 +242,8 @@ RUN which gpg && gpg --version
RUN mkdir -p /data/tor/elektrine /data/tor/data /data/certs && \
chown -R nobody:nogroup /data
# Copy Tor config with proper permissions
# Tor config is harmless when tor is not installed; start.sh only launches tor
# when the runtime role/profile needs it.
COPY --chmod=644 deploy/onion/torrc /etc/tor/torrc
# Use a built-in UTF-8 locale available on Ubuntu images

View file

@ -1,5 +1,14 @@
version: '3.8'
# Shared security for BEAM roles that do not need host networking / NET_ADMIN.
x-beam-security: &beam_security
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:size=64M,mode=1777
services:
postgres:
image: pgvector/pgvector:pg16@sha256:00ba258a66dac104fd5171074a0084462a64a1369d8513f3d0a634e2f24d15bc
@ -22,6 +31,7 @@ services:
volumes:
- postgres_data:/var/lib/postgresql/data
- ./initdb:/docker-entrypoint-initdb.d:ro
# Backend network only — Caddy/proxy never joins this network.
networks:
- elektrine_network
restart: unless-stopped
@ -30,8 +40,20 @@ services:
interval: 10s
timeout: 5s
retries: 5
# Postgres needs a small capability set; drop everything else.
cap_drop:
- ALL
cap_add:
- CHOWN
- DAC_OVERRIDE
- FOWNER
- SETGID
- SETUID
security_opt:
- no-new-privileges:true
app:
<<: *beam_security
image: ${ELEKTRINE_IMAGE:-elektrine:local}
build:
context: ../..
@ -39,6 +61,8 @@ services:
args:
RELEASE_NAME: ${RELEASE_NAME:-elektrine}
ELEKTRINE_RELEASE_MODULES: ${ELEKTRINE_RELEASE_MODULES:-chat,social,nerve,atomine}
INSTALL_TOR: ${INSTALL_TOR:-auto}
INSTALL_VPN_TOOLS: ${INSTALL_VPN_TOOLS:-auto}
PRIMARY_DOMAIN: ${PRIMARY_DOMAIN:-localhost}
EMAIL_DOMAIN: ${EMAIL_DOMAIN:-${PRIMARY_DOMAIN:-localhost}}
SUPPORTED_DOMAINS: ${SUPPORTED_DOMAINS:-${PRIMARY_DOMAIN:-localhost}}
@ -83,8 +107,6 @@ services:
retries: 12
start_period: 30s
restart: unless-stopped
security_opt:
- no-new-privileges:true
volumes:
postgres_data:
@ -92,8 +114,10 @@ volumes:
app_data:
networks:
# App + Postgres (+ other backend roles). Edge proxies must not join this.
elektrine_network:
driver: bridge
# App + Caddy only. No database on this network.
caddy_proxy_network:
driver: bridge
ipam:

View file

@ -42,8 +42,12 @@ services:
networks:
- elektrine_edge
restart: unless-stopped
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:size=64M,mode=1777
healthcheck:
test:
[
@ -77,6 +81,10 @@ services:
networks:
- elektrine_edge
restart: unless-stopped
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
security_opt:
- no-new-privileges:true

View file

@ -1,3 +1,12 @@
# Shared security for BEAM roles that do not need host networking / NET_ADMIN.
x-beam-security: &beam_security
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:size=64M,mode=1777
services:
postgres:
image: pgvector/pgvector:pg16@sha256:00ba258a66dac104fd5171074a0084462a64a1369d8513f3d0a634e2f24d15bc
@ -22,6 +31,7 @@ services:
- ./initdb:/docker-entrypoint-initdb.d:ro
ports:
- "${DB_BIND:-127.0.0.1:5432:5432}"
# Backend network only — Caddy never joins this network.
networks:
- elektrine_network
restart: unless-stopped
@ -30,8 +40,19 @@ services:
interval: 10s
timeout: 5s
retries: 5
cap_drop:
- ALL
cap_add:
- CHOWN
- DAC_OVERRIDE
- FOWNER
- SETGID
- SETUID
security_opt:
- no-new-privileges:true
app:
<<: *beam_security
image: ${ELEKTRINE_IMAGE:-elektrine:local}
build:
context: ../..
@ -39,6 +60,8 @@ services:
args:
RELEASE_NAME: ${RELEASE_NAME:-elektrine}
ELEKTRINE_RELEASE_MODULES: ${ELEKTRINE_RELEASE_MODULES:-all}
INSTALL_TOR: ${INSTALL_TOR:-auto}
INSTALL_VPN_TOOLS: ${INSTALL_VPN_TOOLS:-auto}
PRIMARY_DOMAIN: ${PRIMARY_DOMAIN:-example.com}
EMAIL_DOMAIN: ${EMAIL_DOMAIN:-${PRIMARY_DOMAIN:-example.com}}
SUPPORTED_DOMAINS: ${SUPPORTED_DOMAINS:-${PRIMARY_DOMAIN:-example.com}}
@ -84,10 +107,9 @@ services:
retries: 12
start_period: 30s
restart: unless-stopped
security_opt:
- no-new-privileges:true
worker:
<<: *beam_security
image: ${ELEKTRINE_IMAGE:-elektrine:local}
build:
context: ../..
@ -95,6 +117,8 @@ services:
args:
RELEASE_NAME: ${RELEASE_NAME:-elektrine}
ELEKTRINE_RELEASE_MODULES: ${ELEKTRINE_RELEASE_MODULES:-all}
INSTALL_TOR: ${INSTALL_TOR:-auto}
INSTALL_VPN_TOOLS: ${INSTALL_VPN_TOOLS:-auto}
PRIMARY_DOMAIN: ${PRIMARY_DOMAIN:-example.com}
EMAIL_DOMAIN: ${EMAIL_DOMAIN:-${PRIMARY_DOMAIN:-example.com}}
SUPPORTED_DOMAINS: ${SUPPORTED_DOMAINS:-${PRIMARY_DOMAIN:-example.com}}
@ -129,8 +153,6 @@ services:
networks:
- elektrine_network
restart: unless-stopped
security_opt:
- no-new-privileges:true
vpn:
profiles: ["vpn"]
@ -141,6 +163,8 @@ services:
args:
RELEASE_NAME: ${RELEASE_NAME:-elektrine}
ELEKTRINE_RELEASE_MODULES: ${ELEKTRINE_RELEASE_MODULES:-all}
INSTALL_TOR: ${INSTALL_TOR:-auto}
INSTALL_VPN_TOOLS: ${INSTALL_VPN_TOOLS:-auto}
PRIMARY_DOMAIN: ${PRIMARY_DOMAIN:-example.com}
EMAIL_DOMAIN: ${EMAIL_DOMAIN:-${PRIMARY_DOMAIN:-example.com}}
SUPPORTED_DOMAINS: ${SUPPORTED_DOMAINS:-${PRIMARY_DOMAIN:-example.com}}
@ -184,6 +208,7 @@ services:
- no-new-privileges:true
mail:
<<: *beam_security
profiles: ["email"]
image: ${ELEKTRINE_IMAGE:-elektrine:local}
build:
@ -192,6 +217,8 @@ services:
args:
RELEASE_NAME: ${RELEASE_NAME:-elektrine}
ELEKTRINE_RELEASE_MODULES: ${ELEKTRINE_RELEASE_MODULES:-all}
INSTALL_TOR: ${INSTALL_TOR:-auto}
INSTALL_VPN_TOOLS: ${INSTALL_VPN_TOOLS:-auto}
PRIMARY_DOMAIN: ${PRIMARY_DOMAIN:-example.com}
EMAIL_DOMAIN: ${EMAIL_DOMAIN:-${PRIMARY_DOMAIN:-example.com}}
SUPPORTED_DOMAINS: ${SUPPORTED_DOMAINS:-${PRIMARY_DOMAIN:-example.com}}
@ -229,8 +256,6 @@ services:
- "${IMAP_TLS_BIND:-993:2993}"
- "${POP3_TLS_BIND:-995:2995}"
restart: unless-stopped
security_opt:
- no-new-privileges:true
# Mail listeners (SMTP/IMAP/POP3 + TLS variants) hold one file descriptor per
# connection, plus in-flight TLS handshakes. The Docker default soft nofile
# (1024) is far too low and leads to :emfile ("too many open files") storms
@ -241,6 +266,7 @@ services:
hard: 65536
dns:
<<: *beam_security
profiles: ["dns"]
image: ${ELEKTRINE_IMAGE:-elektrine:local}
build:
@ -249,6 +275,8 @@ services:
args:
RELEASE_NAME: ${RELEASE_NAME:-elektrine}
ELEKTRINE_RELEASE_MODULES: ${ELEKTRINE_RELEASE_MODULES:-all}
INSTALL_TOR: ${INSTALL_TOR:-auto}
INSTALL_VPN_TOOLS: ${INSTALL_VPN_TOOLS:-auto}
PRIMARY_DOMAIN: ${PRIMARY_DOMAIN:-localhost}
EMAIL_DOMAIN: ${EMAIL_DOMAIN:-${PRIMARY_DOMAIN:-localhost}}
SUPPORTED_DOMAINS: ${SUPPORTED_DOMAINS:-${PRIMARY_DOMAIN:-localhost}}
@ -280,8 +308,6 @@ services:
networks:
- elektrine_network
restart: unless-stopped
security_opt:
- no-new-privileges:true
turn:
image: ${TURN_IMAGE:-coturn/coturn:latest@sha256:867607152c6ed9504a0b59a2546a9bd2872dcf74313b3bc6cf9b69cc81be6fb3}
@ -345,12 +371,17 @@ services:
- ${CADDY_NETBIRD_ALLOWLIST_PATH:-../caddy/netbird_allowlist.caddy}:/etc/caddy/netbird_allowlist.caddy:ro
- ${CADDY_TLS_MOUNT_DIR:-/opt/elektrine/certs}:/opt/elektrine/certs:ro
- caddy_data:/data
# Proxy network only — no path to Postgres.
networks:
elektrine_network:
caddy_proxy_network:
- caddy_proxy_network
restart: unless-stopped
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
# Caddy needs to bind privileged ports when published as 80/443.
cap_add:
- NET_BIND_SERVICE
bluesky_pds:
image: ghcr.io/bluesky-social/pds:0.4@sha256:e0b756701c924410532c61b546a61810b6a0059bcebb0c21718d161f5b7880db
@ -379,6 +410,10 @@ services:
networks:
- elektrine_network
restart: unless-stopped
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
volumes:
postgres_data:
@ -388,8 +423,10 @@ volumes:
bluesky_pds_data:
networks:
# Backend: app/worker/mail/dns/postgres. Edge proxies must not join this.
elektrine_network:
driver: bridge
# Edge: app + Caddy only. No database on this network.
caddy_proxy_network:
driver: bridge
ipam:

View file

@ -1,34 +1,23 @@
# Composable Platform
# Composable platform
Elektrine has one public module switch and one advanced override:
Two switches control modules:
- `ELEKTRINE_ENABLED_MODULES` controls the normal deployment module set
- `ELEKTRINE_RELEASE_MODULES` optionally overrides build-time selection only
- `ELEKTRINE_ENABLED_MODULES`: runtime set for a deploy
- `ELEKTRINE_RELEASE_MODULES`: optional compile-time set (broader image, fewer modules later)
That means most deploys only need `ELEKTRINE_ENABLED_MODULES`. The release
override stays available for hosters who want to compile a broader image and
hide some compiled modules later.
Most hosts only set `ELEKTRINE_ENABLED_MODULES`.
Current platform module ids:
## Module ids
- `chat`
- `social`
- `email`
- `nerve`
- `vpn`
- `dns`
- `uptime`
- `atomine`
- `kairo`
`chat`, `social`, `email`, `nerve`, `vpn`, `dns`, `uptime`, `atomine`, `kairo`
Accepted aliases:
Aliases: `proofs` and `personhood``atomine`
- `proofs` -> `atomine`
- `personhood` -> `atomine`
## Ops
Operational rules:
- use `scripts/release/deploy_release.sh` for hosted or subset builds
- use `scripts/deploy/docker_deploy.sh` for normal Docker self-hosts
- use `deploy/docker/compose.core.yml` only when you want the app-plus-Postgres baseline
- treat `email`, `dns`, `vpn`, `onion`, TURN, and client artifacts as add-ons around the default self-host image
- Hosted or subset builds: `scripts/release/deploy_release.sh`
- Docker self-host: `scripts/deploy/docker_deploy.sh`
- App plus Postgres only: `deploy/docker/compose.core.yml`
- Treat `email`, `dns`, `vpn`, onion, TURN, and clients as add-ons
- Match `ELEKTRINE_RELEASE_MODULES` to what you need. The runner skips Tor, WireGuard, and Shadowsocks unless modules or `INSTALL_TOR` / `INSTALL_VPN_TOOLS` require them
- Keep Haraka and fleet VPN agents as separate deploys. Do not put SMTP or host-network WireGuard in the default web container

104
docs/security/hardening.md Normal file
View file

@ -0,0 +1,104 @@
# Security hardening
Checklist for operators. Keep secrets off disk dumps. Limit what admin and Docker can reach.
## What an attacker can get
| Access | Result |
|--------|--------|
| Public HTTP only | Normal login and abuse paths |
| Postgres only, no app secrets | Ciphertext bodies. Plain subjects, From, To, usernames |
| Postgres plus `ENCRYPTION_MASTER_SECRET` or `ELEKTRINE_MASTER_SECRET` | Server can decrypt normal mail bodies |
| Admin UI | Mail and chat metadata. No body decrypt. No impersonation |
| Root on the app host | Full access |
App AES protects a stolen disk when secrets stay elsewhere. It does not hide mail from the operator who holds secrets and the database.
## Defaults
| Item | Default |
|------|---------|
| Admin mail and chat bodies | Not shown |
| Admin impersonation | Off |
| `EMAIL_RAW_SOURCE_MAX_BYTES` | `0` (no second RFC822 copy) |
| `EMAIL_STORE_INBOUND_REMOTE_IP` | `false` |
| `EMAIL_TRASH_RETENTION_DAYS` | `30` |
| `EMAIL_SPAM_RETENTION_DAYS` | `30` |
| `EMAIL_INBOX_RETENTION_DAYS` | `0` (keep. Set e.g. `365` to cap age) |
| `VPN_DURABLE_SESSION_LOGS` | `false` |
## Secrets
1. Set long random `ELEKTRINE_MASTER_SECRET` and `DB_PASSWORD`.
2. Keep secrets out of Postgres, git, and plain backup files.
3. Run `chmod 600` on `.env.production`.
4. Put encryption secrets on the app host only when you can. Do not store them in DB dumps.
## Postgres
1. Do not publish `5432` to the public internet. Full compose uses `DB_BIND` default `127.0.0.1:5432`. Core compose has no host port.
2. Encrypt the host disk or the volume.
3. Encrypt dumps. Keep dump keys off the dump store. Short dump retention.
4. For remote DB set `DATABASE_SSL_ENABLED=true` and verify the CA.
5. Compose drops most Postgres capabilities. It keeps `CHOWN`, `DAC_OVERRIDE`, `FOWNER`, `SETGID`, `SETUID`.
## Docker networks and caps
| Network | Members |
|---------|---------|
| `elektrine_network` | app, worker, mail, dns, postgres, bluesky |
| `caddy_proxy_network` | app, caddy |
Caddy does not join the DB network.
BEAM roles (`app`, `worker`, `mail`, `dns`):
- `cap_drop: [ALL]`
- `no-new-privileges:true`
- `tmpfs` on `/tmp`
VPN needs `NET_ADMIN` and host network. Keep VPN out of the web container.
## Slim image packages
The runner installs Tor, WireGuard, and Shadowsocks only when needed.
- Modules `all` or `vpn` → VPN tools
- `INSTALL_TOR=true` or modules `all` → Tor
- Profiles `tor` or `vpn` force packages through `scripts/deploy/render_docker_compose.sh`
```bash
ELEKTRINE_RELEASE_MODULES=chat,social,nerve docker compose build app
```
## Host
1. Few admin accounts. Use hardware 2FA.
2. Put `/pripyat` behind NetBird or an allowlist when you can.
3. Lock SSH. Limit the `docker` group.
4. Update images and OS packages.
5. `docker_deploy.sh` runs `chmod 600` on `.env.production` when it can.
## Mail edge (Haraka)
1. Log level `warn`.
2. `HARAKA_QUEUE_STORE_REMOTE=false`.
3. `HARAKA_DLQ_STORE_PAYLOAD=false`.
4. Optional: `docker-compose.privacy.yml` for Redis without AOF/RDB.
5. After triage run `scripts/purge-dlq.sh`.
## Content that stays private from the operator
1. Private mailbox storage for bodies the server must not open.
2. PGP for high-value mail.
3. Client E2E chat when available.
4. Shorter trash and spam retention. Optional inbox age cap.
Retention deletes rows. Until delete, app secrets still decrypt normal mail.
## See also
- [Mail](../self-hosting/mail.md)
- [VPN](../self-hosting/vpn.md)
- [Docker](../self-hosting/docker.md)
- Haraka: `elektrine-haraka/deployment/README.md`

View file

@ -1,7 +1,6 @@
# Self-hosting
Start with the small Docker self-hosting path, then enable only the pieces you
need.
Start with Docker. Enable only what you need.
```bash
scripts/deploy/self_host.sh init --domain example.com --email admin@example.com --preset simple-web
@ -9,7 +8,7 @@ scripts/deploy/self_host.sh doctor
scripts/deploy/self_host.sh up
```
Advanced services are enabled as presets:
Presets:
```bash
scripts/deploy/self_host.sh presets
@ -20,11 +19,7 @@ scripts/deploy/self_host.sh doctor
scripts/deploy/self_host.sh up
```
The generated `.env.production` stays small. Preset-specific settings are
appended only when you enable that feature.
For production configuration, start with four high-level choices instead of raw
Caddy/DNS internals:
`.env.production` stays small. Preset keys append only when you enable that preset.
```env
DEPLOYMENT_PRESET=simple-web
@ -33,16 +28,19 @@ TLS_MODE=caddy-auto
PUBLIC_DNS_BIND_IP=
```
See `deploy-runbook.md` for the preset matrix, NetBird admin allowlists, DNS
binds, and TLS modes.
See `deploy-runbook.md` for presets, NetBird allowlists, DNS binds, and TLS modes.
Security checklist: [../security/hardening.md](../security/hardening.md).
## Profiles
- `core`: app and Postgres only
- `mail`: Elektrine mail protocols; production SMTP edge/delivery still needs a separate Haraka deployment
- `dns`: optional authoritative DNS service enabled through the Docker `dns` profile
- `vpn`: optional Docker-managed WireGuard, with optional fleet mode
- `addons`: Caddy edge, TURN, Bluesky PDS, onion hosting, and client artifacts
| Profile | Role |
|---------|------|
| `core` | App and Postgres |
| `mail` | Elektrine mail protocols. Production MX and outbound still need Haraka |
| `dns` | Authoritative DNS (`dns` Compose profile) |
| `vpn` | Docker WireGuard. Optional fleet mode |
| `addons` | Caddy, TURN, Bluesky PDS, onion, clients |
## Guides
@ -51,10 +49,10 @@ binds, and TLS modes.
- `core.md`
- `caddy.md`
- `mail.md`
- `vpn.md`
- `turn.md`
- `dnssec-zsk-rollover.md`
- `../architecture/dns-module.md`
- `../architecture/edge-platform.md`
- `dnssec-zsk-rollover.md`
- `turn.md`
- `vpn.md`
- `../addons/onion.md`
- `../clients/nerve-extension.md`

View file

@ -1,48 +1,32 @@
# Core Self-hosting
# Core self-hosting
`core` is the minimal app-plus-Postgres profile.
App plus Postgres only. File: `deploy/docker/compose.core.yml`.
This guide covers the plain app-plus-Postgres baseline in
`deploy/docker/compose.core.yml`. For a normal public Docker host, use
`docs/self-hosting/docker.md`; the self-host wrapper generates a small public
web stack and lets you enable optional services through presets.
For a full public host with presets, use `docker.md` and `self_host.sh`.
It does not include:
- Haraka
- VPN
- onion hosting
- an edge proxy
Not included: Haraka, VPN, onion, edge proxy.
## Start
1. Copy `.env.example` to `.env.production` and fill in real values.
2. Keep `DATABASE_SSL_ENABLED=false` if you are using the bundled Docker Postgres service.
3. Start the core stack:
1. Copy `.env.example` to `.env.production` and set real values.
2. Set `DATABASE_SSL_ENABLED=false` for the bundled Postgres service.
3. Start:
```bash
docker compose --env-file .env.production -f deploy/docker/compose.core.yml up -d --build
```
The core compose file binds the app HTTP port to `127.0.0.1` by default so it
does not bypass an edge proxy on public hosts. Set `APP_HTTP_BIND=8080:8080`
only for local testing or when an external firewall/proxy already protects the
port.
App HTTP binds to `127.0.0.1` by default. Set `APP_HTTP_BIND=8080:8080` only for
local tests or when another edge already protects the port.
If you want the module-aware wrapper, use `docs/self-hosting/docker.md` instead.
That path is for the generated multi-service stack and starts with only the
common public web services.
## Add-ons (generated stack)
## Add-ons
Use the module-aware deploy in `docker.md` when you need more than core:
These apply when you choose a smaller generated Docker stack instead of the full
default profile set.
- Caddy: `--profile caddy`
- TURN: `--profile turn`
- WireGuard: include `vpn` in modules
- DNS: `--profile dns`
- Bluesky PDS: `--profile bluesky`
- Add Caddy with `--profile caddy`
- Add self-hosted STUN/TURN for chat calls with `--profile turn`
- Add Docker-managed WireGuard with `--modules chat,social,nerve,vpn,atomine`
- Add the dedicated DNS service with `--profile dns`
- Add the Bluesky PDS with `--profile bluesky`
See `mail.md`, `turn.md`, `vpn.md`, and `../addons/onion.md` for the optional
services.
See `mail.md`, `turn.md`, `vpn.md`, and `../addons/onion.md`.

View file

@ -425,16 +425,26 @@ Tor starts when the `tor` profile is active. For custom profile subsets, include
## Postgres
- Docker deploy uses `pgvector/pgvector:pg16` for the `postgres` service
- The Postgres container gets `POSTGRES_SHM_SIZE`, defaulting to `512m`, to avoid Docker's small default `/dev/shm` causing `ERROR 53100 (disk_full) could not resize shared memory segment` during larger/parallel queries
- fresh databases load `vector` from `deploy/docker/initdb/010-extensions.sql`
- every deploy also runs `CREATE EXTENSION IF NOT EXISTS` for extensions listed in `POSTGRES_EXTENSIONS`
- `POSTGRES_EXTENSIONS` defaults to `vector`; set a comma-separated list in `.env.production` if you need more
- Image: `pgvector/pgvector:pg16`
- `POSTGRES_SHM_SIZE` defaults to `512m` (avoids small Docker `/dev/shm` errors)
- Full compose: `DB_BIND` default `127.0.0.1:5432`. Do not bind `0.0.0.0`
- Core compose: internal network only, no host port
- Fresh DB loads `vector` from `deploy/docker/initdb/010-extensions.sql`
- Each deploy runs `CREATE EXTENSION IF NOT EXISTS` for `POSTGRES_EXTENSIONS` (default `vector`)
Mail on the same server is supported too, but as a second Docker deployment.
Use this repo for Phoenix, mailbox, JMAP, and WKD, and run
`elektrine-haraka` beside it for SMTP edge and delivery. See
`docs/self-hosting/mail.md`.
## Compose security
- Backend: `elektrine_network` (app, worker, mail, dns, postgres)
- Edge: `caddy_proxy_network` (app + Caddy only). No Postgres there
- App, worker, mail, dns: `cap_drop: ALL`, `no-new-privileges`, `tmpfs` on `/tmp`
- Postgres: drop all caps, then add the small required set
- Tor / WireGuard / Shadowsocks packages follow `ELEKTRINE_RELEASE_MODULES` and
`INSTALL_TOR` / `INSTALL_VPN_TOOLS` (see `deploy/docker/Dockerfile`)
Full checklist: [../security/hardening.md](../security/hardening.md).
Mail on the same host: run `elektrine-haraka` as a second Compose project. See
`mail.md`.
## GitHub Actions

View file

@ -67,10 +67,16 @@ addresses and built-in profile URLs under that host. Keep the mail server host i
`HARAKA_BASE_URL`, and use `CUSTOM_DOMAIN_MX_HOST=mail.example.com` when custom
domains should point their MX records at the Haraka host.
`EMAIL_RAW_SOURCE_MAX_BYTES` limits how much original RFC822 source Elektrine
retains per message. The default is 10 MiB (`10485760`). Messages larger than the
limit are still delivered normally, but their duplicate raw source is omitted and
the omission is recorded in message metadata.
`EMAIL_RAW_SOURCE_MAX_BYTES` defaults to `0` (no second RFC822 copy). Set a
positive size only if you need the original source on the server. Delivery still
works when the raw copy is omitted.
`EMAIL_STORE_INBOUND_REMOTE_IP` defaults to `false`. MTA IPs are not stored on
message metadata. Rate limits still use the live webhook client IP.
Haraka (separate deploy): log level `warn`, queue without remote MTA fields, DLQ
without full RFC822. Optional: `docker-compose.privacy.yml` for non-persistent
Redis. See `elektrine-haraka/deployment/README.md`.
Same-server networking guidance:

View file

@ -150,6 +150,34 @@ product (token mint, hostname bind UX, multi-tenant isolation). Fleet agent
packaging (`scripts/vpn/elektrine-vpn-agent.sh`) is the operational model the
edge tunnel agent is expected to follow later.
## Session logs
Default: `VPN_DURABLE_SESSION_LOGS=false`. Connect events do not write session
rows. Client source IP is never stored on those rows. Free-tier limits use
aggregate bandwidth counters from peer stats.
```sh
# optional durable history
VPN_DURABLE_SESSION_LOGS=true
VPN_CONNECTION_LOG_RETENTION_DAYS=7
```
`Elektrine.VPN.ConnectionLogCleanupWorker` runs daily. With durable logs off it
deletes all session rows. With durable logs on it deletes rows past the
retention window and nulls leftover `client_ip`.
## RAM-only exit nodes
WireGuard peers live in the kernel. For a node with little disk state:
1. Use the fleet agent in `scripts/vpn/` (no Postgres on the node).
2. Set `STATE_DIR` to tmpfs (for example `/run/elektrine-vpn`) if re-register after reboot is fine.
3. Keep short journald retention. Avoid durable packet captures.
4. Keep `VPN_DURABLE_SESSION_LOGS=false` on the control plane.
The control plane still stores accounts, peer keys, and quota counters. Agent
install: `scripts/vpn/README.md`.
## Notes
- the `vpn` service runs with `host` networking and `NET_ADMIN` so it can own the WireGuard interface

View file

@ -1056,6 +1056,11 @@ if [[ " $RENDER_PROFILES " == *" caddy "* ]]; then
fi
fi
if [[ -f "$ENV_FILE" ]]; then
# Secrets live in the env file; keep it owner-only when the deploy user can chmod it.
chmod 600 "$ENV_FILE" 2>/dev/null || true
fi
COMPOSE_BASE_ARGS=(--project-directory "$COMPOSE_PROJECT_DIR" --env-file "$ENV_FILE")
ensure_writable_output_path "$OUTPUT_PATH" "Compose output path"

View file

@ -62,6 +62,7 @@ RELEASE_MODULES="$NORMALIZED_MODULES"
TOR_ENABLED="false"
COTURN_ENABLED="false"
VPN_PROFILE_ENABLED="false"
CADDY_DEFAULT_CONFIG_PATH="${CADDY_DEFAULT_CONFIG_PATH:-../caddy/Caddyfile.baremetal}"
for profile in $RAW_PROFILES; do
if [[ "$profile" == "tor" ]]; then
@ -71,11 +72,35 @@ for profile in $RAW_PROFILES; do
if [[ "$profile" == "turn" ]]; then
COTURN_ENABLED="true"
fi
if [[ "$profile" == "vpn" ]]; then
VPN_PROFILE_ENABLED="true"
fi
done
# Slim images drop tor/wg packages unless modules=all. When a profile needs them,
# force the Docker build args so the runner stage still installs the tools.
if [[ -z "${INSTALL_TOR:-}" || "${INSTALL_TOR}" == "auto" ]]; then
if [[ "$TOR_ENABLED" == "true" ]]; then
INSTALL_TOR="true"
else
INSTALL_TOR="auto"
fi
fi
if [[ -z "${INSTALL_VPN_TOOLS:-}" || "${INSTALL_VPN_TOOLS}" == "auto" ]]; then
if [[ "$VPN_PROFILE_ENABLED" == "true" ]]; then
INSTALL_VPN_TOOLS="true"
else
INSTALL_VPN_TOOLS="auto"
fi
fi
export INSTALL_TOR INSTALL_VPN_TOOLS
mkdir -p "$(dirname "$OUTPUT_PATH")"
awk -v release_modules="$RELEASE_MODULES" -v enabled_modules="$ENABLED_MODULES" -v selected_profiles="$RAW_PROFILES" -v tor_enabled="$TOR_ENABLED" -v turn_enabled="$COTURN_ENABLED" -v caddy_config_default="$CADDY_DEFAULT_CONFIG_PATH" '
awk -v release_modules="$RELEASE_MODULES" -v enabled_modules="$ENABLED_MODULES" -v selected_profiles="$RAW_PROFILES" -v tor_enabled="$TOR_ENABLED" -v turn_enabled="$COTURN_ENABLED" -v install_tor="${INSTALL_TOR}" -v install_vpn_tools="${INSTALL_VPN_TOOLS}" -v caddy_config_default="$CADDY_DEFAULT_CONFIG_PATH" '
function profile_selected(profile) {
return index(" " selected_profiles " ", " " profile " ") > 0
}
@ -130,6 +155,18 @@ awk -v release_modules="$RELEASE_MODULES" -v enabled_modules="$ENABLED_MODULES"
next
}
/INSTALL_TOR:/ {
sub(/\$\{INSTALL_TOR:-[^}]*\}/, "${INSTALL_TOR:-" install_tor "}")
process_line($0)
next
}
/INSTALL_VPN_TOOLS:/ {
sub(/\$\{INSTALL_VPN_TOOLS:-[^}]*\}/, "${INSTALL_VPN_TOOLS:-" install_vpn_tools "}")
process_line($0)
next
}
/ELEKTRINE_ENABLED_MODULES:/ {
sub(/\$\{ELEKTRINE_ENABLED_MODULES:-[^}]*\}/, "${ELEKTRINE_ENABLED_MODULES:-" enabled_modules "}")
process_line($0)

View file

@ -153,3 +153,20 @@ scaling a region means adding a map entry. See its README.
journalctl -u elektrine-vpn-agent -f # watch reconcile/heartbeat logs
wg show wg0 # peers applied by the agent
```
## Low-disk exit node
The agent does not use Postgres. Peers live in the WireGuard kernel table.
1. Keep `VPN_DURABLE_SESSION_LOGS=false` on the control plane (default).
2. Optional tmpfs state (re-register after reboot):
```sh
STATE_DIR=/run/elektrine-vpn
```
3. Short journald retention. Do not keep packet captures on disk.
4. After reboot the next poll reloads peers.
Control plane still stores accounts, peer keys, and quota counters. See
`docs/self-hosting/vpn.md`.

View file

@ -32,4 +32,7 @@ ENDPOINT_PORT=51820
# MANAGE_INTERFACE=1 # 0 to manage wg0 + NAT yourself
# POLL_INTERVAL=60
# ACTIVE_WINDOW=180
# Durable on-disk state (API key + server id). For a RAM-only node that
# re-registers after reboot, use a tmpfs path instead, e.g.:
# STATE_DIR=/run/elektrine-vpn
# STATE_DIR=/var/lib/elektrine-vpn