fix webrtc and login

This commit is contained in:
2026-09-24 13:55:59 +02:00
parent 5bac3174df
commit 844eaadee0
28 changed files with 628 additions and 43 deletions
+1
View File
@@ -2,3 +2,4 @@
/.idea
*.db*
/media/*
/certs
+8 -4
View File
@@ -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
+1
View File
@@ -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",
+21
View File
@@ -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;
+1 -1
View File
@@ -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 {
+60 -6
View File
@@ -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));
}
+1 -1
View File
@@ -2,6 +2,6 @@ import {defineStore} from 'pinia'
export const useAppStore = defineStore('app', {
state: () => ({
baseurl: 'http://goesseau.eu:8080',
baseurl: '',
}),
});
+2
View File
@@ -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')
+1
View File
@@ -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
+125
View File
@@ -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)
}
+18 -2
View File
@@ -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,
},
},
},
+5
View File
@@ -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"
+47
View File
@@ -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
View File
@@ -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,
+3
View File
@@ -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)]
+1
View File
@@ -8,3 +8,4 @@ pub mod server;
pub mod server_permission;
pub mod server_tree;
pub mod user;
pub mod voice_presence;
+12
View File
@@ -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,
}
+1
View File
@@ -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,
}
}
+10
View File
@@ -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);
+13 -3
View File
@@ -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)
}
+1
View File
@@ -6,3 +6,4 @@ use axum::routing::get;
pub fn router() -> Router<AppState> {
Router::new().route("/rtc/{channel_id}", get(handlers::ws_handler))
}
+1
View File
@@ -557,5 +557,6 @@ pub async fn get_tree(
tree.channels,
tree.categories,
tree.unread_counts,
&state.rtc.rooms,
)))
}
+25 -1
View File
@@ -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());
}
}
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+65
View File
@@ -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());
}
}
+2
View File
@@ -341,6 +341,8 @@ mod tests {
hostv6: None,
tcp_port: 8080,
udp_port: 9000,
external_ip: None,
stun_servers: vec![],
tls: None,
}
}