diff --git a/.gitattributes b/.gitattributes index 7273afe43..ac67c53c2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,5 +7,4 @@ *.js.map binary *.css binary -priv/static/instance/static.css diff=css -priv/static/static-fe/static-fe.css diff=css +*.css diff=css diff --git a/.gitignore b/.gitignore index 14373fb8c..95b236af6 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,4 @@ docs/site # docker stuff docker-db +*.iml diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ec7e29b3..f673adc9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased +### Added +- Prometheus metrics exporting from `/api/v1/akkoma/metrics` + ### Removed - Non-finch HTTP adapters - Legacy redirect from /api/pleroma/admin to /api/v1/pleroma/admin ### Changed - Return HTTP error 413 when uploading an avatar or banner that's above the configured upload limit instead of a 500. +- Non-admin users now cannot register `admin` scope tokens (not security-critical, they didn't work before, but you _could_ create them) ### Upgrade notes - Ensure `config :tesla, :adapter` is either unset, or set to `{Tesla.Adapter.Finch, name: MyFinch}` in your .exs config diff --git a/config/config.exs b/config/config.exs index a0176a72d..cb19edde3 100644 --- a/config/config.exs +++ b/config/config.exs @@ -259,7 +259,8 @@ profile_directory: true, privileged_staff: false, local_bubble: [], - max_frontend_settings_json_chars: 100_000 + max_frontend_settings_json_chars: 100_000, + export_prometheus_metrics: true config :pleroma, :welcome, direct_message: [ diff --git a/config/description.exs b/config/description.exs index 4d4306fba..1059039e7 100644 --- a/config/description.exs +++ b/config/description.exs @@ -964,6 +964,11 @@ type: {:list, :string}, description: "List of instances that make up your local bubble (closely-related instances). Used to populate the 'bubble' timeline (domain only)." + }, + %{ + key: :export_prometheus_metrics, + type: :boolean, + description: "Enable prometheus metrics (at /api/v1/akkoma/metrics)" } ] }, diff --git a/docs/docs/administration/monitoring.md b/docs/docs/administration/monitoring.md new file mode 100644 index 000000000..fceb8c3a4 --- /dev/null +++ b/docs/docs/administration/monitoring.md @@ -0,0 +1,33 @@ +# Monitoring Akkoma + +If you run akkoma, you may be inclined to collect metrics to ensure your instance is running smoothly, +and that there's nothing quietly failing in the background. + +To facilitate this, akkoma exposes prometheus metrics to be scraped. + +## Prometheus + +See: [export_prometheus_metrics](../configuration/cheatsheet#instance) + +To scrape prometheus metrics, we need an oauth2 token with the `admin:metrics` scope. + +consider using [constanze](https://akkoma.dev/AkkomaGang/constanze) to make this easier - + +```bash +constanze token --client-app --scopes "admin:metrics" --client-name "Prometheus" +``` + +or see `scripts/create_metrics_app.sh` in the source tree for the process to get this token. + +Once you have your token of the form `Bearer $ACCESS_TOKEN`, you can use that in your prometheus config: + +```yaml +- job_name: akkoma + scheme: https + authorization: + credentials: $ACCESS_TOKEN # this should have the bearer prefix removed + metrics_path: /api/v1/akkoma/metrics + static_configs: + - targets: + - example.com +``` \ No newline at end of file diff --git a/docs/docs/configuration/cheatsheet.md b/docs/docs/configuration/cheatsheet.md index d81275043..22fc4ecbe 100644 --- a/docs/docs/configuration/cheatsheet.md +++ b/docs/docs/configuration/cheatsheet.md @@ -62,6 +62,7 @@ To add configuration to your config file, you can copy it from the base config. * `password_reset_token_validity`: The time after which reset tokens aren't accepted anymore, in seconds (default: one day). * `local_bubble`: Array of domains representing instances closely related to yours. Used to populate the `bubble` timeline. e.g `["example.com"]`, (default: `[]`) * `languages`: List of Language Codes used by the instance. This is used to try and set a default language from the frontend. It will try and find the first match between the languages set here and the user's browser languages. It will default to the first language in this setting if there is no match.. (default `["en"]`) +* `export_prometheus_metrics`: Enable prometheus metrics, served at `/api/v1/akkoma/metrics`, requiring the `admin:metrics` oauth scope. ## :database * `improved_hashtag_timeline`: Setting to force toggle / force disable improved hashtags timeline. `:enabled` forces hashtags to be fetched from `hashtags` table for hashtags timeline. `:disabled` forces object-embedded hashtags to be used (slower). Keep it `:auto` for automatic behaviour (it is auto-set to `:enabled` [unless overridden] when HashtagsTableMigrator completes). diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 48a2623ce..02336d6d1 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -73,7 +73,8 @@ def start(_type, _args) do Pleroma.JobQueueMonitor, {Majic.Pool, [name: Pleroma.MajicPool, pool_size: Config.get([:majic_pool, :size], 2)]}, {Oban, Config.get(Oban)}, - Pleroma.Web.Endpoint + Pleroma.Web.Endpoint, + Pleroma.Web.Telemetry ] ++ elasticsearch_children() ++ task_children(@mix_env) ++ diff --git a/lib/pleroma/http.ex b/lib/pleroma/http.ex index d8028651c..6ae1cdebb 100644 --- a/lib/pleroma/http.ex +++ b/lib/pleroma/http.ex @@ -65,7 +65,7 @@ def request(method, url, body, headers, options) when is_binary(url) do options = put_in(options[:adapter], adapter_opts) params = options[:params] || [] request = build_request(method, headers, options, url, body, params) - client = Tesla.client([Tesla.Middleware.FollowRedirects]) + client = Tesla.client([Tesla.Middleware.FollowRedirects, Tesla.Middleware.Telemetry]) request(client, request) end diff --git a/lib/pleroma/web/akkoma_api/controllers/metrics_controller.ex b/lib/pleroma/web/akkoma_api/controllers/metrics_controller.ex new file mode 100644 index 000000000..cc7a616ee --- /dev/null +++ b/lib/pleroma/web/akkoma_api/controllers/metrics_controller.ex @@ -0,0 +1,24 @@ +defmodule Pleroma.Web.AkkomaAPI.MetricsController do + use Pleroma.Web, :controller + + alias Pleroma.Web.Plugs.OAuthScopesPlug + alias Pleroma.Config + + plug( + OAuthScopesPlug, + %{scopes: ["admin:metrics"]} + when action in [ + :show + ] + ) + + def show(conn, _params) do + if Config.get([:instance, :export_prometheus_metrics], true) do + conn + |> text(TelemetryMetricsPrometheus.Core.scrape()) + else + conn + |> send_resp(404, "Not Found") + end + end +end diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex index baf0c5651..e3a251ca1 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -13,6 +13,7 @@ defmodule Pleroma.Web.Endpoint do plug(Pleroma.Web.Plugs.SetLocalePlug) plug(CORSPlug) + plug(Pleroma.Web.Plugs.CSPNoncePlug) plug(Pleroma.Web.Plugs.HTTPSecurityPlug) plug(Pleroma.Web.Plugs.UploadedMedia) diff --git a/lib/pleroma/web/o_auth/o_auth_controller.ex b/lib/pleroma/web/o_auth/o_auth_controller.ex index 8f32e7219..3943ca449 100644 --- a/lib/pleroma/web/o_auth/o_auth_controller.ex +++ b/lib/pleroma/web/o_auth/o_auth_controller.ex @@ -211,11 +211,11 @@ defp handle_create_authorization_error( {:error, scopes_issue}, %{"authorization" => _} = params ) - when scopes_issue in [:unsupported_scopes, :missing_scopes] do + when scopes_issue in [:unsupported_scopes, :missing_scopes, :user_is_not_an_admin] do # Per https://github.com/tootsuite/mastodon/blob/ # 51e154f5e87968d6bb115e053689767ab33e80cd/app/controllers/api/base_controller.rb#L39 conn - |> put_flash(:error, dgettext("errors", "This action is outside the authorized scopes")) + |> put_flash(:error, dgettext("errors", "This action is outside of authorized scopes")) |> put_status(:unauthorized) |> authorize(params) end @@ -605,7 +605,7 @@ defp do_create_authorization( defp do_create_authorization(%User{} = user, %App{} = app, requested_scopes) when is_list(requested_scopes) do with {:account_status, :active} <- {:account_status, User.account_status(user)}, - {:ok, scopes} <- validate_scopes(app, requested_scopes), + {:ok, scopes} <- validate_scopes(user, app, requested_scopes), {:ok, auth} <- Authorization.create_authorization(app, user, scopes) do {:ok, auth} end @@ -637,15 +637,16 @@ defp build_and_response_mfa_token(user, auth) do end end - @spec validate_scopes(App.t(), map() | list()) :: + @spec validate_scopes(User.t(), App.t(), map() | list()) :: {:ok, list()} | {:error, :missing_scopes | :unsupported_scopes} - defp validate_scopes(%App{} = app, params) when is_map(params) do + defp validate_scopes(%User{} = user, %App{} = app, params) when is_map(params) do requested_scopes = Scopes.fetch_scopes(params, app.scopes) - validate_scopes(app, requested_scopes) + validate_scopes(user, app, requested_scopes) end - defp validate_scopes(%App{} = app, requested_scopes) when is_list(requested_scopes) do - Scopes.validate(requested_scopes, app.scopes) + defp validate_scopes(%User{} = user, %App{} = app, requested_scopes) + when is_list(requested_scopes) do + Scopes.validate(requested_scopes, app.scopes, user) end def default_redirect_uri(%App{} = app) do diff --git a/lib/pleroma/web/o_auth/scopes.ex b/lib/pleroma/web/o_auth/scopes.ex index ada43eae9..7fe04b912 100644 --- a/lib/pleroma/web/o_auth/scopes.ex +++ b/lib/pleroma/web/o_auth/scopes.ex @@ -56,12 +56,20 @@ def to_string(scopes), do: Enum.join(scopes, " ") @doc """ Validates scopes. """ - @spec validate(list() | nil, list()) :: - {:ok, list()} | {:error, :missing_scopes | :unsupported_scopes} - def validate(blank_scopes, _app_scopes) when blank_scopes in [nil, []], + @spec validate(list() | nil, list(), Pleroma.User.t()) :: + {:ok, list()} | {:error, :missing_scopes | :unsupported_scopes, :user_is_not_an_admin} + def validate(blank_scopes, _app_scopes, _user) when blank_scopes in [nil, []], do: {:error, :missing_scopes} - def validate(scopes, app_scopes) do + def validate(scopes, app_scopes, %Pleroma.User{is_admin: is_admin}) do + if !is_admin && contains_admin_scopes?(scopes) do + {:error, :user_is_not_an_admin} + else + validate_scopes_are_supported(scopes, app_scopes) + end + end + + defp validate_scopes_are_supported(scopes, app_scopes) do case OAuthScopesPlug.filter_descendants(scopes, app_scopes) do ^scopes -> {:ok, scopes} _ -> {:error, :unsupported_scopes} diff --git a/lib/pleroma/web/plugs/csp_nonce_plug.ex b/lib/pleroma/web/plugs/csp_nonce_plug.ex new file mode 100644 index 000000000..bc2c6fcd8 --- /dev/null +++ b/lib/pleroma/web/plugs/csp_nonce_plug.ex @@ -0,0 +1,21 @@ +defmodule Pleroma.Web.Plugs.CSPNoncePlug do + import Plug.Conn + + def init(opts) do + opts + end + + def call(conn, _opts) do + assign_csp_nonce(conn) + end + + defp assign_csp_nonce(conn) do + nonce = + :crypto.strong_rand_bytes(128) + |> Base.url_encode64() + |> binary_part(0, 15) + + conn + |> assign(:csp_nonce, nonce) + end +end diff --git a/lib/pleroma/web/plugs/http_security_plug.ex b/lib/pleroma/web/plugs/http_security_plug.ex index 47874a980..5f0b775be 100644 --- a/lib/pleroma/web/plugs/http_security_plug.ex +++ b/lib/pleroma/web/plugs/http_security_plug.ex @@ -13,7 +13,7 @@ def init(opts), do: opts def call(conn, _options) do if Config.get([:http_security, :enabled]) do conn - |> merge_resp_headers(headers()) + |> merge_resp_headers(headers(conn)) |> maybe_send_sts_header(Config.get([:http_security, :sts])) else conn @@ -36,7 +36,8 @@ def custom_http_frontend_headers do end end - def headers do + @spec headers(Plug.Conn.t()) :: [{String.t(), String.t()}] + def headers(conn) do referrer_policy = Config.get([:http_security, :referrer_policy]) report_uri = Config.get([:http_security, :report_uri]) custom_http_frontend_headers = custom_http_frontend_headers() @@ -47,7 +48,7 @@ def headers do {"x-frame-options", "DENY"}, {"x-content-type-options", "nosniff"}, {"referrer-policy", referrer_policy}, - {"content-security-policy", csp_string()}, + {"content-security-policy", csp_string(conn)}, {"permissions-policy", "interest-cohort=()"} ] @@ -77,19 +78,18 @@ def headers do "default-src 'none'", "base-uri 'none'", "frame-ancestors 'none'", - "style-src 'self' 'unsafe-inline'", - "font-src 'self'", "manifest-src 'self'" ] @csp_start [Enum.join(static_csp_rules, ";") <> ";"] - defp csp_string do + defp csp_string(conn) do scheme = Config.get([Pleroma.Web.Endpoint, :url])[:scheme] static_url = Pleroma.Web.Endpoint.static_url() websocket_url = Pleroma.Web.Endpoint.websocket_url() report_uri = Config.get([:http_security, :report_uri]) - + %{assigns: %{csp_nonce: nonce}} = conn + nonce_tag = "nonce-" <> nonce img_src = "img-src 'self' data: blob:" media_src = "media-src 'self'" @@ -111,11 +111,14 @@ defp csp_string do ["connect-src 'self' blob: ", static_url, ?\s, websocket_url] end + style_src = "style-src 'self' 'unsafe-inline'" + font_src = "font-src 'self' data:" + script_src = if Config.get(:env) == :dev do - "script-src 'self' 'unsafe-eval'" + "script-src 'self' 'unsafe-eval' '#{nonce_tag}'" else - "script-src 'self'" + "script-src 'self' '#{nonce_tag}'" end report = if report_uri, do: ["report-uri ", report_uri, ";report-to csp-endpoint"] @@ -126,6 +129,8 @@ defp csp_string do |> add_csp_param(media_src) |> add_csp_param(connect_src) |> add_csp_param(script_src) + |> add_csp_param(font_src) + |> add_csp_param(style_src) |> add_csp_param(insecure) |> add_csp_param(report) |> :erlang.iolist_to_binary() diff --git a/lib/pleroma/web/plugs/rate_limiter.ex b/lib/pleroma/web/plugs/rate_limiter.ex index 5bebe0ad5..3c82654b4 100644 --- a/lib/pleroma/web/plugs/rate_limiter.ex +++ b/lib/pleroma/web/plugs/rate_limiter.ex @@ -197,12 +197,18 @@ defp incorporate_conn_info(action_settings, %{params: params} = conn) do }) end - defp ip(%{remote_ip: remote_ip}) do + defp ip(%{remote_ip: remote_ip}) when is_binary(remote_ip) do + remote_ip + end + + defp ip(%{remote_ip: remote_ip}) when is_tuple(remote_ip) do remote_ip |> Tuple.to_list() |> Enum.join(".") end + defp ip(_), do: nil + defp render_throttled_error(conn) do conn |> render_error(:too_many_requests, "Throttled") diff --git a/lib/pleroma/web/preload.ex b/lib/pleroma/web/preload.ex index 34a181e17..e554965a2 100644 --- a/lib/pleroma/web/preload.ex +++ b/lib/pleroma/web/preload.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.Preload do alias Phoenix.HTML - def build_tags(_conn, params) do + def build_tags(%{assigns: %{csp_nonce: nonce}} = conn, params) do preload_data = Enum.reduce(Pleroma.Config.get([__MODULE__, :providers], []), %{}, fn parser, acc -> terms = @@ -20,16 +20,17 @@ def build_tags(_conn, params) do rendered_html = preload_data |> Jason.encode!() - |> build_script_tag() + |> build_script_tag(nonce) |> HTML.safe_to_string() rendered_html end - def build_script_tag(content) do + def build_script_tag(content, nonce) do HTML.Tag.content_tag(:script, HTML.raw(content), id: "initial-results", - type: "application/json" + type: "application/json", + nonce: nonce ) end end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index e790b1cdb..f47041b0b 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -467,6 +467,7 @@ defmodule Pleroma.Web.Router do scope "/api/v1/akkoma", Pleroma.Web.AkkomaAPI do pipe_through(:authenticated_api) + get("/metrics", MetricsController, :show) get("/translation/languages", TranslationController, :languages) get("/frontend_settings/:frontend_name", FrontendSettingsController, :list_profiles) @@ -867,7 +868,11 @@ defmodule Pleroma.Web.Router do scope "/" do pipe_through([:pleroma_html, :authenticate, :require_admin]) - live_dashboard("/phoenix/live_dashboard") + + live_dashboard("/phoenix/live_dashboard", + metrics: {Pleroma.Web.Telemetry, :live_dashboard_metrics}, + csp_nonce_assign_key: :csp_nonce + ) end # Test-only routes needed to test action dispatching and plug chain execution @@ -906,6 +911,7 @@ defmodule Pleroma.Web.Router do scope "/", Pleroma.Web.Fallback do get("/registration/:token", RedirectController, :registration_page) get("/:maybe_nickname_or_id", RedirectController, :redirector_with_meta) + get("/api/*path", RedirectController, :api_not_implemented) get("/*path", RedirectController, :redirector_with_preload) options("/*path", RedirectController, :empty) diff --git a/lib/pleroma/web/telemetry.ex b/lib/pleroma/web/telemetry.ex new file mode 100644 index 000000000..5b01ee14d --- /dev/null +++ b/lib/pleroma/web/telemetry.ex @@ -0,0 +1,131 @@ +defmodule Pleroma.Web.Telemetry do + use Supervisor + import Telemetry.Metrics + alias Pleroma.Stats + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}, + {TelemetryMetricsPrometheus.Core, metrics: prometheus_metrics()} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + # A seperate set of metrics for distributions because phoenix dashboard does NOT handle them well + defp distribution_metrics do + [ + distribution( + "phoenix.router_dispatch.stop.duration", + # event_name: [:pleroma, :repo, :query, :total_time], + measurement: :duration, + unit: {:native, :second}, + tags: [:route], + reporter_options: [ + buckets: [0.1, 0.2, 0.5, 1, 2.5, 5, 10, 25, 50, 100, 250, 500, 1000] + ] + ), + + # Database Time Metrics + distribution( + "pleroma.repo.query.total_time", + # event_name: [:pleroma, :repo, :query, :total_time], + measurement: :total_time, + unit: {:native, :millisecond}, + reporter_options: [ + buckets: [0.1, 0.2, 0.5, 1, 2.5, 5, 10, 25, 50, 100, 250, 500, 1000] + ] + ), + distribution( + "pleroma.repo.query.queue_time", + # event_name: [:pleroma, :repo, :query, :total_time], + measurement: :queue_time, + unit: {:native, :millisecond}, + reporter_options: [ + buckets: [0.01, 0.025, 0.05, 0.1, 0.2, 0.5, 1, 2.5, 5, 10] + ] + ), + distribution( + "oban_job_exception", + event_name: [:oban, :job, :exception], + measurement: :duration, + tags: [:worker], + tag_values: fn tags -> Map.put(tags, :worker, tags.job.worker) end, + unit: {:native, :second}, + reporter_options: [ + buckets: [0.01, 0.025, 0.05, 0.1, 0.2, 0.5, 1, 2.5, 5, 10] + ] + ), + distribution( + "tesla_request_completed", + event_name: [:tesla, :request, :stop], + measurement: :duration, + tags: [:response_code], + tag_values: fn tags -> Map.put(tags, :response_code, tags.env.status) end, + unit: {:native, :second}, + reporter_options: [ + buckets: [0.01, 0.025, 0.05, 0.1, 0.2, 0.5, 1, 2.5, 5, 10] + ] + ), + distribution( + "oban_job_completion", + event_name: [:oban, :job, :stop], + measurement: :duration, + tags: [:worker], + tag_values: fn tags -> Map.put(tags, :worker, tags.job.worker) end, + unit: {:native, :second}, + reporter_options: [ + buckets: [0.01, 0.025, 0.05, 0.1, 0.2, 0.5, 1, 2.5, 5, 10] + ] + ) + ] + end + + defp summary_metrics do + [ + # Phoenix Metrics + summary("phoenix.endpoint.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("pleroma.repo.query.total_time", unit: {:native, :millisecond}), + summary("pleroma.repo.query.decode_time", unit: {:native, :millisecond}), + summary("pleroma.repo.query.query_time", unit: {:native, :millisecond}), + summary("pleroma.repo.query.queue_time", unit: {:native, :millisecond}), + summary("pleroma.repo.query.idle_time", unit: {:native, :millisecond}), + + # VM Metrics + summary("vm.memory.total", unit: {:byte, :kilobyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io"), + last_value("pleroma.local_users.total"), + last_value("pleroma.domains.total"), + last_value("pleroma.local_statuses.total") + ] + end + + def prometheus_metrics, do: summary_metrics() ++ distribution_metrics() + def live_dashboard_metrics, do: summary_metrics() + + defp periodic_measurements do + [ + {__MODULE__, :instance_stats, []} + ] + end + + def instance_stats do + stats = Stats.get_stats() + :telemetry.execute([:pleroma, :local_users], %{total: stats.user_count}, %{}) + :telemetry.execute([:pleroma, :domains], %{total: stats.domain_count}, %{}) + :telemetry.execute([:pleroma, :local_statuses], %{total: stats.status_count}, %{}) + end +end diff --git a/lib/pleroma/web/templates/layout/app.html.eex b/lib/pleroma/web/templates/layout/app.html.eex index e33bada85..31e6ec52b 100644 --- a/lib/pleroma/web/templates/layout/app.html.eex +++ b/lib/pleroma/web/templates/layout/app.html.eex @@ -4,17 +4,33 @@ <%= Pleroma.Config.get([:instance, :name]) %> - + + -
- - -

<%= Pleroma.Config.get([:instance, :name]) %>

-
-
+ +
+
- <%= @inner_content %> +
+
+
+ <%= @inner_content %> +
+
+ + diff --git a/lib/pleroma/web/templates/layout/static_fe.html.eex b/lib/pleroma/web/templates/layout/static_fe.html.eex index 3d55393f0..d159eb901 100644 --- a/lib/pleroma/web/templates/layout/static_fe.html.eex +++ b/lib/pleroma/web/templates/layout/static_fe.html.eex @@ -20,8 +20,8 @@
-
- <%= @inner_content %> +
+ <%= @inner_content %>