feat(privacy): harden session logs, admin access, and docker isolation
Some checks failed
Deploy Docker Images / Build, push, and deploy (push) Failing after 23m0s
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:
parent
663f0d0676
commit
da169a3869
55 changed files with 2295 additions and 1038 deletions
29
.env.example
29
.env.example
|
|
@ -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
1
.gitignore
vendored
|
|
@ -83,3 +83,4 @@ deps/
|
|||
.elixir_ls/
|
||||
!.env*.example
|
||||
.claude/**/worktrees/
|
||||
.onion-vanity/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
12
apps/elektrine/lib/elektrine/admin/content_access.ex
Normal file
12
apps/elektrine/lib/elektrine/admin/content_access.ex
Normal 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
|
||||
65
apps/elektrine/lib/elektrine/payments/crypto.ex
Normal file
65
apps/elektrine/lib/elektrine/payments/crypto.ex
Normal 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
|
||||
|
|
@ -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
|
||||
12
apps/elektrine/test/elektrine/admin/content_access_test.exs
Normal file
12
apps/elektrine/test/elektrine/admin/content_access_test.exs
Normal 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
|
||||
50
apps/elektrine/test/elektrine/payments/crypto_test.exs
Normal file
50
apps/elektrine/test/elektrine/payments/crypto_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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} ->
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>.
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
171
apps/elektrine_vpn/test/elektrine/vpn/connection_log_test.exs
Normal file
171
apps/elektrine_vpn/test/elektrine/vpn/connection_log_test.exs
Normal 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
|
||||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} ->
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
26
config/runtime/monero.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
104
docs/security/hardening.md
Normal 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`
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue