diff --git a/config/runtime.exs b/config/runtime.exs index f677c3e..c68a359 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -92,6 +92,22 @@ if System.get_env("TEMPEST_BACKUP_STORE") == "s3" do s3: backup_s3_config end +if System.get_env("TEMPEST_SMTP_ENABLED") in ["1", "true", "TRUE"] do + config :tempest, Tempest.Mailer, + adapter: Swoosh.Adapters.SMTP, + relay: System.fetch_env!("TEMPEST_SMTP_HOST"), + port: System.get_env("TEMPEST_SMTP_PORT", "587") |> String.to_integer(), + username: System.get_env("TEMPEST_SMTP_USERNAME"), + password: System.get_env("TEMPEST_SMTP_PASSWORD"), + ssl: System.get_env("TEMPEST_SMTP_SSL") in ["1", "true", "TRUE"], + tls: String.to_existing_atom(System.get_env("TEMPEST_SMTP_TLS", "if_available")), + auth: String.to_existing_atom(System.get_env("TEMPEST_SMTP_AUTH", "if_available")) + + config :tempest, Tempest.Security.Email, + from_name: System.get_env("TEMPEST_SMTP_FROM_NAME", "Tempest"), + from_address: System.fetch_env!("TEMPEST_SMTP_FROM_ADDRESS") +end + if admin_token_hash = System.get_env("TEMPEST_ADMIN_TOKEN_HASH") do config :tempest, :admin_token_hash, admin_token_hash end diff --git a/docs/specs/deployment-observability.md b/docs/specs/deployment-observability.md index 3ade81d..bc403d5 100644 --- a/docs/specs/deployment-observability.md +++ b/docs/specs/deployment-observability.md @@ -66,6 +66,15 @@ TEMPEST_INVITE_REQUIRED=true TEMPEST_BLOB_STORE=local TEMPEST_BLOB_MAX_BYTES=10000000 TEMPEST_SMTP_ENABLED=false +# SMTP profile also sets: +# TEMPEST_SMTP_HOST=smtp.example.com +# TEMPEST_SMTP_PORT=587 +# TEMPEST_SMTP_USERNAME=... +# TEMPEST_SMTP_PASSWORD=... +# TEMPEST_SMTP_FROM_ADDRESS=noreply@example.com +# TEMPEST_SMTP_FROM_NAME=Tempest +# TEMPEST_SMTP_TLS=if_available +# TEMPEST_SMTP_AUTH=if_available TEMPEST_BACKUP_STORE=local # S3/R2-backed profiles also set: diff --git a/docs/tasks/13-admin-operator-features.md b/docs/tasks/13-admin-operator-features.md index dc77368..38b4d41 100644 --- a/docs/tasks/13-admin-operator-features.md +++ b/docs/tasks/13-admin-operator-features.md @@ -30,8 +30,8 @@ This slice exists to increase development observability: - [x] T13-13: Add `mix pds.backup.create` and `mix pds.backup.restore`. - [x] T13-14: Add S3-compatible blob adapter. - [x] T13-15: Add S3-compatible SQLite backup upload for SQLite deployments. -- [ ] T13-16: Add SMTP adapter and account security notifications. -- [ ] T13-17: Add telemetry events for XRPC, repo writes, blobs, and firehose. +- [x] T13-16: Add SMTP adapter and account security notifications. +- [x] T13-17: Add telemetry events for XRPC, repo writes, blobs, and firehose. - [ ] T13-18: Add UI for sessions, OAuth grants, app passwords, and delegated access. - [ ] T13-19: Add UI for email, password, MFA, backup codes, and trusted devices. - [ ] T13-20: Add admin dashboard for account status, sequencer, storage, and relay crawl status. diff --git a/lib/mix/tasks/tempest.lexicon.sync.ex b/lib/mix/tasks/tempest.lexicon.sync.ex index 5bb47b6..61ebc85 100644 --- a/lib/mix/tasks/tempest.lexicon.sync.ex +++ b/lib/mix/tasks/tempest.lexicon.sync.ex @@ -76,9 +76,15 @@ defmodule Mix.Tasks.Tempest.Lexicon.Sync do com/atproto/server/describeServer.json com/atproto/server/getServiceAuth.json com/atproto/server/getSession.json + com/atproto/server/confirmEmail.json com/atproto/server/refreshSession.json com/atproto/server/requestAccountDelete.json + com/atproto/server/requestEmailConfirmation.json + com/atproto/server/requestEmailUpdate.json + com/atproto/server/requestPasswordReset.json com/atproto/server/reserveSigningKey.json + com/atproto/server/resetPassword.json + com/atproto/server/updateEmail.json com/atproto/sync/getBlob.json com/atproto/sync/getBlocks.json com/atproto/sync/getLatestCommit.json diff --git a/lib/tempest/lexicon/bundled.ex b/lib/tempest/lexicon/bundled.ex index 5fe29cb..1c731e6 100644 --- a/lib/tempest/lexicon/bundled.ex +++ b/lib/tempest/lexicon/bundled.ex @@ -10,7 +10,7 @@ defmodule Tempest.Lexicon.Bundled do @behaviour Tempest.Lexicon.Provider @manifest %{ - "document_count" => 45, + "document_count" => 51, "document_ids" => [ "app.bsky.actor.getPreferences", "app.bsky.actor.profile", @@ -33,6 +33,12 @@ defmodule Tempest.Lexicon.Bundled do "com.atproto.repo.uploadBlob", "com.atproto.server.activateAccount", "com.atproto.server.checkAccountStatus", + "com.atproto.server.confirmEmail", + "com.atproto.server.requestEmailConfirmation", + "com.atproto.server.requestEmailUpdate", + "com.atproto.server.requestPasswordReset", + "com.atproto.server.resetPassword", + "com.atproto.server.updateEmail", "com.atproto.server.createAccount", "com.atproto.server.createSession", "com.atproto.server.deactivateAccount", @@ -64,6 +70,122 @@ defmodule Tempest.Lexicon.Bundled do } @documents [ + %{ + "defs" => %{ + "main" => %{ + "description" => "Confirm an email using a token from com.atproto.server.requestEmailConfirmation.", + "errors" => [ + %{"name" => "AccountNotFound"}, + %{"name" => "ExpiredToken"}, + %{"name" => "InvalidToken"}, + %{"name" => "InvalidEmail"} + ], + "input" => %{ + "encoding" => "application/json", + "schema" => %{ + "properties" => %{"email" => %{"type" => "string"}, "token" => %{"type" => "string"}}, + "required" => ["email", "token"], + "type" => "object" + } + }, + "type" => "procedure" + } + }, + "id" => "com.atproto.server.confirmEmail", + "lexicon" => 1 + }, + %{ + "defs" => %{ + "main" => %{ + "description" => "Request an email with a code to confirm ownership of email.", + "type" => "procedure" + } + }, + "id" => "com.atproto.server.requestEmailConfirmation", + "lexicon" => 1 + }, + %{ + "defs" => %{ + "main" => %{ + "description" => "Request a token in order to update email.", + "output" => %{ + "encoding" => "application/json", + "schema" => %{ + "properties" => %{"tokenRequired" => %{"type" => "boolean"}}, + "required" => ["tokenRequired"], + "type" => "object" + } + }, + "type" => "procedure" + } + }, + "id" => "com.atproto.server.requestEmailUpdate", + "lexicon" => 1 + }, + %{ + "defs" => %{ + "main" => %{ + "description" => "Initiate a user account password reset via email.", + "input" => %{ + "encoding" => "application/json", + "schema" => %{ + "properties" => %{"email" => %{"type" => "string"}}, + "required" => ["email"], + "type" => "object" + } + }, + "type" => "procedure" + } + }, + "id" => "com.atproto.server.requestPasswordReset", + "lexicon" => 1 + }, + %{ + "defs" => %{ + "main" => %{ + "description" => "Reset a user account password using a token.", + "errors" => [%{"name" => "ExpiredToken"}, %{"name" => "InvalidToken"}], + "input" => %{ + "encoding" => "application/json", + "schema" => %{ + "properties" => %{"password" => %{"type" => "string"}, "token" => %{"type" => "string"}}, + "required" => ["token", "password"], + "type" => "object" + } + }, + "type" => "procedure" + } + }, + "id" => "com.atproto.server.resetPassword", + "lexicon" => 1 + }, + %{ + "defs" => %{ + "main" => %{ + "description" => "Update an account's email.", + "errors" => [%{"name" => "ExpiredToken"}, %{"name" => "InvalidToken"}, %{"name" => "TokenRequired"}], + "input" => %{ + "encoding" => "application/json", + "schema" => %{ + "properties" => %{ + "email" => %{"type" => "string"}, + "emailAuthFactor" => %{"type" => "boolean"}, + "token" => %{ + "description" => + "Requires a token from com.atproto.sever.requestEmailUpdate if the account's email has been confirmed.", + "type" => "string" + } + }, + "required" => ["email"], + "type" => "object" + } + }, + "type" => "procedure" + } + }, + "id" => "com.atproto.server.updateEmail", + "lexicon" => 1 + }, %{ "defs" => %{ "main" => %{ diff --git a/lib/tempest/records.ex b/lib/tempest/records.ex index fec9b9b..e11ce42 100644 --- a/lib/tempest/records.ex +++ b/lib/tempest/records.ex @@ -437,14 +437,19 @@ defmodule Tempest.Records do } |> maybe_put("prev", Map.get(stored, :prev_record_cid)) - with {:ok, payload} <- commit_payload(did, stored, [path], [op]) do - Tempest.Sequencer.insert_repo_commit(did, stored.rev, stored.commit_cid, action, payload) + with {:ok, payload} <- commit_payload(did, stored, [path], [op]), + {:ok, event} <- Tempest.Sequencer.insert_repo_commit(did, stored.rev, stored.commit_cid, action, payload) do + emit_repo_write_telemetry(did, action, stored, 1) + {:ok, event} end end defp insert_apply_writes_event(did, stored) do - with {:ok, payload} <- commit_payload(did, stored, stored.paths, stored.ops) do - Tempest.Sequencer.insert_repo_commit(did, stored.rev, stored.commit_cid, "applyWrites", payload) + with {:ok, payload} <- commit_payload(did, stored, stored.paths, stored.ops), + {:ok, event} <- + Tempest.Sequencer.insert_repo_commit(did, stored.rev, stored.commit_cid, "applyWrites", payload) do + emit_repo_write_telemetry(did, "applyWrites", stored, length(stored.ops)) + {:ok, event} end end @@ -452,13 +457,26 @@ defmodule Tempest.Records do path = collection <> "/" <> rkey op = %{"action" => "delete", "path" => path, "cid" => nil, "prev" => stored.prev_record_cid} - with {:ok, payload} <- commit_payload(did, stored, [path], [op]) do - Tempest.Sequencer.insert_repo_commit(did, stored.rev, stored.commit_cid, "delete", payload) + with {:ok, payload} <- commit_payload(did, stored, [path], [op]), + {:ok, event} <- Tempest.Sequencer.insert_repo_commit(did, stored.rev, stored.commit_cid, "delete", payload) do + emit_repo_write_telemetry(did, "delete", stored, 1) + {:ok, event} end end defp maybe_insert_delete_event(_did, %{deleted?: false}, _collection, _rkey), do: {:ok, nil} + defp emit_repo_write_telemetry(did, action, stored, op_count) do + Tempest.Telemetry.execute([:repo, :write], %{count: 1, op_count: op_count}, %{ + did: did, + action: action, + rev: stored.rev, + commit_cid: stored.commit_cid + }) + + Tempest.Telemetry.execute([:repo, :commit], %{count: 1}, %{did: did, rev: stored.rev, commit_cid: stored.commit_cid}) + end + defp commit_payload(did, stored, paths, ops) do base = %{ "ops" => ops, diff --git a/lib/tempest/security.ex b/lib/tempest/security.ex index 1cdc58b..f132825 100644 --- a/lib/tempest/security.ex +++ b/lib/tempest/security.ex @@ -75,6 +75,8 @@ defmodule Tempest.Security do end) end + def consume_email_token(_raw, _purpose), do: {:error, :invalid_token} + def start_totp_enrollment(%Account{} = account, label \\ nil) do secret = Totp.new_secret() label = label || account.handle @@ -193,11 +195,21 @@ defmodule Tempest.Security do case Repo.get_by(Account, email: identifier) || Repo.get_by(Account, handle: identifier) || Repo.get_by(Account, did: identifier) do - %Account{} = account -> issue_email_token(account, "reset_password") + %Account{} = account -> Tempest.Security.Email.deliver_password_reset(account) nil -> {:ok, :accepted} end end + def request_email_confirmation(%Account{} = account), do: Tempest.Security.Email.deliver_confirmation(account) + + def confirm_email(raw_token), do: consume_email_token(raw_token, "confirm_email") + + def request_email_update(%Account{} = account, new_email) when is_binary(new_email) do + Tempest.Security.Email.deliver_update(account, new_email) + end + + def update_email(raw_token), do: consume_email_token(raw_token, "update_email") + def reset_password(raw_token, new_password) do with :ok <- Password.validate(new_password), {:ok, account} <- consume_email_token(raw_token, "reset_password") do diff --git a/lib/tempest/security/email.ex b/lib/tempest/security/email.ex index f6ae92e..6abafcf 100644 --- a/lib/tempest/security/email.ex +++ b/lib/tempest/security/email.ex @@ -10,28 +10,51 @@ defmodule Tempest.Security.Email do def deliver_confirmation(%Account{} = account) do with {:ok, %{token: token}} <- Security.issue_email_token(account, "confirm_email") do - deliver(account.email, "Confirm your Tempest email", "Use this token to confirm your email: #{token}") + deliver(account.email, "Confirm your Tempest email", "Use this token to confirm your email: #{token}", %{ + purpose: "confirm_email" + }) end end def deliver_update(%Account{} = account, new_email) do with {:ok, %{token: token}} <- Security.issue_email_token(account, "update_email", new_email) do - deliver(new_email, "Confirm your Tempest email change", "Use this token to confirm your new email: #{token}") + deliver(new_email, "Confirm your Tempest email change", "Use this token to confirm your new email: #{token}", %{ + purpose: "update_email" + }) end end def deliver_password_reset(%Account{} = account) do with {:ok, %{token: token}} <- Security.issue_email_token(account, "reset_password") do - deliver(account.email, "Reset your Tempest password", "Use this token to reset your password: #{token}") + deliver(account.email, "Reset your Tempest password", "Use this token to reset your password: #{token}", %{ + purpose: "reset_password" + }) end end - defp deliver(to, subject, body) do - new() - |> from({"Tempest", "noreply@localhost"}) - |> to(to) - |> subject(subject) - |> text_body(body) - |> Mailer.deliver() + defp deliver(to, subject, body, metadata) do + email = + new() + |> from(from_address()) + |> to(to) + |> subject(subject) + |> text_body(body) + + case Mailer.deliver(email) do + {:ok, result} -> + :telemetry.execute([:tempest, :email, :deliver], %{count: 1}, Map.merge(metadata, %{status: :ok})) + {:ok, result} + + {:error, reason} -> + :telemetry.execute([:tempest, :email, :deliver], %{count: 1}, Map.merge(metadata, %{status: :error})) + {:error, reason} + end + end + + defp from_address do + config = Application.get_env(:tempest, __MODULE__, []) + name = Keyword.get(config, :from_name, "Tempest") + address = Keyword.get(config, :from_address, "noreply@localhost") + {name, address} end end diff --git a/lib/tempest/telemetry.ex b/lib/tempest/telemetry.ex new file mode 100644 index 0000000..831a9f3 --- /dev/null +++ b/lib/tempest/telemetry.ex @@ -0,0 +1,22 @@ +defmodule Tempest.Telemetry do + @moduledoc """ + Small helpers for Tempest telemetry events. + """ + + def execute(event, measurements, metadata \\ %{}) when is_list(event) and is_map(measurements) and is_map(metadata) do + :telemetry.execute([:tempest | event], measurements, metadata) + end + + def timed(event, metadata, fun) when is_function(fun, 0) do + start = System.monotonic_time() + result = fun.() + duration = System.monotonic_time() - start + execute(event, %{count: 1, duration: duration}, Map.put(metadata, :status, status(result))) + result + end + + defp status({:ok, _value}), do: :ok + defp status({:error, _status, _error, _message}), do: :error + defp status({:error, _reason}), do: :error + defp status(_other), do: :ok +end diff --git a/lib/tempest/xrpc/registry.ex b/lib/tempest/xrpc/registry.ex index 4ba27ba..523ad07 100644 --- a/lib/tempest/xrpc/registry.ex +++ b/lib/tempest/xrpc/registry.ex @@ -154,6 +154,60 @@ defmodule Tempest.Xrpc.Registry do handler: {Tempest.Xrpc.Server, :revoke_app_password}, errors: ["ExpiredToken", "InvalidToken"] }, + %Method{ + nsid: "com.atproto.server.requestPasswordReset", + kind: :procedure, + auth: :none, + input: @json, + output: @json, + handler: {Tempest.Xrpc.Server, :request_password_reset}, + errors: ["InvalidRequest"] + }, + %Method{ + nsid: "com.atproto.server.resetPassword", + kind: :procedure, + auth: :none, + input: @json, + output: @json, + handler: {Tempest.Xrpc.Server, :reset_password}, + errors: ["InvalidRequest"] + }, + %Method{ + nsid: "com.atproto.server.requestEmailConfirmation", + kind: :procedure, + auth: :bearer, + input: @json, + output: @json, + handler: {Tempest.Xrpc.Server, :request_email_confirmation}, + errors: ["ExpiredToken", "InvalidToken"] + }, + %Method{ + nsid: "com.atproto.server.confirmEmail", + kind: :procedure, + auth: :none, + input: @json, + output: @json, + handler: {Tempest.Xrpc.Server, :confirm_email}, + errors: ["InvalidRequest"] + }, + %Method{ + nsid: "com.atproto.server.requestEmailUpdate", + kind: :procedure, + auth: :bearer, + input: @json, + output: @json, + handler: {Tempest.Xrpc.Server, :request_email_update}, + errors: ["ExpiredToken", "InvalidToken", "InvalidRequest"] + }, + %Method{ + nsid: "com.atproto.server.updateEmail", + kind: :procedure, + auth: :none, + input: @json, + output: @json, + handler: {Tempest.Xrpc.Server, :update_email}, + errors: ["InvalidRequest"] + }, %Method{ nsid: "com.atproto.identity.resolveHandle", kind: :query, diff --git a/lib/tempest/xrpc/repo.ex b/lib/tempest/xrpc/repo.ex index e6210a7..d81c3d8 100644 --- a/lib/tempest/xrpc/repo.ex +++ b/lib/tempest/xrpc/repo.ex @@ -30,6 +30,11 @@ defmodule Tempest.Xrpc.Repo do {:ok, metadata} <- Blobs.validate_upload(bytes, declared_size, declared_mime_type, config), {:ok, _stored} <- Blobs.put_temp_blob(config, conn.assigns.auth_context.account.did, metadata.cid, bytes), :ok <- Blobs.put_temp_metadata(conn.assigns.auth_context.account.did, metadata) do + Tempest.Telemetry.execute([:blob, :upload], %{count: 1, bytes: metadata.size}, %{ + did: conn.assigns.auth_context.account.did, + mime_type: metadata.mime_type + }) + {:ok, %{ blob: %{ diff --git a/lib/tempest/xrpc/server.ex b/lib/tempest/xrpc/server.ex index 734cf00..b98aa57 100644 --- a/lib/tempest/xrpc/server.ex +++ b/lib/tempest/xrpc/server.ex @@ -3,7 +3,7 @@ defmodule Tempest.Xrpc.Server do Handlers for `com.atproto.server.*` XRPC methods. """ - alias Tempest.Accounts + alias Tempest.{Accounts, Security} def describe_server(_conn, _params, _method) do config = Tempest.Config.load!() @@ -139,9 +139,61 @@ defmodule Tempest.Xrpc.Server do end end + def request_password_reset(_conn, params, _method) do + case Security.request_password_reset(Map.get(params, "email") || Map.get(params, "identifier")) do + {:ok, _result} -> {:ok, %{}} + {:error, reason} -> email_error(reason) + end + end + + def reset_password(_conn, params, _method) do + case Security.reset_password(Map.get(params, "token"), Map.get(params, "password")) do + {:ok, _account} -> {:ok, %{}} + {:error, :invalid_token} -> {:error, 400, "InvalidRequest", "token is invalid or expired"} + {:error, message} when is_binary(message) -> {:error, 400, "InvalidRequest", message} + {:error, reason} -> email_error(reason) + end + end + + def request_email_confirmation(conn, _params, _method) do + case Security.request_email_confirmation(conn.assigns.auth_context.account) do + {:ok, _result} -> {:ok, %{}} + {:error, reason} -> email_error(reason) + end + end + + def confirm_email(_conn, params, _method) do + case Security.confirm_email(Map.get(params, "token")) do + {:ok, _account} -> {:ok, %{}} + {:error, :invalid_token} -> {:error, 400, "InvalidRequest", "token is invalid or expired"} + {:error, reason} -> email_error(reason) + end + end + + def request_email_update(conn, params, _method) do + case Security.request_email_update(conn.assigns.auth_context.account, Map.get(params, "email")) do + {:ok, _result} -> {:ok, %{}} + {:error, reason} -> email_error(reason) + end + end + + def update_email(_conn, params, _method) do + case Security.update_email(Map.get(params, "token")) do + {:ok, _account} -> {:ok, %{}} + {:error, :invalid_token} -> {:error, 400, "InvalidRequest", "token is invalid or expired"} + {:error, reason} -> email_error(reason) + end + end + defp available_user_domain("." <> _domain = hostname), do: hostname defp available_user_domain(hostname), do: "." <> hostname + defp email_error(%Ecto.Changeset{} = changeset), + do: {:error, 400, "InvalidRequest", format_changeset_errors(changeset)} + + defp email_error({:validation, %Ecto.Changeset{} = changeset}), do: email_error(changeset) + defp email_error(reason), do: {:error, 500, "InternalServerError", "email flow failed: #{inspect(reason)}"} + defp lifecycle_error(:pds_service_mismatch), do: {:error, 400, "InvalidRequest", "DID document does not point at this PDS"} diff --git a/lib/tempest_web/controllers/xrpc_controller.ex b/lib/tempest_web/controllers/xrpc_controller.ex index a72a50d..dbc79cf 100644 --- a/lib/tempest_web/controllers/xrpc_controller.ex +++ b/lib/tempest_web/controllers/xrpc_controller.ex @@ -105,9 +105,13 @@ defmodule TempestWeb.XrpcController do end defp dispatch(conn, params, method) do - {module, function} = method.handler + result = + Tempest.Telemetry.timed([:xrpc, :request], %{nsid: method.nsid, method: conn.method}, fn -> + {module, function} = method.handler + apply(module, function, [conn, params, method]) + end) - case apply(module, function, [conn, params, method]) do + case result do {:ok, body} -> respond(conn, method, body) diff --git a/lib/tempest_web/firehose_socket.ex b/lib/tempest_web/firehose_socket.ex index 229c71e..3f05998 100644 --- a/lib/tempest_web/firehose_socket.ex +++ b/lib/tempest_web/firehose_socket.ex @@ -19,6 +19,9 @@ defmodule TempestWeb.FirehoseSocket do {:ok, frames} <- encode_events(events) do state = %__MODULE__{last_seq: last_seq(events, start_seq)} + Tempest.Telemetry.execute([:firehose, :backfill], %{count: length(events)}, %{cursor: cursor}) + Tempest.Telemetry.execute([:firehose, :subscriber], %{count: 1}, %{event: :connect}) + if frames == [] do {:ok, state} else @@ -39,6 +42,11 @@ defmodule TempestWeb.FirehoseSocket do if event.seq > state.last_seq do with {:ok, frame} <- EventStream.encode_message(event), :ok <- ensure_frame_size(frame) do + Tempest.Telemetry.execute([:firehose, :event], %{count: 1, bytes: byte_size(frame)}, %{ + did: event.did, + event_type: event.event_type + }) + {:push, {:binary, frame}, %{state | last_seq: event.seq}} else {:error, reason} -> @@ -59,6 +67,12 @@ defmodule TempestWeb.FirehoseSocket do Enum.reduce_while(events, {:ok, []}, fn event, {:ok, frames} -> with {:ok, frame} <- EventStream.encode_message(event), :ok <- ensure_frame_size(frame) do + Tempest.Telemetry.execute([:firehose, :event], %{count: 1, bytes: byte_size(frame)}, %{ + did: event.did, + event_type: event.event_type, + backfill?: true + }) + {:cont, {:ok, [frame | frames]}} else {:error, reason} -> {:halt, {:error, reason}} diff --git a/lib/tempest_web/telemetry.ex b/lib/tempest_web/telemetry.ex index 4668b93..d3e3847 100644 --- a/lib/tempest_web/telemetry.ex +++ b/lib/tempest_web/telemetry.ex @@ -74,6 +74,19 @@ defmodule TempestWeb.Telemetry do description: "The time the connection spent waiting before being checked out for the query" ), + # Tempest protocol metrics + counter("tempest.xrpc.request.count", tags: [:nsid, :status]), + summary("tempest.xrpc.request.duration", unit: {:native, :millisecond}, tags: [:nsid, :status]), + counter("tempest.repo.write.count", tags: [:action]), + counter("tempest.repo.commit.count"), + counter("tempest.blob.upload.count", tags: [:mime_type]), + sum("tempest.blob.upload.bytes", tags: [:mime_type]), + counter("tempest.firehose.event.count", tags: [:event_type]), + sum("tempest.firehose.event.bytes", tags: [:event_type]), + counter("tempest.firehose.backfill.count"), + counter("tempest.firehose.subscriber.count", tags: [:event]), + counter("tempest.email.deliver.count", tags: [:purpose, :status]), + # VM Metrics summary("vm.memory.total", unit: {:byte, :kilobyte}), summary("vm.total_run_queue_lengths.total"), diff --git a/mix.exs b/mix.exs index e53a817..c0837c8 100644 --- a/mix.exs +++ b/mix.exs @@ -48,6 +48,7 @@ defmodule Tempest.MixProject do {:heroicons, github: "tailwindlabs/heroicons", tag: "v2.2.0", sparse: "optimized", app: false, compile: false, depth: 1}, {:swoosh, "~> 1.16"}, + {:gen_smtp, "~> 1.2"}, {:req, "~> 0.5"}, {:telemetry_metrics, "~> 1.0"}, {:telemetry_poller, "~> 1.0"}, diff --git a/mix.lock b/mix.lock index 23ff6b2..03a3eae 100644 --- a/mix.lock +++ b/mix.lock @@ -16,6 +16,7 @@ "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, "fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"}, + "gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"}, "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, @@ -36,6 +37,7 @@ "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "swoosh": {:hex, :swoosh, "1.25.1", "569fcff34817da8a03f28775146b3c8b71b4c9b14f8f78d37ff3ef422862a18b", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "58b3e8db6406fe417a89b5042358d2e8f15d32a3317d4f8581d7a3ae501e410b"}, "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, diff --git a/priv/lexicons/official/com/atproto/server/confirmEmail.json b/priv/lexicons/official/com/atproto/server/confirmEmail.json new file mode 100644 index 0000000..6c2e429 --- /dev/null +++ b/priv/lexicons/official/com/atproto/server/confirmEmail.json @@ -0,0 +1,27 @@ +{ + "lexicon": 1, + "id": "com.atproto.server.confirmEmail", + "defs": { + "main": { + "type": "procedure", + "description": "Confirm an email using a token from com.atproto.server.requestEmailConfirmation.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["email", "token"], + "properties": { + "email": { "type": "string" }, + "token": { "type": "string" } + } + } + }, + "errors": [ + { "name": "AccountNotFound" }, + { "name": "ExpiredToken" }, + { "name": "InvalidToken" }, + { "name": "InvalidEmail" } + ] + } + } +} diff --git a/priv/lexicons/official/com/atproto/server/requestEmailConfirmation.json b/priv/lexicons/official/com/atproto/server/requestEmailConfirmation.json new file mode 100644 index 0000000..0020ed3 --- /dev/null +++ b/priv/lexicons/official/com/atproto/server/requestEmailConfirmation.json @@ -0,0 +1,10 @@ +{ + "lexicon": 1, + "id": "com.atproto.server.requestEmailConfirmation", + "defs": { + "main": { + "type": "procedure", + "description": "Request an email with a code to confirm ownership of email." + } + } +} diff --git a/priv/lexicons/official/com/atproto/server/requestEmailUpdate.json b/priv/lexicons/official/com/atproto/server/requestEmailUpdate.json new file mode 100644 index 0000000..4cc1a86 --- /dev/null +++ b/priv/lexicons/official/com/atproto/server/requestEmailUpdate.json @@ -0,0 +1,20 @@ +{ + "lexicon": 1, + "id": "com.atproto.server.requestEmailUpdate", + "defs": { + "main": { + "type": "procedure", + "description": "Request a token in order to update email.", + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["tokenRequired"], + "properties": { + "tokenRequired": { "type": "boolean" } + } + } + } + } + } +} diff --git a/priv/lexicons/official/com/atproto/server/requestPasswordReset.json b/priv/lexicons/official/com/atproto/server/requestPasswordReset.json new file mode 100644 index 0000000..41f717e --- /dev/null +++ b/priv/lexicons/official/com/atproto/server/requestPasswordReset.json @@ -0,0 +1,20 @@ +{ + "lexicon": 1, + "id": "com.atproto.server.requestPasswordReset", + "defs": { + "main": { + "type": "procedure", + "description": "Initiate a user account password reset via email.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["email"], + "properties": { + "email": { "type": "string" } + } + } + } + } + } +} diff --git a/priv/lexicons/official/com/atproto/server/resetPassword.json b/priv/lexicons/official/com/atproto/server/resetPassword.json new file mode 100644 index 0000000..cdf7fe3 --- /dev/null +++ b/priv/lexicons/official/com/atproto/server/resetPassword.json @@ -0,0 +1,22 @@ +{ + "lexicon": 1, + "id": "com.atproto.server.resetPassword", + "defs": { + "main": { + "type": "procedure", + "description": "Reset a user account password using a token.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["token", "password"], + "properties": { + "token": { "type": "string" }, + "password": { "type": "string" } + } + } + }, + "errors": [{ "name": "ExpiredToken" }, { "name": "InvalidToken" }] + } + } +} diff --git a/priv/lexicons/official/com/atproto/server/updateEmail.json b/priv/lexicons/official/com/atproto/server/updateEmail.json new file mode 100644 index 0000000..7d90247 --- /dev/null +++ b/priv/lexicons/official/com/atproto/server/updateEmail.json @@ -0,0 +1,30 @@ +{ + "lexicon": 1, + "id": "com.atproto.server.updateEmail", + "defs": { + "main": { + "type": "procedure", + "description": "Update an account's email.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["email"], + "properties": { + "email": { "type": "string" }, + "emailAuthFactor": { "type": "boolean" }, + "token": { + "type": "string", + "description": "Requires a token from com.atproto.sever.requestEmailUpdate if the account's email has been confirmed." + } + } + } + }, + "errors": [ + { "name": "ExpiredToken" }, + { "name": "InvalidToken" }, + { "name": "TokenRequired" } + ] + } + } +} diff --git a/test/tempest/telemetry_test.exs b/test/tempest/telemetry_test.exs new file mode 100644 index 0000000..0232815 --- /dev/null +++ b/test/tempest/telemetry_test.exs @@ -0,0 +1,83 @@ +defmodule Tempest.TelemetryTest do + use TempestWeb.ConnCase + + @password "correct horse battery staple" + + test "emits XRPC repo blob and email telemetry", %{conn: conn} do + parent = self() + ref = make_ref() + + events = [ + [:tempest, :xrpc, :request], + [:tempest, :repo, :write], + [:tempest, :repo, :commit], + [:tempest, :blob, :upload], + [:tempest, :email, :deliver] + ] + + :telemetry.attach_many( + "tempest-test-#{inspect(ref)}", + events, + fn event, measurements, metadata, _config -> send(parent, {ref, event, measurements, metadata}) end, + nil + ) + + on_exit(fn -> :telemetry.detach("tempest-test-#{inspect(ref)}") end) + + account = create_account!(conn) + blob = upload_blob!(conn, account, "telemetry blob")["blob"] + + conn + |> auth_json(account) + |> post(~p"/xrpc/com.atproto.repo.createRecord", %{ + "repo" => account["did"], + "collection" => "app.tempest.blob", + "rkey" => "telemetry", + "validate" => false, + "record" => %{"$type" => "app.tempest.blob", "image" => blob} + }) + |> json_response(200) + + conn + |> recycle() + |> put_req_header("content-type", "application/json") + |> post(~p"/xrpc/com.atproto.server.requestPasswordReset", %{"email" => account["email"]}) + |> json_response(200) + + assert_receive {^ref, [:tempest, :blob, :upload], %{bytes: 14}, %{mime_type: "text/plain"}} + assert_receive {^ref, [:tempest, :repo, :write], %{count: 1}, %{action: "create"}} + assert_receive {^ref, [:tempest, :repo, :commit], %{count: 1}, %{did: _did}} + assert_receive {^ref, [:tempest, :email, :deliver], %{count: 1}, %{purpose: "reset_password", status: :ok}} + assert_received {^ref, [:tempest, :xrpc, :request], %{count: 1, duration: _duration}, %{nsid: _, status: _}} + end + + defp create_account!(conn) do + unique = System.unique_integer([:positive]) + + conn + |> put_req_header("content-type", "application/json") + |> post(~p"/xrpc/com.atproto.server.createAccount", %{ + "handle" => "telemetry-#{unique}.test", + "email" => "telemetry-#{unique}@example.com", + "password" => @password + }) + |> json_response(200) + end + + defp upload_blob!(conn, account, bytes) do + conn + |> recycle() + |> put_req_header("authorization", "Bearer #{account["accessJwt"]}") + |> put_req_header("content-type", "text/plain") + |> put_req_header("content-length", Integer.to_string(byte_size(bytes))) + |> post(~p"/xrpc/com.atproto.repo.uploadBlob", bytes) + |> json_response(200) + end + + defp auth_json(conn, account) do + conn + |> recycle() + |> put_req_header("authorization", "Bearer #{account["accessJwt"]}") + |> put_req_header("content-type", "application/json") + end +end diff --git a/test/tempest_web/xrpc/email_flows_test.exs b/test/tempest_web/xrpc/email_flows_test.exs new file mode 100644 index 0000000..b3e9fe2 --- /dev/null +++ b/test/tempest_web/xrpc/email_flows_test.exs @@ -0,0 +1,110 @@ +defmodule TempestWeb.Xrpc.EmailFlowsTest do + use TempestWeb.ConnCase + + import Swoosh.TestAssertions + + alias Tempest.{Accounts, Repo} + alias Tempest.Accounts.Account + + @password "correct horse battery staple" + + setup do + old_config = Application.get_env(:tempest, Tempest.Security.Email) + + Application.put_env(:tempest, Tempest.Security.Email, + from_name: "Tempest Test", + from_address: "noreply@example.com" + ) + + on_exit(fn -> + if old_config do + Application.put_env(:tempest, Tempest.Security.Email, old_config) + else + Application.delete_env(:tempest, Tempest.Security.Email) + end + end) + + :ok + end + + test "requestPasswordReset sends reset email and resetPassword consumes token", %{conn: conn} do + account = create_account!("email-reset.test", "email-reset@example.com") + + conn + |> put_req_header("content-type", "application/json") + |> post(~p"/xrpc/com.atproto.server.requestPasswordReset", %{"email" => account.email}) + |> json_response(200) + + token = assert_email_token_sent(to: {nil, account.email}, subject: "Reset your Tempest password") + + conn + |> recycle() + |> put_req_header("content-type", "application/json") + |> post(~p"/xrpc/com.atproto.server.resetPassword", %{ + "token" => token, + "password" => "new correct horse battery staple" + }) + |> json_response(200) + + assert {:ok, session} = Accounts.create_session(account.handle, "new correct horse battery staple") + assert session["did"] == account.did + end + + test "email confirmation and update flows send account emails", %{conn: conn} do + account = create_account!("email-update.test", "email-update@example.com") + {:ok, session} = Accounts.create_session(account.handle, @password) + + conn + |> put_req_header("authorization", "Bearer #{session["accessJwt"]}") + |> put_req_header("content-type", "application/json") + |> post(~p"/xrpc/com.atproto.server.requestEmailConfirmation", %{}) + |> json_response(200) + + confirm_token = assert_email_token_sent(to: {nil, account.email}, subject: "Confirm your Tempest email") + + conn + |> recycle() + |> put_req_header("content-type", "application/json") + |> post(~p"/xrpc/com.atproto.server.confirmEmail", %{"token" => confirm_token}) + |> json_response(200) + + conn + |> recycle() + |> put_req_header("authorization", "Bearer #{session["accessJwt"]}") + |> put_req_header("content-type", "application/json") + |> post(~p"/xrpc/com.atproto.server.requestEmailUpdate", %{"email" => "new-email@example.com"}) + |> json_response(200) + + update_token = + assert_email_token_sent(to: {nil, "new-email@example.com"}, subject: "Confirm your Tempest email change") + + conn + |> recycle() + |> put_req_header("content-type", "application/json") + |> post(~p"/xrpc/com.atproto.server.updateEmail", %{"token" => update_token}) + |> json_response(200) + + assert Repo.get_by!(Account, did: account.did).email == "new-email@example.com" + end + + defp create_account!(handle, email) do + {:ok, session} = Accounts.create_account(%{"handle" => handle, "email" => email, "password" => @password}) + Repo.get_by!(Account, did: session["did"]) + end + + defp assert_email_token_sent(assertions) do + assert_email_sent(fn email -> + Enum.each(assertions, fn + {:to, to} -> assert Swoosh.Email.Recipient.format(to) in email.to + {:subject, subject} -> assert email.subject == subject + end) + + [token] = Regex.run(~r/[A-Za-z0-9_-]{40,}/, email.text_body) + send(self(), {:email_token, token}) + true + end) + + assert_received {:email_token, token} + token + end +end diff --git a/test/tempest_web/xrpc/proxy_fallback_test.exs b/test/tempest_web/xrpc/proxy_fallback_test.exs index b25563f..91e5407 100644 --- a/test/tempest_web/xrpc/proxy_fallback_test.exs +++ b/test/tempest_web/xrpc/proxy_fallback_test.exs @@ -40,7 +40,7 @@ defmodule TempestWeb.Xrpc.ProxyFallbackTest do test "unknown PDS endpoints are not proxied", %{conn: conn} do Application.put_env(:tempest, Tempest.Xrpc.Proxy, upstream_base_url: "https://appview.example") - unknown_conn = get(conn, ~p"/xrpc/com.atproto.server.requestPasswordReset") + unknown_conn = get(conn, ~p"/xrpc/com.atproto.server.unknownMethod") assert %{"error" => "UnknownMethod"} = json_response(unknown_conn, 404) end