feat(webrtc): coturn ICE config endpoint + frontend wiring + ops template (v1.0.9 item 1.2)
Closes FUNCTIONAL_AUDIT.md §4 #1: WebRTC 1:1 calls had working signaling but no NAT traversal, so calls between two peers behind symmetric NAT (corporate firewalls, mobile carrier CGNAT, Incus container default networking) failed silently after the SDP exchange. Backend: - GET /api/v1/config/webrtc (public) returns {iceServers: [...]} built from WEBRTC_STUN_URLS / WEBRTC_TURN_URLS / *_USERNAME / *_CREDENTIAL env vars. Half-config (URLs without creds, or vice versa) deliberately omits the TURN block — a half-configured TURN surfaces auth errors at call time instead of falling back cleanly to STUN-only. - 4 handler tests cover the matrix. Frontend: - services/api/webrtcConfig.ts caches the config for the page lifetime and falls back to the historical hardcoded Google STUN if the fetch fails. - useWebRTC fetches at mount, hands iceServers synchronously to every RTCPeerConnection, exposes a {hasTurn, loaded} hint. - CallButton tooltip warns up-front when TURN isn't configured instead of letting calls time out silently. Ops: - infra/coturn/turnserver.conf — annotated template with the SSRF- safe denied-peer-ip ranges, prometheus exporter, TLS for TURNS, static lt-cred-mech (REST-secret rotation deferred to v1.1). - infra/coturn/README.md — Incus deploy walkthrough, smoke test via turnutils_uclient, capacity rules of thumb. - docs/ENV_VARIABLES.md gains a 13bis. WebRTC ICE servers section. Coturn deployment itself is a separate ops action — this commit lands the plumbing so the deploy can light up the path with zero code changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
85bdce6b46
commit
b8eed72f96
20 changed files with 1053 additions and 4 deletions
|
|
@ -8,6 +8,13 @@ interface CallButtonProps {
|
|||
targetUserId: string;
|
||||
onCall: () => void;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* v1.0.9 item 1.2 — operator-configured TURN coverage status
|
||||
* (forwarded from `useWebRTC().nat`). When false, the tooltip warns
|
||||
* the user up-front that calls may fail behind symmetric NAT, instead
|
||||
* of letting the connection time out silently.
|
||||
*/
|
||||
hasTurn?: boolean;
|
||||
}
|
||||
|
||||
export function CallButton({
|
||||
|
|
@ -15,9 +22,13 @@ export function CallButton({
|
|||
targetUserId: _targetUserId,
|
||||
onCall,
|
||||
disabled = false,
|
||||
hasTurn,
|
||||
}: CallButtonProps) {
|
||||
const tooltip = hasTurn
|
||||
? 'Démarrer un appel'
|
||||
: 'Fonctionne mieux sur le même réseau local (TURN non configuré)';
|
||||
return (
|
||||
<Tooltip content="Fonctionne mieux sur le même réseau local">
|
||||
<Tooltip content={tooltip}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -217,6 +217,7 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ conversationId }) => {
|
|||
webrtc.startCall(conversationId, targetUserId, 'audio')
|
||||
}
|
||||
disabled={wsStatus !== 'connected'}
|
||||
hasTurn={webrtc.nat.loaded ? webrtc.nat.hasTurn : undefined}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -2,8 +2,18 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||
import { useChatStore } from '../store/chatStore';
|
||||
import type { OutgoingMessage } from '../types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import {
|
||||
fetchWebRTCConfig,
|
||||
hasTurn,
|
||||
type WebRTCConfig,
|
||||
} from '@/services/api/webrtcConfig';
|
||||
|
||||
const ICE_SERVERS: RTCConfiguration['iceServers'] = [
|
||||
// v1.0.9 item 1.2 — STUN-only fallback used if the backend
|
||||
// `/config/webrtc` fetch fails AND we still need to start a call. Same
|
||||
// public Google STUN the pre-v1.0.9 hardcoded list used; doesn't help
|
||||
// with symmetric NAT but keeps calls between flat-network peers
|
||||
// working.
|
||||
const FALLBACK_ICE: RTCIceServer[] = [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
];
|
||||
|
||||
|
|
@ -30,6 +40,38 @@ export function useWebRTC({ sendMessage }: UseWebRTCOptions) {
|
|||
const localStreamRef = useRef<MediaStream | null>(null);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
|
||||
// v1.0.9 item 1.2 — ICE config fetched once at mount, cached for the
|
||||
// page lifetime. `iceConfigRef` carries the latest resolved value so
|
||||
// startCall/acceptCall can synchronously hand it to RTCPeerConnection
|
||||
// without awaiting (calls feel instantaneous when the prefetch has
|
||||
// landed). `nat` exposes a "we may need TURN to reach the peer" hint
|
||||
// to the UI for the advisory banner.
|
||||
const iceConfigRef = useRef<RTCIceServer[]>(FALLBACK_ICE);
|
||||
const [nat, setNat] = useState<{ hasTurn: boolean; loaded: boolean }>({
|
||||
hasTurn: false,
|
||||
loaded: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchWebRTCConfig().then((cfg: WebRTCConfig) => {
|
||||
if (cancelled) return;
|
||||
// RTCIceServer.urls accepts string | string[] — the spec is
|
||||
// happy with either; we hand the array through verbatim.
|
||||
iceConfigRef.current = cfg.iceServers.length > 0
|
||||
? cfg.iceServers.map((s) => ({
|
||||
urls: s.urls,
|
||||
...(s.username !== undefined ? { username: s.username } : {}),
|
||||
...(s.credential !== undefined ? { credential: s.credential } : {}),
|
||||
}))
|
||||
: FALLBACK_ICE;
|
||||
setNat({ hasTurn: hasTurn(cfg), loaded: true });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (pcRef.current) {
|
||||
pcRef.current.close();
|
||||
|
|
@ -53,7 +95,7 @@ export function useWebRTC({ sendMessage }: UseWebRTCOptions) {
|
|||
});
|
||||
localStreamRef.current = stream;
|
||||
|
||||
const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
|
||||
const pc = new RTCPeerConnection({ iceServers: iceConfigRef.current });
|
||||
pcRef.current = pc;
|
||||
|
||||
stream.getTracks().forEach((track) => pc.addTrack(track, stream));
|
||||
|
|
@ -111,7 +153,7 @@ export function useWebRTC({ sendMessage }: UseWebRTCOptions) {
|
|||
});
|
||||
localStreamRef.current = stream;
|
||||
|
||||
const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
|
||||
const pc = new RTCPeerConnection({ iceServers: iceConfigRef.current });
|
||||
pcRef.current = pc;
|
||||
|
||||
stream.getTracks().forEach((track) => pc.addTrack(track, stream));
|
||||
|
|
@ -277,5 +319,13 @@ export function useWebRTC({ sendMessage }: UseWebRTCOptions) {
|
|||
callState,
|
||||
isMuted,
|
||||
cleanup,
|
||||
// v1.0.9 item 1.2 — `nat` exposes the operator's TURN coverage
|
||||
// status to the UI. `loaded=false` means the config fetch is still
|
||||
// in flight (we're using STUN-only fallback in the meantime).
|
||||
// `hasTurn=false` after `loaded=true` means the operator did NOT
|
||||
// configure a TURN relay; calls between symmetric-NAT peers will
|
||||
// fail and the UI should warn the user up-front rather than after
|
||||
// the connection times out.
|
||||
nat,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
104
apps/web/src/services/api/webrtcConfig.test.ts
Normal file
104
apps/web/src/services/api/webrtcConfig.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
fetchWebRTCConfig,
|
||||
hasTurn,
|
||||
_resetWebRTCConfigCache,
|
||||
} from './webrtcConfig';
|
||||
import { apiClient } from './client';
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
apiClient: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedGet = apiClient.get as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
_resetWebRTCConfigCache();
|
||||
});
|
||||
|
||||
describe('fetchWebRTCConfig', () => {
|
||||
it('returns the backend payload when /config/webrtc succeeds', async () => {
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
iceServers: [
|
||||
{ urls: ['stun:s.test:3478'] },
|
||||
{
|
||||
urls: ['turn:t.test:3478', 'turns:t.test:5349'],
|
||||
username: 'u',
|
||||
credential: 'c',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const cfg = await fetchWebRTCConfig();
|
||||
expect(cfg.iceServers).toHaveLength(2);
|
||||
expect(cfg.iceServers[0]?.urls).toEqual(['stun:s.test:3478']);
|
||||
expect(cfg.iceServers[1]?.username).toBe('u');
|
||||
expect(mockedGet).toHaveBeenCalledWith('/config/webrtc');
|
||||
});
|
||||
|
||||
it('caches the resolved promise across calls', async () => {
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
data: { iceServers: [{ urls: ['stun:cached:3478'] }] },
|
||||
});
|
||||
|
||||
const a = await fetchWebRTCConfig();
|
||||
const b = await fetchWebRTCConfig();
|
||||
expect(a).toBe(b);
|
||||
// The cached path must NOT re-issue the HTTP call.
|
||||
expect(mockedGet).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to public STUN when the request fails', async () => {
|
||||
mockedGet.mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
const cfg = await fetchWebRTCConfig();
|
||||
expect(cfg.iceServers).toEqual([
|
||||
{ urls: ['stun:stun.l.google.com:19302'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back when the backend returns a malformed payload', async () => {
|
||||
mockedGet.mockResolvedValueOnce({ data: { iceServers: null } });
|
||||
|
||||
const cfg = await fetchWebRTCConfig();
|
||||
expect(cfg.iceServers).toEqual([
|
||||
{ urls: ['stun:stun.l.google.com:19302'] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasTurn', () => {
|
||||
it('detects TURN entries', () => {
|
||||
expect(
|
||||
hasTurn({
|
||||
iceServers: [
|
||||
{ urls: ['stun:x:3478'] },
|
||||
{ urls: ['turn:y:3478'], username: 'u', credential: 'c' },
|
||||
],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('detects TURNS entries (TLS)', () => {
|
||||
expect(
|
||||
hasTurn({
|
||||
iceServers: [
|
||||
{ urls: ['turns:y:5349'], username: 'u', credential: 'c' },
|
||||
],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when only STUN is configured', () => {
|
||||
expect(hasTurn({ iceServers: [{ urls: ['stun:s:3478'] }] })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false on an empty iceServers list', () => {
|
||||
expect(hasTurn({ iceServers: [] })).toBe(false);
|
||||
});
|
||||
});
|
||||
83
apps/web/src/services/api/webrtcConfig.ts
Normal file
83
apps/web/src/services/api/webrtcConfig.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { apiClient } from '@/services/api/client';
|
||||
import { logger } from '@/utils/logger';
|
||||
|
||||
/**
|
||||
* IceServer mirrors the WebRTC `RTCIceServer` dictionary, scoped to the
|
||||
* fields our backend emits (urls / username / credential). We type
|
||||
* `urls` as `string[]` because the backend always emits an array even
|
||||
* for a single STUN entry — matches `iceServers[0].urls` in the
|
||||
* RTCPeerConnection spec.
|
||||
*/
|
||||
export interface IceServer {
|
||||
urls: string[];
|
||||
username?: string;
|
||||
credential?: string;
|
||||
}
|
||||
|
||||
export interface WebRTCConfig {
|
||||
iceServers: IceServer[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hardcoded fallback used only when the backend `/config/webrtc` call
|
||||
* fails (network down at page load, backend cold start, etc.). Public
|
||||
* Google STUN works for non-symmetric NAT — same behavior the SPA had
|
||||
* pre-v1.0.9, so falling back here is strictly no-worse-than-before.
|
||||
*/
|
||||
const FALLBACK_CONFIG: WebRTCConfig = {
|
||||
iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }],
|
||||
};
|
||||
|
||||
let cached: Promise<WebRTCConfig> | null = null;
|
||||
|
||||
/**
|
||||
* Fetch the operator-provided ICE server set from the backend (v1.0.9
|
||||
* item 1.2). Cached for the page lifetime — RTCPeerConnection takes
|
||||
* `iceServers` once at construction time, so we don't need a refresh
|
||||
* loop. If the backend returns an empty `iceServers` array (no STUN, no
|
||||
* TURN configured at all), we still hand the result through so callers
|
||||
* see the operator's choice rather than silently substituting Google
|
||||
* STUN; that lets `hasTurn()` honestly report false and the UI surface
|
||||
* a "calls may fail behind NAT" advisory.
|
||||
*/
|
||||
export function fetchWebRTCConfig(): Promise<WebRTCConfig> {
|
||||
if (cached) return cached;
|
||||
cached = apiClient
|
||||
.get<WebRTCConfig>('/config/webrtc')
|
||||
.then((response) => {
|
||||
const data = response.data;
|
||||
if (!data || !Array.isArray(data.iceServers)) {
|
||||
logger.warn(
|
||||
'[webrtcConfig] Backend response missing iceServers, using fallback',
|
||||
{ responseData: data },
|
||||
);
|
||||
return FALLBACK_CONFIG;
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.warn(
|
||||
'[webrtcConfig] Failed to fetch /config/webrtc, falling back to public STUN',
|
||||
{ error: err instanceof Error ? err.message : String(err) },
|
||||
);
|
||||
return FALLBACK_CONFIG;
|
||||
});
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* `true` iff the operator-configured ICE set includes at least one TURN
|
||||
* URL. Used by the UI to decide whether to surface a "calls may fail
|
||||
* across NAT" banner — TURN is the only ICE server that can RELAY
|
||||
* packets when peer-to-peer hole-punching fails.
|
||||
*/
|
||||
export function hasTurn(cfg: WebRTCConfig): boolean {
|
||||
return cfg.iceServers.some((s) =>
|
||||
s.urls.some((u) => u.startsWith('turn:') || u.startsWith('turns:')),
|
||||
);
|
||||
}
|
||||
|
||||
/** Test-only: clear the in-memory promise cache between vitest cases. */
|
||||
export function _resetWebRTCConfigCache(): void {
|
||||
cached = null;
|
||||
}
|
||||
146
apps/web/src/services/generated/config/config.ts
Normal file
146
apps/web/src/services/generated/config/config.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* Generated by orval v8.8.1 🍺
|
||||
* Do not edit manually.
|
||||
* Veza Backend API
|
||||
* Backend API for Veza platform.
|
||||
* OpenAPI spec version: 1.2.0
|
||||
*/
|
||||
import {
|
||||
useQuery
|
||||
} from '@tanstack/react-query';
|
||||
import type {
|
||||
DataTag,
|
||||
DefinedInitialDataOptions,
|
||||
DefinedUseQueryResult,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UndefinedInitialDataOptions,
|
||||
UseQueryOptions,
|
||||
UseQueryResult
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import type {
|
||||
InternalHandlersWebRTCConfigResponse
|
||||
} from '../model';
|
||||
|
||||
import { vezaMutator } from '../../api/orval-mutator';
|
||||
|
||||
|
||||
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Public — returns the ICE-server set the SPA feeds to RTCPeerConnection. STUN-only when no TURN is configured. TURN credentials are always emitted as static (REST shared-secret rotation deferred to v1.1).
|
||||
* @summary WebRTC ICE configuration
|
||||
*/
|
||||
export type getConfigWebrtcResponse200 = {
|
||||
data: InternalHandlersWebRTCConfigResponse
|
||||
status: 200
|
||||
}
|
||||
|
||||
export type getConfigWebrtcResponseSuccess = (getConfigWebrtcResponse200) & {
|
||||
headers: Headers;
|
||||
};
|
||||
;
|
||||
|
||||
export type getConfigWebrtcResponse = (getConfigWebrtcResponseSuccess)
|
||||
|
||||
export const getGetConfigWebrtcUrl = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
return `/config/webrtc`
|
||||
}
|
||||
|
||||
export const getConfigWebrtc = async ( options?: RequestInit): Promise<getConfigWebrtcResponse> => {
|
||||
|
||||
return vezaMutator<getConfigWebrtcResponse>(getGetConfigWebrtcUrl(),
|
||||
{
|
||||
...options,
|
||||
method: 'GET'
|
||||
|
||||
|
||||
}
|
||||
);}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export const getGetConfigWebrtcQueryKey = () => {
|
||||
return [
|
||||
`/config/webrtc`
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
export const getGetConfigWebrtcQueryOptions = <TData = Awaited<ReturnType<typeof getConfigWebrtc>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getConfigWebrtc>>, TError, TData>>, request?: SecondParameter<typeof vezaMutator>}
|
||||
) => {
|
||||
|
||||
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetConfigWebrtcQueryKey();
|
||||
|
||||
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getConfigWebrtc>>> = ({ signal }) => getConfigWebrtc({ signal, ...requestOptions });
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getConfigWebrtc>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
}
|
||||
|
||||
export type GetConfigWebrtcQueryResult = NonNullable<Awaited<ReturnType<typeof getConfigWebrtc>>>
|
||||
export type GetConfigWebrtcQueryError = unknown
|
||||
|
||||
|
||||
export function useGetConfigWebrtc<TData = Awaited<ReturnType<typeof getConfigWebrtc>>, TError = unknown>(
|
||||
options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getConfigWebrtc>>, TError, TData>> & Pick<
|
||||
DefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof getConfigWebrtc>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof getConfigWebrtc>>
|
||||
> , 'initialData'
|
||||
>, request?: SecondParameter<typeof vezaMutator>}
|
||||
, queryClient?: QueryClient
|
||||
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
export function useGetConfigWebrtc<TData = Awaited<ReturnType<typeof getConfigWebrtc>>, TError = unknown>(
|
||||
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getConfigWebrtc>>, TError, TData>> & Pick<
|
||||
UndefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof getConfigWebrtc>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof getConfigWebrtc>>
|
||||
> , 'initialData'
|
||||
>, request?: SecondParameter<typeof vezaMutator>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
export function useGetConfigWebrtc<TData = Awaited<ReturnType<typeof getConfigWebrtc>>, TError = unknown>(
|
||||
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getConfigWebrtc>>, TError, TData>>, request?: SecondParameter<typeof vezaMutator>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
/**
|
||||
* @summary WebRTC ICE configuration
|
||||
*/
|
||||
|
||||
export function useGetConfigWebrtc<TData = Awaited<ReturnType<typeof getConfigWebrtc>>, TError = unknown>(
|
||||
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getConfigWebrtc>>, TError, TData>>, request?: SecondParameter<typeof vezaMutator>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||
|
||||
const queryOptions = getGetConfigWebrtcQueryOptions(options)
|
||||
|
||||
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -212,6 +212,7 @@ export * from './internalHandlersDeleteAccountRequest';
|
|||
export * from './internalHandlersDisableTwoFactorRequest';
|
||||
export * from './internalHandlersFrontendLogRequest';
|
||||
export * from './internalHandlersFrontendLogRequestContext';
|
||||
export * from './internalHandlersIceServer';
|
||||
export * from './internalHandlersImportPlaylistRequest';
|
||||
export * from './internalHandlersImportPlaylistRequestPlaylist';
|
||||
export * from './internalHandlersImportPlaylistRequestTracksItem';
|
||||
|
|
@ -239,6 +240,7 @@ export * from './internalHandlersUpdateProfileRequestSocialLinks';
|
|||
export * from './internalHandlersValidateRequest';
|
||||
export * from './internalHandlersValidateResponse';
|
||||
export * from './internalHandlersVerifyTwoFactorRequest';
|
||||
export * from './internalHandlersWebRTCConfigResponse';
|
||||
export * from './postApiV1LogsFrontend200';
|
||||
export * from './postApiV1LogsFrontend200Data';
|
||||
export * from './postAuth2faDisable200';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* Generated by orval v8.8.1 🍺
|
||||
* Do not edit manually.
|
||||
* Veza Backend API
|
||||
* Backend API for Veza platform.
|
||||
* OpenAPI spec version: 1.2.0
|
||||
*/
|
||||
|
||||
export interface InternalHandlersIceServer {
|
||||
credential?: string;
|
||||
urls?: string[];
|
||||
username?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Generated by orval v8.8.1 🍺
|
||||
* Do not edit manually.
|
||||
* Veza Backend API
|
||||
* Backend API for Veza platform.
|
||||
* OpenAPI spec version: 1.2.0
|
||||
*/
|
||||
import type { InternalHandlersIceServer } from './internalHandlersIceServer';
|
||||
|
||||
export interface InternalHandlersWebRTCConfigResponse {
|
||||
iceServers?: InternalHandlersIceServer[];
|
||||
}
|
||||
|
|
@ -264,6 +264,25 @@ Pour migrer un environnement :
|
|||
|
||||
Rollback : revert `TRACK_STORAGE_BACKEND=local`, nouveaux uploads repartent en local. Les tracks déjà migrés restent en `s3` (read path les sert via signed URL en Phase 2).
|
||||
|
||||
## 13bis. WebRTC ICE servers (v1.0.9 item 1.2)
|
||||
|
||||
Public endpoint `GET /api/v1/config/webrtc` returns `{ iceServers }` to
|
||||
the SPA so 1:1 calls (`features/chat/hooks/useWebRTC.ts`) can hand a
|
||||
runtime ICE list to `RTCPeerConnection` without baking secrets into the
|
||||
bundle. See `infra/coturn/README.md` for the relay deploy guide.
|
||||
|
||||
| Variable | Défaut | Lu à | Rôle |
|
||||
| --- | --- | --- | --- |
|
||||
| `WEBRTC_STUN_URLS` | `stun:stun.l.google.com:19302` | `config.go` | Liste comma-separated. STUN seul suffit hors NAT symétrique. |
|
||||
| `WEBRTC_TURN_URLS` | (vide) | `config.go` | Liste comma-separated, e.g. `turn:turn.veza.fr:3478,turns:turn.veza.fr:5349`. Vide = pas de relais TURN exposé, le SPA bascule sur l'advisory "TURN non configuré". |
|
||||
| `WEBRTC_TURN_USERNAME` | (vide) | `config.go` | Identifiant statique `lt-cred-mech` côté coturn. **Doit matcher `user=` dans `infra/coturn/turnserver.conf`**. |
|
||||
| `WEBRTC_TURN_CREDENTIAL` | (vide) | `config.go` | Mot de passe statique. Rotation = update conf coturn + ces deux env vars + reload coturn. |
|
||||
|
||||
**Half-config = aucune TURN diffusée.** Le handler omet le bloc TURN si
|
||||
n'importe lequel de URLs/Username/Credential est vide — un demi-config
|
||||
est pire que rien (le navigateur surfacerait une erreur d'auth au
|
||||
moment du call au lieu du fallback STUN propre).
|
||||
|
||||
## 14. Stream server (backend ↔ stream)
|
||||
|
||||
| Variable | Défaut | Lu à | Rôle |
|
||||
|
|
|
|||
117
infra/coturn/README.md
Normal file
117
infra/coturn/README.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# coturn — Veza TURN/STUN relay (v1.0.9 item 1.2)
|
||||
|
||||
Deployed alongside Veza to fix the **NAT traversal hole-punching gap**
|
||||
identified in `FUNCTIONAL_AUDIT.md` §4 #1: WebRTC 1:1 calls signaling
|
||||
works (chat WebSocket relays the SDP offer/answer/ICE candidates) but
|
||||
the actual media stream fails between two peers behind symmetric NAT
|
||||
(corporate firewalls, mobile carrier CGNAT, Incus container default
|
||||
networking). coturn provides the relay that lets the media flow
|
||||
through a public IP when peer-to-peer hole-punching fails.
|
||||
|
||||
## Topology
|
||||
|
||||
```
|
||||
Caller (browser) Veza backend coturn (Incus)
|
||||
│ │ │
|
||||
│── GET /api/v1/config/webrtc ──▶ │ HTTPS
|
||||
│◀── { iceServers: [...] } ── │
|
||||
│ │
|
||||
│── WS POST /chat ─▶ [SDP offer] ─▶ Callee │ WSS
|
||||
│ │
|
||||
│── ICE candidate (UDP probe) ─▶ Callee │ fail
|
||||
│ │
|
||||
│── ICE candidate (TURN relay) ─────────────▶─────────┤ UDP 3478
|
||||
│◀──────────────── relay ────────────────────────────┤
|
||||
│ │
|
||||
```
|
||||
|
||||
The backend never proxies media itself. coturn is the only component
|
||||
that handles the relay path; backend-api just hands the client a list
|
||||
of candidate ICE servers it can try.
|
||||
|
||||
## Deploy on Incus
|
||||
|
||||
```bash
|
||||
# 1. Create the container with host-network UDP forwarding for the
|
||||
# listener port and the relay range.
|
||||
incus launch images:debian/12 turn-veza
|
||||
incus config device add turn-veza turn-udp proxy \
|
||||
listen=udp:0.0.0.0:3478 connect=udp:127.0.0.1:3478
|
||||
# Range proxy is more involved; the cleanest is to put the container
|
||||
# directly on the host network:
|
||||
# incus config set turn-veza security.privileged true
|
||||
# incus config device add turn-veza host-network nic nictype=macvlan parent=eno1
|
||||
# Or use a dedicated public IP and skip macvlan.
|
||||
|
||||
# 2. Install coturn.
|
||||
incus exec turn-veza -- apt-get update
|
||||
incus exec turn-veza -- apt-get install -y coturn
|
||||
|
||||
# 3. Render this directory's turnserver.conf with secrets — Ansible
|
||||
# template OR sops-decrypt OR raw envsubst:
|
||||
#
|
||||
# WEBRTC_TURN_PUBLIC_IP=<public_ip> \
|
||||
# WEBRTC_TURN_REALM=turn.veza.fr \
|
||||
# WEBRTC_TURN_USERNAME=<from_vault> \
|
||||
# WEBRTC_TURN_CREDENTIAL=<from_vault> \
|
||||
# envsubst < turnserver.conf | incus file push - turn-veza/etc/turnserver.conf
|
||||
|
||||
# 4. Drop in the TLS cert+key (Let's Encrypt or a rotated wildcard).
|
||||
incus file push fullchain.pem turn-veza/etc/coturn/cert.pem
|
||||
incus file push privkey.pem turn-veza/etc/coturn/key.pem
|
||||
|
||||
# 5. Enable + start.
|
||||
incus exec turn-veza -- systemctl enable coturn
|
||||
incus exec turn-veza -- systemctl start coturn
|
||||
```
|
||||
|
||||
## Configure Veza backend
|
||||
|
||||
Set these env vars on the backend container so the SPA gets the right
|
||||
ICE servers from `GET /api/v1/config/webrtc`:
|
||||
|
||||
```bash
|
||||
WEBRTC_STUN_URLS=stun:turn.veza.fr:3478 # comma-separated
|
||||
WEBRTC_TURN_URLS=turn:turn.veza.fr:3478,turns:turn.veza.fr:5349
|
||||
WEBRTC_TURN_USERNAME=<same as turnserver.conf>
|
||||
WEBRTC_TURN_CREDENTIAL=<same as turnserver.conf>
|
||||
```
|
||||
|
||||
If any of the TURN vars is empty, the handler returns STUN-only and the
|
||||
SPA's `useWebRTC().nat.hasTurn` reports false — the CallButton tooltip
|
||||
warns the user up-front instead of letting calls time out silently.
|
||||
|
||||
## Smoke test
|
||||
|
||||
```bash
|
||||
# From any machine outside the Incus host network:
|
||||
turnutils_uclient \
|
||||
-u <WEBRTC_TURN_USERNAME> \
|
||||
-w <WEBRTC_TURN_CREDENTIAL> \
|
||||
-p 3478 turn.veza.fr
|
||||
|
||||
# Should succeed within ~1s. Failure modes:
|
||||
# "Cannot get a TURN allocation" — listening-ip/port wrong, or NAT not forwarded
|
||||
# "401 Unauthorized" — username/credential mismatch with config
|
||||
# "BAD-REQUEST" — realm mismatch
|
||||
```
|
||||
|
||||
For an end-to-end test from the SPA: open the browser devtools, start a
|
||||
call, watch `chrome://webrtc-internals` for `iceConnectionState=connected`
|
||||
and the candidate pair selected — should be `relay`/`relay` when on
|
||||
symmetric-NAT networks.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- Static credentials are rotated by changing `user=` in `turnserver.conf`
|
||||
and reloading coturn (`systemctl reload coturn`). The backend env
|
||||
vars must be updated to match in the same change window — the SPA
|
||||
caches the config for the page lifetime, so rotation is invisible to
|
||||
in-flight users.
|
||||
- v1.1 will switch to RFC-draft REST shared-secret credentials so the
|
||||
backend can mint per-user, per-call ephemeral credentials without
|
||||
reloading coturn. See `ORIGIN_SECURITY_FRAMEWORK.md` (deferred).
|
||||
- Capacity rule of thumb: each TURN relay session uses ~50 KB/s for
|
||||
audio. A 4-vCPU coturn handles ~1000 concurrent audio sessions before
|
||||
CPU saturation. Scale horizontally with a second container behind a
|
||||
DNS round-robin if needed; coturn is stateless across instances.
|
||||
100
infra/coturn/turnserver.conf
Normal file
100
infra/coturn/turnserver.conf
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# coturn configuration template — Veza v1.0.9 item 1.2
|
||||
#
|
||||
# Deploy onto an Incus container reachable from the public internet on
|
||||
# UDP/3478 (TURN) and UDP/49152-65535 (relay range). The container
|
||||
# **must** be on the host network or have UDP NAT forwarded for those
|
||||
# ranges — TURN traffic does NOT survive Docker/Incus default NAT.
|
||||
#
|
||||
# Substitute the ${UPPERCASE} tokens via your secrets manager before
|
||||
# rendering this file (Ansible `ansible.builtin.template`, sops, age,
|
||||
# etc.). Never commit the rendered version to git.
|
||||
#
|
||||
# Smoke test post-deploy:
|
||||
# $ turnutils_uclient -u veza -w "${WEBRTC_TURN_CREDENTIAL}" \
|
||||
# -p 3478 turn.veza.fr
|
||||
# (succeeds = relay works; failure = check listening-ip / external-ip)
|
||||
|
||||
# --- Listening setup -------------------------------------------------
|
||||
|
||||
# Listen on every interface so coturn picks up the public IP regardless
|
||||
# of how the container's network stack is wired.
|
||||
listening-port=3478
|
||||
tls-listening-port=5349
|
||||
|
||||
# Public reachable IP. REQUIRED behind NAT — without it, coturn
|
||||
# advertises the container's RFC1918 address in candidates and clients
|
||||
# can't reach the relay.
|
||||
external-ip=${WEBRTC_TURN_PUBLIC_IP}
|
||||
|
||||
# UDP relay port range. 16k ports is the common-sense default; raise if
|
||||
# you expect more than ~8k concurrent TURN sessions (each session uses
|
||||
# 1-2 ports).
|
||||
min-port=49152
|
||||
max-port=65535
|
||||
|
||||
# Realm — used in the TURN handshake. Must match what the frontend
|
||||
# sends (it doesn't, today: the frontend sends only urls/username/cred).
|
||||
# Set to your domain so logs are unambiguous.
|
||||
realm=${WEBRTC_TURN_REALM}
|
||||
|
||||
# --- Authentication --------------------------------------------------
|
||||
|
||||
# Static long-term credentials — simple, works, and matches what
|
||||
# `internal/handlers/webrtc_config_handler.go` returns to the SPA.
|
||||
#
|
||||
# v1.0.9 keeps this static. v1.1 should switch to the time-limited REST
|
||||
# shared-secret scheme (RFC draft-uberti-behave-turn-rest), which lets
|
||||
# the backend mint per-session credentials and rotate without restarting
|
||||
# coturn. Documented in ORIGIN_SECURITY_FRAMEWORK.md (deferred section).
|
||||
lt-cred-mech
|
||||
user=${WEBRTC_TURN_USERNAME}:${WEBRTC_TURN_CREDENTIAL}
|
||||
|
||||
# --- TLS for TURNS ---------------------------------------------------
|
||||
|
||||
# Cert chain. Either Let's Encrypt (cert-manager / certbot cron) or a
|
||||
# rotated wildcard mounted into the container at these paths.
|
||||
cert=/etc/coturn/cert.pem
|
||||
pkey=/etc/coturn/key.pem
|
||||
|
||||
# Modern TLS only. coturn defaults are permissive; tighten here so the
|
||||
# whole chain matches the rest of the platform.
|
||||
no-tlsv1
|
||||
no-tlsv1_1
|
||||
|
||||
# --- Hardening ------------------------------------------------------
|
||||
|
||||
# Disable the multiplexed TLS-on-TCP-3478 listener — TURN-over-TCP is
|
||||
# slow and we already expose port 5349 for TURNS-over-TLS. Keeps the
|
||||
# attack surface predictable.
|
||||
no-multicast-peers
|
||||
no-cli
|
||||
no-loopback-peers
|
||||
|
||||
# Avoid relaying to RFC1918 / loopback / link-local ranges. Without
|
||||
# this, a malicious peer could use Veza's TURN as an SSRF springboard.
|
||||
denied-peer-ip=0.0.0.0-0.255.255.255
|
||||
denied-peer-ip=10.0.0.0-10.255.255.255
|
||||
denied-peer-ip=100.64.0.0-100.127.255.255
|
||||
denied-peer-ip=127.0.0.0-127.255.255.255
|
||||
denied-peer-ip=169.254.0.0-169.254.255.255
|
||||
denied-peer-ip=172.16.0.0-172.31.255.255
|
||||
denied-peer-ip=192.0.0.0-192.0.0.255
|
||||
denied-peer-ip=192.0.2.0-192.0.2.255
|
||||
denied-peer-ip=192.88.99.0-192.88.99.255
|
||||
denied-peer-ip=192.168.0.0-192.168.255.255
|
||||
denied-peer-ip=198.18.0.0-198.19.255.255
|
||||
denied-peer-ip=198.51.100.0-198.51.100.255
|
||||
denied-peer-ip=203.0.113.0-203.0.113.255
|
||||
denied-peer-ip=240.0.0.0-255.255.255.255
|
||||
|
||||
# --- Observability ---------------------------------------------------
|
||||
|
||||
# Prometheus exporter on a non-public port. Scrape from the same
|
||||
# Prometheus instance that already targets backend-api.
|
||||
prometheus
|
||||
|
||||
# Verbose enough to diagnose, quiet enough not to flood the disk on
|
||||
# peak traffic. Bump to `verbose` temporarily during incidents.
|
||||
log-file=/var/log/coturn/turnserver.log
|
||||
no-stdout-log
|
||||
syslog
|
||||
|
|
@ -1892,6 +1892,26 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/config/webrtc": {
|
||||
"get": {
|
||||
"description": "Public — returns the ICE-server set the SPA feeds to RTCPeerConnection. STUN-only when no TURN is configured. TURN credentials are always emitted as static (REST shared-secret rotation deferred to v1.1).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Config"
|
||||
],
|
||||
"summary": "WebRTC ICE configuration",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "ICE servers",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_handlers.WebRTCConfigResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/internal/tracks/{id}/stream-ready": {
|
||||
"post": {
|
||||
"description": "Internal endpoint called by the Rust stream server when HLS transcoding completes or fails. Updates the track's stream_status and stream_manifest_url. Requires internal API key (not user-facing).",
|
||||
|
|
@ -9280,6 +9300,23 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"internal_handlers.IceServer": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"credential": {
|
||||
"type": "string"
|
||||
},
|
||||
"urls": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_handlers.ImportPlaylistRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -9677,6 +9714,17 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"internal_handlers.WebRTCConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"iceServers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/internal_handlers.IceServer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"veza-backend-api_internal_core_marketplace.LicenseType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
|
|
|||
|
|
@ -1886,6 +1886,26 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/config/webrtc": {
|
||||
"get": {
|
||||
"description": "Public — returns the ICE-server set the SPA feeds to RTCPeerConnection. STUN-only when no TURN is configured. TURN credentials are always emitted as static (REST shared-secret rotation deferred to v1.1).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Config"
|
||||
],
|
||||
"summary": "WebRTC ICE configuration",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "ICE servers",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_handlers.WebRTCConfigResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/internal/tracks/{id}/stream-ready": {
|
||||
"post": {
|
||||
"description": "Internal endpoint called by the Rust stream server when HLS transcoding completes or fails. Updates the track's stream_status and stream_manifest_url. Requires internal API key (not user-facing).",
|
||||
|
|
@ -9274,6 +9294,23 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"internal_handlers.IceServer": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"credential": {
|
||||
"type": "string"
|
||||
},
|
||||
"urls": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_handlers.ImportPlaylistRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -9671,6 +9708,17 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"internal_handlers.WebRTCConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"iceServers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/internal_handlers.IceServer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"veza-backend-api_internal_core_marketplace.LicenseType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
|
|
|||
|
|
@ -342,6 +342,17 @@ definitions:
|
|||
timestamp:
|
||||
type: string
|
||||
type: object
|
||||
internal_handlers.IceServer:
|
||||
properties:
|
||||
credential:
|
||||
type: string
|
||||
urls:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
username:
|
||||
type: string
|
||||
type: object
|
||||
internal_handlers.ImportPlaylistRequest:
|
||||
properties:
|
||||
playlist:
|
||||
|
|
@ -616,6 +627,13 @@ definitions:
|
|||
- code
|
||||
- secret
|
||||
type: object
|
||||
internal_handlers.WebRTCConfigResponse:
|
||||
properties:
|
||||
iceServers:
|
||||
items:
|
||||
$ref: '#/definitions/internal_handlers.IceServer'
|
||||
type: array
|
||||
type: object
|
||||
veza-backend-api_internal_core_marketplace.LicenseType:
|
||||
enum:
|
||||
- basic
|
||||
|
|
@ -2310,6 +2328,21 @@ paths:
|
|||
summary: Get comment replies
|
||||
tags:
|
||||
- Comment
|
||||
/config/webrtc:
|
||||
get:
|
||||
description: Public — returns the ICE-server set the SPA feeds to RTCPeerConnection.
|
||||
STUN-only when no TURN is configured. TURN credentials are always emitted
|
||||
as static (REST shared-secret rotation deferred to v1.1).
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: ICE servers
|
||||
schema:
|
||||
$ref: '#/definitions/internal_handlers.WebRTCConfigResponse'
|
||||
summary: WebRTC ICE configuration
|
||||
tags:
|
||||
- Config
|
||||
/internal/tracks/{id}/stream-ready:
|
||||
post:
|
||||
consumes:
|
||||
|
|
|
|||
|
|
@ -239,6 +239,15 @@ func (r *APIRouter) setupCorePublicRoutes(router *gin.Engine) {
|
|||
announcementHandler := handlers.NewAnnouncementHandler(announcementSvc)
|
||||
v1Public.GET("/announcements/active", announcementHandler.GetActive)
|
||||
}
|
||||
|
||||
// v1.0.9 item 1.2 — WebRTC ICE servers for the SPA. Public so the
|
||||
// frontend can fetch it before the user is authenticated (the call
|
||||
// surface lives in the chat tab, but the STUN/TURN bootstrap is
|
||||
// page-load-time, not call-time, to minimise latency on the first
|
||||
// call attempt).
|
||||
if r.config != nil {
|
||||
v1Public.GET("/config/webrtc", handlers.GetWebRTCConfig(r.config))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -106,6 +106,24 @@ type Config struct {
|
|||
// operators can roll out per environment and roll back by flipping the env var.
|
||||
TrackStorageBackend string
|
||||
|
||||
// WebRTC ICE servers (v1.0.9 item 1.2 — coturn).
|
||||
//
|
||||
// WebRTCStunURLs is a list of STUN server URLs (e.g. "stun:stun.l.google.com:19302").
|
||||
// Empty defaults to the Google public STUN — fine for dev / behind-the-NAT
|
||||
// scenarios where both peers can reach the same STUN, broken in production
|
||||
// where symmetric NAT requires a TURN relay.
|
||||
//
|
||||
// WebRTCTurnURLs / Username / Credential drive the optional self-hosted
|
||||
// coturn relay deployed alongside Veza in prod (see infra/coturn/). When
|
||||
// any of TurnURLs / Username / Credential is empty, the TURN block is
|
||||
// omitted from the iceServers payload — the frontend falls back to STUN
|
||||
// only and can warn the user. Static credentials are accepted; for the
|
||||
// shared-secret REST scheme see ORIGIN_SECURITY_FRAMEWORK.md (deferred).
|
||||
WebRTCStunURLs []string
|
||||
WebRTCTurnURLs []string
|
||||
WebRTCTurnUsername string
|
||||
WebRTCTurnCredential string
|
||||
|
||||
// Sentry configuration
|
||||
SentryDsn string // DSN Sentry pour error tracking
|
||||
SentryEnvironment string // Environnement Sentry (dev, staging, prod)
|
||||
|
|
@ -376,6 +394,16 @@ func NewConfig() (*Config, error) {
|
|||
// ValidateForEnvironment() — s3 requires S3Enabled.
|
||||
TrackStorageBackend: getEnv("TRACK_STORAGE_BACKEND", "local"),
|
||||
|
||||
// WebRTC ICE configuration (v1.0.9 item 1.2 — coturn). Read by
|
||||
// the public GET /api/v1/config/webrtc handler so the frontend
|
||||
// hydrates RTCPeerConnection({ iceServers }) without baking
|
||||
// secrets into the bundle. Empty defaults = STUN-only fallback,
|
||||
// TURN block omitted from the response.
|
||||
WebRTCStunURLs: getEnvStringSlice("WEBRTC_STUN_URLS", []string{"stun:stun.l.google.com:19302"}),
|
||||
WebRTCTurnURLs: getEnvStringSlice("WEBRTC_TURN_URLS", nil),
|
||||
WebRTCTurnUsername: getEnv("WEBRTC_TURN_USERNAME", ""),
|
||||
WebRTCTurnCredential: getEnv("WEBRTC_TURN_CREDENTIAL", ""),
|
||||
|
||||
// Sentry configuration
|
||||
SentryDsn: getEnv("SENTRY_DSN", ""),
|
||||
SentryEnvironment: env, // Utiliser l'environnement détecté
|
||||
|
|
|
|||
75
veza-backend-api/internal/handlers/webrtc_config_handler.go
Normal file
75
veza-backend-api/internal/handlers/webrtc_config_handler.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"veza-backend-api/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// IceServer mirrors the WebRTC `RTCIceServer` dictionary. Username and
|
||||
// credential are omitted (not zero-valued) when empty so STUN-only
|
||||
// entries don't carry meaningless auth fields, and so frontends that
|
||||
// validate the response don't have to special-case empty strings.
|
||||
//
|
||||
// Wire shape is intentionally lowercase to match the JS API the
|
||||
// frontend feeds directly into `new RTCPeerConnection({ iceServers })`.
|
||||
type IceServer struct {
|
||||
URLs []string `json:"urls"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Credential string `json:"credential,omitempty"`
|
||||
}
|
||||
|
||||
// WebRTCConfigResponse is the body of GET /api/v1/config/webrtc. The
|
||||
// frontend caches it for the lifetime of the page and uses it to build
|
||||
// every RTCPeerConnection (1:1 calls today, screen-share / multi-party
|
||||
// later). Public endpoint by design — TURN credentials returned here
|
||||
// are short-lived rotation candidates, never long-lived secrets.
|
||||
type WebRTCConfigResponse struct {
|
||||
IceServers []IceServer `json:"iceServers"`
|
||||
}
|
||||
|
||||
// GetWebRTCConfig returns the ICE-server set the frontend should hand to
|
||||
// `new RTCPeerConnection`. v1.0.9 item 1.2 — closes the
|
||||
// FUNCTIONAL_AUDIT.md §4 #1 NAT-traversal gap. Before this endpoint the
|
||||
// frontend hardcoded `stun:stun.l.google.com:19302`, which works on
|
||||
// flat networks but fails behind symmetric NAT (corporate, mobile
|
||||
// carrier, Incus container default networking). Returning the operator-
|
||||
// configured TURN block lets the browser fall back to a relay when
|
||||
// hole-punching fails, which is the only thing that makes WebRTC reach
|
||||
// "actually works for end users" status.
|
||||
//
|
||||
// @Summary WebRTC ICE configuration
|
||||
// @Description Public — returns the ICE-server set the SPA feeds to RTCPeerConnection. STUN-only when no TURN is configured. TURN credentials are always emitted as static (REST shared-secret rotation deferred to v1.1).
|
||||
// @Tags Config
|
||||
// @Produce json
|
||||
// @Success 200 {object} handlers.WebRTCConfigResponse "ICE servers"
|
||||
// @Router /config/webrtc [get]
|
||||
func GetWebRTCConfig(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
resp := WebRTCConfigResponse{
|
||||
IceServers: []IceServer{},
|
||||
}
|
||||
|
||||
if cfg != nil && len(cfg.WebRTCStunURLs) > 0 {
|
||||
resp.IceServers = append(resp.IceServers, IceServer{URLs: cfg.WebRTCStunURLs})
|
||||
}
|
||||
|
||||
// TURN block is only emitted when fully configured. Half-configured
|
||||
// is worse than missing — the browser would surface auth failures
|
||||
// instead of falling back cleanly to STUN-only.
|
||||
if cfg != nil &&
|
||||
len(cfg.WebRTCTurnURLs) > 0 &&
|
||||
cfg.WebRTCTurnUsername != "" &&
|
||||
cfg.WebRTCTurnCredential != "" {
|
||||
resp.IceServers = append(resp.IceServers, IceServer{
|
||||
URLs: cfg.WebRTCTurnURLs,
|
||||
Username: cfg.WebRTCTurnUsername,
|
||||
Credential: cfg.WebRTCTurnCredential,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
}
|
||||
117
veza-backend-api/internal/handlers/webrtc_config_handler_test.go
Normal file
117
veza-backend-api/internal/handlers/webrtc_config_handler_test.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"veza-backend-api/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newWebRTCRouter(cfg *config.Config) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/config/webrtc", GetWebRTCConfig(cfg))
|
||||
return r
|
||||
}
|
||||
|
||||
func decodeWebRTC(t *testing.T, w *httptest.ResponseRecorder) WebRTCConfigResponse {
|
||||
t.Helper()
|
||||
var resp WebRTCConfigResponse
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestGetWebRTCConfig_StunOnlyWhenNoTurn(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
WebRTCStunURLs: []string{"stun:stun.example.org:3478"},
|
||||
}
|
||||
r := newWebRTCRouter(cfg)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/config/webrtc", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
resp := decodeWebRTC(t, w)
|
||||
require.Len(t, resp.IceServers, 1)
|
||||
assert.Equal(t, []string{"stun:stun.example.org:3478"}, resp.IceServers[0].URLs)
|
||||
assert.Empty(t, resp.IceServers[0].Username)
|
||||
assert.Empty(t, resp.IceServers[0].Credential)
|
||||
}
|
||||
|
||||
func TestGetWebRTCConfig_EmitsTurnBlockWhenFullyConfigured(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
WebRTCStunURLs: []string{"stun:stun.example.org:3478"},
|
||||
WebRTCTurnURLs: []string{"turn:turn.veza.local:3478", "turns:turn.veza.local:5349"},
|
||||
WebRTCTurnUsername: "vezauser",
|
||||
WebRTCTurnCredential: "shhhhh",
|
||||
}
|
||||
r := newWebRTCRouter(cfg)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/config/webrtc", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
resp := decodeWebRTC(t, w)
|
||||
require.Len(t, resp.IceServers, 2)
|
||||
|
||||
assert.Equal(t, []string{"stun:stun.example.org:3478"}, resp.IceServers[0].URLs)
|
||||
assert.Equal(t, []string{"turn:turn.veza.local:3478", "turns:turn.veza.local:5349"}, resp.IceServers[1].URLs)
|
||||
assert.Equal(t, "vezauser", resp.IceServers[1].Username)
|
||||
assert.Equal(t, "shhhhh", resp.IceServers[1].Credential)
|
||||
}
|
||||
|
||||
func TestGetWebRTCConfig_OmitsTurnBlockWhenHalfConfigured(t *testing.T) {
|
||||
// Half-configured TURN (URLs but no creds) is worse than missing —
|
||||
// the browser would surface auth failures instead of falling back
|
||||
// cleanly. The handler omits the block in that case.
|
||||
cases := map[string]*config.Config{
|
||||
"missing username": {
|
||||
WebRTCStunURLs: []string{"stun:s.test:3478"},
|
||||
WebRTCTurnURLs: []string{"turn:t.test:3478"},
|
||||
WebRTCTurnCredential: "creds",
|
||||
},
|
||||
"missing credential": {
|
||||
WebRTCStunURLs: []string{"stun:s.test:3478"},
|
||||
WebRTCTurnURLs: []string{"turn:t.test:3478"},
|
||||
WebRTCTurnUsername: "user",
|
||||
},
|
||||
"missing urls": {
|
||||
WebRTCStunURLs: []string{"stun:s.test:3478"},
|
||||
WebRTCTurnUsername: "user",
|
||||
WebRTCTurnCredential: "creds",
|
||||
},
|
||||
}
|
||||
for name, cfg := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
r := newWebRTCRouter(cfg)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/config/webrtc", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
resp := decodeWebRTC(t, w)
|
||||
assert.Len(t, resp.IceServers, 1, "only the STUN entry should be present when TURN is partial")
|
||||
assert.Equal(t, []string{"stun:s.test:3478"}, resp.IceServers[0].URLs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWebRTCConfig_EmptyConfigReturnsEmptyIceServers(t *testing.T) {
|
||||
cfg := &config.Config{} // no STUN, no TURN
|
||||
r := newWebRTCRouter(cfg)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/config/webrtc", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
resp := decodeWebRTC(t, w)
|
||||
assert.Empty(t, resp.IceServers)
|
||||
}
|
||||
|
|
@ -342,6 +342,17 @@ definitions:
|
|||
timestamp:
|
||||
type: string
|
||||
type: object
|
||||
internal_handlers.IceServer:
|
||||
properties:
|
||||
credential:
|
||||
type: string
|
||||
urls:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
username:
|
||||
type: string
|
||||
type: object
|
||||
internal_handlers.ImportPlaylistRequest:
|
||||
properties:
|
||||
playlist:
|
||||
|
|
@ -616,6 +627,13 @@ definitions:
|
|||
- code
|
||||
- secret
|
||||
type: object
|
||||
internal_handlers.WebRTCConfigResponse:
|
||||
properties:
|
||||
iceServers:
|
||||
items:
|
||||
$ref: '#/definitions/internal_handlers.IceServer'
|
||||
type: array
|
||||
type: object
|
||||
veza-backend-api_internal_core_marketplace.LicenseType:
|
||||
enum:
|
||||
- basic
|
||||
|
|
@ -2310,6 +2328,21 @@ paths:
|
|||
summary: Get comment replies
|
||||
tags:
|
||||
- Comment
|
||||
/config/webrtc:
|
||||
get:
|
||||
description: Public — returns the ICE-server set the SPA feeds to RTCPeerConnection.
|
||||
STUN-only when no TURN is configured. TURN credentials are always emitted
|
||||
as static (REST shared-secret rotation deferred to v1.1).
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: ICE servers
|
||||
schema:
|
||||
$ref: '#/definitions/internal_handlers.WebRTCConfigResponse'
|
||||
summary: WebRTC ICE configuration
|
||||
tags:
|
||||
- Config
|
||||
/internal/tracks/{id}/stream-ready:
|
||||
post:
|
||||
consumes:
|
||||
|
|
|
|||
Loading…
Reference in a new issue