mirror of
https://github.com/processone/ejabberd.git
synced 2024-11-24 16:23:40 +01:00
940 lines
39 KiB
Erlang
940 lines
39 KiB
Erlang
%%%----------------------------------------------------------------------
|
|
%%% File : mod_matrix_gw.erl
|
|
%%% Author : Alexey Shchepin <alexey@process-one.net>
|
|
%%% Purpose : Matrix gateway
|
|
%%% Created : 23 Apr 2022 by Alexey Shchepin <alexey@process-one.net>
|
|
%%%
|
|
%%%
|
|
%%% ejabberd, Copyright (C) 2002-2024 ProcessOne
|
|
%%%
|
|
%%% This program is free software; you can redistribute it and/or
|
|
%%% modify it under the terms of the GNU General Public License as
|
|
%%% published by the Free Software Foundation; either version 2 of the
|
|
%%% License, or (at your option) any later version.
|
|
%%%
|
|
%%% This program is distributed in the hope that it will be useful,
|
|
%%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
%%% General Public License for more details.
|
|
%%%
|
|
%%% You should have received a copy of the GNU General Public License along
|
|
%%% with this program; if not, write to the Free Software Foundation, Inc.,
|
|
%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
|
%%%
|
|
%%%----------------------------------------------------------------------
|
|
|
|
-module(mod_matrix_gw).
|
|
-ifndef(OTP_BELOW_24).
|
|
|
|
-author('alexey@process-one.net').
|
|
|
|
-ifndef(GEN_SERVER).
|
|
-define(GEN_SERVER, gen_server).
|
|
-endif.
|
|
-behaviour(?GEN_SERVER).
|
|
-behaviour(gen_mod).
|
|
|
|
-export([start/2, stop/1, reload/3, process/2,
|
|
start_link/1,
|
|
procname/1,
|
|
init/1, handle_call/3, handle_cast/2,
|
|
handle_info/2, terminate/2, code_change/3,
|
|
depends/2, mod_opt_type/1, mod_options/1, mod_doc/0]).
|
|
-export([parse_auth/1, encode_canonical_json/1,
|
|
get_id_domain_exn/1,
|
|
base64_decode/1, base64_encode/1,
|
|
prune_event/2, get_event_id/2, content_hash/1,
|
|
sign_event/3, sign_pruned_event/2, sign_json/2,
|
|
send_request/8, s2s_out_bounce_packet/2, user_receive_packet/1,
|
|
route/1]).
|
|
|
|
-include_lib("xmpp/include/xmpp.hrl").
|
|
-include("logger.hrl").
|
|
-include("ejabberd_http.hrl").
|
|
-include("translate.hrl").
|
|
-include("ejabberd_web_admin.hrl").
|
|
-include("mod_matrix_gw.hrl").
|
|
|
|
-define(MAX_REQUEST_SIZE, 1000000).
|
|
|
|
process([<<"key">>, <<"v2">>, <<"server">> | _],
|
|
#request{method = 'GET', host = _Host} = _Request) ->
|
|
Host = ejabberd_config:get_myname(),
|
|
KeyName = mod_matrix_gw_opt:key_name(Host),
|
|
KeyID = <<"ed25519:", KeyName/binary>>,
|
|
ServerName = mod_matrix_gw_opt:matrix_domain(Host),
|
|
TS = erlang:system_time(millisecond) + timer:hours(24 * 7),
|
|
{PubKey, _PrivKey} = mod_matrix_gw_opt:key(Host),
|
|
JSON = #{<<"old_verify_keys">> => #{},
|
|
<<"server_name">> => ServerName,
|
|
<<"valid_until_ts">> => TS,
|
|
<<"verify_keys">> => #{
|
|
KeyID => #{
|
|
<<"key">> => base64_encode(PubKey)
|
|
}
|
|
}},
|
|
SJSON = sign_json(Host, JSON),
|
|
{200, [{<<"Content-Type">>, <<"application/json;charset=UTF-8">>}],
|
|
misc:json_encode(SJSON)};
|
|
process([<<"federation">>, <<"v1">>, <<"version">>],
|
|
#request{method = 'GET', host = _Host} = _Request) ->
|
|
JSON = #{<<"server">> => #{<<"name">> => <<"ejabberd/mod_matrix_gw">>,
|
|
<<"version">> => <<"0.1">>}},
|
|
{200, [{<<"Content-Type">>, <<"application/json;charset=UTF-8">>}],
|
|
misc:json_encode(JSON)};
|
|
process([<<"federation">>, <<"v1">>, <<"query">>, <<"profile">>],
|
|
#request{method = 'GET', host = _Host} = Request) ->
|
|
case proplists:get_value(<<"user_id">>, Request#request.q) of
|
|
UserID when is_binary(UserID) ->
|
|
Field =
|
|
case proplists:get_value(<<"field">>, Request#request.q) of
|
|
<<"displayname">> -> displayname;
|
|
<<"avatar_url">> -> avatar_url;
|
|
undefined -> all;
|
|
_ -> error
|
|
end,
|
|
case Field of
|
|
error ->
|
|
{400, [], <<"400 Bad Request: bad 'field' parameter">>};
|
|
_ ->
|
|
case preprocess_federation_request(Request) of
|
|
{ok, _JSON, _Origin} ->
|
|
{200, [{<<"Content-Type">>, <<"application/json;charset=UTF-8">>}], <<"{}">>};
|
|
{result, HTTPResult} ->
|
|
HTTPResult
|
|
end
|
|
end;
|
|
undefined ->
|
|
{400, [], <<"400 Bad Request: missing 'user_id' parameter">>}
|
|
end;
|
|
process([<<"federation">>, <<"v1">>, <<"user">>, <<"devices">>, UserID],
|
|
#request{method = 'GET', host = _Host} = Request) ->
|
|
case preprocess_federation_request(Request) of
|
|
{ok, _JSON, _Origin} ->
|
|
Res = #{<<"devices">> =>
|
|
[#{<<"device_id">> => <<"ejabberd/mod_matrix_gw">>,
|
|
<<"keys">> => []}],
|
|
<<"stream_id">> => 1,
|
|
<<"user_id">> => UserID},
|
|
{200, [{<<"Content-Type">>, <<"application/json;charset=UTF-8">>}], misc:json_encode(Res)};
|
|
{result, HTTPResult} ->
|
|
HTTPResult
|
|
end;
|
|
process([<<"federation">>, <<"v1">>, <<"user">>, <<"keys">>, <<"query">>],
|
|
#request{method = 'POST', host = _Host} = Request) ->
|
|
case preprocess_federation_request(Request, false) of
|
|
{ok, #{<<"device_keys">> := DeviceKeys}, _Origin} ->
|
|
DeviceKeys2 = maps:map(fun(_Key, _) -> #{} end, DeviceKeys),
|
|
Res = #{<<"device_keys">> => DeviceKeys2},
|
|
{200, [{<<"Content-Type">>, <<"application/json;charset=UTF-8">>}],
|
|
misc:json_encode(Res)};
|
|
{ok, _JSON, _Origin} ->
|
|
{400, [], <<"400 Bad Request: invalid format">>};
|
|
{result, HTTPResult} ->
|
|
HTTPResult
|
|
end;
|
|
process([<<"federation">>, <<"v2">>, <<"invite">>, RoomID, EventID],
|
|
#request{method = 'PUT', host = _Host} = Request) ->
|
|
case preprocess_federation_request(Request) of
|
|
{ok, #{<<"event">> := #{%<<"origin">> := Origin,
|
|
<<"room_id">> := RoomID,
|
|
<<"sender">> := Sender,
|
|
<<"state_key">> := UserID} = Event,
|
|
<<"room_version">> := RoomVer},
|
|
Origin} ->
|
|
case mod_matrix_gw_room:binary_to_room_version(RoomVer) of
|
|
#room_version{} = RoomVersion ->
|
|
%% TODO: check type and userid
|
|
Host = ejabberd_config:get_myname(),
|
|
PrunedEvent = prune_event(Event, RoomVersion),
|
|
?DEBUG("invite ~p~n", [{RoomID, EventID, Event, RoomVer, catch mod_matrix_gw_s2s:check_signature(Host, PrunedEvent), get_pruned_event_id(PrunedEvent)}]),
|
|
case mod_matrix_gw_s2s:check_signature(Host, PrunedEvent) of
|
|
true ->
|
|
case get_pruned_event_id(PrunedEvent) of
|
|
EventID ->
|
|
SEvent = sign_pruned_event(Host, PrunedEvent),
|
|
?DEBUG("sign event ~p~n", [SEvent]),
|
|
ResJSON = #{<<"event">> => SEvent},
|
|
mod_matrix_gw_room:join(Host, Origin, RoomID, Sender, UserID),
|
|
?DEBUG("res ~s~n", [misc:json_encode(ResJSON)]),
|
|
{200, [{<<"Content-Type">>, <<"application/json;charset=UTF-8">>}], misc:json_encode(ResJSON)};
|
|
_ ->
|
|
{400, [], <<"400 Bad Request: bad event id">>}
|
|
end;
|
|
false ->
|
|
{400, [], <<"400 Bad Request: signature check failed">>}
|
|
end;
|
|
false ->
|
|
{400, [], <<"400 Bad Request: unsupported room version">>}
|
|
end;
|
|
{ok, _JSON, _Origin} ->
|
|
{400, [], <<"400 Bad Request: invalid format">>};
|
|
{result, HTTPResult} ->
|
|
HTTPResult
|
|
end;
|
|
process([<<"federation">>, <<"v1">>, <<"send">>, _TxnID],
|
|
#request{method = 'PUT', host = _Host} = Request) ->
|
|
case preprocess_federation_request(Request, false) of
|
|
{ok, #{<<"origin">> := Origin,
|
|
<<"pdus">> := PDUs} = JSON,
|
|
Origin} ->
|
|
?DEBUG("send request ~p~n", [JSON]),
|
|
Host = ejabberd_config:get_myname(),
|
|
Res = lists:map(
|
|
fun(PDU) ->
|
|
case mod_matrix_gw_room:process_pdu(Host, Origin, PDU) of
|
|
{ok, EventID} -> {EventID, #{}};
|
|
{error, Error} ->
|
|
{get_event_id(PDU, mod_matrix_gw_room:binary_to_room_version(<<"9">>)),
|
|
#{<<"error">> => Error}}
|
|
end
|
|
end, PDUs),
|
|
?DEBUG("send res ~p~n", [Res]),
|
|
{200, [{<<"Content-Type">>, <<"application/json;charset=UTF-8">>}],
|
|
misc:json_encode(maps:from_list(Res))};
|
|
{ok, _JSON, _Origin} ->
|
|
{400, [], <<"400 Bad Request: invalid format">>};
|
|
{result, HTTPResult} ->
|
|
HTTPResult
|
|
end;
|
|
process([<<"federation">>, <<"v1">>, <<"get_missing_events">>, RoomID],
|
|
#request{method = 'POST', host = _Host} = Request) ->
|
|
case preprocess_federation_request(Request, false) of
|
|
{ok, #{<<"earliest_events">> := EarliestEvents,
|
|
<<"latest_events">> := LatestEvents} = JSON,
|
|
Origin} ->
|
|
?DEBUG("get_missing_events request ~p~n", [JSON]),
|
|
Limit = maps:get(<<"limit">>, JSON, 10),
|
|
MinDepth = maps:get(<<"min_depth">>, JSON, 0),
|
|
Host = ejabberd_config:get_myname(),
|
|
PDUs = mod_matrix_gw_room:get_missing_events(
|
|
Host, Origin, RoomID, EarliestEvents, LatestEvents, Limit, MinDepth),
|
|
?DEBUG("get_missing_events res ~p~n", [PDUs]),
|
|
Res = #{<<"events">> => PDUs},
|
|
{200, [{<<"Content-Type">>, <<"application/json;charset=UTF-8">>}],
|
|
misc:json_encode(Res)};
|
|
{ok, _JSON, _Origin} ->
|
|
{400, [], <<"400 Bad Request: invalid format">>};
|
|
{result, HTTPResult} ->
|
|
HTTPResult
|
|
end;
|
|
process([<<"federation">>, <<"v1">>, <<"backfill">>, RoomID],
|
|
#request{method = 'GET', host = _Host} = Request) ->
|
|
case catch binary_to_integer(proplists:get_value(<<"limit">>, Request#request.q)) of
|
|
Limit when is_integer(Limit) ->
|
|
case preprocess_federation_request(Request, false) of
|
|
{ok, _JSON, Origin} ->
|
|
LatestEvents = proplists:get_all_values(<<"v">>, Request#request.q),
|
|
?DEBUG("backfill request ~p~n", [{Limit, LatestEvents}]),
|
|
Host = ejabberd_config:get_myname(),
|
|
PDUs1 = mod_matrix_gw_room:get_missing_events(
|
|
Host, Origin, RoomID, [], LatestEvents, Limit, 0),
|
|
PDUs2 = lists:flatmap(
|
|
fun(EventID) ->
|
|
case mod_matrix_gw_room:get_event(Host, RoomID, EventID) of
|
|
{ok, PDU} ->
|
|
[PDU];
|
|
_ ->
|
|
[]
|
|
end
|
|
end, LatestEvents),
|
|
PDUs = PDUs2 ++ PDUs1,
|
|
?DEBUG("backfill res ~p~n", [PDUs]),
|
|
MatrixServer = mod_matrix_gw_opt:matrix_domain(Host),
|
|
Res = #{<<"origin">> => MatrixServer,
|
|
<<"origin_server_ts">> => erlang:system_time(millisecond),
|
|
<<"pdus">> => PDUs},
|
|
{200, [{<<"Content-Type">>, <<"application/json;charset=UTF-8">>}],
|
|
misc:json_encode(Res)};
|
|
{result, HTTPResult} ->
|
|
HTTPResult
|
|
end;
|
|
_ ->
|
|
{400, [], <<"400 Bad Request: bad 'limit' parameter">>}
|
|
end;
|
|
process([<<"federation">>, <<"v1">>, <<"state_ids">>, RoomID],
|
|
#request{method = 'GET', host = _Host} = Request) ->
|
|
case proplists:get_value(<<"event_id">>, Request#request.q) of
|
|
EventID when is_binary(EventID) ->
|
|
case preprocess_federation_request(Request) of
|
|
{ok, _JSON, Origin} ->
|
|
Host = ejabberd_config:get_myname(),
|
|
case mod_matrix_gw_room:get_state_ids(Host, Origin, RoomID, EventID) of
|
|
{ok, AuthChain, PDUs} ->
|
|
Res = #{<<"auth_chain_ids">> => AuthChain,
|
|
<<"pdu_ids">> => PDUs},
|
|
?DEBUG("get_state_ids res ~p~n", [Res]),
|
|
{200, [{<<"Content-Type">>, <<"application/json;charset=UTF-8">>}],
|
|
misc:json_encode(Res)};
|
|
{error, room_not_found} ->
|
|
{400, [], <<"400 Bad Request: room not found">>};
|
|
{error, not_allowed} ->
|
|
{403, [], <<"403 Forbidden: origin not in room">>};
|
|
{error, event_not_found} ->
|
|
{400, [], <<"400 Bad Request: 'event_id' not found">>}
|
|
end;
|
|
{result, HTTPResult} ->
|
|
HTTPResult
|
|
end;
|
|
undefined ->
|
|
{400, [], <<"400 Bad Request: missing 'event_id' parameter">>}
|
|
end;
|
|
process([<<"federation">>, <<"v1">>, <<"event">>, EventID],
|
|
#request{method = 'GET', host = _Host} = Request) ->
|
|
case preprocess_federation_request(Request) of
|
|
{ok, _JSON, _Origin} ->
|
|
Host = ejabberd_config:get_myname(),
|
|
%% TODO: very inefficient, replace with an SQL call
|
|
PDU =
|
|
lists:foldl(
|
|
fun(RoomID, undefined) ->
|
|
case mod_matrix_gw_room:get_event(Host, RoomID, EventID) of
|
|
{ok, PDU} ->
|
|
PDU;
|
|
_ ->
|
|
undefined
|
|
end;
|
|
(_, Acc) ->
|
|
Acc
|
|
end, undefined, mod_matrix_gw_room:get_rooms_list()),
|
|
?DEBUG("get_event res ~p~n", [PDU]),
|
|
case PDU of
|
|
undefined ->
|
|
{400, [], <<"400 Bad Request: event not found">>};
|
|
_ ->
|
|
MatrixServer = mod_matrix_gw_opt:matrix_domain(Host),
|
|
Res = #{<<"origin">> => MatrixServer,
|
|
<<"origin_server_ts">> => erlang:system_time(millisecond),
|
|
<<"pdus">> => [PDU]},
|
|
{200, [{<<"Content-Type">>, <<"application/json;charset=UTF-8">>}],
|
|
misc:json_encode(Res)}
|
|
end;
|
|
{result, HTTPResult} ->
|
|
HTTPResult
|
|
end;
|
|
process([<<"federation">>, <<"v1">>, <<"make_join">>, RoomID, UserID],
|
|
#request{method = 'GET', host = _Host, q = Params} = Request) ->
|
|
case preprocess_federation_request(Request) of
|
|
{ok, _JSON, Origin} ->
|
|
Host = ejabberd_config:get_myname(),
|
|
case get_id_domain_exn(UserID) of
|
|
Origin ->
|
|
case mod_matrix_gw_room:make_join(Host, RoomID, UserID, Params) of
|
|
{error, room_not_found} ->
|
|
Res = #{<<"errcode">> => <<"M_NOT_FOUND">>,
|
|
<<"error">> => <<"Unknown room">>},
|
|
{404, [{<<"Content-Type">>, <<"application/json;charset=UTF-8">>}],
|
|
misc:json_encode(Res)};
|
|
{error, not_invited} ->
|
|
Res = #{<<"errcode">> => <<"M_FORBIDDEN">>,
|
|
<<"error">> => <<"You are not invited to this room">>},
|
|
{403, [{<<"Content-Type">>, <<"application/json;charset=UTF-8">>}],
|
|
misc:json_encode(Res)};
|
|
{error, {incompatible_version, Ver}} ->
|
|
Res = #{<<"errcode">> => <<"M_INCOMPATIBLE_ROOM_VERSION">>,
|
|
<<"error">> => <<"Your homeserver does not support the features required to join this room">>,
|
|
<<"room_version">> => Ver},
|
|
{400, [{<<"Content-Type">>, <<"application/json;charset=UTF-8">>}],
|
|
misc:json_encode(Res)};
|
|
{ok, Res} ->
|
|
{200, [{<<"Content-Type">>, <<"application/json;charset=UTF-8">>}],
|
|
misc:json_encode(Res)}
|
|
end;
|
|
_ ->
|
|
Res = #{<<"errcode">> => <<"M_FORBIDDEN">>,
|
|
<<"error">> => <<"User not from origin">>},
|
|
{403, [{<<"Content-Type">>, <<"application/json;charset=UTF-8">>}],
|
|
misc:json_encode(Res)}
|
|
end;
|
|
{result, HTTPResult} ->
|
|
HTTPResult
|
|
end;
|
|
process([<<"federation">>, <<"v2">>, <<"send_join">>, RoomID, EventID],
|
|
#request{method = 'PUT', host = _Host} = Request) ->
|
|
case preprocess_federation_request(Request) of
|
|
{ok, #{<<"content">> := #{<<"membership">> := <<"join">>},
|
|
%<<"origin">> := Origin,
|
|
<<"room_id">> := RoomID,
|
|
<<"sender">> := Sender,
|
|
<<"state_key">> := Sender,
|
|
<<"type">> := <<"m.room.member">>} = JSON, Origin} ->
|
|
Host = ejabberd_config:get_myname(),
|
|
case get_id_domain_exn(Sender) of
|
|
Origin ->
|
|
case mod_matrix_gw_room:send_join(Host, Origin, RoomID, EventID, JSON) of
|
|
{error, Error} when is_binary(Error) ->
|
|
Res = #{<<"errcode">> => <<"M_BAD_REQUEST">>,
|
|
<<"error">> => Error},
|
|
{403, [{<<"Content-Type">>, <<"application/json;charset=UTF-8">>}],
|
|
misc:json_encode(Res)};
|
|
{ok, Res} ->
|
|
?DEBUG("send_join res: ~p~n", [Res]),
|
|
{200, [{<<"Content-Type">>, <<"application/json;charset=UTF-8">>}],
|
|
misc:json_encode(Res)}
|
|
end;
|
|
_ ->
|
|
Res = #{<<"errcode">> => <<"M_FORBIDDEN">>,
|
|
<<"error">> => <<"User not from origin">>},
|
|
{403, [{<<"Content-Type">>, <<"application/json;charset=UTF-8">>}],
|
|
misc:json_encode(Res)}
|
|
end;
|
|
{ok, _JSON, _Origin} ->
|
|
Res = #{<<"errcode">> => <<"M_BAD_REQUEST">>,
|
|
<<"error">> => <<"Invalid event format">>},
|
|
{400, [{<<"Content-Type">>, <<"application/json;charset=UTF-8">>}],
|
|
misc:json_encode(Res)};
|
|
{result, HTTPResult} ->
|
|
HTTPResult
|
|
end;
|
|
process(Path, Request) ->
|
|
?DEBUG("matrix 404: ~p~n~p~n", [Path, Request]),
|
|
ejabberd_web:error(not_found).
|
|
|
|
preprocess_federation_request(Request) ->
|
|
preprocess_federation_request(Request, true).
|
|
|
|
preprocess_federation_request(Request, DoSignCheck) ->
|
|
?DEBUG("matrix federation: ~p~n", [Request]),
|
|
case proplists:get_value('Authorization', Request#request.headers) of
|
|
Auth when is_binary(Auth) ->
|
|
case parse_auth(Auth) of
|
|
#{<<"origin">> := MatrixServer,
|
|
<<"key">> := _,
|
|
<<"sig">> := _} = AuthParams ->
|
|
?DEBUG("auth ~p~n", [AuthParams]),
|
|
if
|
|
Request#request.length =< ?MAX_REQUEST_SIZE ->
|
|
Request2 = recv_data(Request),
|
|
JSON =
|
|
if
|
|
Request#request.length > 0 ->
|
|
try
|
|
misc:json_decode(Request2#request.data)
|
|
catch
|
|
_:_ ->
|
|
error
|
|
end;
|
|
true ->
|
|
none
|
|
end,
|
|
?DEBUG("json ~p~n", [JSON]),
|
|
case JSON of
|
|
error ->
|
|
{result, {400, [], <<"400 Bad Request: invalid JSON">>}};
|
|
JSON when not DoSignCheck ->
|
|
{ok, JSON, MatrixServer};
|
|
JSON ->
|
|
Host = ejabberd_config:get_myname(),
|
|
case mod_matrix_gw_s2s:check_auth(
|
|
Host, MatrixServer,
|
|
AuthParams, JSON,
|
|
Request2) of
|
|
true ->
|
|
?DEBUG("auth ok~n", []),
|
|
{ok, JSON, MatrixServer};
|
|
false ->
|
|
?DEBUG("auth failed~n", []),
|
|
{result, {401, [], <<"401 Unauthorized">>}}
|
|
end
|
|
end;
|
|
true ->
|
|
{result, {400, [], <<"400 Bad Request: size limit">>}}
|
|
end;
|
|
_ ->
|
|
{result, {400, [], <<"400 Bad Request: bad 'Authorization' header">>}}
|
|
end;
|
|
undefined ->
|
|
{result, {400, [], <<"400 Bad Request: no 'Authorization' header">>}}
|
|
end.
|
|
|
|
recv_data(#request{length = Len, data = Trail,
|
|
sockmod = SockMod, socket = Socket} = Request) ->
|
|
NewLen = Len - byte_size(Trail),
|
|
if NewLen > 0 ->
|
|
case SockMod:recv(Socket, NewLen, 60000) of
|
|
{ok, Data} ->
|
|
Request#request{data = <<Trail/binary, Data/binary>>};
|
|
{error, _} -> Request
|
|
end;
|
|
true ->
|
|
Request
|
|
end.
|
|
|
|
|
|
-record(state,
|
|
{host :: binary(),
|
|
server_host :: binary()}).
|
|
|
|
-type state() :: #state{}.
|
|
|
|
start(Host, _Opts) ->
|
|
case mod_matrix_gw_sup:start(Host) of
|
|
{ok, _} ->
|
|
{ok, [{hook, s2s_out_bounce_packet, s2s_out_bounce_packet, 50},
|
|
{hook, user_receive_packet, user_receive_packet, 50}]};
|
|
Err ->
|
|
Err
|
|
end.
|
|
|
|
stop(Host) ->
|
|
Proc = mod_matrix_gw_sup:procname(Host),
|
|
supervisor:terminate_child(ejabberd_gen_mod_sup, Proc),
|
|
supervisor:delete_child(ejabberd_gen_mod_sup, Proc).
|
|
|
|
reload(_Host, _NewOpts, _OldOpts) ->
|
|
ok.
|
|
|
|
start_link(Host) ->
|
|
Proc = procname(Host),
|
|
?GEN_SERVER:start_link({local, Proc}, ?MODULE, [Host],
|
|
ejabberd_config:fsm_limit_opts([])).
|
|
|
|
-spec init(list()) -> {ok, state()}.
|
|
init([Host]) ->
|
|
process_flag(trap_exit, true),
|
|
mod_matrix_gw_s2s:create_db(),
|
|
mod_matrix_gw_room:create_db(),
|
|
Opts = gen_mod:get_module_opts(Host, ?MODULE),
|
|
MyHost = gen_mod:get_opt(host, Opts),
|
|
register_routes(Host, [MyHost]),
|
|
{ok, #state{server_host = Host, host = MyHost}}.
|
|
|
|
-spec handle_call(term(), {pid(), term()}, state()) ->
|
|
{reply, ok | {ok, pid()} | {error, any()}, state()} |
|
|
{stop, normal, ok, state()}.
|
|
handle_call(stop, _From, State) ->
|
|
{stop, normal, ok, State}.
|
|
|
|
-spec handle_cast(term(), state()) -> {noreply, state()}.
|
|
handle_cast(Msg, State) ->
|
|
?WARNING_MSG("Unexpected cast: ~p", [Msg]),
|
|
{noreply, State}.
|
|
|
|
-spec handle_info(term(), state()) -> {noreply, state()}.
|
|
handle_info(Info, State) ->
|
|
?WARNING_MSG("Unexpected info: ~p", [Info]),
|
|
{noreply, State}.
|
|
|
|
-spec terminate(term(), state()) -> any().
|
|
terminate(_Reason, #state{host = Host}) ->
|
|
unregister_routes([Host]).
|
|
|
|
-spec code_change(term(), state(), term()) -> {ok, state()}.
|
|
code_change(_OldVsn, State, _Extra) -> {ok, State}.
|
|
|
|
|
|
-spec register_routes(binary(), [binary()]) -> ok.
|
|
register_routes(ServerHost, Hosts) ->
|
|
lists:foreach(
|
|
fun(Host) ->
|
|
ejabberd_router:register_route(
|
|
Host, ServerHost, {apply, ?MODULE, route})
|
|
end, Hosts).
|
|
|
|
unregister_routes(Hosts) ->
|
|
lists:foreach(
|
|
fun(Host) ->
|
|
ejabberd_router:unregister_route(Host)
|
|
end, Hosts).
|
|
|
|
procname(Host) ->
|
|
binary_to_atom(
|
|
<<(atom_to_binary(?MODULE, latin1))/binary, "_", Host/binary>>, utf8).
|
|
|
|
parse_auth(<<"X-Matrix ", S/binary>>) ->
|
|
parse_auth1(S, <<>>, []);
|
|
parse_auth(_) ->
|
|
error.
|
|
|
|
parse_auth1(<<$=, Cs/binary>>, S, Ts) ->
|
|
parse_auth2(Cs, S, <<>>, Ts);
|
|
parse_auth1(<<$,, Cs/binary>>, <<>>, Ts) -> parse_auth1(Cs, [], Ts);
|
|
parse_auth1(<<$\s, Cs/binary>>, <<>>, Ts) -> parse_auth1(Cs, [], Ts);
|
|
parse_auth1(<<C, Cs/binary>>, S, Ts) -> parse_auth1(Cs, <<S/binary, C>>, Ts);
|
|
parse_auth1(<<>>, <<>>, T) -> maps:from_list(T);
|
|
parse_auth1(<<>>, _S, _T) -> error.
|
|
|
|
parse_auth2(<<$", Cs/binary>>, Key, Val, Ts) ->
|
|
parse_auth3(Cs, Key, Val, Ts);
|
|
parse_auth2(<<C, Cs/binary>>, Key, Val, Ts) ->
|
|
parse_auth4(Cs, Key, <<Val/binary, C>>, Ts);
|
|
parse_auth2(<<>>, _, _, _) -> error.
|
|
|
|
parse_auth3(<<$", Cs/binary>>, Key, Val, Ts) ->
|
|
parse_auth4(Cs, Key, Val, Ts);
|
|
parse_auth3(<<$\\, C, Cs/binary>>, Key, Val, Ts) ->
|
|
parse_auth3(Cs, Key, <<Val/binary, C>>, Ts);
|
|
parse_auth3(<<C, Cs/binary>>, Key, Val, Ts) ->
|
|
parse_auth3(Cs, Key, <<Val/binary, C>>, Ts);
|
|
parse_auth3(<<>>, _, _, _) -> error.
|
|
|
|
parse_auth4(<<$,, Cs/binary>>, Key, Val, Ts) ->
|
|
parse_auth1(Cs, <<>>, [{Key, Val} | Ts]);
|
|
parse_auth4(<<$\s, Cs/binary>>, Key, Val, Ts) ->
|
|
parse_auth4(Cs, Key, Val, Ts);
|
|
parse_auth4(<<C, Cs/binary>>, Key, Val, Ts) ->
|
|
parse_auth4(Cs, Key, <<Val/binary, C>>, Ts);
|
|
parse_auth4(<<>>, Key, Val, Ts) ->
|
|
parse_auth1(<<>>, <<>>, [{Key, Val} | Ts]).
|
|
|
|
prune_event(#{<<"type">> := Type, <<"content">> := Content} = Event,
|
|
RoomVersion) ->
|
|
Event2 =
|
|
case RoomVersion#room_version.updated_redaction_rules of
|
|
false ->
|
|
maps:with(
|
|
[<<"event_id">>, <<"type">>, <<"room_id">>, <<"sender">>,
|
|
<<"state_key">>, <<"content">>, <<"hashes">>,
|
|
<<"signatures">>, <<"depth">>, <<"prev_events">>,
|
|
<<"prev_state">>, <<"auth_events">>, <<"origin">>,
|
|
<<"origin_server_ts">>, <<"membership">>], Event);
|
|
true ->
|
|
maps:with(
|
|
[<<"event_id">>, <<"type">>, <<"room_id">>, <<"sender">>,
|
|
<<"state_key">>, <<"content">>, <<"hashes">>,
|
|
<<"signatures">>, <<"depth">>, <<"prev_events">>,
|
|
<<"auth_events">>, <<"origin_server_ts">>], Event)
|
|
end,
|
|
Content2 =
|
|
case Type of
|
|
<<"m.room.member">> ->
|
|
C3 = maps:with([<<"membership">>,
|
|
<<"join_authorised_via_users_server">>],
|
|
Content),
|
|
case RoomVersion#room_version.updated_redaction_rules of
|
|
false ->
|
|
C3;
|
|
true ->
|
|
case Content of
|
|
#{<<"third_party_invite">> :=
|
|
#{<<"signed">> := InvSign}} ->
|
|
C3#{<<"third_party_invite">> =>
|
|
#{<<"signed">> => InvSign}};
|
|
_ ->
|
|
C3
|
|
end
|
|
end;
|
|
<<"m.room.create">> ->
|
|
case RoomVersion#room_version.updated_redaction_rules of
|
|
false ->
|
|
maps:with([<<"creator">>], Content);
|
|
true ->
|
|
Content
|
|
end;
|
|
<<"m.room.join_rules">> ->
|
|
maps:with([<<"join_rule">>, <<"allow">>], Content);
|
|
<<"m.room.power_levels">> ->
|
|
case RoomVersion#room_version.updated_redaction_rules of
|
|
false ->
|
|
maps:with(
|
|
[<<"ban">>, <<"events">>, <<"events_default">>,
|
|
<<"kick">>, <<"redact">>, <<"state_default">>,
|
|
<<"users">>, <<"users_default">>], Content);
|
|
true ->
|
|
maps:with(
|
|
[<<"ban">>, <<"events">>, <<"events_default">>,
|
|
<<"invite">>,
|
|
<<"kick">>, <<"redact">>, <<"state_default">>,
|
|
<<"users">>, <<"users_default">>], Content)
|
|
end;
|
|
<<"m.room.history_visibility">> ->
|
|
maps:with([<<"history_visibility">>], Content);
|
|
<<"m.room.redaction">> ->
|
|
case RoomVersion#room_version.updated_redaction_rules of
|
|
false ->
|
|
#{};
|
|
true ->
|
|
maps:with([<<"redacts">>], Content)
|
|
end;
|
|
_ -> #{}
|
|
end,
|
|
Event2#{<<"content">> := Content2}.
|
|
|
|
reference_hash(PrunedEvent) ->
|
|
Event2 = maps:without([<<"signatures">>, <<"age_ts">>, <<"unsigned">>],
|
|
PrunedEvent),
|
|
S = encode_canonical_json(Event2),
|
|
crypto:hash(sha256, S).
|
|
|
|
content_hash(Event) ->
|
|
Event2 = maps:without([<<"signatures">>, <<"age_ts">>, <<"unsigned">>,
|
|
<<"hashes">>, <<"outlier">>, <<"destinations">>],
|
|
Event),
|
|
S = encode_canonical_json(Event2),
|
|
crypto:hash(sha256, S).
|
|
|
|
get_event_id(Event, RoomVersion) ->
|
|
PrunedEvent = prune_event(Event, RoomVersion),
|
|
get_pruned_event_id(PrunedEvent).
|
|
|
|
get_pruned_event_id(PrunedEvent) ->
|
|
B = base64url_encode(reference_hash(PrunedEvent)),
|
|
<<$$, B/binary>>.
|
|
|
|
encode_canonical_json(JSON) ->
|
|
JSON2 = sort_json(JSON),
|
|
misc:json_encode_with_kv_lists(JSON2).
|
|
|
|
sort_json(#{} = Map) ->
|
|
Map2 = maps:map(fun(_K, V) ->
|
|
sort_json(V)
|
|
end, Map),
|
|
{lists:sort(maps:to_list(Map2))};
|
|
sort_json(List) when is_list(List) ->
|
|
lists:map(fun sort_json/1, List);
|
|
sort_json(JSON) ->
|
|
JSON.
|
|
|
|
base64_decode(B) ->
|
|
Fixed =
|
|
case size(B) rem 4 of
|
|
0 -> B;
|
|
%1 -> <<B/binary, "===">>;
|
|
2 -> <<B/binary, "==">>;
|
|
3 -> <<B/binary, "=">>
|
|
end,
|
|
base64:decode(Fixed).
|
|
|
|
base64_encode(B) ->
|
|
D = base64:encode(B),
|
|
K = binary:longest_common_suffix([D, <<"==">>]),
|
|
binary:part(D, 0, size(D) - K).
|
|
|
|
base64url_encode(B) ->
|
|
D = base64_encode(B),
|
|
D1 = binary:replace(D, <<"+">>, <<"-">>, [global]),
|
|
binary:replace(D1, <<"/">>, <<"_">>, [global]).
|
|
|
|
sign_event(Host, Event, RoomVersion) ->
|
|
PrunedEvent = prune_event(Event, RoomVersion),
|
|
case sign_pruned_event(Host, PrunedEvent) of
|
|
#{<<"signatures">> := Signatures} ->
|
|
Event#{<<"signatures">> => Signatures}
|
|
end.
|
|
|
|
sign_pruned_event(Host, PrunedEvent) ->
|
|
Event2 = maps:without([<<"age_ts">>, <<"unsigned">>], PrunedEvent),
|
|
sign_json(Host, Event2).
|
|
|
|
sign_json(Host, JSON) ->
|
|
Signatures = maps:get(<<"signatures">>, JSON, #{}),
|
|
JSON2 = maps:without([<<"signatures">>, <<"unsigned">>], JSON),
|
|
Msg = encode_canonical_json(JSON2),
|
|
SignatureName = mod_matrix_gw_opt:matrix_domain(Host),
|
|
KeyName = mod_matrix_gw_opt:key_name(Host),
|
|
{_PubKey, PrivKey} = mod_matrix_gw_opt:key(Host),
|
|
KeyID = <<"ed25519:", KeyName/binary>>,
|
|
Sig = crypto:sign(eddsa, none, Msg, [PrivKey, ed25519]),
|
|
Sig64 = base64_encode(Sig),
|
|
Signatures2 = Signatures#{SignatureName => #{KeyID => Sig64}},
|
|
JSON#{<<"signatures">> => Signatures2}.
|
|
|
|
-spec send_request(
|
|
binary(),
|
|
get | post | put,
|
|
binary(),
|
|
[binary()],
|
|
[{binary(), binary()}],
|
|
none | #{atom() | binary() => misc:json_value()},
|
|
[any()],
|
|
[any()]) -> {ok, any()} | {error, any()}.
|
|
|
|
send_request(Host, Method, MatrixServer, Path, Query, JSON,
|
|
HTTPOptions, Options) ->
|
|
URI1 = iolist_to_binary(
|
|
lists:map(fun(P) -> [$/, misc:uri_quote(P)] end, Path)),
|
|
URI =
|
|
case Query of
|
|
[] -> URI1;
|
|
_ ->
|
|
URI2 = str:join(
|
|
lists:map(
|
|
fun({K, V}) ->
|
|
iolist_to_binary(
|
|
[misc:uri_quote(K), $=,
|
|
misc:uri_quote(V)])
|
|
end, Query), $&),
|
|
<<URI1/binary, $?, URI2/binary>>
|
|
end,
|
|
{MHost, MPort} = mod_matrix_gw_s2s:get_matrix_host_port(Host, MatrixServer),
|
|
URL = <<"https://", MHost/binary,
|
|
":", (integer_to_binary(MPort))/binary,
|
|
URI/binary>>,
|
|
SMethod =
|
|
case Method of
|
|
get -> <<"GET">>;
|
|
put -> <<"PUT">>;
|
|
post -> <<"POST">>
|
|
end,
|
|
Auth = make_auth_header(Host, MatrixServer, SMethod, URI, JSON),
|
|
Headers = [{"Authorization", binary_to_list(Auth)}],
|
|
Content =
|
|
case JSON of
|
|
none -> <<>>;
|
|
_ -> misc:json_encode(JSON)
|
|
end,
|
|
Request =
|
|
case Method of
|
|
get ->
|
|
{URL, Headers};
|
|
_ ->
|
|
{URL, Headers, "application/json;charset=UTF-8", Content}
|
|
end,
|
|
httpc:request(Method,
|
|
Request,
|
|
HTTPOptions,
|
|
Options).
|
|
|
|
make_auth_header(Host, MatrixServer, Method, URI, Content) ->
|
|
Origin = mod_matrix_gw_opt:matrix_domain(Host),
|
|
JSON = #{<<"method">> => Method,
|
|
<<"uri">> => URI,
|
|
<<"origin">> => Origin,
|
|
<<"destination">> => MatrixServer
|
|
},
|
|
JSON2 =
|
|
case Content of
|
|
none -> JSON;
|
|
_ ->
|
|
JSON#{<<"content">> => Content}
|
|
end,
|
|
JSON3 = sign_json(Host, JSON2),
|
|
#{<<"signatures">> := #{Origin := #{} = KeySig}} = JSON3,
|
|
{KeyID, Sig, _} = maps:next(maps:iterator(KeySig)),
|
|
<<"X-Matrix origin=", Origin/binary, ",key=\"", KeyID/binary,
|
|
"\",sig=\"", Sig/binary, "\",",
|
|
"destination=\"", MatrixServer/binary, "\"">>.
|
|
|
|
get_id_domain_exn(B) ->
|
|
case binary:split(B, <<":">>) of
|
|
[_, Tail] -> Tail;
|
|
_ -> error({invalid_id, B})
|
|
end.
|
|
|
|
s2s_out_bounce_packet(S2SState, Pkt) ->
|
|
#{server_host := Host} = S2SState,
|
|
case mod_matrix_gw_opt:matrix_id_as_jid(Host) of
|
|
false ->
|
|
S2SState;
|
|
true ->
|
|
To = xmpp:get_to(Pkt),
|
|
ServiceHost = mod_matrix_gw_opt:host(Host),
|
|
EscU = mod_matrix_gw_room:escape(To#jid.user),
|
|
EscS = mod_matrix_gw_room:escape(To#jid.lserver),
|
|
NewTo = jid:make(<<EscU/binary, $%, EscS/binary>>, ServiceHost),
|
|
ejabberd_router:route(xmpp:set_to(Pkt, NewTo)),
|
|
{stop, ignore}
|
|
end.
|
|
|
|
user_receive_packet({Pkt, C2SState} = Acc) ->
|
|
#{lserver := Host} = C2SState,
|
|
case mod_matrix_gw_opt:matrix_id_as_jid(Host) of
|
|
false ->
|
|
Acc;
|
|
true ->
|
|
ServiceHost = mod_matrix_gw_opt:host(Host),
|
|
From = xmpp:get_from(Pkt),
|
|
case From#jid.lserver of
|
|
ServiceHost ->
|
|
case binary:split(From#jid.user, <<"%">>) of
|
|
[EscU, EscS] ->
|
|
U = mod_matrix_gw_room:unescape(EscU),
|
|
S = mod_matrix_gw_room:unescape(EscS),
|
|
NewFrom = jid:make(U, S),
|
|
{xmpp:set_from(Pkt, NewFrom), C2SState};
|
|
_ ->
|
|
Acc
|
|
end;
|
|
_ ->
|
|
Acc
|
|
end
|
|
end.
|
|
|
|
route(Pkt) ->
|
|
mod_matrix_gw_room:route(Pkt).
|
|
|
|
depends(_Host, _Opts) ->
|
|
[].
|
|
|
|
mod_opt_type(host) ->
|
|
econf:host();
|
|
mod_opt_type(matrix_domain) ->
|
|
econf:host();
|
|
mod_opt_type(key_name) ->
|
|
econf:binary();
|
|
mod_opt_type(key) ->
|
|
fun(Key) ->
|
|
Key1 = (yconf:binary())(Key),
|
|
Key2 = base64_decode(Key1),
|
|
crypto:generate_key(eddsa, ed25519, Key2)
|
|
end;
|
|
mod_opt_type(matrix_id_as_jid) ->
|
|
econf:bool().
|
|
|
|
-spec mod_options(binary()) -> [{key, {binary(), binary()}} |
|
|
{atom(), any()}].
|
|
|
|
mod_options(Host) ->
|
|
[{matrix_domain, Host},
|
|
{host, <<"matrix.", Host/binary>>},
|
|
{key_name, <<"">>},
|
|
{key, {<<"">>, <<"">>}},
|
|
{matrix_id_as_jid, false}].
|
|
|
|
mod_doc() ->
|
|
#{desc =>
|
|
[?T("https://matrix.org/[Matrix] gateway.")],
|
|
note => "added in 24.02",
|
|
example =>
|
|
["listen:",
|
|
" -",
|
|
" port: 8448",
|
|
" module: ejabberd_http",
|
|
" tls: true",
|
|
" request_handlers:",
|
|
" \"/_matrix\": mod_matrix_gw",
|
|
"",
|
|
"modules:",
|
|
" mod_matrix_gw:",
|
|
" key_name: \"key1\"",
|
|
" key: \"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\"",
|
|
" matrix_id_as_jid: true"],
|
|
opts =>
|
|
[{matrix_domain,
|
|
#{value => ?T("Domain"),
|
|
desc =>
|
|
?T("Specify a domain in the Matrix federation. "
|
|
"The keyword '@HOST@' is replaced with the hostname. "
|
|
"The default value is '@HOST@'.")}},
|
|
{host,
|
|
#{value => ?T("Host"),
|
|
desc =>
|
|
?T("This option defines the Jabber IDs of the service. "
|
|
"If the 'host' option is not specified, the Jabber ID will be "
|
|
"the hostname of the virtual host with the prefix '\"matrix.\"'. "
|
|
"The keyword '@HOST@' is replaced with the real virtual host name.")}},
|
|
{key_name,
|
|
#{value => "string()",
|
|
desc =>
|
|
?T("Name of the matrix signing key.")}},
|
|
{key,
|
|
#{value => "string()",
|
|
desc =>
|
|
?T("Value of the matrix signing key, in base64.")}},
|
|
{matrix_id_as_jid,
|
|
#{value => "true | false",
|
|
desc =>
|
|
?T("If set to 'true', all packets failing to be delivered via an XMPP "
|
|
"server-to-server connection will then be routed to the Matrix gateway "
|
|
"by translating a Jabber ID 'user@matrixdomain.tld' to a Matrix user "
|
|
"identifier '@user:matrixdomain.tld'. When set to 'false', messages "
|
|
"must be explicitly sent to the matrix gateway service Jabber ID to be "
|
|
"routed to a remote Matrix server. In this case, to send a message to "
|
|
"Matrix user '@user:matrixdomain.tld', the client must send a message "
|
|
"to the JID 'user%matrixdomain.tld@matrix.myxmppdomain.tld', where "
|
|
"'matrix.myxmppdomain.tld' is the JID of the gateway service as set by the "
|
|
"'host' option. The default is 'false'.")}}
|
|
]
|
|
}.
|
|
-endif.
|