fix webrtc and login
This commit is contained in:
@@ -2,3 +2,4 @@
|
||||
/.idea
|
||||
*.db*
|
||||
/media/*
|
||||
/certs
|
||||
+8
-4
@@ -7,12 +7,16 @@ host = "0.0.0.0"
|
||||
tcp_port = 8080
|
||||
# Voice/Video port
|
||||
udp_port = 8080
|
||||
# If the server is behind NAT, set its public IPv4 here and forward UDP 8080 to it.
|
||||
# external_ip = "203.0.113.1"
|
||||
# STUN is enabled by default; customize or set [] to disable it.
|
||||
# stun_servers = ["stun:stun.l.google.com:19302"]
|
||||
|
||||
# Uncomment for native self-signed HTTPS/WSS. Keep both files between restarts.
|
||||
# [network.tls]
|
||||
# cert_path = "certs/server.pem"
|
||||
# key_path = "certs/server-key.pem"
|
||||
# names = ["localhost", "127.0.0.1"] # include the host clients actually use
|
||||
[network.tls]
|
||||
cert_path = "certs/server.pem"
|
||||
key_path = "certs/server-key.pem"
|
||||
names = ["localhost", "127.0.0.1", "goesseau.eu"] # include the host clients actually use
|
||||
|
||||
[database]
|
||||
# DSN for database
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"eslint-config-vuetify": "^4.3.4",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"sass-embedded": "^1.98.0",
|
||||
"smol-toml": "^1.9.0",
|
||||
"typescript": "~5.9.3",
|
||||
"unplugin-fonts": "^1.4.0",
|
||||
"vite": "^8.0.0",
|
||||
|
||||
@@ -7,8 +7,10 @@ import ContextMenu from "@/components/ContextMenu.vue";
|
||||
import UserListDrawer from '@/components/UserListDrawer.vue'
|
||||
import ServerSettingsDialog from '@/components/server/ServerSettingsDialog.vue'
|
||||
import {useContextMenu} from '@/composables/useContextMenu'
|
||||
import {useVoiceStore} from '@/stores/voice'
|
||||
|
||||
const serverStore = useServerStore()
|
||||
const voiceStore = useVoiceStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const {openContextMenu} = useContextMenu()
|
||||
@@ -212,6 +214,14 @@ function onServerContextMenu(event: MouseEvent, server: Server) {
|
||||
/>
|
||||
|
||||
<router-view/>
|
||||
<div v-if="voiceStore.channelId" class="voice-controls pa-3 elevation-4 bg-surface">
|
||||
<span class="text-success font-weight-bold"><v-icon icon="mdi-volume-high" /> Vocal connecté</span>
|
||||
<v-btn size="small" prepend-icon="mdi-phone-hangup" @click="voiceStore.leave()">Quitter</v-btn>
|
||||
</div>
|
||||
<v-snackbar :model-value="!!voiceStore.error" color="error" @update:model-value="voiceStore.error = null">
|
||||
{{ voiceStore.error }}
|
||||
<template #actions><v-btn @click="voiceStore.error = null">Fermer</v-btn></template>
|
||||
</v-snackbar>
|
||||
<!-- Menu contextuel global -->
|
||||
<ContextMenu/>
|
||||
<v-dialog v-model="showDialog" width="400">
|
||||
@@ -284,6 +294,17 @@ function onServerContextMenu(event: MouseEvent, server: Server) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.voice-controls {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.space-y-4 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -60,7 +60,7 @@ async function handleRegister() {
|
||||
throw new Error(errData.message || 'Échec de l\'inscription')
|
||||
}
|
||||
|
||||
await router.push(serverId ? {name: 'login', query: {redirect: `/server/${serverId}`}} : '/login')
|
||||
await router.push(serverId ? {name: 'login', query: {redirect: `/server/${serverId}`}} : {name: 'login'})
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Une erreur est survenue'
|
||||
} finally {
|
||||
|
||||
@@ -14,7 +14,8 @@ import type {Channel} from '@/stores/channel'
|
||||
import ChannelPermissionsDialog from '@/components/permissions/ChannelPermissionsDialog.vue'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import ServerSettingsDialog from '@/components/server/ServerSettingsDialog.vue'
|
||||
import {onReloadAll} from '@/plugins/events.ts'
|
||||
import {onGatewayEvent, onReloadAll} from '@/plugins/events.ts'
|
||||
import {useVoiceStore} from '@/stores/voice'
|
||||
|
||||
const props = defineProps<{
|
||||
serverId: string
|
||||
@@ -30,6 +31,11 @@ const serverStore = useServerStore()
|
||||
const {currentTree} = storeToRefs(serverStore)
|
||||
const {openContextMenu} = useContextMenu()
|
||||
const authStore = useAuthStore()
|
||||
const voiceStore = useVoiceStore()
|
||||
const voiceChannels = computed<Channel[]>(() => currentTree.value.flatMap(item =>
|
||||
'Category' in item ? item.Category[1].filter((channel: Channel) => channel.channel_type === 'voice') :
|
||||
'Channel' in item && item.Channel.channel_type === 'voice' ? [item.Channel] : [],
|
||||
))
|
||||
const showPermissionsDialog = ref(false)
|
||||
const selectedChannel = ref<any | null>(null)
|
||||
const channelToEdit = ref<Channel | null>(null)
|
||||
@@ -37,6 +43,19 @@ const showEditChannelDialog = ref(false)
|
||||
const showServerSettings = ref(false)
|
||||
const serverName = computed(() => serverStore.servers.find(server => server.id === props.serverId)?.name || 'Serveur')
|
||||
|
||||
function selectChannel(channel: Channel) {
|
||||
if (channel.channel_type === 'voice') {
|
||||
void voiceStore.join(props.serverId, channel.id)
|
||||
if (route.params.channelId) void router.push(`/server/${props.serverId}`)
|
||||
}
|
||||
else void router.push(`/server/${props.serverId}/channel/${channel.id}`)
|
||||
}
|
||||
|
||||
watch([() => props.channelId, voiceChannels], () => {
|
||||
const channel = voiceChannels.value.find(item => item.id === props.channelId)
|
||||
if (channel) selectChannel(channel)
|
||||
})
|
||||
|
||||
|
||||
const loadServerData = async (targetServerId: string) => {
|
||||
if (!targetServerId) return
|
||||
@@ -55,13 +74,27 @@ const loadServerData = async (targetServerId: string) => {
|
||||
}
|
||||
|
||||
let stopReloadAll: (() => void) | null = null
|
||||
let stopVoicePresence: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
stopReloadAll = onReloadAll(() => loadServerData(props.serverId))
|
||||
stopVoicePresence = onGatewayEvent('VoicePresence', ({action, content}) => {
|
||||
const {server_id, channel_id, user} = content ?? {}
|
||||
if (server_id !== props.serverId || !user?.id) return
|
||||
const channel = voiceChannels.value.find(item => item.id === channel_id)
|
||||
if (!channel) return
|
||||
const participants = channel.voice_participants ?? []
|
||||
if (action === 'joined' && !participants.some(participant => participant.id === user.id)) {
|
||||
channel.voice_participants = [...participants, user].sort((a, b) => a.username.localeCompare(b.username))
|
||||
} else if (action === 'left') {
|
||||
channel.voice_participants = participants.filter(participant => participant.id !== user.id)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopReloadAll?.()
|
||||
stopVoicePresence?.()
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -286,17 +319,17 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-for="channel in item.Category[1]" :key="channel.id">
|
||||
<v-list-item
|
||||
v-for="channel in item.Category[1]"
|
||||
:key="channel.id"
|
||||
:title="channel.name"
|
||||
:to="`/server/${serverId}/channel/${channel.id}`"
|
||||
:active="voiceStore.channelId === channel.id"
|
||||
:class="{
|
||||
'font-weight-bold': (channel.unread_count ?? 0) > 0,
|
||||
'server-item-drop-before': isDropTarget(channel.id, 'before'),
|
||||
'server-item-drop-after': isDropTarget(channel.id, 'after'),
|
||||
}"
|
||||
link
|
||||
@click="selectChannel(channel)"
|
||||
draggable="true"
|
||||
@dragstart="startDragging($event, channel.id, 'channel')"
|
||||
@dragover="dragOver($event, channel.id, 'channel', item.Category[0].id)"
|
||||
@@ -304,6 +337,7 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
||||
@dragend="stopDragging"
|
||||
@contextmenu="onChannelContextMenu($event, channel)"
|
||||
>
|
||||
<template #prepend><v-icon :icon="channel.channel_type === 'voice' ? 'mdi-volume-high' : 'mdi-pound'" /></template>
|
||||
<template #append>
|
||||
<v-chip
|
||||
v-if="(channel.unread_count ?? 0) > 0"
|
||||
@@ -316,20 +350,27 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<div v-if="channel.channel_type === 'voice'" class="voice-participants">
|
||||
<div v-for="participant in channel.voice_participants ?? []" :key="participant.id" class="text-body-2 py-1">
|
||||
<v-icon size="small" icon="mdi-account-circle-outline" class="mr-2" />{{ participant.username }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-group>
|
||||
|
||||
<!-- Canal orphelin (racine) -->
|
||||
<template v-else-if="'Channel' in item">
|
||||
<v-list-item
|
||||
v-else-if="'Channel' in item"
|
||||
:key="item.Channel.id"
|
||||
:title="item.Channel.name"
|
||||
:to="`/server/${serverId}/channel/${item.Channel.id}`"
|
||||
:active="voiceStore.channelId === item.Channel.id"
|
||||
:class="{
|
||||
'font-weight-bold': (item.Channel.unread_count ?? 0) > 0,
|
||||
'server-item-drop-before': isDropTarget(item.Channel.id, 'before'),
|
||||
'server-item-drop-after': isDropTarget(item.Channel.id, 'after'),
|
||||
}"
|
||||
link
|
||||
@click="selectChannel(item.Channel)"
|
||||
draggable="true"
|
||||
@dragstart="startDragging($event, item.Channel.id, 'channel')"
|
||||
@dragover="dragOver($event, item.Channel.id, 'channel', null)"
|
||||
@@ -337,6 +378,7 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
||||
@dragend="stopDragging"
|
||||
@contextmenu="onChannelContextMenu($event, item.Channel)"
|
||||
>
|
||||
<template #prepend><v-icon :icon="item.Channel.channel_type === 'voice' ? 'mdi-volume-high' : 'mdi-pound'" /></template>
|
||||
<template #append>
|
||||
<v-chip
|
||||
v-if="(item.Channel.unread_count ?? 0) > 0"
|
||||
@@ -349,9 +391,16 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<div v-if="item.Channel.channel_type === 'voice'" class="voice-participants">
|
||||
<div v-for="participant in item.Channel.voice_participants ?? []" :key="participant.id" class="text-body-2 py-1">
|
||||
<v-icon size="small" icon="mdi-account-circle-outline" class="mr-2" />{{ participant.username }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</v-list>
|
||||
|
||||
|
||||
</v-navigation-drawer>
|
||||
|
||||
<CreateChannelDialog
|
||||
@@ -391,6 +440,11 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.voice-participants {
|
||||
padding-left: 48px;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
opacity: 0.75;
|
||||
}
|
||||
.server-item-drop-before {
|
||||
border-top: 2px solid rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@ import {defineStore} from 'pinia'
|
||||
|
||||
export const useAppStore = defineStore('app', {
|
||||
state: () => ({
|
||||
baseurl: 'http://goesseau.eu:8080',
|
||||
baseurl: '',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {defineStore} from 'pinia'
|
||||
import {useApi} from "@/composables/useApi";
|
||||
import {useVoiceStore} from '@/stores/voice'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
@@ -55,6 +56,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
|
||||
async logout() {
|
||||
useVoiceStore().leave()
|
||||
const api = useApi()
|
||||
try {
|
||||
await api.post('/auth/logout')
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface Channel {
|
||||
id: string
|
||||
name?: string
|
||||
channel_type: string
|
||||
voice_participants?: {id: string; username: string}[]
|
||||
server_id?: string | null
|
||||
category_id?: string | null
|
||||
created_at: string
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import {defineStore} from 'pinia'
|
||||
|
||||
export const useVoiceStore = defineStore('voice', {
|
||||
state: () => ({
|
||||
channelId: null as string | null,
|
||||
serverId: null as string | null,
|
||||
connecting: false,
|
||||
error: null as string | null,
|
||||
}),
|
||||
actions: {
|
||||
async join(serverId: string, channelId: string) {
|
||||
if (this.channelId === channelId) return
|
||||
this.leave()
|
||||
this.connecting = true
|
||||
this.error = null
|
||||
const generation = ++voiceGeneration
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({audio: true})
|
||||
if (generation !== voiceGeneration) {
|
||||
stream.getTracks().forEach(track => track.stop())
|
||||
return
|
||||
}
|
||||
microphone = stream
|
||||
const connection = new RTCPeerConnection()
|
||||
peer = connection
|
||||
stream.getAudioTracks().forEach(track => connection.addTrack(track, stream))
|
||||
const url = new URL(`/ws/rtc/${encodeURIComponent(channelId)}`, window.location.href)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const ws = new WebSocket(url)
|
||||
socket = ws
|
||||
this.channelId = channelId
|
||||
this.serverId = serverId
|
||||
const pendingCandidates: string[] = []
|
||||
const tracks = new Map<string, MediaStreamTrack>()
|
||||
connection.ontrack = ({track, streams}) => {
|
||||
if (peer !== connection) return
|
||||
const audio = new Audio()
|
||||
audio.autoplay = true
|
||||
audio.srcObject = new MediaStream([track])
|
||||
audioElements.set(track, audio)
|
||||
if (streams[0]) tracks.set(streams[0].id, track)
|
||||
track.onended = () => removeAudio(track)
|
||||
void audio.play().catch(() => { this.error = 'Lecture du son distant impossible.' })
|
||||
}
|
||||
connection.onicecandidate = ({candidate}) => {
|
||||
if (candidate && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({action: 'ice-candidate', candidate: candidate.candidate}))
|
||||
}
|
||||
}
|
||||
ws.onopen = async () => {
|
||||
try {
|
||||
const offer = await connection.createOffer()
|
||||
await connection.setLocalDescription(offer)
|
||||
if (socket === ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({action: 'sdp-offer', sdp: connection.localDescription?.sdp}))
|
||||
}
|
||||
} catch (error) {
|
||||
if (socket === ws) { this.error = String(error); this.leave() }
|
||||
}
|
||||
}
|
||||
let queue = Promise.resolve()
|
||||
ws.onmessage = ({data}) => {
|
||||
queue = queue.then(async () => {
|
||||
if (socket !== ws) return
|
||||
const message = JSON.parse(data)
|
||||
if (message.action === 'answer') {
|
||||
await connection.setRemoteDescription({type: 'answer', sdp: message.sdp})
|
||||
for (const candidate of pendingCandidates) await connection.addIceCandidate({candidate, sdpMLineIndex: 0})
|
||||
pendingCandidates.length = 0
|
||||
} else if (message.action === 'sdp-offer') {
|
||||
await connection.setRemoteDescription({type: 'offer', sdp: message.sdp})
|
||||
const answer = await connection.createAnswer()
|
||||
await connection.setLocalDescription(answer)
|
||||
ws.send(JSON.stringify({action: 'sdp-answer', sdp: connection.localDescription?.sdp}))
|
||||
} else if (message.action === 'ice-candidate') {
|
||||
if (connection.remoteDescription) await connection.addIceCandidate({candidate: message.candidate, sdpMLineIndex: 0})
|
||||
else pendingCandidates.push(message.candidate)
|
||||
} else if (message.action === 'source-left') {
|
||||
const track = tracks.get(message.id)
|
||||
if (track) removeAudio(track)
|
||||
tracks.delete(message.id)
|
||||
} else if (message.action === 'error') {
|
||||
this.error = message.message
|
||||
this.leave()
|
||||
}
|
||||
}).catch(error => { if (socket === ws) { this.error = String(error); this.leave() } })
|
||||
}
|
||||
ws.onerror = () => { if (socket === ws) this.error = 'Connexion vocale impossible.' }
|
||||
ws.onclose = () => { if (socket === ws) this.leave() }
|
||||
} catch (error) {
|
||||
if (generation === voiceGeneration) this.error = `Microphone indisponible : ${String(error)}`
|
||||
} finally {
|
||||
if (generation === voiceGeneration) this.connecting = false
|
||||
}
|
||||
},
|
||||
leave() {
|
||||
voiceGeneration++
|
||||
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({action: 'leave'}))
|
||||
socket?.close()
|
||||
socket = null
|
||||
peer?.close()
|
||||
peer = null
|
||||
microphone?.getTracks().forEach(track => track.stop())
|
||||
microphone = null
|
||||
for (const track of audioElements.keys()) removeAudio(track)
|
||||
this.channelId = null
|
||||
this.serverId = null
|
||||
this.connecting = false
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let voiceGeneration = 0
|
||||
let socket: WebSocket | null = null
|
||||
let peer: RTCPeerConnection | null = null
|
||||
let microphone: MediaStream | null = null
|
||||
const audioElements = new Map<MediaStreamTrack, HTMLAudioElement>()
|
||||
|
||||
function removeAudio(track: MediaStreamTrack) {
|
||||
const audio = audioElements.get(track)
|
||||
if (!audio) return
|
||||
audio.pause()
|
||||
audio.srcObject = null
|
||||
audioElements.delete(track)
|
||||
}
|
||||
@@ -1,8 +1,21 @@
|
||||
import {readFileSync} from 'node:fs'
|
||||
import {resolve} from 'node:path'
|
||||
import {fileURLToPath, URL} from 'node:url'
|
||||
import Vue from '@vitejs/plugin-vue'
|
||||
import Fonts from 'unplugin-fonts/vite'
|
||||
import {defineConfig} from 'vite'
|
||||
import Vuetify, {transformAssetUrls} from 'vite-plugin-vuetify'
|
||||
import {parse} from 'smol-toml'
|
||||
|
||||
const projectRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
const config = parse(readFileSync(resolve(projectRoot, 'config.toml'), 'utf8'))
|
||||
const network = config.network as {tcp_port: number, tls?: {cert_path: string, key_path: string}}
|
||||
const tls = network.tls
|
||||
const https = tls ? {
|
||||
cert: readFileSync(resolve(projectRoot, tls.cert_path)),
|
||||
key: readFileSync(resolve(projectRoot, tls.key_path)),
|
||||
} : undefined
|
||||
const target = `${tls ? 'https' : 'http'}://localhost:${network.tcp_port}`
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
@@ -47,15 +60,18 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
https,
|
||||
allowedHosts: ["goesseau.eu"],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://goesseau.eu:8080',
|
||||
target,
|
||||
changeOrigin: true,
|
||||
secure: !tls,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://goesseau.eu:8080',
|
||||
target,
|
||||
ws: true,
|
||||
secure: !tls,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2519,6 +2519,11 @@ sisteransi@^1.0.5:
|
||||
resolved "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz"
|
||||
integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==
|
||||
|
||||
smol-toml@^1.9.0:
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/smol-toml/-/smol-toml-1.9.0.tgz#f36b8dc7eb621c541c48f9fba6925dab95172817"
|
||||
integrity sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==
|
||||
|
||||
"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2, source-map-js@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz"
|
||||
|
||||
@@ -50,6 +50,10 @@ host = "0.0.0.0"
|
||||
tcp_port = 8080
|
||||
# WebRTC ICE/Media UDP multiplexing port
|
||||
udp_port = 8080
|
||||
# Public IPv4 address advertised by ICE when behind NAT (forward udp_port to this server).
|
||||
# external_ip = "203.0.113.1"
|
||||
# STUN servers used when external_ip is absent; set [] to disable discovery.
|
||||
# stun_servers = ["stun:stun.l.google.com:19302"]
|
||||
|
||||
# Optional native HTTPS (omit this section to keep plain HTTP behind a proxy).
|
||||
# [network.tls]
|
||||
@@ -110,9 +114,17 @@ pub struct NetworkConfig {
|
||||
pub tcp_port: u16,
|
||||
pub udp_port: u16,
|
||||
#[serde(default)]
|
||||
pub external_ip: Option<Ipv4Addr>,
|
||||
#[serde(default = "default_stun_servers")]
|
||||
pub stun_servers: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub tls: Option<TlsConfig>,
|
||||
}
|
||||
|
||||
fn default_stun_servers() -> Vec<String> {
|
||||
vec!["stun:stun.l.google.com:19302".to_string()]
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct TlsConfig {
|
||||
pub cert_path: std::path::PathBuf,
|
||||
@@ -170,3 +182,38 @@ impl AppConfig {
|
||||
Self::load()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{AppConfig, DEFAULT_CONFIG_TOML};
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
#[test]
|
||||
fn external_ip_is_optional_and_accepts_public_ipv4() {
|
||||
let default: AppConfig = toml::from_str(DEFAULT_CONFIG_TOML).unwrap();
|
||||
assert_eq!(default.network.external_ip, None);
|
||||
assert_eq!(default.network.stun_servers, vec!["stun:stun.l.google.com:19302"]);
|
||||
|
||||
let configured = DEFAULT_CONFIG_TOML.replace(
|
||||
"# external_ip = \"203.0.113.1\"",
|
||||
"external_ip = \"203.0.113.1\"",
|
||||
);
|
||||
let config: AppConfig = toml::from_str(&configured).unwrap();
|
||||
assert_eq!(config.network.external_ip, Some(Ipv4Addr::new(203, 0, 113, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stun_servers_can_be_disabled_or_replaced() {
|
||||
for (value, expected) in [
|
||||
("[]", vec![]),
|
||||
("[\"stun:example.org:3478\"]", vec!["stun:example.org:3478"]),
|
||||
] {
|
||||
let text = DEFAULT_CONFIG_TOML.replace(
|
||||
"# stun_servers = [\"stun:stun.l.google.com:19302\"]",
|
||||
&format!("stun_servers = {value}"),
|
||||
);
|
||||
let config: AppConfig = toml::from_str(&text).unwrap();
|
||||
assert_eq!(config.network.stun_servers, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -83,12 +83,15 @@ impl App {
|
||||
))
|
||||
.start(event_bus.clone());
|
||||
|
||||
let rtc = Arc::new(RTCManager::new(
|
||||
let rtc = Arc::new(
|
||||
RTCManager::new(
|
||||
&config.network,
|
||||
repositories.clone(),
|
||||
services.clone(),
|
||||
event_bus.clone(),
|
||||
));
|
||||
)
|
||||
.await,
|
||||
);
|
||||
|
||||
let state = AppState {
|
||||
db,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::models::channel::ChannelType;
|
||||
use crate::rtc::VoiceParticipant;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
@@ -49,6 +50,8 @@ pub struct ChannelResponse {
|
||||
/// Some(value) : valeur de computed_permission (0 si absente).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub permission: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub voice_participants: Option<Vec<VoiceParticipant>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
|
||||
@@ -8,3 +8,4 @@ pub mod server;
|
||||
pub mod server_permission;
|
||||
pub mod server_tree;
|
||||
pub mod user;
|
||||
pub mod voice_presence;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
use crate::rtc::VoiceParticipant;
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct VoicePresenceEvent {
|
||||
pub server_id: Uuid,
|
||||
pub channel_id: Uuid,
|
||||
pub user: VoiceParticipant,
|
||||
#[serde(skip)]
|
||||
pub joined: bool,
|
||||
}
|
||||
@@ -25,6 +25,7 @@ pub fn channel_model_to_channel_response_with_permission(
|
||||
updated_at: model.updated_at,
|
||||
unread_count: None,
|
||||
permission,
|
||||
voice_participants: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::domain::events::message::{
|
||||
};
|
||||
use crate::domain::events::server::{ServerCreatedEvent, ServerDeletedEvent, ServerUpdatedEvent};
|
||||
use crate::domain::events::server_tree::ServerTreeInvalidatedEvent;
|
||||
use crate::domain::events::voice_presence::VoicePresenceEvent;
|
||||
use crate::models::{server_user, user};
|
||||
use crate::repositories::Repositories;
|
||||
use crate::routes::category::mapper::category_model_to_category_response;
|
||||
@@ -115,6 +116,15 @@ impl RealtimeRouter {
|
||||
}
|
||||
|
||||
pub fn start(self: &Arc<Self>, event_bus: Arc<EventBus>) {
|
||||
let router = Arc::clone(self);
|
||||
event_bus.on_async::<VoicePresenceEvent>(move |event| {
|
||||
let router = Arc::clone(&router);
|
||||
async move {
|
||||
let users = router.server_users(event.server_id).await;
|
||||
let action = if event.joined { "joined" } else { "left" };
|
||||
router.gateway.send_to_users(users, "VoicePresence", action, event);
|
||||
}
|
||||
});
|
||||
let router = Arc::clone(self);
|
||||
event_bus.on_async::<MessageCreatedEvent>(move |event| {
|
||||
let router = Arc::clone(&router);
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::core::AppState;
|
||||
use crate::http::context::CurrentUser;
|
||||
use crate::http::error::HTTPError;
|
||||
use crate::rtc::ws_entrypoint::ws_entrypoint_handler;
|
||||
use crate::models::channel::{self, ChannelType};
|
||||
use axum::extract::{Path, State, WebSocketUpgrade};
|
||||
use axum::response::IntoResponse;
|
||||
use uuid::Uuid;
|
||||
@@ -12,13 +13,22 @@ pub async fn ws_handler(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
) -> Result<impl IntoResponse, HTTPError> {
|
||||
let channel = checked_channel(&state, channel_id, user.id).await?;
|
||||
Ok(ws.on_upgrade(move |socket| ws_entrypoint_handler(socket, state, channel, user)))
|
||||
}
|
||||
|
||||
|
||||
async fn checked_channel(state: &AppState, channel_id: Uuid, user_id: Uuid) -> Result<channel::Model, HTTPError> {
|
||||
let channel = state
|
||||
.repositories
|
||||
.channel
|
||||
.get_by_id(channel_id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
// todo : check des permissions
|
||||
// aiinsi que vérifier qu'on est sur un chan vocal OU un chan de message privé
|
||||
Ok(ws.on_upgrade(move |socket| ws_entrypoint_handler(socket, state, channel, user)))
|
||||
if channel.channel_type != ChannelType::Voice { return Err(HTTPError::NotFound); }
|
||||
let server_id = channel.server_id.ok_or(HTTPError::Forbidden)?;
|
||||
if state.repositories.server.get_user(server_id, user_id).await?.is_none() {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
@@ -6,3 +6,4 @@ use axum::routing::get;
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/rtc/{channel_id}", get(handlers::ws_handler))
|
||||
}
|
||||
|
||||
|
||||
@@ -557,5 +557,6 @@ pub async fn get_tree(
|
||||
tree.channels,
|
||||
tree.categories,
|
||||
tree.unread_counts,
|
||||
&state.rtc.rooms,
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ use crate::domain::dto::server::{
|
||||
CreateServerRequest, ServerExplorerItemResponse, ServerResponse, ServerRolePermissionResponse,
|
||||
ServerTreeResponse, ServerUserPermissionResponse, UpdateServerRequest,
|
||||
};
|
||||
use crate::models::{server, server_item_order, server_role_permission, server_user_permission};
|
||||
use crate::models::{channel::ChannelType, server, server_item_order, server_role_permission, server_user_permission};
|
||||
use crate::repositories::types::{CategoryWithPermissions, ChannelWithPermissions};
|
||||
use crate::rtc::VoiceRoomManager;
|
||||
use crate::routes::category::mapper::category_model_to_category_response_with_permission;
|
||||
use crate::routes::channel::mapper::channel_model_to_channel_response_with_permission;
|
||||
use sea_orm::Set;
|
||||
@@ -77,6 +78,7 @@ pub fn build_server_tree(
|
||||
channels: Vec<ChannelWithPermissions>,
|
||||
categories: Vec<CategoryWithPermissions>,
|
||||
unread_counts: HashMap<Uuid, u64>,
|
||||
rooms: &VoiceRoomManager,
|
||||
) -> ServerTreeResponse {
|
||||
let order_map: HashMap<(Option<Uuid>, Uuid), i64> = orders
|
||||
.into_iter()
|
||||
@@ -135,6 +137,7 @@ pub fn build_server_tree(
|
||||
Some(chan_perm_bits),
|
||||
);
|
||||
response.unread_count = Some(*unread_counts.get(&response.id).unwrap_or(&0));
|
||||
if response.channel_type == ChannelType::Voice { response.voice_participants = Some(rooms.participants(response.id)); }
|
||||
response
|
||||
})
|
||||
.collect();
|
||||
@@ -156,6 +159,7 @@ pub fn build_server_tree(
|
||||
Some(chan_perm_bits),
|
||||
);
|
||||
chan_response.unread_count = Some(*unread_counts.get(&chan_response.id).unwrap_or(&0));
|
||||
if chan_response.channel_type == ChannelType::Voice { chan_response.voice_participants = Some(rooms.participants(chan_response.id)); }
|
||||
root_items.push((
|
||||
ServerExplorerItemResponse::Channel(chan_response),
|
||||
order_key,
|
||||
@@ -250,6 +254,7 @@ mod tests {
|
||||
],
|
||||
vec![category],
|
||||
HashMap::new(),
|
||||
&VoiceRoomManager::new(),
|
||||
);
|
||||
|
||||
let ServerExplorerItemResponse::Category(_, channels) = &response.items[0] else {
|
||||
@@ -258,4 +263,23 @@ mod tests {
|
||||
assert_eq!(channels[0].name.as_deref(), Some("first"));
|
||||
assert_eq!(channels[1].name.as_deref(), Some("second"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_includes_voice_participants_only_for_voice_channels() {
|
||||
let rooms = VoiceRoomManager::new();
|
||||
let server = Uuid::new_v4();
|
||||
let voice_id = Uuid::new_v4();
|
||||
let text_id = Uuid::new_v4();
|
||||
let user = crate::rtc::VoiceParticipant { id: Uuid::new_v4(), username: "alice".into() };
|
||||
rooms.join_user(server, voice_id, Uuid::new_v4(), user.clone());
|
||||
let mut voice = channel(voice_id, None, "voice");
|
||||
voice.channel.channel_type = channel::ChannelType::Voice;
|
||||
let tree = build_server_tree(vec![], vec![voice, channel(text_id, None, "text")], vec![], HashMap::new(), &rooms);
|
||||
let json = serde_json::to_value(tree).unwrap();
|
||||
let items = json["items"].as_array().unwrap();
|
||||
let voice = items.iter().find(|item| item["Channel"]["id"] == voice_id.to_string()).unwrap();
|
||||
let text = items.iter().find(|item| item["Channel"]["id"] == text_id.to_string()).unwrap();
|
||||
assert_eq!(voice["Channel"]["voice_participants"], serde_json::json!([user]));
|
||||
assert!(text["Channel"].get("voice_participants").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ Les actions JSON sont définies dans `rtc/messages.rs`. Le `channel_id` n'est pa
|
||||
3. `create_initial_answer` crée et applique la réponse SDP locale. `opus_sdp` reprend le type de charge utile Opus proposé par le navigateur ; `ws_on_message` renvoie ensuite `answer` et lance `run_room` pour écouter les événements du salon.
|
||||
4. Le navigateur envoie ses messages `ice-candidate` : `handle_ice_candidate` les transmet à `rustrtc`. Un candidat vide marque la fin de collecte et est ignoré. En sens inverse, `forward_ice_candidates` transmet les candidats locaux sous forme de messages `ice-candidate` sur le WebSocket. Un candidat refusé produit un message `error` ; les noms mDNS `.local` peuvent notamment être refusés par `rustrtc`.
|
||||
|
||||
Pour tester depuis Internet, `network.host = "0.0.0.0"` ne définit que l'adresse d'écoute. Sans `network.external_ip`, le serveur utilise par défaut `stun:stun.l.google.com:19302` pour découvrir son IPv4 publique au démarrage et la fournir à ICE ; `network.stun_servers` permet de changer la liste ou de désactiver STUN avec `[]`. Définir `network.external_ip` prend la priorité et évite STUN côté serveur. Rediriger `network.udp_port` en UDP vers le serveur et ouvrir ce port dans le pare-feu : STUN ne crée pas de redirection NAT. Les logs `Configuration ICE du serveur`, `Candidat ICE local envoyé` et `Candidat ICE distant accepté` sont visibles au niveau `info` (par exemple `RUST_LOG=info`). Vérifier que les candidats locaux annoncent une adresse publique **et un port** effectivement joignables ; si le NAT modifie le port ou en cas de NAT symétrique, une redirection adaptée ou un relais TURN peut être nécessaire. Ces logs exposent des adresses IP : éviter de les partager sans les masquer.
|
||||
|
||||
Au démarrage, si `external_ip` est absent, les logs `Diagnostic STUN IPv4` montrent une requête Binding indépendante vers les serveurs configurés, sa réponse ou l'erreur (résolution DNS, délai de 5 secondes, réponse invalide). La première IPv4 publique découverte est injectée dans la configuration ICE avant d'accepter les connexions ; son **port temporaire distinct d'ICE est ignoré** et ICE annonce `network.udp_port`. Si la découverte échoue, la collecte ICE native reste active. Le résultat du diagnostic ne prouve pas que le port ICE est joignable ni que l'IP reste stable après le démarrage : contrôler `Candidat ICE local envoyé` et redémarrer après un changement d'adresse. Pour les erreurs de collecte de `rustrtc`, utiliser `RUST_LOG=info,rustrtc::transports::ice=debug` pendant une connexion.
|
||||
|
||||
### Audio et changements de participants
|
||||
|
||||
`VoiceRoomManager` (`rtc/mod.rs`) maintient un salon par identifiant de canal. `join` crée une source audio propre au client, prévient les autres participants avec `RoomEvent::Joined` et informe le nouvel arrivant des sources existantes. Aucun événement n'inscrit sa propre source comme piste à recevoir : le client ne s'entend pas lui-même.
|
||||
|
||||
+7
-5
@@ -1,5 +1,5 @@
|
||||
use super::messages::{VoiceClientMessage, VoiceServerMessage};
|
||||
use super::{RoomEvent, VoiceRoomManager};
|
||||
use super::{RoomEvent, VoiceParticipant, VoiceRoomManager};
|
||||
use crate::models::{channel, user};
|
||||
use axum::extract::ws::Message;
|
||||
use rustrtc::media::{self, MediaStreamTrack};
|
||||
@@ -99,7 +99,7 @@ impl RTCClient {
|
||||
message: error.to_string(),
|
||||
});
|
||||
} else {
|
||||
info!(channel_id = %self.channel.id, "Candidat ICE distant accepté");
|
||||
info!(channel_id = %self.channel.id, client_id = %self.client_id, candidate = %candidate, "Candidat ICE distant accepté");
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -136,7 +136,9 @@ impl RTCClient {
|
||||
info!(channel_id = %self.channel.id, "Audio du salon configuré");
|
||||
let channel_id = self.channel.id;
|
||||
let client_id = self.client_id;
|
||||
let room_events = self.room_manager.join(channel_id, client_id);
|
||||
let room_events = self.room_manager.join_user(self.channel.server_id.expect("canal RTC serveur"), channel_id, client_id, VoiceParticipant {
|
||||
id: self.user.id, username: self.user.username.clone(),
|
||||
});
|
||||
let room_manager = self.room_manager.clone();
|
||||
tokio::spawn(async move { Self::forward_audio(incoming, room_manager, channel_id, client_id).await });
|
||||
let answer = self.create_initial_answer(opus_payload_type).await?;
|
||||
@@ -298,7 +300,7 @@ impl RTCClient {
|
||||
// recv attend un candidat ; une erreur termine l'écoute.
|
||||
while let Ok(candidate) = candidates.recv().await {
|
||||
// Le candidat est sérialisé pour être envoyé au navigateur via send_response.
|
||||
info!(channel_id = %client.channel.id, "Candidat ICE local envoyé");
|
||||
info!(channel_id = %client.channel.id, client_id = %client.client_id, candidate = %candidate.to_sdp(), "Candidat ICE local envoyé");
|
||||
client.send_response(VoiceServerMessage::IceCandidate {
|
||||
candidate: format!("candidate:{}", candidate.to_sdp()),
|
||||
});
|
||||
@@ -350,7 +352,7 @@ mod tests {
|
||||
},
|
||||
channel::Model {
|
||||
id: Uuid::new_v4(),
|
||||
server_id: None,
|
||||
server_id: Some(Uuid::new_v4()),
|
||||
category_id: None,
|
||||
channel_type: channel::ChannelType::Voice,
|
||||
name: None,
|
||||
|
||||
+182
-13
@@ -1,18 +1,22 @@
|
||||
mod client;
|
||||
mod messages;
|
||||
mod metrics;
|
||||
mod stun_diagnostic;
|
||||
pub mod ws_entrypoint;
|
||||
|
||||
use crate::config::NetworkConfig;
|
||||
use crate::domain::events::voice_presence::VoicePresenceEvent;
|
||||
use crate::repositories::Repositories;
|
||||
use crate::services::Services;
|
||||
use event_bus::EventBus;
|
||||
use rustrtc::{PeerConnection, RtcConfiguration, RtcConfigurationBuilder};
|
||||
use rustrtc::{IceServer, PeerConnection, RtcConfiguration, RtcConfigurationBuilder};
|
||||
use rustrtc::media::MediaSample;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::Arc;
|
||||
use serde::Serialize;
|
||||
// 1. Client crée une RTCPeerConnection
|
||||
// 2. Client crée une SDP offer
|
||||
// 3. Client envoie l'offer via WebSocket
|
||||
@@ -27,6 +31,14 @@ use std::sync::Arc;
|
||||
pub struct VoiceRoom {
|
||||
participants: HashMap<uuid::Uuid, tokio::sync::mpsc::UnboundedSender<RoomEvent>>,
|
||||
sources: HashMap<uuid::Uuid, tokio::sync::broadcast::Sender<MediaSample>>,
|
||||
users: HashMap<uuid::Uuid, VoiceParticipant>,
|
||||
server_id: Option<uuid::Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct VoiceParticipant {
|
||||
pub id: uuid::Uuid,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
pub enum RoomEvent {
|
||||
@@ -36,6 +48,7 @@ pub enum RoomEvent {
|
||||
|
||||
pub struct VoiceRoomManager {
|
||||
rooms: Mutex<HashMap<uuid::Uuid, VoiceRoom>>,
|
||||
event_bus: Option<Arc<EventBus>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -59,7 +72,7 @@ impl fmt::Debug for RTCManager {
|
||||
|
||||
impl VoiceRoom {
|
||||
pub fn new() -> Self {
|
||||
Self { participants: HashMap::new(), sources: HashMap::new() }
|
||||
Self { participants: HashMap::new(), sources: HashMap::new(), users: HashMap::new(), server_id: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,10 +80,23 @@ impl VoiceRoomManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
rooms: Mutex::new(HashMap::new()),
|
||||
event_bus: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_event_bus(event_bus: Arc<EventBus>) -> Self {
|
||||
Self { rooms: Mutex::new(HashMap::new()), event_bus: Some(event_bus) }
|
||||
}
|
||||
|
||||
pub fn join(&self, channel_id: uuid::Uuid, client_id: uuid::Uuid) -> tokio::sync::mpsc::UnboundedReceiver<RoomEvent> {
|
||||
self.join_inner(channel_id, client_id, None)
|
||||
}
|
||||
|
||||
pub fn join_user(&self, server_id: uuid::Uuid, channel_id: uuid::Uuid, client_id: uuid::Uuid, user: VoiceParticipant) -> tokio::sync::mpsc::UnboundedReceiver<RoomEvent> {
|
||||
self.join_inner(channel_id, client_id, Some((server_id, user)))
|
||||
}
|
||||
|
||||
fn join_inner(&self, channel_id: uuid::Uuid, client_id: uuid::Uuid, user: Option<(uuid::Uuid, VoiceParticipant)>) -> tokio::sync::mpsc::UnboundedReceiver<RoomEvent> {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut rooms = self.rooms.lock();
|
||||
let room = rooms.entry(channel_id).or_insert_with(VoiceRoom::new);
|
||||
@@ -83,9 +109,25 @@ impl VoiceRoomManager {
|
||||
}
|
||||
room.sources.insert(client_id, source);
|
||||
room.participants.insert(client_id, tx);
|
||||
if let Some((server_id, user)) = user {
|
||||
room.server_id = Some(server_id);
|
||||
let first = !room.users.values().any(|existing| existing.id == user.id);
|
||||
room.users.insert(client_id, user.clone());
|
||||
if first { if let Some(bus) = &self.event_bus { bus.emit(VoicePresenceEvent { server_id, channel_id, user, joined: true }); } }
|
||||
}
|
||||
rx
|
||||
}
|
||||
|
||||
pub fn participants(&self, channel_id: uuid::Uuid) -> Vec<VoiceParticipant> {
|
||||
let rooms = self.rooms.lock();
|
||||
let mut users: Vec<_> = rooms.get(&channel_id).into_iter()
|
||||
.flat_map(|room| room.users.values()).cloned().collect();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
users.retain(|user| seen.insert(user.id));
|
||||
users.sort_by(|a, b| a.username.cmp(&b.username).then(a.id.cmp(&b.id)));
|
||||
users
|
||||
}
|
||||
|
||||
pub fn forward(&self, channel_id: uuid::Uuid, client_id: uuid::Uuid, sample: MediaSample) {
|
||||
if let Some(source) = self.rooms.lock().get(&channel_id).and_then(|room| room.sources.get(&client_id).cloned()) {
|
||||
let _ = source.send(sample);
|
||||
@@ -97,6 +139,12 @@ impl VoiceRoomManager {
|
||||
if let Some(room) = rooms.get_mut(&channel_id) {
|
||||
room.participants.remove(&client_id);
|
||||
room.sources.remove(&client_id);
|
||||
let removed = room.users.remove(&client_id);
|
||||
if let (Some(user), Some(server_id), Some(bus)) = (removed, room.server_id, &self.event_bus) {
|
||||
if !room.users.values().any(|existing| existing.id == user.id) {
|
||||
bus.emit(VoicePresenceEvent { server_id, channel_id, user, joined: false });
|
||||
}
|
||||
}
|
||||
for participant in room.participants.values() {
|
||||
let _ = participant.send(RoomEvent::Left(client_id));
|
||||
}
|
||||
@@ -108,19 +156,53 @@ impl VoiceRoomManager {
|
||||
}
|
||||
|
||||
impl RTCManager {
|
||||
pub fn new(
|
||||
fn ice_config(network: &NetworkConfig) -> RtcConfiguration {
|
||||
Self::ice_config_with_discovered_ip(network, None)
|
||||
}
|
||||
|
||||
fn ice_config_with_discovered_ip(network: &NetworkConfig, discovered_ip: Option<Ipv4Addr>) -> RtcConfiguration {
|
||||
let mut builder = RtcConfigurationBuilder::new()
|
||||
.ice_udp_mux(true)
|
||||
.ice_udp_mux_port(network.udp_port)
|
||||
.bind_ip(network.host.to_string());
|
||||
if let Some(ip) = network.external_ip.or(discovered_ip) {
|
||||
builder = builder.external_ip(ip.to_string());
|
||||
} else {
|
||||
for url in &network.stun_servers {
|
||||
builder = builder.ice_server(IceServer::new(vec![url.clone()]));
|
||||
}
|
||||
}
|
||||
builder.build()
|
||||
}
|
||||
|
||||
pub async fn new(
|
||||
network: &NetworkConfig,
|
||||
repositories: Arc<Repositories>,
|
||||
services: Arc<Services>,
|
||||
event_bus: Arc<EventBus>,
|
||||
) -> Self {
|
||||
let builder = RtcConfigurationBuilder::new()
|
||||
.ice_udp_mux(true)
|
||||
.ice_udp_mux_port(network.udp_port)
|
||||
.bind_ip(network.host.to_string());
|
||||
let config = builder.build();
|
||||
let mut discovered_ip = None;
|
||||
if network.external_ip.is_none() {
|
||||
for url in &network.stun_servers {
|
||||
match stun_diagnostic::probe(url, network.host).await {
|
||||
Ok(mapped) => {
|
||||
let std::net::IpAddr::V4(ip) = mapped.ip() else { unreachable!() };
|
||||
if ip.is_unspecified() || ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_multicast() || ip.is_broadcast() {
|
||||
tracing::warn!(%url, %mapped, "Adresse STUN IPv4 non publique ignorée");
|
||||
continue;
|
||||
}
|
||||
tracing::info!(%url, %mapped, udp_port = network.udp_port, "IPv4 STUN injectée dans ICE ; port temporaire ignoré");
|
||||
discovered_ip = Some(ip);
|
||||
break;
|
||||
}
|
||||
Err(error) => tracing::warn!(%url, %error, "Diagnostic STUN IPv4 échoué"),
|
||||
}
|
||||
}
|
||||
}
|
||||
let config = Self::ice_config_with_discovered_ip(network, discovered_ip);
|
||||
tracing::info!(bind_ip = %network.host, udp_port = network.udp_port, external_ip = ?config.external_ip, stun_servers = ?config.ice_servers.iter().flat_map(|server| &server.urls).collect::<Vec<_>>(), "Configuration ICE du serveur");
|
||||
|
||||
let rooms = Arc::new(VoiceRoomManager::new());
|
||||
let rooms = Arc::new(VoiceRoomManager::with_event_bus(event_bus.clone()));
|
||||
|
||||
Self {
|
||||
config,
|
||||
@@ -132,18 +214,105 @@ impl RTCManager {
|
||||
}
|
||||
|
||||
pub fn new_peer_connection(&self) -> Arc<PeerConnection> {
|
||||
let pc = Arc::new(PeerConnection::new(self.config.clone()));
|
||||
// todo : some logging ?
|
||||
pc
|
||||
Arc::new(PeerConnection::new(self.config.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ice_config_tests {
|
||||
use super::RTCManager;
|
||||
use crate::config::{AppConfig, DEFAULT_CONFIG_TOML};
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
#[test]
|
||||
fn stun_is_used_only_without_manual_external_ip() {
|
||||
let mut network = toml::from_str::<AppConfig>(DEFAULT_CONFIG_TOML).unwrap().network;
|
||||
let config = RTCManager::ice_config(&network);
|
||||
assert_eq!(config.ice_servers[0].urls, network.stun_servers);
|
||||
assert!(config.ice_udp_mux);
|
||||
assert_eq!(config.ice_udp_mux_port, Some(network.udp_port));
|
||||
|
||||
network.external_ip = Some(Ipv4Addr::new(203, 0, 113, 1));
|
||||
let config = RTCManager::ice_config(&network);
|
||||
assert!(config.ice_servers.is_empty());
|
||||
assert_eq!(config.external_ip.as_deref(), Some("203.0.113.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovered_ipv4_is_used_without_advertising_the_probe_port() {
|
||||
let network = toml::from_str::<AppConfig>(DEFAULT_CONFIG_TOML).unwrap().network;
|
||||
let config = RTCManager::ice_config_with_discovered_ip(&network, Some(Ipv4Addr::new(82, 64, 205, 121)));
|
||||
assert_eq!(config.external_ip.as_deref(), Some("82.64.205.121"));
|
||||
assert_eq!(config.ice_udp_mux_port, Some(network.udp_port));
|
||||
assert!(config.ice_servers.is_empty());
|
||||
|
||||
let fallback = RTCManager::ice_config_with_discovered_ip(&network, None);
|
||||
assert!(fallback.external_ip.is_none());
|
||||
assert!(!fallback.ice_servers.is_empty());
|
||||
|
||||
let mut manual = network;
|
||||
manual.external_ip = Some(Ipv4Addr::new(203, 0, 113, 1));
|
||||
let config = RTCManager::ice_config_with_discovered_ip(&manual, Some(Ipv4Addr::new(82, 64, 205, 121)));
|
||||
assert_eq!(config.external_ip.as_deref(), Some("203.0.113.1"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod room_tests {
|
||||
use super::{RoomEvent, VoiceRoomManager};
|
||||
use super::{RoomEvent, VoiceParticipant, VoiceRoomManager};
|
||||
use crate::domain::events::voice_presence::VoicePresenceEvent;
|
||||
use event_bus::EventBus;
|
||||
use rustrtc::media::{AudioFrame, MediaSample};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn presence_is_scoped_deduplicated_and_removed_per_connection() {
|
||||
let rooms = VoiceRoomManager::new();
|
||||
let channel = Uuid::new_v4();
|
||||
let other = Uuid::new_v4();
|
||||
let user = VoiceParticipant { id: Uuid::new_v4(), username: "alice".into() };
|
||||
let first = Uuid::new_v4();
|
||||
let second = Uuid::new_v4();
|
||||
let server = Uuid::new_v4();
|
||||
rooms.join_user(server, channel, first, user.clone());
|
||||
rooms.join_user(server, channel, second, user.clone());
|
||||
rooms.join_user(server, other, Uuid::new_v4(), user.clone());
|
||||
assert_eq!(rooms.participants(channel), vec![user.clone()]);
|
||||
rooms.leave(channel, first);
|
||||
assert_eq!(rooms.participants(channel), vec![user.clone()]);
|
||||
rooms.leave(channel, second);
|
||||
assert!(rooms.participants(channel).is_empty());
|
||||
assert_eq!(rooms.participants(other), vec![user]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_events_only_on_first_join_and_last_leave() {
|
||||
let bus = Arc::new(EventBus::new());
|
||||
let mut events = bus.subscribe::<VoicePresenceEvent>();
|
||||
let rooms = VoiceRoomManager::with_event_bus(bus);
|
||||
let server_id = Uuid::new_v4();
|
||||
let channel_id = Uuid::new_v4();
|
||||
let user = VoiceParticipant { id: Uuid::new_v4(), username: "alice".into() };
|
||||
let first = Uuid::new_v4();
|
||||
let second = Uuid::new_v4();
|
||||
rooms.join_user(server_id, channel_id, first, user.clone());
|
||||
let joined = events.try_recv().unwrap();
|
||||
assert!(joined.joined);
|
||||
assert_eq!(serde_json::to_value(&joined).unwrap(), serde_json::json!({
|
||||
"server_id": server_id, "channel_id": channel_id,
|
||||
"user": { "id": user.id, "username": "alice" }
|
||||
}));
|
||||
rooms.join_user(server_id, channel_id, second, user.clone());
|
||||
rooms.leave(channel_id, first);
|
||||
assert!(events.try_recv().is_err());
|
||||
rooms.leave(channel_id, second);
|
||||
let left = events.try_recv().unwrap();
|
||||
assert!(!left.joined);
|
||||
assert_eq!(left.user, user);
|
||||
assert!(events.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn audio_reaches_only_other_clients_in_same_room() {
|
||||
let rooms = VoiceRoomManager::new();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
use tokio::net::{UdpSocket, lookup_host};
|
||||
use tokio::time::timeout;
|
||||
|
||||
pub(super) async fn probe(url: &str, bind_ip: Ipv4Addr) -> anyhow::Result<SocketAddr> {
|
||||
let endpoint = url.strip_prefix("stun:").ok_or_else(|| anyhow::anyhow!("URL STUN non prise en charge"))?;
|
||||
let endpoint = endpoint.split('?').next().unwrap_or(endpoint);
|
||||
let mut addresses = lookup_host(endpoint).await?;
|
||||
let server = addresses.find(SocketAddr::is_ipv4).ok_or_else(|| anyhow::anyhow!("aucune adresse IPv4 pour le serveur STUN"))?;
|
||||
let socket = UdpSocket::bind((bind_ip, 0)).await?;
|
||||
let local = socket.local_addr()?;
|
||||
let transaction = *uuid::Uuid::new_v4().as_bytes();
|
||||
let mut request = [0u8; 20];
|
||||
request[..2].copy_from_slice(&0x0001u16.to_be_bytes());
|
||||
request[4..8].copy_from_slice(&0x2112a442u32.to_be_bytes());
|
||||
request[8..20].copy_from_slice(&transaction[..12]);
|
||||
tracing::info!(%server, %local, "Diagnostic STUN IPv4 : requête Binding envoyée (socket distinct d’ICE)");
|
||||
socket.send_to(&request, server).await?;
|
||||
let mut response = [0u8; 1500];
|
||||
let (length, sender) = timeout(Duration::from_secs(5), socket.recv_from(&mut response)).await??;
|
||||
anyhow::ensure!(sender == server, "réponse STUN d'une autre adresse : {sender}");
|
||||
let mapped = mapped_address(&response[..length], &transaction[..12])?;
|
||||
tracing::info!(%server, %local, %mapped, "Diagnostic STUN IPv4 : réponse Binding reçue");
|
||||
Ok(mapped)
|
||||
}
|
||||
|
||||
fn mapped_address(response: &[u8], transaction: &[u8]) -> anyhow::Result<SocketAddr> {
|
||||
anyhow::ensure!(response.len() >= 20 && response[0..2] == [0x01, 0x01]
|
||||
&& response[4..8] == 0x2112a442u32.to_be_bytes()
|
||||
&& response[8..20] == *transaction, "réponse STUN invalide ou transaction différente");
|
||||
let size = u16::from_be_bytes([response[2], response[3]]) as usize;
|
||||
anyhow::ensure!(response.len() >= 20 + size, "réponse STUN tronquée");
|
||||
let mut offset = 20;
|
||||
while offset + 4 <= 20 + size {
|
||||
let kind = u16::from_be_bytes([response[offset], response[offset + 1]]);
|
||||
let len = u16::from_be_bytes([response[offset + 2], response[offset + 3]]) as usize;
|
||||
offset += 4;
|
||||
anyhow::ensure!(offset + len <= 20 + size, "attribut STUN tronqué");
|
||||
if kind == 0x0020 && len >= 8 && response[offset + 1] == 1 {
|
||||
let port = u16::from_be_bytes([response[offset + 2], response[offset + 3]]) ^ 0x2112;
|
||||
let cookie = 0x2112a442u32.to_be_bytes();
|
||||
let ip = Ipv4Addr::from(std::array::from_fn(|i| response[offset + 4 + i] ^ cookie[i]));
|
||||
return Ok(SocketAddr::new(IpAddr::V4(ip), port));
|
||||
}
|
||||
offset += (len + 3) & !3;
|
||||
}
|
||||
anyhow::bail!("aucune adresse IPv4 XOR-MAPPED-ADDRESS dans la réponse STUN")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::mapped_address;
|
||||
|
||||
#[test]
|
||||
fn decodes_ipv4_and_rejects_unrelated_response() {
|
||||
let transaction = [7; 12];
|
||||
let mut response = vec![0x01, 0x01, 0, 12, 0x21, 0x12, 0xa4, 0x42];
|
||||
response.extend(transaction);
|
||||
response.extend([0, 0x20, 0, 8, 0, 1, 0x3f, 0x52, 0x6a, 0x22, 0xa5, 0x43]);
|
||||
assert_eq!(mapped_address(&response, &transaction).unwrap().to_string(), "75.48.1.1:7744");
|
||||
assert!(mapped_address(&response, &[8; 12]).is_err());
|
||||
assert!(mapped_address(&response[..25], &transaction).is_err());
|
||||
}
|
||||
}
|
||||
@@ -341,6 +341,8 @@ mod tests {
|
||||
hostv6: None,
|
||||
tcp_port: 8080,
|
||||
udp_port: 9000,
|
||||
external_ip: None,
|
||||
stun_servers: vec![],
|
||||
tls: None,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user