Merge pull request 'metrics' (#375) from stats into develop

Reviewed-on: AkkomaGang/akkoma#375
This commit is contained in:
floatingghost 2022-12-16 12:34:16 +00:00
commit 18bf82d747
35 changed files with 609 additions and 154 deletions

3
.gitattributes vendored
View file

@ -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

1
.gitignore vendored
View file

@ -76,3 +76,4 @@ docs/site
# docker stuff
docker-db
*.iml

View file

@ -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

View file

@ -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: [

View file

@ -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)"
}
]
},

View file

@ -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
```

View file

@ -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).

View file

@ -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) ++

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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}

View file

@ -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

View file

@ -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()

View file

@ -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")

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -4,17 +4,33 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,minimal-ui">
<title><%= Pleroma.Config.get([:instance, :name]) %></title>
<link rel="stylesheet" href="/instance/static.css">
<link rel="stylesheet" href="/static-fe/static-fe.css">
<link rel="stylesheet" href="/static-fe/forms.css">
</head>
<body>
<div class="instance-header">
<a class="instance-header__content" href="/">
<img class="instance-header__thumbnail" src="<%= Pleroma.Config.get([:instance, :instance_thumbnail]) %>">
<h1 class="instance-header__title"><%= Pleroma.Config.get([:instance, :name]) %></h1>
</a>
</div>
<div class="background-image"></div>
<nav>
<div class="inner-nav">
<a class="site-brand" href="/">
<img class="favicon" src="/favicon.png" />
<span><%= Pleroma.Config.get([:instance, :name]) %></span>
</a>
</div>
</nav>
<div class="container">
<%= @inner_content %>
<div class="underlay"></div>
<div class="column main flex">
<div class="panel oauth">
<%= @inner_content %>
</div>
</div>
</div>
</body>
<style>
:root {
--background-image: url("<%= Pleroma.Config.get([:instance, :background_image]) %>");
}
</style>
</html>

View file

@ -20,8 +20,8 @@
</nav>
<div class="container">
<div class="underlay"></div>
<div class="column main">
<%= @inner_content %>
<div class="column main">
<%= @inner_content %>
</div>
<div class="column sidebar">
<div class="about panel">

View file

@ -1,24 +1,29 @@
<%= if get_flash(@conn, :info) do %>
<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
<% end %>
<%= if get_flash(@conn, :error) do %>
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
<% end %>
<div>
<%= if get_flash(@conn, :info) do %>
<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
<% end %>
<%= if get_flash(@conn, :error) do %>
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
<% end %>
<div class="panel-heading">
<%= Gettext.dpgettext("static_pages", "mfa recover page title", "Two-factor recovery") %>
</div>
<div class="panel-content">
<%= form_for @conn, Routes.mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %>
<div class="input">
<%= label f, :code, Gettext.dpgettext("static_pages", "mfa recover recovery code prompt", "Recovery code") %>
<%= text_input f, :code, [autocomplete: false, autocorrect: "off", autocapitalize: "off", autofocus: true, spellcheck: false] %>
<%= hidden_input f, :mfa_token, value: @mfa_token %>
<%= hidden_input f, :state, value: @state %>
<%= hidden_input f, :redirect_uri, value: @redirect_uri %>
<%= hidden_input f, :challenge_type, value: "recovery" %>
</div>
<h2><%= Gettext.dpgettext("static_pages", "mfa recover page title", "Two-factor recovery") %></h2>
<%= submit Gettext.dpgettext("static_pages", "mfa recover verify recovery code button", "Verify") %>
<% end %>
<a href="<%= Routes.mfa_path(@conn, :show, %{challenge_type: "totp", mfa_token: @mfa_token, state: @state, redirect_uri: @redirect_uri}) %>">
<%= Gettext.dpgettext("static_pages", "mfa recover use 2fa code link", "Enter a two-factor code") %>
</a>
<%= form_for @conn, Routes.mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %>
<div class="input">
<%= label f, :code, Gettext.dpgettext("static_pages", "mfa recover recovery code prompt", "Recovery code") %>
<%= text_input f, :code, [autocomplete: false, autocorrect: "off", autocapitalize: "off", autofocus: true, spellcheck: false] %>
<%= hidden_input f, :mfa_token, value: @mfa_token %>
<%= hidden_input f, :state, value: @state %>
<%= hidden_input f, :redirect_uri, value: @redirect_uri %>
<%= hidden_input f, :challenge_type, value: "recovery" %>
</div>
</div>
<%= submit Gettext.dpgettext("static_pages", "mfa recover verify recovery code button", "Verify") %>
<% end %>
<a href="<%= Routes.mfa_path(@conn, :show, %{challenge_type: "totp", mfa_token: @mfa_token, state: @state, redirect_uri: @redirect_uri}) %>">
<%= Gettext.dpgettext("static_pages", "mfa recover use 2fa code link", "Enter a two-factor code") %>
</a>

View file

@ -1,24 +1,28 @@
<%= if get_flash(@conn, :info) do %>
<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
<% end %>
<%= if get_flash(@conn, :error) do %>
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
<% end %>
<div>
<%= if get_flash(@conn, :info) do %>
<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
<% end %>
<%= if get_flash(@conn, :error) do %>
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
<% end %>
<div class="panel-heading">
<%= Gettext.dpgettext("static_pages", "mfa auth page title", "Two-factor authentication") %>
</div>
<div class="panel-content">
<%= form_for @conn, Routes.mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %>
<div class="input">
<%= label f, :code, Gettext.dpgettext("static_pages", "mfa auth code prompt", "Authentication code") %>
<%= text_input f, :code, [autocomplete: "one-time-code", autocorrect: "off", autocapitalize: "off", autofocus: true, pattern: "[0-9]*", spellcheck: false] %>
<%= hidden_input f, :mfa_token, value: @mfa_token %>
<%= hidden_input f, :state, value: @state %>
<%= hidden_input f, :redirect_uri, value: @redirect_uri %>
<%= hidden_input f, :challenge_type, value: "totp" %>
</div>
<h2><%= Gettext.dpgettext("static_pages", "mfa auth page title", "Two-factor authentication") %></h2>
<%= form_for @conn, Routes.mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %>
<div class="input">
<%= label f, :code, Gettext.dpgettext("static_pages", "mfa auth code prompt", "Authentication code") %>
<%= text_input f, :code, [autocomplete: "one-time-code", autocorrect: "off", autocapitalize: "off", autofocus: true, pattern: "[0-9]*", spellcheck: false] %>
<%= hidden_input f, :mfa_token, value: @mfa_token %>
<%= hidden_input f, :state, value: @state %>
<%= hidden_input f, :redirect_uri, value: @redirect_uri %>
<%= hidden_input f, :challenge_type, value: "totp" %>
<%= submit Gettext.dpgettext("static_pages", "mfa auth verify code button", "Verify") %>
<% end %>
<a href="<%= Routes.mfa_path(@conn, :show, %{challenge_type: "recovery", mfa_token: @mfa_token, state: @state, redirect_uri: @redirect_uri}) %>">
<%= Gettext.dpgettext("static_pages", "mfa auth page use recovery code link", "Enter a two-factor recovery code") %>
</a>
</div>
</div>
<%= submit Gettext.dpgettext("static_pages", "mfa auth verify code button", "Verify") %>
<% end %>
<a href="<%= Routes.mfa_path(@conn, :show, %{challenge_type: "recovery", mfa_token: @mfa_token, state: @state, redirect_uri: @redirect_uri}) %>">
<%= Gettext.dpgettext("static_pages", "mfa auth page use recovery code link", "Enter a two-factor recovery code") %>
</a>

View file

@ -1,2 +1,8 @@
<h1><%= Gettext.dpgettext("static_pages", "oauth authorized page title", "Successfully authorized") %></h1>
<h2><%= raw Gettext.dpgettext("static_pages", "oauth token code message", "Token code is <br>%{token}", token: safe_to_string(html_escape(@auth.token))) %></h2>
<div>
<div class="panel-heading">
<%= Gettext.dpgettext("static_pages", "oauth authorized page title", "Successfully authorized") %>
</div>
<div class="panel-content">
<%= raw Gettext.dpgettext("static_pages", "oauth token code message", "Token code is <br>%{token}", token: safe_to_string(html_escape(@auth.token))) %>
</div>
</div>

View file

@ -1,2 +1,8 @@
<h1><%= Gettext.dpgettext("static_pages", "oauth authorization exists page title", "Authorization exists") %></h1>
<h2><%= raw Gettext.dpgettext("static_pages", "oauth token code message", "Token code is <br>%{token}", token: safe_to_string(html_escape(@token.token))) %></h2>
<div>
<div class="panel-heading">
<%= Gettext.dpgettext("static_pages", "oauth authorization exists page title", "Authorization exists") %>
</div>
<div class="panel-content">
<%= raw Gettext.dpgettext("static_pages", "oauth token code message", "Token code is <br>%{token}", token: safe_to_string(html_escape(@token.token))) %>
</div>
</div>

View file

@ -10,50 +10,56 @@
<%= if @user do %>
<div class="account-header">
<div class="account-header__banner" style="background-image: url('<%= Pleroma.User.banner_url(@user) %>')"></div>
<div class="account-header__avatar" style="background-image: url('<%= Pleroma.User.avatar_url(@user) %>')"></div>
<div class="account-header__meta">
<div class="account-header__avatar" style="background-image: url('<%= Pleroma.User.avatar_url(@user) %>')">
<div class="account-header__meta">
<div class="account-header__display-name"><%= @user.name %></div>
<div class="account-header__nickname">@<%= @user.nickname %>@<%= Pleroma.User.get_host(@user) %></div>
</div>
</div>
</div>
<% end %>
<div class="container__content">
<%= if @app do %>
<p><%= raw Gettext.dpgettext("static_pages", "oauth authorize message", "Application <strong>%{client_name}</strong> is requesting access to your account.", client_name: safe_to_string(html_escape(@app.client_name))) %></p>
<%= render @view_module, "_scopes.html", Map.merge(assigns, %{form: f}) %>
<div class="panel-heading">
<p><%= raw Gettext.dpgettext("static_pages", "oauth authorize message", "Application <strong>%{client_name}</strong> is requesting access to your account.", client_name: safe_to_string(html_escape(@app.client_name))) %></p>
</div>
<% end %>
<%= if @user do %>
<div class="actions">
<a class="button button--cancel" href="/">
<%= Gettext.dpgettext("static_pages", "oauth authorize cancel button", "Cancel") %>
</a>
<%= submit Gettext.dpgettext("static_pages", "oauth authorize approve button", "Approve"), class: "button--approve" %>
</div>
<% else %>
<%= if @params["registration"] in ["true", true] do %>
<h3><%= Gettext.dpgettext("static_pages", "oauth register page title", "This is the first time you visit! Please enter your Pleroma handle.") %></h3>
<p><%= Gettext.dpgettext("static_pages", "oauth register nickname unchangeable warning", "Choose carefully! You won't be able to change this later. You will be able to change your display name, though.") %></p>
<div class="input">
<%= label f, :nickname, Gettext.dpgettext("static_pages", "oauth register nickname prompt", "Pleroma Handle") %>
<%= text_input f, :nickname, placeholder: "lain", autocomplete: "username" %>
<div class="panel-content">
<%= render @view_module, "_scopes.html", Map.merge(assigns, %{form: f}) %>
<%= if @user do %>
<div class="actions">
<a class="button button-cancel" href="/">
<%= Gettext.dpgettext("static_pages", "oauth authorize cancel button", "Cancel") %>
</a>
<%= submit Gettext.dpgettext("static_pages", "oauth authorize approve button", "Approve"), class: "button--approve" %>
</div>
<%= hidden_input f, :name, value: @params["name"] %>
<%= hidden_input f, :password, value: @params["password"] %>
<br>
<% else %>
<div class="input">
<%= label f, :name, Gettext.dpgettext("static_pages", "oauth login username prompt", "Username") %>
<%= text_input f, :name %>
</div>
<div class="input">
<%= label f, :password, Gettext.dpgettext("static_pages", "oauth login password prompt", "Password") %>
<%= password_input f, :password %>
</div>
<%= submit Gettext.dpgettext("static_pages", "oauth login button", "Log In") %>
<%= if @params["registration"] in ["true", true] do %>
<h3><%= Gettext.dpgettext("static_pages", "oauth register page title", "This is your first visit! Please enter your Akkoma handle.") %></h3>
<p><%= Gettext.dpgettext("static_pages", "oauth register nickname unchangeable warning", "Choose carefully! You won't be able to change this later. You will be able to change your display name, though.") %></p>
<div class="input">
<%= label f, :nickname, Gettext.dpgettext("static_pages", "oauth register nickname prompt", "Pleroma Handle") %>
<%= text_input f, :nickname, placeholder: "lain", autocomplete: "username" %>
</div>
<%= hidden_input f, :name, value: @params["name"] %>
<%= hidden_input f, :password, value: @params["password"] %>
<br>
<% else %>
<div class="input">
<%= label f, :name, Gettext.dpgettext("static_pages", "oauth login username prompt", "Username") %>
<%= text_input f, :name %>
</div>
<div class="input">
<%= label f, :password, Gettext.dpgettext("static_pages", "oauth login password prompt", "Password") %>
<%= password_input f, :password %>
</div>
<%= submit Gettext.dpgettext("static_pages", "oauth login button", "Log In") %>
<% end %>
<% end %>
<% end %>
</div>
</div>
<%= hidden_input f, :client_id, value: @client_id %>

View file

@ -161,6 +161,9 @@ defp deps do
git: "https://akkoma.dev/AkkomaGang/linkify.git", branch: "bugfix/line-ending-buffer"},
{:http_signatures, "~> 0.1.1"},
{:telemetry, "~> 0.3"},
{:telemetry_poller, "~> 0.4"},
{:telemetry_metrics, "~> 0.4"},
{:telemetry_metrics_prometheus_core, "~> 1.1.0"},
{:poolboy, "~> 1.5"},
{:recon, "~> 2.5"},
{:joken, "~> 2.0"},

View file

@ -112,6 +112,9 @@
"table_rex": {:hex, :table_rex, "3.1.1", "0c67164d1714b5e806d5067c1e96ff098ba7ae79413cc075973e17c38a587caa", [:mix], [], "hexpm", "678a23aba4d670419c23c17790f9dcd635a4a89022040df7d5d772cb21012490"},
"telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"},
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"},
"telemetry_metrics_prometheus": {:hex, :telemetry_metrics_prometheus, "1.1.0", "1cc23e932c1ef9aa3b91db257ead31ea58d53229d407e059b29bb962c1505a13", [:mix], [{:plug_cowboy, "~> 2.1", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.0", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}], "hexpm", "d43b3659b3244da44fe0275b717701542365d4519b79d9ce895b9719c1ce4d26"},
"telemetry_metrics_prometheus_core": {:hex, :telemetry_metrics_prometheus_core, "1.1.0", "4e15f6d7dbedb3a4e3aed2262b7e1407f166fcb9c30ca3f96635dfbbef99965c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "0dd10e7fe8070095df063798f82709b0a1224c31b8baf6278b423898d591a069"},
"telemetry_poller": {:hex, :telemetry_poller, "0.5.1", "21071cc2e536810bac5628b935521ff3e28f0303e770951158c73eaaa01e962a", [:rebar3], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4cab72069210bc6e7a080cec9afffad1b33370149ed5d379b81c7c5f0c663fd4"},
"temple": {:git, "https://akkoma.dev/AkkomaGang/temple.git", "066a699ade472d8fa42a9d730b29a61af9bc8b59", [ref: "066a699ade472d8fa42a9d730b29a61af9bc8b59"]},
"tesla": {:hex, :tesla, "1.4.4", "bb89aa0c9745190930366f6a2ac612cdf2d0e4d7fff449861baa7875afd797b2", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.3", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "d5503a49f9dec1b287567ea8712d085947e247cb11b06bc54adb05bfde466457"},
"timex": {:hex, :timex, "3.7.9", "790cdfc4acfce434e442f98c02ea6d84d0239073bfd668968f82ac63e9a6788d", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "64691582e5bb87130f721fc709acfb70f24405833998fabf35be968984860ce1"},

Binary file not shown.

Binary file not shown.

65
scripts/create_metrics_app.sh Executable file
View file

@ -0,0 +1,65 @@
#!/bin/sh
read -p "Instance URL (e.g https://example.com): " INSTANCE_URL
echo "Creating oauth app..."
RESP=$(curl \
-XPOST \
$INSTANCE_URL/api/v1/apps \
--silent \
--data-urlencode 'client_name=fedibash' \
--data-urlencode 'redirect_uris=urn:ietf:wg:oauth:2.0:oob' \
--data-urlencode 'scopes=admin:metrics' \
--header "Content-Type: application/x-www-form-urlencoded"
)
client_id=$(echo $RESP | jq -r .client_id)
client_secret=$(echo $RESP | jq -r .client_secret)
if [ -z "$client_id" ]; then
echo "Could not create an app"
echo "$RESP"
exit 1
fi
echo "Please visit the following URL and input the code provided"
AUTH_URL="$INSTANCE_URL/oauth/authorize?client_id=$client_id&redirect_uri=urn:ietf:wg:oauth:2.0:oob&scope=admin:metrics&response_type=code"
if [ ! -z "$BROWSER" ]; then
$BROWSER $AUTH_URL
fi;
echo $AUTH_URL
read -p "Code: " CODE
echo "Requesting code..."
RESP=$(curl \
-XPOST \
$INSTANCE_URL/oauth/token \
--silent \
--header "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "client_id=$client_id" \
--data-urlencode "client_secret=$client_secret" \
--data-urlencode "code=$CODE" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode 'redirect_uri=urn:ietf:wg:oauth:2.0:oob' \
--data-urlencode "scope=admin:metrics"
)
echo $RESP
ACCESS_TOKEN="$(echo $RESP | jq -r .access_token)"
echo "Token is $ACCESS_TOKEN"
DOMAIN=$(echo $INSTANCE_URL | sed -e 's/^https:\/\///')
echo "Use the following config in your prometheus.yml:
- job_name: akkoma
scheme: https
authorization:
credentials: $ACCESS_TOKEN
metrics_path: /api/v1/akkoma/metrics
static_configs:
- targets:
- $DOMAIN
"

View file

@ -0,0 +1,33 @@
defmodule Pleroma.Web.AkkomaAPI.MetricsControllerTest do
use Pleroma.Web.ConnCase, async: true
describe "GET /api/v1/akkoma/metrics" do
test "should return metrics when the user has admin:metrics" do
%{conn: conn} = oauth_access(["admin:metrics"])
resp =
conn
|> get("/api/v1/akkoma/metrics")
|> text_response(200)
assert resp =~ "# HELP"
end
test "should not allow users that do not have the admin:metrics scope" do
%{conn: conn} = oauth_access(["read:metrics"])
conn
|> get("/api/v1/akkoma/metrics")
|> json_response(403)
end
test "should be disabled by export_prometheus_metrics" do
clear_config([:instance, :export_prometheus_metrics], false)
%{conn: conn} = oauth_access(["admin:metrics"])
conn
|> get("/api/v1/akkoma/metrics")
|> response(404)
end
end
end

View file

@ -184,14 +184,15 @@ test "with html format, it redirects to user feed", %{conn: conn} do
note_activity = insert(:note_activity)
user = User.get_cached_by_ap_id(note_activity.data["actor"])
%{assigns: %{csp_nonce: nonce}} = resp_conn = get(conn, "/users/#{user.nickname}")
response =
conn
|> get("/users/#{user.nickname}")
resp_conn
|> response(200)
assert response ==
Pleroma.Web.Fallback.RedirectController.redirector_with_meta(
conn,
assign(conn, :csp_nonce, nonce),
%{user: user}
).resp_body
end

View file

@ -693,45 +693,76 @@ test "with existing authentication and OOB `redirect_uri`, redirects to app with
describe "POST /oauth/authorize" do
test "redirects with oauth authorization, " <>
"granting requested app-supported scopes to both admin- and non-admin users" do
"granting requested app-supported scopes to both admin users" do
app_scopes = ["read", "write", "admin", "secret_scope"]
app = insert(:oauth_app, scopes: app_scopes)
redirect_uri = OAuthController.default_redirect_uri(app)
scopes_subset = ["read:subscope", "write", "admin"]
admin = insert(:user, is_admin: true)
# In case scope param is missing, expecting _all_ app-supported scopes to be granted
conn =
post(
build_conn(),
"/oauth/authorize",
%{
"authorization" => %{
"name" => admin.nickname,
"password" => "test",
"client_id" => app.client_id,
"redirect_uri" => redirect_uri,
"scope" => scopes_subset,
"state" => "statepassed"
}
}
)
target = redirected_to(conn)
assert target =~ redirect_uri
query = URI.parse(target).query |> URI.query_decoder() |> Map.new()
assert %{"state" => "statepassed", "code" => code} = query
auth = Repo.get_by(Authorization, token: code)
assert auth
assert auth.scopes == scopes_subset
end
test "redirects with oauth authorization, " <>
"granting requested app-supported scopes for non-admin users" do
app_scopes = ["read", "write", "secret_scope", "admin"]
app = insert(:oauth_app, scopes: app_scopes)
redirect_uri = OAuthController.default_redirect_uri(app)
non_admin = insert(:user, is_admin: false)
admin = insert(:user, is_admin: true)
scopes_subset = ["read:subscope", "write", "admin"]
scopes_subset = ["read:subscope", "write"]
# In case scope param is missing, expecting _all_ app-supported scopes to be granted
for user <- [non_admin, admin],
{requested_scopes, expected_scopes} <-
%{scopes_subset => scopes_subset, nil: app_scopes} do
conn =
post(
build_conn(),
"/oauth/authorize",
%{
"authorization" => %{
"name" => user.nickname,
"password" => "test",
"client_id" => app.client_id,
"redirect_uri" => redirect_uri,
"scope" => requested_scopes,
"state" => "statepassed"
}
conn =
post(
build_conn(),
"/oauth/authorize",
%{
"authorization" => %{
"name" => non_admin.nickname,
"password" => "test",
"client_id" => app.client_id,
"redirect_uri" => redirect_uri,
"scope" => scopes_subset,
"state" => "statepassed"
}
)
}
)
target = redirected_to(conn)
assert target =~ redirect_uri
target = redirected_to(conn)
assert target =~ redirect_uri
query = URI.parse(target).query |> URI.query_decoder() |> Map.new()
query = URI.parse(target).query |> URI.query_decoder() |> Map.new()
assert %{"state" => "statepassed", "code" => code} = query
auth = Repo.get_by(Authorization, token: code)
assert auth
assert auth.scopes == expected_scopes
end
assert %{"state" => "statepassed", "code" => code} = query
auth = Repo.get_by(Authorization, token: code)
assert auth
assert auth.scopes == scopes_subset
end
test "authorize from cookie" do
@ -831,6 +862,33 @@ test "returns 401 for wrong credentials", %{conn: conn} do
assert result =~ "Invalid Username/Password"
end
test "returns 401 when attempting to use an admin scope with a non-admin", %{conn: conn} do
user = insert(:user)
app = insert(:oauth_app, scopes: ["admin"])
redirect_uri = OAuthController.default_redirect_uri(app)
result =
conn
|> post("/oauth/authorize", %{
"authorization" => %{
"name" => user.nickname,
"password" => "test",
"client_id" => app.client_id,
"redirect_uri" => redirect_uri,
"state" => "statepassed",
"scope" => Enum.join(app.scopes, " ")
}
})
|> html_response(:unauthorized)
# Keep the details
assert result =~ app.client_id
assert result =~ redirect_uri
# Error message
assert result =~ "outside of authorized scopes"
end
test "returns 401 for missing scopes" do
user = insert(:user, is_admin: false)
app = insert(:oauth_app, scopes: ["read", "write", "admin"])
@ -855,7 +913,7 @@ test "returns 401 for missing scopes" do
assert result =~ redirect_uri
# Error message
assert result =~ "This action is outside the authorized scopes"
assert result =~ "This action is outside of authorized scopes"
end
test "returns 401 for scopes beyond app scopes hierarchy", %{conn: conn} do
@ -882,7 +940,7 @@ test "returns 401 for scopes beyond app scopes hierarchy", %{conn: conn} do
assert result =~ redirect_uri
# Error message
assert result =~ "This action is outside the authorized scopes"
assert result =~ "This action is outside of authorized scopes"
end
end

View file

@ -554,7 +554,7 @@ def oauth_app_factory do
%Pleroma.Web.OAuth.App{
client_name: sequence(:client_name, &"Some client #{&1}"),
redirect_uris: "https://example.com/callback",
scopes: ["read", "write", "follow", "push", "admin"],
scopes: ["read", "write", "follow", "push"],
website: "https://example.com",
client_id: Ecto.UUID.generate(),
client_secret: "aaa;/&bbb"