Chat Federation (and a lot more) WIP:

Note: websocket s2s is not working yet, still WIP.

New Features

* Chat Federation:
  * You can now connect to a remote chat with your local account.
  * This remote connection is done using a custom implementation of [XEP-0468: WebSocket S2S](https://xmpp.org/extensions/xep-0468.html), using some specific discovering method (so that it will work without any DNS configuration).

Minor changes and fixes

* Possibility to debug Prosody in development environments.
* Using process.spawn instead of process.exec to launch Prosody (safer, and more optimal).
* Prosody AppImage: fix path mapping: we only map necessary /etc/ subdir, so that the AppImage can access to /etc/resolv.conf, /etc/hosts, ...
* Prosody AppImage: hidden debug mode to disable lua-unbound, that seems broken in some docker dev environments.
This commit is contained in:
John Livingston
2023-05-19 12:52:52 +02:00
parent 1174f661be
commit 9a2da60b7d
27 changed files with 1592 additions and 106 deletions

View File

@ -2,6 +2,11 @@ import type { RegisterServerOptions } from '@peertube/peertube-types'
import * as path from 'path'
import * as fs from 'fs'
/**
* Check if debug mode is enabled
* @param options server options
* @returns true if debug mode enabled
*/
export function isDebugMode (options: RegisterServerOptions): boolean {
const peertubeHelpers = options.peertubeHelpers
const logger = peertubeHelpers.logger
@ -17,3 +22,84 @@ export function isDebugMode (options: RegisterServerOptions): boolean {
}
return false
}
interface ProsodyDebuggerOptions {
mobdebugPath: string
mobdebugHost: string
mobdebugPort: string
}
/**
* On dev environnement, it is possible to enable a Lua debugger.
* @param options server options
* @returns false if we dont use the Prosody debugger. Else the need information to launch the debugger.
*/
export function prosodyDebuggerOptions (options: RegisterServerOptions): false | ProsodyDebuggerOptions {
if (process.env.NODE_ENV !== 'dev') { return false }
if (!isDebugMode(options)) { return false }
const peertubeHelpers = options.peertubeHelpers
const logger = peertubeHelpers.logger
try {
const filepath = path.resolve(peertubeHelpers.plugin.getDataDirectoryPath(), 'debug_mode')
const content = fs.readFileSync(filepath).toString()
if (!content) { return false }
const json = JSON.parse(content)
if (!json) { return false }
if (typeof json !== 'object') { return false }
if (!json.debug_prosody) { return false }
if (typeof json.debug_prosody !== 'object') { return false }
if (!json.debug_prosody.debugger_path) { return false }
if (typeof json.debug_prosody.debugger_path !== 'string') { return false }
const mobdebugPath = json.debug_prosody.debugger_path
if (!fs.statSync(mobdebugPath).isDirectory()) {
logger.error('The should be a debugger, but cant find it. Path should be: ', mobdebugPath)
return false
}
const mobdebugHost = json.debug_prosody.host?.toString() || 'localhost'
const mobdebugPort = json.debug_prosody.port?.toString() || '8172'
return {
mobdebugPath,
mobdebugHost,
mobdebugPort
}
} catch (err) {
logger.error('Failed to read the debug_mode file content:', err)
return false
}
}
/**
* In some dev environment, Prosody will fail DNS queries when using Lua-unbound.
* I did not managed to properly configure lua-unbound.
* So, here is a dirty hack to disable lua-unbound: just put a `no_lua_unbound`
* file in the plugin data dir. This will delete the lua file from the AppImage extraction.
* You must restart Peertube after adding or deleting this file.
* @param options server options
* @param squashfsPath the folder where the AppImage is extracted
*/
export function disableLuaUnboundIfNeeded (options: RegisterServerOptions, squashfsPath: string): void {
const peertubeHelpers = options.peertubeHelpers
const logger = peertubeHelpers.logger
if (!peertubeHelpers.plugin) {
return
}
const filepath = path.resolve(peertubeHelpers.plugin.getDataDirectoryPath(), 'no_lua_unbound')
logger.debug('Testing if file exists: ' + filepath)
if (!fs.existsSync(filepath)) {
return
}
logger.info('Must disable lua-unbound.')
try {
for (const luaVersion of ['5.1', '5.2', '5.3', '5.4']) {
const fp = path.resolve(squashfsPath, 'squashfs-root/usr/lib/x86_64-linux-gnu/lua/', luaVersion, 'lunbound.so')
if (fs.existsSync(fp)) {
fs.rmSync(fp)
}
}
} catch (err) {
logger.error(err)
}
}

View File

@ -36,6 +36,7 @@ function remoteAuthenticatedConnectionEnabled (livechatInfos: LiveChatJSONLDAttr
if (!livechatInfos.links) { return false }
if (livechatInfos.type !== 'xmpp') { return false }
for (const link of livechatInfos.links) {
if (link.type === 'xmpp-peertube-livechat-ws-s2s') { return true }
if (link.type === 'xmpp-s2s') { return true }
}
return false

View File

@ -1,6 +1,6 @@
import type { RegisterServerOptions } from '@peertube/peertube-types'
import type { RemoteVideoHandlerParams } from './types'
import { storeVideoLiveChatInfos } from './storage'
import { storeVideoLiveChatInfos, storeRemoteServerInfos } from './storage'
import { sanitizePeertubeLiveChatInfos } from './sanitize'
/**
@ -19,6 +19,9 @@ async function readIncomingAPVideo (
peertubeLiveChat = sanitizePeertubeLiveChatInfos(peertubeLiveChat)
await storeVideoLiveChatInfos(options, video, peertubeLiveChat)
if (video.remote) {
await storeRemoteServerInfos(options, peertubeLiveChat)
}
}
export {

View File

@ -2,7 +2,7 @@ import type { RegisterServerOptions, VideoObject } from '@peertube/peertube-type
import type { LiveChatVideoObject, VideoBuildResultContext, LiveChatJSONLDLink, LiveChatJSONLDAttribute } from './types'
import { storeVideoLiveChatInfos } from './storage'
import { videoHasWebchat } from '../../../shared/lib/video'
import { getBoshUri, getWSUri } from '../uri/webchat'
import { getBoshUri, getWSUri, getWSS2SUri } from '../uri/webchat'
import { canonicalizePluginUri } from '../uri/canonicalize'
import { getProsodyDomain } from '../prosody/config/domain'
import { fillVideoCustomFields } from '../custom-fields'
@ -32,7 +32,8 @@ async function videoBuildJSONLD (
'prosody-room-type',
'federation-dont-publish-remotely',
'chat-no-anonymous',
'prosody-room-allow-s2s'
'prosody-room-allow-s2s',
'prosody-s2s-port'
])
if (settings['federation-dont-publish-remotely']) {
@ -71,9 +72,23 @@ async function videoBuildJSONLD (
}
const links: LiveChatJSONLDLink[] = []
if (!settings['federation-dont-publish-remotely']) {
const wsS2SUri = getWSS2SUri(options)
if (wsS2SUri) {
links.push({
type: 'xmpp-peertube-livechat-ws-s2s',
url: canonicalizePluginUri(options, wsS2SUri, {
removePluginVersion: true,
protocol: 'ws'
})
})
}
}
if (settings['prosody-room-allow-s2s']) {
links.push({
type: 'xmpp-s2s'
type: 'xmpp-s2s',
host: prosodyDomain,
port: (settings['prosody-s2s-port'] as string) ?? ''
})
}
if (!settings['chat-no-anonymous']) {

View File

@ -37,8 +37,34 @@ function sanitizePeertubeLiveChatInfos (chatInfos: any): LiveChatJSONLDAttribute
})
}
if (link.type === 'xmpp-s2s') {
if (!/^\d+$/.test(link.port)) {
continue
}
const host = _validateHost(link.host)
if (!host) {
continue
}
r.links.push({
type: link.type
type: link.type,
host,
port: link.port
})
}
if (link.type === 'xmpp-peertube-livechat-ws-s2s') {
if ((typeof link.url) !== 'string') { continue }
if (
!_validUrl(link.url, {
noSearchParams: true,
protocol: 'ws.'
})
) {
continue
}
r.links.push({
type: link.type,
url: link.url
})
}
}
@ -81,6 +107,16 @@ function _validUrl (s: string, constraints: URLConstraints): boolean {
return true
}
function _validateHost (s: string): false | string {
try {
if (s.includes('/')) { return false }
const url = new URL('http://' + s)
return url.hostname
} catch (_err) {
return false
}
}
export {
sanitizePeertubeLiveChatInfos
}

View File

@ -1,5 +1,5 @@
import type { RegisterServerOptions, MVideoFullLight, MVideoAP, Video, MVideoThumbnail } from '@peertube/peertube-types'
import type { LiveChatJSONLDAttribute } from './types'
import type { LiveChatJSONLDAttribute, LiveChatJSONLDS2SLink, LiveChatJSONLDPeertubeWSS2SLink } from './types'
import { sanitizePeertubeLiveChatInfos } from './sanitize'
import { URL } from 'url'
import * as fs from 'fs'
@ -97,6 +97,60 @@ async function getVideoLiveChatInfos (
return r
}
/**
* When receiving livechat information for remote servers, we store some information
* about remote server capatibilities: has it s2s enabled? can it proxify s2s in Peertube?
* These information can then be read by Prosody module mod_s2s_peertubelivechat.
*
* We simply store the more recent informations. Indeed, it should be consistent between videos.
* @param options server optiosn
* @param liveChatInfos livechat stored data
*/
async function storeRemoteServerInfos (
options: RegisterServerOptions,
liveChatInfos: LiveChatJSONLDAttribute
): Promise<void> {
if (!liveChatInfos) { return }
const logger = options.peertubeHelpers.logger
const roomJID = liveChatInfos.jid
const host = roomJID.split('@')[1]
if (!host) {
logger.error(`Room JID seems not correct, no host: ${roomJID}`)
return
}
if (host.includes('..')) {
logger.error(`Room host seems not correct, contains ..: ${host}`)
return
}
const dir = path.resolve(
options.peertubeHelpers.plugin.getDataDirectoryPath(),
'serverInfos',
host
)
const s2sFilePath = path.resolve(dir, 's2s')
const wsS2SFilePath = path.resolve(dir, 'ws-s2s')
const s2sLink = liveChatInfos.links.find(v => v.type === 'xmpp-s2s')
if (s2sLink) {
await _store(options, s2sFilePath, {
host: (s2sLink as LiveChatJSONLDS2SLink).host,
port: (s2sLink as LiveChatJSONLDS2SLink).port
})
} else {
await _del(options, s2sFilePath)
}
const wsS2SLink = liveChatInfos.links.find(v => v.type === 'xmpp-peertube-livechat-ws-s2s')
if (wsS2SLink) {
await _store(options, wsS2SFilePath, {
url: (wsS2SLink as LiveChatJSONLDPeertubeWSS2SLink).url
})
} else {
await _del(options, wsS2SFilePath)
}
}
async function _getFilePath (
options: RegisterServerOptions,
remote: boolean,
@ -152,13 +206,22 @@ async function _del (options: RegisterServerOptions, filePath: string): Promise<
async function _store (options: RegisterServerOptions, filePath: string, content: any): Promise<void> {
const logger = options.peertubeHelpers.logger
try {
const jsonContent = JSON.stringify(content)
if (!fs.existsSync(filePath)) {
const dir = path.dirname(filePath)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
} else {
// only write if the content is different
try {
const currentJSONContent = await fs.promises.readFile(filePath, {
encoding: 'utf-8'
})
if (currentJSONContent === jsonContent) { return }
} catch (_err) {}
}
await fs.promises.writeFile(filePath, JSON.stringify(content), {
await fs.promises.writeFile(filePath, jsonContent, {
encoding: 'utf-8'
})
} catch (err) {
@ -182,7 +245,16 @@ async function _get (options: RegisterServerOptions, filePath: string): Promise<
}
}
function getRemoteServerInfosDir (options: RegisterServerOptions): string {
return path.resolve(
options.peertubeHelpers.plugin.getDataDirectoryPath(),
'serverInfos'
)
}
export {
storeVideoLiveChatInfos,
getVideoLiveChatInfos
storeRemoteServerInfos,
getVideoLiveChatInfos,
getRemoteServerInfosDir
}

View File

@ -4,8 +4,15 @@ interface VideoBuildResultContext {
video: MVideoAP
}
interface LiveChatJSONLDPeertubeWSS2SLink {
type: 'xmpp-peertube-livechat-ws-s2s'
url: string
}
interface LiveChatJSONLDS2SLink {
type: 'xmpp-s2s'
host: string
port: string
}
interface LiveChatJSONLDAnonymousWebsocketLink {
@ -20,7 +27,11 @@ interface LiveChatJSONLDAnonymousBOSHLink {
jid: string
}
type LiveChatJSONLDLink = LiveChatJSONLDS2SLink | LiveChatJSONLDAnonymousBOSHLink | LiveChatJSONLDAnonymousWebsocketLink
type LiveChatJSONLDLink =
LiveChatJSONLDPeertubeWSS2SLink
| LiveChatJSONLDS2SLink
| LiveChatJSONLDAnonymousBOSHLink
| LiveChatJSONLDAnonymousWebsocketLink
interface LiveChatJSONLDInfos {
type: 'xmpp'
@ -42,6 +53,8 @@ interface RemoteVideoHandlerParams {
export {
VideoBuildResultContext,
LiveChatJSONLDLink,
LiveChatJSONLDS2SLink,
LiveChatJSONLDPeertubeWSS2SLink,
LiveChatJSONLDInfos,
LiveChatJSONLDAttribute,
LiveChatVideoObject,

View File

@ -8,6 +8,7 @@ import { ConfigLogExpiration, ProsodyConfigContent } from './config/content'
import { getProsodyDomain } from './config/domain'
import { getAPIKey } from '../apikey'
import { parseExternalComponents } from './config/components'
import { getRemoteServerInfosDir } from '../federation/storage'
async function getWorkingDir (options: RegisterServerOptions): Promise<string> {
const peertubeHelpers = options.peertubeHelpers
@ -64,26 +65,24 @@ async function getProsodyFilePaths (options: RegisterServerOptions): Promise<Pro
let certsDir: string | undefined = path.resolve(dir, 'certs')
let certsDirIsCustom = false
if (settings['prosody-room-allow-s2s']) {
if ((settings['prosody-certificates-dir'] as string ?? '') !== '') {
if (!fs.statSync(settings['prosody-certificates-dir'] as string).isDirectory()) {
// We can throw an exception here...
// Because if the user input a wrong directory, the plugin will not register,
// and he will never be able to fix the conf
logger.error('Certificate directory does not exist or is not a directory')
certsDir = undefined
} else {
certsDir = settings['prosody-certificates-dir'] as string
}
certsDirIsCustom = true
if (settings['prosody-room-allow-s2s'] && (settings['prosody-certificates-dir'] as string ?? '') !== '') {
if (!fs.statSync(settings['prosody-certificates-dir'] as string).isDirectory()) {
// We can throw an exception here...
// Because if the user input a wrong directory, the plugin will not register,
// and he will never be able to fix the conf
logger.error('Certificate directory does not exist or is not a directory')
certsDir = undefined
} else {
// In this case we are generating and using self signed certificates
// Note: when using prosodyctl to generate self-signed certificates,
// there are wrongly generated in the data dir.
// So we will use this dir as the certs dir.
certsDir = path.resolve(dir, 'data')
certsDir = settings['prosody-certificates-dir'] as string
}
certsDirIsCustom = true
} else {
// In this case we are generating and using self signed certificates
// Note: when using prosodyctl to generate self-signed certificates,
// there are wrongly generated in the data dir.
// So we will use this dir as the certs dir.
certsDir = path.resolve(dir, 'data')
}
return {
@ -139,7 +138,8 @@ async function getProsodyConfig (options: RegisterServerOptionsV5): Promise<Pros
'prosody-components',
'prosody-components-port',
'prosody-components-list',
'chat-no-anonymous'
'chat-no-anonymous',
'federation-dont-publish-remotely'
])
const valuesToHideInDiagnostic = new Map<string, string>()
@ -151,23 +151,27 @@ async function getProsodyConfig (options: RegisterServerOptionsV5): Promise<Pros
const disableAnon = (settings['chat-no-anonymous'] as boolean) || false
const logExpirationSetting = (settings['prosody-muc-expiration'] as string) ?? DEFAULTLOGEXPIRATION
const enableC2S = (settings['prosody-c2s'] as boolean) || false
// enableRoomS2S: room can be joined from remote XMPP servers (Peertube or not)
const enableRoomS2S = (settings['prosody-room-allow-s2s'] as boolean) || false
const enableComponents = (settings['prosody-components'] as boolean) || false
const prosodyDomain = await getProsodyDomain(options)
const paths = await getProsodyFilePaths(options)
const roomType = settings['prosody-room-type'] === 'channel' ? 'channel' : 'video'
const enableUserS2S = enableRoomS2S && !(settings['federation-no-remote-chat'] as boolean)
// enableRemoteChatConnections: local users can communicate with external rooms
const enableRemoteChatConnections = !(settings['federation-dont-publish-remotely'] as boolean)
let certificates: ProsodyConfigCertificates = false
const apikey = await getAPIKey(options)
valuesToHideInDiagnostic.set('APIKey', apikey)
const publicServerUrl = options.peertubeHelpers.config.getWebserverUrl()
let basePeertubeUrl = settings['prosody-peertube-uri'] as string
if (basePeertubeUrl && !/^https?:\/\/[a-z0-9.-_]+(?::\d+)?$/.test(basePeertubeUrl)) {
throw new Error('Invalid prosody-peertube-uri')
}
if (!basePeertubeUrl) {
basePeertubeUrl = options.peertubeHelpers.config.getWebserverUrl()
basePeertubeUrl = publicServerUrl
}
const baseApiUrl = basePeertubeUrl + getBaseRouterRoute(options) + 'api/'
@ -181,7 +185,7 @@ async function getProsodyConfig (options: RegisterServerOptionsV5): Promise<Pros
}
config.useHttpAuthentication(authApiUrl)
const useWS = !!options.registerWebSocketRoute // this comes with Peertube >=5.0.0, and is a prerequisite to websocket
config.usePeertubeBoshAndWebsocket(prosodyDomain, port, options.peertubeHelpers.config.getWebserverUrl(), useWS)
config.usePeertubeBoshAndWebsocket(prosodyDomain, port, publicServerUrl, useWS)
config.useMucHttpDefault(roomApiUrl)
if (enableC2S) {
@ -204,27 +208,33 @@ async function getProsodyConfig (options: RegisterServerOptionsV5): Promise<Pros
config.useExternalComponents(componentsPort, components)
}
if (enableRoomS2S || enableUserS2S) {
if (enableRoomS2S || enableRemoteChatConnections) {
certificates = 'generate-self-signed'
if (config.paths.certsDirIsCustom) {
certificates = 'use-from-dir'
}
const s2sPort = (settings['prosody-s2s-port'] as string) || '5269'
if (!/^\d+$/.test(s2sPort)) {
throw new Error('Invalid s2s port')
let s2sPort, s2sInterfaces
if (enableRoomS2S) {
s2sPort = (settings['prosody-s2s-port'] as string) || '5269'
if (!/^\d+$/.test(s2sPort)) {
throw new Error('Invalid s2s port')
}
s2sInterfaces = ((settings['prosody-s2s-interfaces'] as string) || '')
.split(',')
.map(s => s.trim())
// Check that there is no invalid values (to avoid injections):
s2sInterfaces.forEach(networkInterface => {
if (networkInterface === '*') return
if (networkInterface === '::') return
if (networkInterface.match(/^\d+\.\d+\.\d+\.\d+$/)) return
if (networkInterface.match(/^[a-f0-9:]+$/)) return
throw new Error('Invalid s2s interfaces')
})
} else {
s2sPort = null
s2sInterfaces = null
}
const s2sInterfaces = ((settings['prosody-s2s-interfaces'] as string) || '')
.split(',')
.map(s => s.trim())
// Check that there is no invalid values (to avoid injections):
s2sInterfaces.forEach(networkInterface => {
if (networkInterface === '*') return
if (networkInterface === '::') return
if (networkInterface.match(/^\d+\.\d+\.\d+\.\d+$/)) return
if (networkInterface.match(/^[a-f0-9:]+$/)) return
throw new Error('Invalid s2s interfaces')
})
config.useS2S(s2sPort, s2sInterfaces, !enableUserS2S)
config.useS2S(s2sPort, s2sInterfaces, publicServerUrl, getRemoteServerInfosDir(options))
}
const logExpiration = readLogExpiration(options, logExpirationSetting)

View File

@ -72,6 +72,18 @@ abstract class ProsodyConfigBlock {
this.entries.set(name, entry)
}
remove (name: string, value: ConfigEntryValue): void {
if (!this.entries.has(name)) {
return
}
let entry = this.entries.get(name) as ConfigEntryValue
if (!Array.isArray(entry)) {
entry = [entry]
}
entry = entry.filter(v => v !== value)
this.entries.set(name, entry)
}
write (): string {
let content = ''
// Map keeps order :)
@ -258,17 +270,35 @@ class ProsodyConfigContent {
this.global.set('c2s_ports', [c2sPort])
}
useS2S (s2sPort: string, s2sInterfaces: string[], mucOnly: boolean): void {
this.global.set('s2s_ports', [s2sPort])
this.global.set('s2s_interfaces', s2sInterfaces)
this.global.set('s2s_secure_auth', false)
this.global.add('modules_enabled', 'tls') // required for s2s and co
this.muc.add('modules_enabled', 's2s')
this.muc.add('modules_enabled', 'dialback') // This allows s2s connections without certicicates!
if (!mucOnly && this.authenticated) {
this.authenticated.add('modules_enabled', 's2s')
this.authenticated.add('modules_enabled', 'dialback') // This allows s2s connections without certicicates!
useS2S (
s2sPort: string | null,
s2sInterfaces: string[] | null,
publicServerUrl: string,
serverInfosDir: string
): void {
if (s2sPort !== null) {
this.global.set('s2s_ports', [s2sPort])
} else {
this.global.set('s2s_ports', [])
}
if (s2sInterfaces !== null) {
this.global.set('s2s_interfaces', s2sInterfaces)
} else {
this.global.set('s2s_interfaces', [])
}
this.global.set('s2s_secure_auth', false)
this.global.remove('modules_disabled', 's2s')
this.global.add('modules_enabled', 's2s')
this.global.add('modules_enabled', 'tls') // required for s2s and co
this.global.add('modules_enabled', 's2s_peertubelivechat')
this.global.set('peertubelivechat_server_infos_path', serverInfosDir)
this.global.set('peertubelivechat_instance_url', publicServerUrl)
this.global.add('modules_enabled', 'websocket_s2s_peertubelivechat')
this.muc.add('modules_enabled', 'dialback') // This allows s2s connections without certicicates!
this.authenticated?.add('modules_enabled', 'dialback') // This allows s2s connections without certicicates!
}
useExternalComponents (componentsPort: string, components: ExternalComponent[]): void {

View File

@ -8,6 +8,7 @@ import { disableProxyRoute, enableProxyRoute } from '../routers/webchat'
import { fixRoomSubject } from './fix-room-subject'
import * as fs from 'fs'
import * as child_process from 'child_process'
import { disableLuaUnboundIfNeeded, prosodyDebuggerOptions } from '../../lib/debug'
async function _ensureWorkingDir (
options: RegisterServerOptions,
@ -98,6 +99,7 @@ async function prepareProsody (options: RegisterServerOptions): Promise<void> {
})
spawned.on('error', reject)
spawned.on('close', (_code) => { // 'close' and not 'exit', to be sure it is finished.
disableLuaUnboundIfNeeded(options, filePaths.appImageExtractPath)
resolve()
})
})
@ -275,20 +277,28 @@ async function testProsodyCorrectlyRunning (options: RegisterServerOptions): Pro
return result
}
async function ensureProsodyRunning (options: RegisterServerOptions): Promise<void> {
async function ensureProsodyRunning (
options: RegisterServerOptions,
forceRestart?: boolean,
restartProsodyInDebugMode?: boolean
): Promise<void> {
const { peertubeHelpers } = options
const logger = peertubeHelpers.logger
logger.debug('Calling ensureProsodyRunning')
const r = await testProsodyCorrectlyRunning(options)
if (r.ok) {
r.messages.forEach(m => logger.debug(m))
logger.info('Prosody is already running correctly')
// Stop here. Nothing to change.
return
if (forceRestart) {
logger.info('We want to force Prosody restart, skip checking the current state')
} else {
const r = await testProsodyCorrectlyRunning(options)
if (r.ok) {
r.messages.forEach(m => logger.debug(m))
logger.info('Prosody is already running correctly')
// Stop here. Nothing to change.
return
}
logger.info('Prosody is not running correctly: ')
r.messages.forEach(m => logger.info(m))
}
logger.info('Prosody is not running correctly: ')
r.messages.forEach(m => logger.info(m))
// Shutting down...
logger.debug('Shutting down prosody')
@ -309,9 +319,30 @@ async function ensureProsodyRunning (options: RegisterServerOptions): Promise<vo
await ensureProsodyCertificates(options, config)
// launch prosody
const execCmd = filePaths.exec + (filePaths.execArgs.length ? ' ' + filePaths.execArgs.join(' ') : '')
logger.info('Going to launch prosody (' + execCmd + ')')
const prosody = child_process.exec(execCmd, {
let execArgs: string[] = filePaths.execArgs
if (restartProsodyInDebugMode) {
if (!filePaths.exec.includes('squashfs-root')) {
logger.error('Trying to enable the Prosody Debugger, but not using the AppImage. Cant work.')
} else {
const debuggerOptions = prosodyDebuggerOptions(options)
if (debuggerOptions) {
execArgs = [
'debug',
debuggerOptions.mobdebugPath,
debuggerOptions.mobdebugHost,
debuggerOptions.mobdebugPort,
...execArgs
]
}
}
}
logger.info(
'Going to launch prosody (' +
filePaths.exec +
(execArgs.length ? ' ' + execArgs.join(' ') : '') +
')'
)
const prosody = child_process.spawn(filePaths.exec, execArgs, {
cwd: filePaths.dir,
env: {
...process.env,

View File

@ -9,6 +9,8 @@ import { Affiliations, getVideoAffiliations, getChannelAffiliations } from '../p
import { getProsodyDomain } from '../prosody/config/domain'
import { fillVideoCustomFields } from '../custom-fields'
import { getChannelInfosById } from '../database/channel'
import { ensureProsodyRunning } from '../prosody/ctl'
import { isDebugMode } from '../debug'
// See here for description: https://modules.prosody.im/mod_muc_http_defaults.html
interface RoomDefaults {
@ -222,6 +224,33 @@ async function initApiRouter (options: RegisterServerOptions): Promise<Router> {
}
))
// router.get('/federation_server_infos', asyncMiddleware(
// async (req: Request, res: Response, _next: NextFunction) => {
// logger.info('federation_server_infos api call')
// // TODO/FIXME: return server infos.
// // TODO/FIXME: store these informations on the other side.
// res.json({ ok: true })
// }
// ))
if (isDebugMode(options)) {
// Only add this route if the debug mode is enabled at time of the server launch.
// Note: the isDebugMode will be tested again when the API is called.
// Note: we dont authenticate the user. We want this API to be callable from debug tools.
// This should not be an issue, as debug_mode should only be available on dev environments.
router.get('/restart_prosody', asyncMiddleware(
async (req: Request, res: Response, _next: NextFunction) => {
if (!isDebugMode(options)) {
res.json({ ok: false })
return
}
const restartProsodyInDebugMode = req.query.debugger === 'true'
await ensureProsodyRunning(options, true, restartProsodyInDebugMode)
res.json({ ok: true })
}
))
}
return router
}

View File

@ -16,6 +16,8 @@ import { getBoshUri, getWSUri } from '../uri/webchat'
import { getVideoLiveChatInfos } from '../federation/storage'
import { LiveChatJSONLDAttribute } from '../federation/types'
import { anonymousConnectionInfos, remoteAuthenticatedConnectionEnabled } from '../federation/connection-infos'
// import { XMPPWsProxyServer } from '../xmpp-ws-proxy/server'
// import { checkRemote } from '../xmpp-ws-proxy/check-remote'
import * as path from 'path'
const got = require('got')
@ -28,6 +30,7 @@ interface ProsodyProxyInfo {
let currentProsodyProxyInfo: ProsodyProxyInfo | null = null
let currentHttpBindProxy: ReturnType<typeof createProxyServer> | null = null
let currentWebsocketProxy: ReturnType<typeof createProxyServer> | null = null
let currentS2SWebsocketProxy: ReturnType<typeof createProxyServer> | null = null
async function initWebchatRouter (options: RegisterServerOptionsV5): Promise<Router> {
const {
@ -202,13 +205,9 @@ async function initWebchatRouter (options: RegisterServerOptionsV5): Promise<Rou
page = page.replace(/{{REMOTE_BOSH_SERVICE_URL}}/g, remoteConnectionInfos?.anonymous?.boshUri ?? '')
page = page.replace(/{{REMOTE_WS_SERVICE_URL}}/g, remoteConnectionInfos?.anonymous?.wsUri ?? '')
page = page.replace(/{{REMOTE_ANONYMOUS_XMPP_SERVER}}/g, remoteConnectionInfos?.anonymous ? 'true' : 'false')
// Note: to be able to connect to remote XMPP server, with a local account,
// we must enable prosody-room-allow-s2s
// (which is required, so we can use outgoing S2S from the authenticated virtualhost).
// TODO: There should be another settings, rather than prosody-room-allow-s2s
page = page.replace(
/{{REMOTE_AUTHENTICATED_XMPP_SERVER}}/g,
settings['prosody-room-allow-s2s'] && remoteConnectionInfos?.authenticated ? 'true' : 'false'
remoteConnectionInfos?.authenticated ? 'true' : 'false'
)
page = page.replace(/{{AUTHENTICATION_URL}}/g, authenticationUrl)
page = page.replace(/{{AUTOVIEWERMODE}}/g, autoViewerMode ? 'true' : 'false')
@ -270,6 +269,29 @@ async function initWebchatRouter (options: RegisterServerOptionsV5): Promise<Rou
currentWebsocketProxy.ws(request, socket, head)
}
})
registerWebSocketRoute({
route: '/xmpp-websocket-s2s',
handler: (request, socket, head) => {
if (!currentS2SWebsocketProxy) {
peertubeHelpers.logger.error('There is no current websocket s2s proxy, should not get here.')
// no need to close the socket, Peertube will
// (see https://github.com/Chocobozzz/PeerTube/issues/5752#issuecomment-1510870894)
return
}
currentS2SWebsocketProxy.ws(request, socket, head)
}
})
// registerWebSocketRoute({
// route: '/xmpp-websocket-proxy',
// handler: async (request, socket, head) => {
// const remoteInstanceUrl = request.headers['peertube-livechat-ws-proxy-instance-url']
// if (!await checkRemote(options, remoteInstanceUrl)) {
// return
// }
// XMPPWsProxyServer.singleton(options).handleUpgrade(request, socket, head)
// }
// })
}
router.get('/prosody-list-rooms', asyncMiddleware(
@ -345,6 +367,11 @@ async function disableProxyRoute ({ peertubeHelpers }: RegisterServerOptions): P
currentWebsocketProxy.close()
currentWebsocketProxy = null
}
if (currentS2SWebsocketProxy) {
peertubeHelpers.logger.info('Closing the s2s websocket proxy...')
currentS2SWebsocketProxy.close()
currentS2SWebsocketProxy = null
}
} catch (err) {
peertubeHelpers.logger.error('Seems that the http bind proxy close has failed: ' + (err as string))
}
@ -403,6 +430,28 @@ async function enableProxyRoute (
currentWebsocketProxy.on('close', () => {
logger.info('Got a close event for the websocket proxy')
})
logger.info('Creating a new s2s websocket proxy')
currentS2SWebsocketProxy = createProxyServer({
target: 'http://localhost:' + prosodyProxyInfo.port + '/xmpp-websocket-s2s',
ignorePath: true,
ws: true
})
currentS2SWebsocketProxy.on('error', (err, req, res) => {
// We must handle errors, otherwise Peertube server crashes!
logger.error(
'The s2s websocket proxy got an error ' +
'(this can be normal if you updated/uninstalled the plugin, or shutdowned peertube while users were chatting): ' +
err.message
)
if ('writeHead' in res) {
res.writeHead(500)
}
res.end('')
})
currentS2SWebsocketProxy.on('close', () => {
logger.info('Got a close event for the s2s websocket proxy')
})
}
interface WCRemoteConnectionInfos {

View File

@ -11,3 +11,9 @@ export function getWSUri (options: RegisterServerOptions): string | undefined {
if (base === undefined) { return undefined }
return base + 'xmpp-websocket'
}
export function getWSS2SUri (options: RegisterServerOptions): string | undefined {
const base = getBaseWebSocketRoute(options) // can be undefined if Peertube is too old
if (base === undefined) { return undefined }
return base + 'xmpp-websocket-s2s'
}

View File

@ -0,0 +1,73 @@
import type { RegisterServerOptions } from '@peertube/peertube-types'
// import { getBaseRouterRoute } from '../helpers'
// import { canonicalizePluginUri } from '../uri/canonicalize'
// import { URL } from 'url'
// const got = require('got')
/**
* FIXME: this method should not be necessary anymore, it was a proof of concept.
*
* This function checks that there is a valid Peertube instance behind
* the remote url, to avoid spoofing.
* It also ensure that we have needed serverInfos for the federation
* (so we can also open outgoing proxyfied connection to that instance)
* @param options server options
* @param remoteInstanceUrl remote instance url to check (as readed in the request header)
* @returns true if the remote instance is ok
*/
async function checkRemote (
_options: RegisterServerOptions,
_remoteInstanceUrl: any
): Promise<boolean> {
throw new Error('Not Implemented Yet')
// const logger = options.peertubeHelpers.logger
// if (typeof remoteInstanceUrl !== 'string') {
// logger.info('WS-Proxy-Check: Received invalid request on xmpp-websocket-proxy: invalid remoteInstanceUrl header')
// return false
// }
// logger.debug(
// `WS-Proxy-Check: Receiving request on xmpp-websocket-proxy for host ${remoteInstanceUrl}, ` +
// 'checking the host is a valid Peertube server'
// )
// let url: string
// try {
// const u = new URL(remoteInstanceUrl)
// // Assuming that the path on the remote instance is the same as on this one
// // (but canonicalized to remove the plugin version)
// u.pathname = getBaseRouterRoute(options) + 'api/federation_server_infos'
// url = canonicalizePluginUri(options, u.toString(), {
// protocol: 'http',
// removePluginVersion: true
// })
// } catch (_err) {
// logger.info('WS-Proxy-Check: Invalid remote instance url provided: ' + remoteInstanceUrl)
// return false
// }
// try {
// logger.debug('WS-Proxy-Check: We must check remote server infos using url: ' + url)
// const response = await got(url, {
// method: 'GET',
// headers: {},
// responseType: 'json'
// }).json()
// if (!response) {
// logger.info('WS-Proxy-Check: Invalid remote server options')
// return false
// }
// // FIXME/TODO
// return true
// } catch (_err) {
// logger.info('WS-Proxy-Check: Can\'t get remote instance informations using url ' + url)
// return false
// }
}
export {
checkRemote
}

View File

@ -0,0 +1,119 @@
// import type { RegisterServerOptions } from '@peertube/peertube-types'
// import type { IncomingMessage } from 'http'
// import type { Duplex } from 'stream'
// import { WebSocketServer, WebSocket } from 'ws'
// import { Socket } from 'net'
// FIXME: this method should not be necessary anymore, it was a proof of concept.
// interface ProxyLogger {
// debug: (s: string) => void
// info: (s: string) => void
// error: (s: string) => void
// }
// let xmppWsProxyServer: XMPPWsProxyServer | undefined
// class XMPPWsProxyServer {
// private readonly logger: ProxyLogger
// private readonly options: RegisterServerOptions
// private readonly wsProxyServer: WebSocketServer
// private prosodyPort: number | undefined
// private readonly connections: Map<WebSocket, Socket> = new Map<WebSocket, Socket>()
// constructor (options: RegisterServerOptions) {
// const logger = options.peertubeHelpers.logger
// this.logger = {
// debug: s => logger.debug('XMPP-WS-PROXY: ' + s),
// info: s => logger.info('XMPP-WS-PROXY: ' + s),
// error: s => logger.error('XMPP-WS-PROXY: ' + s)
// }
// this.options = options
// this.wsProxyServer = new WebSocketServer({ noServer: true, perMessageDeflate: false })
// }
// public handleUpgrade (request: IncomingMessage, incomingSocket: Duplex, head: Buffer): void {
// this.wsProxyServer.handleUpgrade(request, incomingSocket, head, ws => {
// this.handleUpgradeCallback(ws).then(() => {}, () => {})
// })
// }
// public async handleUpgradeCallback (ws: WebSocket): Promise<void> {
// this.logger.debug('Opening a Websocket Proxy connection')
// const port = await this.getProsodyPort()
// if (!port) {
// this.logger.error('No port configured for Prosody, closing the websocket stream')
// ws.close() // FIXME: add a code and error message
// return
// }
// // Opening a tcp connection to local Prosody:
// const prosodySocket = new Socket()
// this.connections.set(ws, prosodySocket)
// prosodySocket.connect(port, 'localhost', () => {
// // TODO: write the remote IP in the header line.
// prosodySocket.write('LIVECHAT-WS-PROXY\n')
// })
// prosodySocket.on('close', () => {
// ws.close()
// })
// prosodySocket.on('data', (data) => {
// ws.send(data)
// })
// ws.on('message', (data) => {
// // TODO: remove this log
// this.logger.debug('Receiving raw data')
// if (Array.isArray(data)) {
// data.forEach(chunck => {
// prosodySocket.write(chunck)
// })
// } else if (data instanceof ArrayBuffer) {
// prosodySocket.write(Buffer.from(data))
// } else {
// prosodySocket.write(data)
// }
// })
// ws.on('close', () => {
// this.logger.debug('Websocket connection is closed, closing socket')
// prosodySocket.end()
// })
// }
// private async getProsodyPort (): Promise<number> {
// if (this.prosodyPort) {
// return this.prosodyPort
// }
// const port = await this.options.settingsManager.getSetting('prosody-port') as string
// this.prosodyPort = parseInt(port)
// return this.prosodyPort
// }
// private async closeConnections (): Promise<void> {
// this.logger.debug('Closing XMPPWsProxyServer connections...')
// this.connections.forEach((socket, _ws) => {
// socket.end()
// // ws.terminate() // not necessary, socket close event should be called
// })
// // FIXME: wait for all connections to be closed...
// }
// static singleton (options: RegisterServerOptions): XMPPWsProxyServer {
// if (!xmppWsProxyServer) {
// xmppWsProxyServer = new XMPPWsProxyServer(options)
// }
// return xmppWsProxyServer
// }
// static async destroySingleton (): Promise<void> {
// if (!xmppWsProxyServer) { return }
// const server = xmppWsProxyServer
// xmppWsProxyServer = undefined
// await server.closeConnections()
// }
// }
// export {
// XMPPWsProxyServer
// }