From 62af899a506eaa604cfa95d674620a5d65f82406 Mon Sep 17 00:00:00 2001 From: John Livingston Date: Thu, 29 Apr 2021 16:50:30 +0200 Subject: [PATCH] Builtin Prosody modules: * initialize prosody modules folder * comment on prosody modules licensing * use mod_muc_http_defaults to set rooms properties and prevent unauthorized room creation WIP --- CHANGELOG.md | 4 + README.md | 2 + package.json | 3 +- prosody-modules/COPYING | 20 ++ .../mod_muc_http_defaults/README.markdown | 191 ++++++++++++++++++ .../mod_muc_http_defaults.lua | 150 ++++++++++++++ server/lib/diagnostic/prosody.ts | 2 + server/lib/prosody/config.ts | 39 +++- 8 files changed, 408 insertions(+), 3 deletions(-) create mode 100644 prosody-modules/COPYING create mode 100644 prosody-modules/mod_muc_http_defaults/README.markdown create mode 100644 prosody-modules/mod_muc_http_defaults/mod_muc_http_defaults.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b1f8563..655afb6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## ??? + +* Builtin Prosody: use mod_muc_http_defaults to set rooms properties and prevent unauthorized room creation. + ## v2.0.3 * Fix Peertube server crash when prosody is not installed diff --git a/README.md b/README.md index 45994884..ad67dbc6 100644 --- a/README.md +++ b/README.md @@ -35,3 +35,5 @@ Then, please refer to the documentation associated with the mode you are plannin Thanks to David Revoy for his work on Peertube's mascot, [Sepia](https://www.davidrevoy.com/index.php?tag/peertube). Some material icons downloaded from this website where used for icons: [Material.io](https://material.io/resources/icons) + +Some Prosody Modules in the `prosody-modules` folder are under MIT license. Please refer to README files in each subfolder, and to the [COPYING](./prosody-modules/COPYING) file. For more informations, here is [the official Prosody Modules website](https://modules.prosody.im). diff --git a/package.json b/package.json index add55364..d8e441f9 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,8 @@ "build:webpack": "webpack --mode=production", "build:server": "npx tsc --build server/tsconfig.json", "build:serverconverse": "mkdir -p dist/server/conversejs && cp conversejs/index.html dist/server/conversejs/", - "build": "npm-run-all -s clean -p build:converse build:images build:webpack build:server build:serverconverse", + "build:prosodymodules": "mkdir -p dist/server/prosody-modules && cp -r prosody-modules/* dist/server/prosody-modules/", + "build": "npm-run-all -s clean -p build:converse build:images build:webpack build:server build:serverconverse build:prosodymodules", "lint": "npx eslint --ext .js --ext .ts ." }, "staticDirs": { diff --git a/prosody-modules/COPYING b/prosody-modules/COPYING new file mode 100644 index 00000000..754bfe4b --- /dev/null +++ b/prosody-modules/COPYING @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2009-2015 Various Contributors (see individual files and source control) + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/prosody-modules/mod_muc_http_defaults/README.markdown b/prosody-modules/mod_muc_http_defaults/README.markdown new file mode 100644 index 00000000..66e2cf28 --- /dev/null +++ b/prosody-modules/mod_muc_http_defaults/README.markdown @@ -0,0 +1,191 @@ +--- +summary: Seed MUC configuration from JSON REST API +--- + +# Introduction + +This module fetches configuration for MUC rooms from an API when rooms +are created. + +# Requirements + +Should work with Prosody 0.11. + +# Configuration + +`muc_create_api_url` +: URL template for the API endpoint to get settings. `{room.jid}` is + replaced by the address of the room in question. + +`muc_create_api_auth` +: The value of the Authorization header to authenticate against the + API. E.g. `"Bearer /rXU4tkQTYQMgdHfMLH6"`{.lua} + +In the URL template variable, the room JID is available as `{room.jid}`, +which would be turned into `room@muc.host`. To only get the room +localpart, `{room.jid|jid_node}` can be used, and `{room.jid|jid_host}` +splits out the `muc.host` part. + +## Example + +``` {.lua} +Component "channels.example.net" "muc" +modules_enabled = { "muc_http_defaults" } +muc_create_api_url = "https://api.example.net/muc/config?jid={room.jid}" +``` + +# API + +A RESTful JSON API is used. Any error causes the room to be destroyed. + +The returned JSON consists of two main parts, the room configuration and +the affiliations (member list). + +## Room Configuration + +The top level `config` field contains a map of properties corresponding +to the fields in the room configuration dialog, named similarly to the +[room configuration default][doc:modules:mod_muc#room-configuration-defaults] in +Prosodys config file. + +| Property | Type | Description | +|------------------------|---------|---------------------------------------------------------------------------| +| `name` | string | Name of the chat | +| `description` | string | Longer description of the chat | +| `language` | string | Language code | +| `persistent` | boolean | Whether the room should keep existing if it becomes empty | +| `public` | boolean | `true` to include in public listing | +| `members_only` | boolean | Membership or open | +| `allow_member_invites` | boolean | If members can invite others into members-only rooms | +| `public_jids` | boolean | If everyone or only moderators should see real identities | +| `subject` | string | In-room subject or topic message | +| `changesubject` | boolean | If `true` then everyone can change the subject, otherwise only moderators | +| `historylength` | integer | Number of messages to keep in memory (legacy method) | +| `moderated` | boolean | New participants start without voice privileges if set to `true` | +| `archiving` | boolean | Whether [archiving][doc:modules:mod_muc_mam] is enabled | + +## Affiliations + +The list of members go in `affiliations` which is either an object +mapping addresses to affiliations (e.g. `{"user@host":"admin"}`{.json}), +or it can be an array of address, affiliation and optionally a reserved +nickname (e.g. +`[{"jid":"user@host","affiliation":"member","nick":"joe"}]`{.json}). + +## Schema + +Here's a JSON Schema in YAML format describing the expected JSON +response data: + +``` {.yaml} +--- +type: object +properties: + config: + type: object + properties: + name: + type: string + description: + type: string + language: + type: string + persistent: + type: boolean + public: + type: boolean + members_only: + type: boolean + allow_member_invites: + type: boolean + public_jids: + type: boolean + subject: + type: string + changesubject: + type: boolean + historylength: + type: integer + moderated: + type: boolean + archiving: + type: boolean + affiliations: + oneOf: + - type: array + items: + type: object + required: + - jid + - affiliation + properties: + jid: + type: string + pattern: ^[^@/]+@[^/]+$ + affiliation: + $ref: '#/definitions/affiliation' + nick: + type: string + - type: object + additionalProperties: + $ref: '#/definitions/affiliation' +definitions: + affiliation: + type: string + enum: + - owner + - admin + - member + - none + - outcast +... +``` + +## Example + +A basic example with some config settings and a few affiliations: + +``` {.json} +GET /muc/config?jid=place@channels.example.net +Accept: application/json + +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "affiliations" : [ + { + "affiliation" : "owner", + "jid" : "bosmang@example.net", + "nick" : "bosmang" + }, + { + "affiliation" : "admin", + "jid" : "xo@example.net", + "nick" : "xo" + }, + { + "affiliation" : "member", + "jid" : "john@example.net" + } + ], + "config" : { + "archiving" : true, + "description" : "This is the place", + "members_only" : true, + "moderated" : false, + "name" : "The Place", + "persistent" : true, + "public" : false, + "subject" : "Discussions regarding The Place" + } +} +``` + +To allow the creation without making any changes, letting whoever +created it be the owner, just return an empty JSON object: + + HTTP/1.1 200 OK + Content-Type: application/json + + {} diff --git a/prosody-modules/mod_muc_http_defaults/mod_muc_http_defaults.lua b/prosody-modules/mod_muc_http_defaults/mod_muc_http_defaults.lua new file mode 100644 index 00000000..6d71ad69 --- /dev/null +++ b/prosody-modules/mod_muc_http_defaults/mod_muc_http_defaults.lua @@ -0,0 +1,150 @@ +-- Copyright (C) 2021 Kim Alvefur +-- +-- This file is MIT licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local http = require "net.http"; +local async = require "util.async"; +local uh = require "util.http"; +local jid = require "util.jid"; +local json = require "util.json"; +local st = require "util.stanza"; + +local funcs = {jid_bare = jid.bare; jid_host = jid.host; jid_node = jid.node}; +local render = require"util.interpolation".new("%b{}", uh.urlencode, funcs); + +module:depends"muc"; + +local url_template = assert(module:get_option_string("muc_create_api_url", nil), "'muc_create_api_url' is a required option"); +local apiauth = module:get_option_string("muc_create_api_auth", nil); + +local ex = { + headers = { + accept = "application/json"; + authorization = apiauth; + } +}; + +local problems = { + format = "API server returned invalid data, see logs", + config = "A problem occured while creating the room, see logs", +}; + +local function apply_config(room, settings) + local affiliations = settings.affiliations; + if type(affiliations) == "table" then + + -- COMPAT the room creator is unconditionally made 'owner' + -- clear existing affiliation + for existing_affiliation in pairs(room._affiliations) do + room:set_affiliation(true, existing_affiliation, "none"); + end + + if affiliations[1] ~= nil then -- array of ( jid, affiliation, nick ) + for _, aff in ipairs(affiliations) do + if type(aff) == "table" and type(aff.jid) == "string" and (aff.nick == nil or type(aff.nick) == "string") then + local prepped_jid = jid.prep(aff.jid); + if prepped_jid then + local ok, err = room:set_affiliation(true, prepped_jid, aff.affiliation, aff.nick and { nick = aff.nick }); + if not ok then + module:log("error", "Could not set affiliation in %s: %s", room.jid, err); + return nil, "config"; + end + else + module:log("error", "Invalid JID returned from API for %s: %q", room.jid, aff.jid); + return nil, "format"; + end + else + module:log("error", "Invalid affiliation item returned from API for %s: %q", room.jid, aff); + return nil, "format"; + end + end + else -- map of jid : affiliation + for user_jid, aff in pairs(affiliations) do + if type(user_jid) == "string" and type(aff) == "string" then + local prepped_jid = jid.prep(user_jid); + if prepped_jid then + local ok, err = room:set_affiliation(true, prepped_jid, aff); + if not ok then + module:log("error", "Could not set affiliation in %s: %s", room.jid, err); + return nil, "config"; + end + else + module:log("error", "Invalid JID returned from API: %q", aff.jid); + return nil, "format"; + end + end + end + end + elseif affiliations ~= nil then + module:log("error", "Invalid affiliations returned from API for %s: %q", room.jid, affiliations); + return nil, "format", { field = "affiliations" }; + end + + local config = settings.config; + if type(config) == "table" then + -- TODO reject invalid fields instead of ignoring them + if type(config.name) == "string" then room:set_name(config.name); end + if type(config.description) == "string" then room:set_description(config.description); end + if type(config.language) == "string" then room:set_language(config.language); end + if type(config.password) == "string" then room:set_password(config.password); end + if type(config.subject) == "string" then room:set_subject(config.subject); end + + if type(config.public) == "boolean" then room:set_public(config.public); end + if type(config.members_only) == "boolean" then room:set_members_only(config.members_only); end + if type(config.allow_member_invites) == "boolean" then room:set_allow_member_invites(config.allow_member_invites); end + if type(config.moderated) == "boolean" then room:set_moderated(config.moderated); end + if type(config.persistent) == "boolean" then room:set_persistent(config.persistent); end + if type(config.changesubject) == "boolean" then room:set_changesubject(config.changesubject); end + + if type(config.historylength) == "number" then room:set_historylength(config.historylength); end + if type(config.public_jids) == "boolean" then room:set_whois(config.public_jids and "anyone" or "moderators"); end + -- Leaving out presence_broadcast for now + + -- mod_muc_mam + if type(config.archiving) == "boolean" then room._config.archiving = config.archiving; end + elseif config ~= nil then + module:log("error", "Invalid config returned from API for %s: %q", room.jid, config); + return nil, "format", { field = "config" }; + end + return true; +end + +module:hook("muc-room-pre-create", function(event) + local url = render(url_template, event); + module:log("debug", "Calling API at %q for room %s", url, event.room.jid); + local wait, done = async.waiter(); + + local ret, err; + http.request(url, ex, function (body, code) + if math.floor(code / 100) == 2 then + local parsed, parse_err = json.decode(body); + if not parsed then + module:log("debug", "Got invalid JSON from %s: %s", url, parse_err); + err = problems.format; + else + ret = parsed; + end + else + module:log("debug", "Rejected by API: ", body); + err = "Rejected by API"; + end + + done() + end); + + wait(); + if not ret then + event.room:destroy(); + event.origin.send(st.error_reply(event.stanza, "cancel", "internal-server-error", err, module.host)); + return true; + end + + local configured, err = apply_config(event.room, ret); + if not configured then + event.room:destroy(); + event.origin.send(st.error_reply(event.stanza, "cancel", "internal-server-error", err, event.room.jid or module.host)); + return true; + end +end, -2); diff --git a/server/lib/diagnostic/prosody.ts b/server/lib/diagnostic/prosody.ts index a259b349..badd15bd 100644 --- a/server/lib/diagnostic/prosody.ts +++ b/server/lib/diagnostic/prosody.ts @@ -23,6 +23,8 @@ export async function diagProsody (test: string, options: RegisterServerOptions) result.messages.push(`Prosody will run on port '${wantedConfig.port}'`) + result.messages.push(`Prosody modules path will be '${wantedConfig.paths.modules}'`) + await fs.promises.access(filePath, fs.constants.R_OK) // throw an error if file does not exist. result.messages.push(`The prosody configuration file (${filePath}) exists`) const actualContent = await fs.promises.readFile(filePath, { diff --git a/server/lib/prosody/config.ts b/server/lib/prosody/config.ts index 1aa15aa7..2be19762 100644 --- a/server/lib/prosody/config.ts +++ b/server/lib/prosody/config.ts @@ -69,6 +69,7 @@ interface ProsodyFilePaths { log: string config: string data: string + modules: string } async function getProsodyFilePaths (options: RegisterServerOptions): Promise { const logger = options.peertubeHelpers.logger @@ -81,10 +82,19 @@ async function getProsodyFilePaths (options: RegisterServerOptions): Promise +} + interface ProsodyConfig { content: string paths: ProsodyFilePaths @@ -101,11 +111,31 @@ async function getProsodyConfig (options: RegisterServerOptions): Promise ' "' + m.module + '";').join('\n') + const mucModulesOptions: string = mucModules.map(m => { + return m.options.map(o => { + return ' ' + o.name + ' = "' + o.value + '"' + }).join('\n') + }).join('\n') + const content = ` daemonize = false pidfile = "${paths.pid}" -plugin_paths = { } +plugin_paths = { "${paths.modules}" } data_path = "${paths.data}" interfaces = { "127.0.0.1" } c2s_ports = { } @@ -170,6 +200,9 @@ VirtualHost "localhost" Component "room.localhost" "muc" restrict_room_creation = "local" + modules_enabled = { +${mucModulesEnabled} + } muc_room_locking = false muc_tombstones = false muc_room_default_language = "en" @@ -180,7 +213,9 @@ Component "room.localhost" "muc" muc_room_default_public_jids = false muc_room_default_change_subject = false muc_room_default_history_length = 20 +${mucModulesOptions} ` + return { content, paths,