Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
267 changes: 212 additions & 55 deletions app/src/main/java/io/nekohasekai/sagernet/fmt/olcrtc/OlcrtcFmt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,51 @@ package io.nekohasekai.sagernet.fmt.olcrtc
*/

private const val SCHEME = "olcrtc://"
private val SUPPORTED_TRANSPORTS = setOf("vp8channel", "datachannel")
private val SUPPORTED_CARRIERS = setOf("jitsi", "telemost", "wbstream")
private const val TRANSPORT_VP8 = "vp8channel"
private const val TRANSPORT_DATA = "datachannel"
private const val DEFAULT_VP8_FPS = 30
private const val DEFAULT_VP8_BATCH = 8
private val SUPPORTED_TRANSPORTS_BY_CARRIER = mapOf(
"jitsi" to setOf(TRANSPORT_VP8, TRANSPORT_DATA),
"telemost" to setOf(TRANSPORT_VP8),
"wbstream" to setOf(TRANSPORT_VP8),
)
private val DELIMITERS = setOf('<', '>', '&', '=', '@', '#', '$', '?')
private val VP8_FPS_RANGE = 1..120
private val VP8_BATCH_RANGE = 1..64

/**
* Validates fields shared by URI import/export, the profile editor, and runtime args.
* Plain upstream URIs do not carry a client id, so callers opt into that requirement.
*/
fun OlcrtcBean.validateOlcrtcProfile(requireClientId: Boolean = false) {
val carrierName = carrier.orEmpty()
val transportName = transport.orEmpty()
val room = roomId.orEmpty()
val identity = clientId.orEmpty()
val hex = keyHex.orEmpty()
val fps = vp8Fps ?: 0
val batchSize = vp8BatchSize ?: 0
val resolver = dnsServer.orEmpty()

val allowedTransports = SUPPORTED_TRANSPORTS_BY_CARRIER[carrierName]
require(allowedTransports != null) { "olcRTC: unsupported carrier" }
require(transportName in allowedTransports) { "olcRTC: transport is not supported by carrier" }
require(room.isNotBlank()) { "olcRTC: room id / URL is required" }
if (requireClientId) {
require(identity.isNotBlank()) { "olcRTC: client id is required" }
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
require(hex.length == 64 && hex.all { it.isHexDigit() }) {
"olcRTC: encryption key must be 64 hex characters"
}
if (transportName == TRANSPORT_VP8) {
require(fps in VP8_FPS_RANGE) { "olcRTC: VP8 FPS must be between 1 and 120" }
require(batchSize in VP8_BATCH_RANGE) { "olcRTC: VP8 batch size must be between 1 and 64" }
}
require(resolver.isBlank() || resolver.isIpPortLiteral()) {
"olcRTC: DNS resolver must be an IP literal with a valid port"
}
}

/** Parses an `olcrtc://` link into an [OlcrtcBean]. Fails fast on malformed input. */
fun parseOlcrtc(url: String): OlcrtcBean {
Expand Down Expand Up @@ -59,10 +101,7 @@ fun parseOlcrtc(url: String): OlcrtcBean {
require(q >= 0) { "invalid olcrtc link: missing '?' before transport" }
val carrier = body.substring(0, q).trim()
require(carrier.isNotEmpty()) { "invalid olcrtc link: empty carrier" }
require(carrier in SUPPORTED_CARRIERS) {
"olcrtc link unsupported carrier '$carrier' (supported: ${SUPPORTED_CARRIERS.joinToString()})"
}
var afterQ = body.substring(q + 1)
val afterQ = body.substring(q + 1)

// roomId after the FIRST '@' that is NOT inside the `<...>` payload block.
val payloadEnd = if (afterQ.startsWith("<") || afterQ.contains('<')) afterQ.indexOf('>') else -1
Expand All @@ -79,70 +118,84 @@ fun parseOlcrtc(url: String): OlcrtcBean {
if (lt >= 0) {
val gt = transportPart.indexOf('>', lt)
require(gt >= 0) { "invalid olcrtc link: unterminated '<...>' transport payload" }
require(transportPart.substring(gt + 1).isBlank()) {
"invalid olcrtc link: unexpected text after transport payload"
}
payload = transportPart.substring(lt + 1, gt)
transportPart = transportPart.substring(0, lt)
}
val transport = transportPart.trim().ifEmpty { "vp8channel" }
val transport = transportPart.trim().ifEmpty { TRANSPORT_VP8 }
val payloadValues = parsePayload(payload)

return OlcrtcBean().apply {
// serverAddress/Port are unused by this protocol; keep a stable placeholder.
serverAddress = "olcrtc"
this.carrier = carrier
this.roomId = roomId
this.keyHex = keyHex
require(transport in SUPPORTED_TRANSPORTS) {
"olcrtc link unsupported transport '$transport' (supported: ${SUPPORTED_TRANSPORTS.joinToString()})"
}
this.transport = transport
name = comment
initializeDefaultValues()

payloadValues.forEach { (key, value) ->
when (key) {
"vp8-fps" -> vp8Fps = value.toIntOrNull()
?: throw IllegalArgumentException("olcRTC: VP8 FPS must be an integer")

"vp8-batch" -> vp8BatchSize = value.toIntOrNull()
?: throw IllegalArgumentException("olcRTC: VP8 batch size must be an integer")

parsePayload(payload).forEach { (k, v) ->
when (k) {
"vp8-fps" -> v.toIntOrNull()?.let { vp8Fps = it }
"vp8-batch" -> v.toIntOrNull()?.let { vp8BatchSize = it }
// Our non-standard pairing-token carrier.
"cid", "client-id", "clientid" -> clientId = v
"cid", "client-id", "clientid" -> {
require(value.none { it in DELIMITERS }) {
"olcRTC: client id contains a reserved delimiter"
}
clientId = value
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

initializeDefaultValues()

require(keyHex.isNotBlank()) { "olcrtc link missing encryption key" }
require(keyHex.length == 64 && keyHex.all { it.isHexDigit() }) {
"olcrtc link encryption key must be 64 hex characters"
}
validateOlcrtcProfile()
}
}

/** Serializes an [OlcrtcBean] to a shareable `olcrtc://` link, carrying clientId as `&cid=`. */
fun OlcrtcBean.toUri(): String {
require(carrier.isNotBlank()) { "olcRTC: cannot build share link without a carrier" }
require(roomId.isNotBlank()) { "olcRTC: cannot build share link without a room id" }
validateOlcrtcProfile()
val carrierName = carrier.orEmpty()
val transportName = transport.orEmpty()
val room = roomId.orEmpty()
val shareClientId = clientId.orEmpty()
val hex = keyHex.orEmpty()
val fps = vp8Fps ?: DEFAULT_VP8_FPS
val batchSize = vp8BatchSize ?: DEFAULT_VP8_BATCH
val profileName = name.orEmpty()

// The URI uses bare delimiters with no escaping convention; refuse to emit a link that
// would not round-trip rather than silently producing a corrupt one.
require(clientId.none { it in DELIMITERS }) {
"olcRTC: clientId must not contain any of: ${DELIMITERS.joinToString(" ")}"
require(shareClientId.none { it in DELIMITERS }) {
"olcRTC: client id contains a reserved delimiter"
}
// roomId is emitted raw before '#'; a '$' in it would be mis-parsed as the comment
// delimiter on re-import. Refuse rather than emit a link that won't round-trip.
require(roomId.none { it == '$' }) { "olcRTC: room id must not contain '\$'" }
require(name.none { it == '$' }) { "olcRTC: profile name must not contain '\$'" }
require(room.none { it == '$' }) { "olcRTC: room id must not contain '\$'" }
require(profileName.none { it == '$' }) { "olcRTC: profile name must not contain '\$'" }

val payloadParts = mutableListOf<String>()
if (transport == "vp8channel") {
if (transportName == TRANSPORT_VP8) {
val defaults = OlcrtcBean().apply { initializeDefaultValues() }
if (vp8Fps != defaults.vp8Fps) payloadParts += "vp8-fps=$vp8Fps"
if (vp8BatchSize != defaults.vp8BatchSize) payloadParts += "vp8-batch=$vp8BatchSize"
if (fps != defaults.vp8Fps) payloadParts += "vp8-fps=$fps"
if (batchSize != defaults.vp8BatchSize) payloadParts += "vp8-batch=$batchSize"
}
if (clientId.isNotBlank()) payloadParts += "cid=$clientId"
if (shareClientId.isNotBlank()) payloadParts += "cid=$shareClientId"

val payload = if (payloadParts.isEmpty()) "" else "<${payloadParts.joinToString("&")}>"

val sb = StringBuilder(SCHEME)
sb.append(carrier).append('?').append(transport).append(payload)
sb.append('@').append(roomId)
sb.append('#').append(keyHex)
if (name.isNotBlank()) sb.append('$').append(name)
sb.append(carrierName).append('?').append(transportName).append(payload)
sb.append('@').append(room)
sb.append('#').append(hex)
if (profileName.isNotBlank()) sb.append('$').append(profileName)
return sb.toString()
}

Expand All @@ -164,23 +217,36 @@ fun OlcrtcBean.buildOlcrtcArgs(
dnsFallback: String,
readyTimeoutMs: Long,
): List<String> {
require(!carrier.isNullOrBlank()) { "olcRTC: carrier is required" }
require(!roomId.isNullOrBlank()) { "olcRTC: room id is required" }
val hex = keyHex ?: ""
require(hex.length == 64 && hex.all { it.isHexDigit() }) {
"olcRTC: encryption key must be 64 hex characters"
validateOlcrtcProfile(requireClientId = true)
val carrierName = carrier.orEmpty()
val transportName = transport.orEmpty()
val room = roomId.orEmpty()
val identity = clientId.orEmpty()
val hex = keyHex.orEmpty()
val fps = if (transportName == TRANSPORT_VP8) {
vp8Fps ?: DEFAULT_VP8_FPS
} else {
DEFAULT_VP8_FPS
}
val batchSize = if (transportName == TRANSPORT_VP8) {
vp8BatchSize ?: DEFAULT_VP8_BATCH
} else {
DEFAULT_VP8_BATCH
}
val resolver = dnsServer.orEmpty().ifBlank { dnsFallback }
require(resolver.isIpPortLiteral()) {
"olcRTC: DNS resolver must be an IP literal with a valid port"
}
val transportName = if (transport in SUPPORTED_TRANSPORTS) transport else "vp8channel"
val args = mutableListOf(
"-carrier", carrier,
"-carrier", carrierName,
"-transport", transportName,
"-room", roomId,
"-client-id", clientId ?: "",
"-room", room,
"-client-id", identity,
"-key", hex,
"-socks-port", port.toString(),
"-dns", (dnsServer ?: "").ifBlank { dnsFallback },
"-vp8-fps", vp8Fps.toString(),
"-vp8-batch", vp8BatchSize.toString(),
"-dns", resolver,
"-vp8-fps", fps.toString(),
"-vp8-batch", batchSize.toString(),
"-protect-path", protectPath,
"-ready-timeout-ms", readyTimeoutMs.toString(),
)
Expand All @@ -199,11 +265,20 @@ fun OlcrtcBean.buildOlcrtcArgs(
* may resolve further hosts at runtime; those rely on the sidecar's own protected
* resolver. ICE candidates are typically raw IPs, so signaling is the common blocker.
*/
fun OlcrtcBean.carrierHost(): String? = when (carrier) {
fun OlcrtcBean.carrierHost(): String? = when (carrier.orEmpty()) {
"jitsi" -> {
// roomId is host/room or https://host/room; extract the host.
val s = roomId.substringAfter("://").trimStart('/')
s.substringBefore('/').substringBefore(':').ifBlank { null }
// Mirror upstream's permissive host/room split, but keep bracketed IPv6 intact.
val room = roomId.orEmpty().trim()
val authority = room.substringAfter("://", room).trimStart('/').substringBefore('/').trim()
when {
authority.isBlank() -> null
authority.startsWith('[') -> {
val closingBracket = authority.indexOf(']')
Comment on lines +275 to +276

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 IPv6 hosts become domains

When a Jitsi room uses a bracketed IPv6 signaling host, this branch returns the bare literal, such as 2001:db8::1. The DNS direct-force caller only skips values that isIpAddress() recognizes, and that check handles IPv4-style octets, so this IPv6 literal is treated as a domain and emitted as full:2001:db8::1. That can leave the config with an invalid or non-matching DNS rule instead of cleanly skipping IP signaling targets. The IPv6 host path should either return no domain here or be filtered by an IP check that recognizes IPv6 before adding full:.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

No change is needed here. ConfigBuilder filters this value through String.isIpAddress(), which explicitly checks both NGUtil.isIpv4Address() and NGUtil.isIpv6Address(). The bare compressed IPv6 literal is recognized and skipped, so it is not emitted as a full: domain rule.

if (closingBracket <= 1) null else authority.substring(1, closingBracket)
}
authority.count { it == ':' } == 1 -> authority.substringBefore(':').ifBlank { null }
else -> authority
}
}
"telemost" -> "telemost.yandex.ru"
"wbstream" -> "stream.wb.ru"
Expand All @@ -212,10 +287,92 @@ fun OlcrtcBean.carrierHost(): String? = when (carrier) {

private fun parsePayload(payload: String): Map<String, String> {
if (payload.isBlank()) return emptyMap()
return payload.split('&').mapNotNull { pair ->
val i = pair.indexOf('=')
if (i <= 0) null else pair.substring(0, i).trim() to pair.substring(i + 1).trim()
}.toMap()
return payload.split('&').associate { pair ->
val separator = pair.indexOf('=')
require(separator > 0) { "invalid olcrtc transport payload" }
val key = pair.substring(0, separator).trim()
require(key.isNotEmpty()) { "invalid olcrtc transport payload" }
key to pair.substring(separator + 1).trim()
}
}

private fun String.isIpPortLiteral(): Boolean {
val (host, port) = if (startsWith('[')) {
val closingBracket = indexOf(']')
if (closingBracket <= 1 || closingBracket != lastIndexOf(']')) return false
if (closingBracket + 1 >= length || this[closingBracket + 1] != ':') return false
substring(1, closingBracket) to substring(closingBracket + 2)
} else {
val separator = indexOf(':')
if (separator <= 0 || separator != lastIndexOf(':')) return false
substring(0, separator) to substring(separator + 1)
}
if (!port.isValidPort()) return false
return if (startsWith('[')) host.isIpv6Literal() else host.isIpv4Literal()
}

private fun String.isValidPort(): Boolean {
if (isEmpty() || any { it !in '0'..'9' }) return false
val value = toIntOrNull() ?: return false
return value in 1..65535
}

private fun String.isIpv4Literal(): Boolean {
val octets = split('.')
return octets.size == 4 && octets.all { octet ->
if (octet.isEmpty() || octet.any { it !in '0'..'9' }) return@all false
if (octet.length > 1 && octet.startsWith('0')) return@all false
val value = octet.toIntOrNull() ?: return@all false
value in 0..255
}
}

private fun String.isIpv6Literal(): Boolean {
val zoneSeparator = indexOf('%')
val address: String
if (zoneSeparator >= 0) {
if (zoneSeparator == 0 || zoneSeparator != lastIndexOf('%')) return false
val zone = substring(zoneSeparator + 1)
if (zone.isEmpty() || zone.any { !it.isSafeZoneCharacter() }) return false
address = substring(0, zoneSeparator)
} else {
address = this
}
if (':' !in address) return false

val compression = address.indexOf("::")
if (compression != address.lastIndexOf("::")) return false
val left: List<String>
val right: List<String>
if (compression >= 0) {
left = address.substring(0, compression).ipv6Segments() ?: return false
right = address.substring(compression + 2).ipv6Segments() ?: return false
if (left.any { '.' in it }) return false
} else {
left = address.ipv6Segments() ?: return false
right = emptyList()
}
val segments = left + right
var groups = 0
segments.forEachIndexed { index, segment ->
if ('.' in segment) {
if (index != segments.lastIndex || !segment.isIpv4Literal()) return false
groups += 2
} else {
if (segment.length !in 1..4 || !segment.all { it.isHexDigit() }) return false
groups += 1
}
}
return if (compression >= 0) groups < 8 else groups == 8
}

private fun String.ipv6Segments(): List<String>? {
if (isEmpty()) return emptyList()
if (startsWith(':') || endsWith(':')) return null
return split(':').takeIf { segments -> segments.none { it.isEmpty() } }
}

private fun Char.isSafeZoneCharacter(): Boolean =
this in 'a'..'z' || this in 'A'..'Z' || this in '0'..'9' || this == '_' || this == '-' || this == '.'

private fun Char.isHexDigit(): Boolean = this in '0'..'9' || this in 'a'..'f' || this in 'A'..'F'
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,17 @@
package io.nekohasekai.sagernet.ui.profile

import android.os.Bundle
import android.widget.Toast
import androidx.preference.EditTextPreference
import androidx.preference.PreferenceFragmentCompat
import io.nekohasekai.sagernet.Key
import io.nekohasekai.sagernet.R
import io.nekohasekai.sagernet.database.DataStore
import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers
import io.nekohasekai.sagernet.fmt.olcrtc.OlcrtcBean
import io.nekohasekai.sagernet.fmt.olcrtc.validateOlcrtcProfile
import io.nekohasekai.sagernet.ktx.applyDefaultValues
import io.nekohasekai.sagernet.ktx.onMainDispatcher

class OlcrtcSettingsActivity : ProfileSettingsActivity<OlcrtcBean>() {

Expand Down Expand Up @@ -51,12 +54,22 @@ class OlcrtcSettingsActivity : ProfileSettingsActivity<OlcrtcBean>() {
dnsServer = DataStore.olcrtcDnsServer

// Fail fast in the editor instead of saving a profile that can only fail at connect.
// DataStore string() may return null if a field was never edited; coalesce to "".
require(!roomId.isNullOrBlank()) { "olcRTC: room id / URL is required" }
require(!clientId.isNullOrBlank()) { "olcRTC: client id is required" }
val hex = keyHex ?: ""
require(hex.length == 64 && hex.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) {
"olcRTC: encryption key must be 64 hex characters"
validateOlcrtcProfile(requireClientId = true)
}

override suspend fun saveAndExit() {
try {
// Validate a temporary bean before the base path can stop an active profile.
createEntity().apply { serialize() }
super.saveAndExit()
} catch (e: IllegalArgumentException) {
onMainDispatcher {
Toast.makeText(
applicationContext,
e.message ?: "Invalid olcRTC profile",
Toast.LENGTH_LONG,
).show()
}
}
}

Expand Down
Loading
Loading