Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f862b4b
ref: Pin the colibri WebSocket password to the initial ICE Agent
bgrozev Aug 5, 2026
b80ae46
feat: Support in-place ICE restart with a new Agent
bgrozev Aug 5, 2026
ec87d2b
ref: Update jitsi-xmpp-extensions to 1.0-119-gc67bc81
bgrozev Aug 10, 2026
6261483
ref: Move the colibri WebSocket password out of IceTransport
bgrozev Aug 13, 2026
0afc79e
fix: Only route generation-tagged transport updates to a pending restart
bgrozev Aug 13, 2026
6ef1da9
fix: Keep the existing transport when ICE is not established yet
bgrozev Aug 13, 2026
9e45f49
fix: Handle a failure to create the ICE Agent of a restart
bgrozev Aug 13, 2026
fb2c103
fix: Make a repeated ICE restart request idempotent
bgrozev Aug 13, 2026
f5d7079
fix: Free the retired ICE Agent off the scheduled pool
bgrozev Aug 13, 2026
dc48d7a
fix: Free a retiring ICE Agent and cancel restart tasks in stop()
bgrozev Aug 13, 2026
b486bea
fix: Count superseded ICE restarts in their own metric
bgrozev Aug 13, 2026
d16a865
fix: Reject an ICE restart if the configured timeout is not positive
bgrozev Aug 13, 2026
982cf8e
fix: Only stamp ice-generation on a pending restart's transport
bgrozev Aug 13, 2026
2eefe5c
fix: Arm an ICE restart only once the rest of the request has succeeded
bgrozev Aug 13, 2026
ba94688
doc: Correct how ice4j demultiplexes between the two ICE Agents
bgrozev Aug 13, 2026
0f9d2a9
doc: Describe how a refused ICE restart is signaled
bgrozev Aug 13, 2026
bb66485
ref: Raise the ICE restart timeouts
bgrozev Aug 13, 2026
ed6aa44
test: Cover the ICE restart state machine of IceTransport
bgrozev Aug 13, 2026
bc047e2
fix: Do not replace the remote credentials of a running restart Agent
bgrozev Aug 17, 2026
77dd7ba
fix: Escalate when the ICE restart Agent can not be created
bgrozev Aug 17, 2026
00c5d51
test: Cover the ICE restart timers and the ordinary transport path
bgrozev Aug 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions jvb/src/main/kotlin/org/jitsi/videobridge/Endpoint.kt
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,13 @@ import org.jitsi.videobridge.relay.RelayedEndpoint
import org.jitsi.videobridge.rest.root.debug.EndpointDebugFeatures
import org.jitsi.videobridge.stats.PacketTransitStats
import org.jitsi.videobridge.transport.dtls.DtlsTransport
import org.jitsi.videobridge.transport.ice.IceRestartResult
import org.jitsi.videobridge.transport.ice.IceTransport
import org.jitsi.videobridge.util.ByteBufferPool
import org.jitsi.videobridge.util.TaskPools
import org.jitsi.videobridge.util.looksLikeDtls
import org.jitsi.videobridge.websocket.colibriWebSocketServiceSupplier
import org.jitsi.videobridge.websocket.generateColibriWebSocketPassword
import org.jitsi.xmpp.extensions.colibri.WebSocketPacketExtension
import org.jitsi.xmpp.extensions.jingle.DtlsFingerprintPacketExtension
import org.jitsi.xmpp.extensions.jingle.IceUdpTransportPacketExtension
Expand Down Expand Up @@ -170,6 +172,13 @@ class Endpoint @JvmOverloads constructor(
}
}

/**
* The password which authenticates the colibri WebSocket of this endpoint. It is independent of ICE and
* stays the same for the lifetime of the endpoint, because the client re-dials the URL which contains it
* each time the WebSocket reconnects. See [generateColibriWebSocketPassword].
*/
private val webSocketPassword = generateColibriWebSocketPassword()

/* TODO: do we ever want to support useUniquePort for an Endpoint? */
private val iceTransport = IceTransport(id, iceControlling, false, supportsPrivateAddresses, logger)
private val dtlsTransport = DtlsTransport(logger, id).also { it.cryptex = CryptexConfig.endpoint }
Expand Down Expand Up @@ -731,11 +740,8 @@ class Endpoint @JvmOverloads constructor(
* @return {@code true} iff the password matches.
*/
fun acceptWebSocket(password: String): Boolean {
if (iceTransport.icePassword != password) {
logger.warn(
"Incoming web socket request with an invalid password. " +
"Expected: ${iceTransport.icePassword} received $password"
)
if (webSocketPassword != password) {
logger.warn("Incoming web socket request with an invalid password.")
return false
}
return true
Expand Down Expand Up @@ -786,6 +792,19 @@ class Endpoint @JvmOverloads constructor(
iceTransport.startConnectivityEstablishment(transportInfo)
}

/**
* Handles an explicit ICE restart request from this endpoint (colibri2 `<transport ice-restart="true"/>`).
*
* Creates a new ice4j Agent with freshly rotated local credentials alongside the established one, which
* keeps carrying media until the new one connects (make-before-break).
*
* @return what the caller must signal back to the endpoint: our new transport (returned by
* [describeTransport], which then describes the pending Agent) for [IceRestartResult.STARTED], the
* unchanged established transport for [IceRestartResult.KEEP_EXISTING], or no transport at all for
* [IceRestartResult.UNAVAILABLE].
*/
fun requestIceRestart(): IceRestartResult = iceTransport.requestIceRestart()

fun describeTransport(): IceUdpTransportPacketExtension {
val iceUdpTransportPacketExtension = IceUdpTransportPacketExtension()
iceTransport.describe(iceUdpTransportPacketExtension)
Expand All @@ -794,7 +813,7 @@ class Endpoint @JvmOverloads constructor(
colibriWebsocketService.getColibriWebSocketUrls(
conference.id,
id,
iceTransport.icePassword
webSocketPassword
).forEach { wsUrl ->
val wsPacketExtension = WebSocketPacketExtension(wsUrl)
iceUdpTransportPacketExtension.addChildExtension(wsPacketExtension)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import org.jitsi.videobridge.relay.AudioSourceDesc
import org.jitsi.videobridge.relay.Relay
import org.jitsi.videobridge.relay.RelayConfig
import org.jitsi.videobridge.sctp.SctpConfig
import org.jitsi.videobridge.transport.ice.IceRestartResult
import org.jitsi.videobridge.util.PayloadTypeUtil.Companion.create
import org.jitsi.videobridge.websocket.config.WebsocketServiceConfig
import org.jitsi.videobridge.xmpp.MediaSourceFactory
Expand Down Expand Up @@ -257,19 +258,6 @@ class Colibri2ConferenceHandler(
}

c2endpoint.transport?.iceUdpTransport?.let { endpoint.setTransportInfo(it) }
if (c2endpoint.create) {
val transBuilder = Transport.getBuilder()
transBuilder.setIceUdpExtension(endpoint.describeTransport())
if (c2endpoint.transport?.sctp != null) {
transBuilder.setSctp(
Sctp.Builder()
.setPort(DcSctpTransport.DEFAULT_SCTP_PORT)
.setRole(Sctp.Role.SERVER)
.build()
)
}
respBuilder.setTransport(transBuilder.build())
}

c2endpoint.sources?.let { sources ->
if (endpoint.visitor && sources.mediaSources.isNotEmpty()) {
Expand Down Expand Up @@ -309,6 +297,50 @@ class Colibri2ConferenceHandler(
endpoint.updateForceMute(it.audio, it.video)
}

// An explicit ICE restart request. Handled after setTransportInfo, so any credentials included in this
// same request still belong to (and are applied to) the pre-restart Agent. The restart rotates our own
// ICE credentials, so the endpoint has to be told the new ones — its connectivity checks are addressed
// to them — even though this is not a create.
//
// Handled last, once nothing else in this request can throw: a restart is armed as a side effect (it
// rotates the credentials we advertise and starts the timeout), so failing the request after arming it
// would leave the bridge waiting out that timeout for an answer the endpoint was never asked for.
val iceRestartResult = if (c2endpoint.transport?.iceRestart == true) {
endpoint.requestIceRestart().also {
if (it != IceRestartResult.STARTED) {
logger.warn("Did not restart ICE for endpoint ${c2endpoint.id}: $it.")
}
}
} else {
null
}

// How the refusal of a restart is signaled: with the *absence* of a <transport> in the
// conference-modified for this endpoint. There is no explicit "refused" flag, and the request is not
// failed with an error, because an error would fail the whole conference-modify and take every other
// endpoint's updates with it. Jicofo pairs a request it sent with the answer it gets back: a
// <transport> means the restart happened and is relayed to the endpoint, no <transport> means it did
// not and jicofo falls back to a re-invite.
//
// So a transport is signaled back for a restart that started (the new Agent's rotated credentials) and
// for one that kept the existing Agent (its unchanged credentials, so the endpoint keeps the connection
// it has, with no re-invite). Only IceRestartResult.UNAVAILABLE signals nothing.
if (c2endpoint.create || iceRestartResult == IceRestartResult.STARTED ||
iceRestartResult == IceRestartResult.KEEP_EXISTING

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KEEP_EXISTING is signalled as a transport the client discards -- worth confirming the far end agrees.

The transport described here for KEEP_EXISTING carries no ice-generation (correct -- describe() only stamps a pending bundle). Following it through jicofo's ColibriV2SessionManager.endpointIceRestarted:

  • It passes the staleness guard, then hits participantInfo.lastRelayedIceGeneration = generation unconditionally, which resets the high-water mark to GENERATION_UNSPECIFIED and loses staleness protection for the next reordered response.
  • jicofo counts it as a relayed restart, so IceRestartMetrics will over-count in-place restarts that never happened.
  • It is relayed to the client, whose generation guard then drops it -- which is what actually implements "keep what you have". The lib-jitsi-meet tests cover a non-numeric generation and 0; worth confirming an absent attribute takes the same path.

The reasoning behind KEEP_EXISTING itself looks right for the not-established case: the client has not rotated anything, so the initial Agent can still pick up its new address peer-reflexively. It's just that "keep existing" is currently expressed as "send something the peer ignores" rather than signalled explicitly.

) {
val transBuilder = Transport.getBuilder()
transBuilder.setIceUdpExtension(endpoint.describeTransport())
if (c2endpoint.transport?.sctp != null) {
transBuilder.setSctp(
Sctp.Builder()
.setPort(DcSctpTransport.DEFAULT_SCTP_PORT)
.setRole(Sctp.Role.SERVER)
.build()
)
}
respBuilder.setTransport(transBuilder.build())
}

return respBuilder.build()
}

Expand Down
29 changes: 29 additions & 0 deletions jvb/src/main/kotlin/org/jitsi/videobridge/ice/IceConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import org.jitsi.config.JitsiConfig
import org.jitsi.metaconfig.config
import org.jitsi.metaconfig.from
import org.jitsi.metaconfig.optionalconfig
import java.time.Duration

class IceConfig private constructor() {
/**
Expand Down Expand Up @@ -70,6 +71,34 @@ class IceConfig private constructor() {
"videobridge.ice.advertise-private-candidates".from(JitsiConfig.newConfig)
)

/**
* Whether ICE restarts are enabled: when an endpoint explicitly requests one (colibri2
* `<transport ice-restart="true"/>`), create a second [org.ice4j.ice.Agent] with rotated local credentials
* and run it alongside the established one, instead of rejecting the request.
*/
val restartEnabled: Boolean by config(
"videobridge.ice.restart.enabled".from(JitsiConfig.newConfig)
)

/**
* How long the old [org.ice4j.ice.Agent] is kept alive after an ICE restart has cut over to the new one.
* Both Agents keep their sockets during this window, and ice4j routes each packet by the address it came
* from, so the endpoint's old-generation checks — which come from its old address — are answered by the old
* Agent rather than dropped.
*/
val restartTransitionWindow: Duration by config(
"videobridge.ice.restart.transition-window".from(JitsiConfig.newConfig)
)

/**
* How long to wait for the new [org.ice4j.ice.Agent] of an ICE restart to connect before giving up on the
* restart and keeping the existing Agent. A value that is not positive disables ICE restarts: the new Agent
* would be freed before the endpoint could answer it.
*/
val restartTimeout: Duration by config(
Comment thread
bgrozev marked this conversation as resolved.
"videobridge.ice.restart.timeout".from(JitsiConfig.newConfig)
)

companion object {
@JvmField
val config = IceConfig()
Expand Down
17 changes: 11 additions & 6 deletions jvb/src/main/kotlin/org/jitsi/videobridge/relay/Relay.kt
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ import org.jitsi.videobridge.util.ByteBufferPool
import org.jitsi.videobridge.util.TaskPools
import org.jitsi.videobridge.util.looksLikeDtls
import org.jitsi.videobridge.websocket.colibriWebSocketServiceSupplier
import org.jitsi.videobridge.websocket.generateColibriWebSocketPassword
import org.jitsi.xmpp.extensions.colibri.WebSocketPacketExtension
import org.jitsi.xmpp.extensions.colibri2.Sctp
import org.jitsi.xmpp.extensions.jingle.DtlsFingerprintPacketExtension
Expand Down Expand Up @@ -201,6 +202,13 @@ class Relay @JvmOverloads constructor(
}
}

/**
* The password which authenticates the colibri WebSocket of this relay. It is independent of ICE and stays
* the same for the lifetime of the relay, because the peer re-dials the URL which contains it each time the
* WebSocket reconnects. See [generateColibriWebSocketPassword].
*/
private val webSocketPassword = generateColibriWebSocketPassword()

private val iceTransport = IceTransport(
id = id,
controlling = iceControlling,
Expand Down Expand Up @@ -540,7 +548,7 @@ class Relay @JvmOverloads constructor(
val urls = colibriWebsocketService.getColibriRelayWebSocketUrls(
conference.id,
id,
iceTransport.icePassword
webSocketPassword
)
if (urls.isEmpty()) {
logger.warn("No colibri relay URLs configured")
Expand Down Expand Up @@ -872,11 +880,8 @@ class Relay @JvmOverloads constructor(
* @return {@code true} iff the password matches.
*/
fun acceptWebSocket(password: String): Boolean {
if (iceTransport.icePassword != password) {
logger.warn(
"Incoming web socket request with an invalid password. " +
"Expected: ${iceTransport.icePassword} received $password"
)
if (webSocketPassword != password) {
logger.warn("Incoming web socket request with an invalid password.")
return false
}
return true
Expand Down
Loading
Loading