Merge branch 'master' into f/public-groups

This commit is contained in:
spaced4ndy
2026-06-16 16:14:44 +04:00
5 changed files with 139 additions and 11 deletions
@@ -3,7 +3,7 @@
useWorker = typeof window.Worker !== "undefined";
isDesktop = true;
// Create WebSocket connection.
const socket = new WebSocket(`ws://${location.host}`);
const socket = new WebSocket(`ws://${location.host}${location.search}`);
socket.addEventListener("open", (_event) => {
console.log("Opened socket");
sendMessageToNative = (msg) => {
@@ -192,4 +192,4 @@ function updateCallInfoView(state, description) {
document.getElementById("state").innerText = state;
document.getElementById("description").innerText = description;
}
//# sourceMappingURL=ui.js.map
//# sourceMappingURL=ui.js.map
@@ -18,10 +18,12 @@ import org.nanohttpd.protocols.http.response.Status
import org.nanohttpd.protocols.websockets.*
import java.io.IOException
import java.net.BindException
import java.net.URI
import java.security.SecureRandom
import java.util.Base64
private const val SERVER_HOST = "localhost"
private const val SERVER_PORT = 50395
private const val CALL_SERVER_TOKEN_BYTES = 32
val connections = ArrayList<WebSocket>()
// Spec: spec/services/calls.md#ActiveCallView
@@ -153,14 +155,15 @@ private fun SendStateUpdates() {
@Composable
fun WebRTCController(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIMessage) -> Unit) {
val uriHandler = LocalUriHandler.current
val token = remember { newCallServerToken() }
val endCall = {
val call = chatModel.activeCall.value
if (call != null) withBGApi { chatModel.callManager.endCall(call) }
}
val server = remember {
startServer(onResponse).apply {
startServer(onResponse, token = token).apply {
try {
uriHandler.openUri("http://${SERVER_HOST}:${listeningPort}/simplex/call/")
uriHandler.openUri("http://${SERVER_HOST}:${listeningPort}/simplex/call/?token=$token")
} catch (e: Exception) {
Log.e(TAG, "Unable to open browser: ${e.stackTraceToString()}")
AlertManager.shared.showAlertMsg(
@@ -208,7 +211,11 @@ fun WebRTCController(callCommand: SnapshotStateList<WCallCommand>, onResponse: (
}
}
fun startServer(onResponse: (WVAPIMessage) -> Unit, port: Int = SERVER_PORT): NanoWSD {
fun startServer(
onResponse: (WVAPIMessage) -> Unit,
port: Int = SERVER_PORT,
token: String = newCallServerToken(),
): NanoWSD {
val server = object: NanoWSD(SERVER_HOST, port) {
override fun openWebSocket(session: IHTTPSession): WebSocket = MyWebSocket(onResponse, session)
@@ -227,8 +234,18 @@ fun startServer(onResponse: (WVAPIMessage) -> Unit, port: Int = SERVER_PORT): Na
override fun handle(session: IHTTPSession): Response {
return when {
session.headers["upgrade"] == "websocket" -> super.handle(session)
session.uri.contains("/simplex/call/") -> resourcesToResponse("/desktop/call.html")
session.headers["upgrade"] == "websocket" ->
if (hasValidCallServerToken(session.parameters, token)) {
super.handle(session)
} else {
unauthorizedResponse()
}
session.uri.contains("/simplex/call/") ->
if (hasValidCallServerToken(session.parameters, token)) {
resourcesToResponse("/desktop/call.html")
} else {
unauthorizedResponse()
}
else -> resourcesToResponse(uriCreateOrNull(session.uri)?.path ?: return newFixedLengthResponse("Error parsing URL"))
}
}
@@ -239,11 +256,23 @@ fun startServer(onResponse: (WVAPIMessage) -> Unit, port: Int = SERVER_PORT): Na
if (port == 0) throw e
Log.w(TAG, "Call server port $port is busy, using a random port: ${e.message}")
server.stop()
return startServer(onResponse, port = 0)
return startServer(onResponse, port = 0, token = token)
}
return server
}
internal fun newCallServerToken(): String {
val bytes = ByteArray(CALL_SERVER_TOKEN_BYTES)
SecureRandom().nextBytes(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
internal fun hasValidCallServerToken(parameters: Map<String, List<String>>, token: String): Boolean =
token.isNotEmpty() && parameters["token"]?.any { it == token } == true
private fun unauthorizedResponse(): Response =
newFixedLengthResponse(Status.UNAUTHORIZED, "text/plain", "Unauthorized")
class MyWebSocket(val onResponse: (WVAPIMessage) -> Unit, handshakeRequest: IHTTPSession) : WebSocket(handshakeRequest) {
override fun onOpen() {
connections.add(this)
@@ -0,0 +1,70 @@
package chat.simplex.app
import chat.simplex.common.views.call.startServer
import java.net.Socket
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
// Integration test for the desktop call server's token gate (the handle() enforcement),
// which the unit-level CallServerTokenTest does not exercise.
class CallServerAuthTest {
private val token = "integration-test-token"
// port = 0 binds a random free port, avoiding a clash with a real call server on SERVER_PORT
private val server = startServer(onResponse = {}, port = 0, token = token)
private val port get() = server.listeningPort
@AfterTest
fun tearDown() = server.stop()
@Test
fun testWebSocketUpgradeRejectedWithoutToken() {
assertEquals(401, requestStatus(webSocketUpgrade(path = "/")))
}
@Test
fun testWebSocketUpgradeRejectedWithWrongToken() {
assertEquals(401, requestStatus(webSocketUpgrade(path = "/?token=wrong")))
}
@Test
fun testWebSocketUpgradeAcceptedWithToken() {
assertEquals(101, requestStatus(webSocketUpgrade(path = "/?token=$token")))
}
@Test
fun testCallPageRejectedWithoutToken() {
assertEquals(401, requestStatus(get(path = "/simplex/call/")))
}
@Test
fun testCallPagePassesAuthGateWithToken() {
// Resource serving may differ in the test classpath, so assert only that the auth gate was passed (not 401)
assertNotEquals(401, requestStatus(get(path = "/simplex/call/?token=$token")))
}
private fun get(path: String): List<String> = listOf("GET $path HTTP/1.1", "Host: localhost:$port")
private fun webSocketUpgrade(path: String): List<String> =
listOf(
"GET $path HTTP/1.1",
"Host: localhost:$port",
"Upgrade: websocket",
"Connection: Upgrade",
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
"Sec-WebSocket-Version: 13",
)
// Sends a raw HTTP request and returns the response status code from the status line.
private fun requestStatus(requestLines: List<String>): Int =
Socket("localhost", port).use { socket ->
socket.soTimeout = 5000
socket.getOutputStream().apply {
write((requestLines.joinToString("\r\n") + "\r\n\r\n").toByteArray())
flush()
}
val statusLine = socket.getInputStream().bufferedReader().readLine() ?: error("no response from call server")
statusLine.split(" ")[1].toInt()
}
}
@@ -0,0 +1,29 @@
package chat.simplex.app
import chat.simplex.common.views.call.hasValidCallServerToken
import chat.simplex.common.views.call.newCallServerToken
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class CallServerTokenTest {
@Test
fun testCallServerTokenRequiresExactTokenParameter() {
val token = "secret"
assertTrue(hasValidCallServerToken(mapOf("token" to listOf(token)), token))
assertFalse(hasValidCallServerToken(mapOf("token" to listOf("wrong")), token))
assertFalse(hasValidCallServerToken(mapOf("x-token" to listOf(token)), token))
assertFalse(hasValidCallServerToken(mapOf("token" to listOf(token)), ""))
}
@Test
fun testCallServerTokenIsUrlSafe() {
val token = newCallServerToken()
assertTrue(token.length >= 40)
assertFalse(token.contains("+"))
assertFalse(token.contains("/"))
assertFalse(token.contains("="))
}
}