@@ -61,7 +61,7 @@ class GooseRelayVpnService : VpnService() {
6161 private var connectJob: Job ? = null
6262 private var vpnInterface: ParcelFileDescriptor ? = null
6363 private var goClientJob: Job ? = null
64- private var httpProxyJob : Job ? = null
64+ private var sharingHttpJob : Job ? = null
6565 private var sharingSocksJob: Job ? = null
6666 private var sharingSocksServer: java.net.ServerSocket ? = null
6767 private var sharingHttpServer: java.net.ServerSocket ? = null
@@ -192,11 +192,11 @@ class GooseRelayVpnService : VpnService() {
192192
193193 // Start Internet Sharing proxies if enabled
194194 if (globalSettings.internetSharingEnabled) {
195- val socksPort = globalSettings.internetSharingSocksPort
196- val httpPort = globalSettings.internetSharingHttpPort
195+ val sharingSocksPort = globalSettings.internetSharingSocksPort
196+ val sharingHttpPort = globalSettings.internetSharingHttpPort
197197 val user = globalSettings.internetSharingUser
198198 val pass = globalSettings.internetSharingPass
199- startInternetSharing(socksPort, httpPort , user, pass)
199+ startInternetSharing(sharingSocksPort, sharingHttpPort, activeLocalSocksPort , user, pass)
200200 }
201201
202202 if (proxyMode) {
@@ -462,8 +462,8 @@ class GooseRelayVpnService : VpnService() {
462462 // Cancel coroutines
463463 VpnManager .appendLog(" Stopping Android session jobs..." )
464464 goClientJob?.cancel()
465- httpProxyJob?.cancel()
466465 sharingSocksJob?.cancel()
466+ sharingHttpJob?.cancel()
467467 keepaliveJob?.cancel()
468468 logTailJob?.cancel()
469469
@@ -734,74 +734,194 @@ class GooseRelayVpnService : VpnService() {
734734 }
735735 }
736736
737- private suspend fun startInternetSharing (socksPort : Int , httpPort : Int , username : String , password : String ) {
738- // Ensure ports are available before starting
739- if (isLocalPortInUse(socksPort) || isLocalPortInUse(httpPort)) {
740- VpnManager .appendLog(" Sharing ports in use, attempting to free..." )
741- if (mobile.Mobile .isRunning()) {
742- runCatching { mobile.Mobile .stopClient() }
743- }
744- delay(500L )
737+ private suspend fun startInternetSharing (
738+ socksPort : Int ,
739+ httpPort : Int ,
740+ coreSocksPort : Int ,
741+ username : String ,
742+ password : String
743+ ) {
744+ stopSharingServers()
745+
746+ // ponytail: both-or-neither — half-blank creds are rejected instead of falling back to
747+ // an open or locked proxy. UI flags this too; this is the trust-boundary enforcement.
748+ val userBlank = username.isBlank()
749+ val passBlank = password.isBlank()
750+ if (userBlank != passBlank) {
751+ throw IllegalStateException (
752+ " Internet Sharing requires both username and password, or neither. Set both in Settings."
753+ )
745754 }
746-
747- sharingSocksJob?.cancel()
748- sharingSocksServer?.close()
749- sharingSocksServer = null
755+ val authEnabled = ! userBlank && ! passBlank
756+ ensureSharingPortFree(socksPort, coreSocksPort)
757+ ensureSharingPortFree(httpPort, coreSocksPort)
750758
751759 sharingSocksJob = serviceScope.launch {
752760 try {
753- val server = java.net.ServerSocket (socksPort, 50 , InetAddress .getByName(" 0.0.0.0" ))
754- server.reuseAddress = true
761+ val server = java.net.ServerSocket ().apply {
762+ reuseAddress = true
763+ bind(InetSocketAddress (InetAddress .getByName(" 0.0.0.0" ), socksPort), 50 )
764+ }
755765 sharingSocksServer = server
756- VpnManager .appendLog(" Sharing SOCKS5 proxy ready on 0.0.0.0:$socksPort " )
766+ VpnManager .appendLog(
767+ " Sharing SOCKS5 proxy ready on 0.0.0.0:$socksPort " +
768+ if (authEnabled) " (auth enabled)" else " (open, no auth)"
769+ )
757770 while (isActive) {
758- val client = server.accept() ? : continue
771+ val client = server.accept()
772+ if (! isActive) { runCatching { client.close() }; break }
759773 launch(Dispatchers .IO ) {
760- handleSharingSocksClient(client)
774+ handleSharingSocksClient(client, coreSocksPort, username, password )
761775 }
762776 }
763777 } catch (e: Exception ) {
778+ if (e is CancellationException ) throw e
764779 Log .e(TAG , " Sharing SOCKS5 proxy error" , e)
765780 VpnManager .appendLog(" Sharing SOCKS5 proxy error: ${e.message} " )
766781 }
767782 }
768783
769- httpProxyJob?.cancel()
770- sharingHttpServer?.close()
771- sharingHttpServer = null
772-
773- httpProxyJob = serviceScope.launch {
784+ sharingHttpJob = serviceScope.launch {
774785 try {
775- val server = java.net.ServerSocket (httpPort, 50 , InetAddress .getByName(" 0.0.0.0" ))
776- server.reuseAddress = true
786+ val server = java.net.ServerSocket ().apply {
787+ reuseAddress = true
788+ bind(InetSocketAddress (InetAddress .getByName(" 0.0.0.0" ), httpPort), 50 )
789+ }
777790 sharingHttpServer = server
778- VpnManager .appendLog(" HTTP proxy ready on 0.0.0.0:$httpPort " )
791+ VpnManager .appendLog(
792+ " HTTP proxy ready on 0.0.0.0:$httpPort " +
793+ if (authEnabled) " (auth enabled)" else " (open, no auth)"
794+ )
779795 while (isActive) {
780- val client = server.accept() ? : continue
796+ val client = server.accept()
797+ if (! isActive) { runCatching { client.close() }; break }
781798 launch(Dispatchers .IO ) {
782799 handleHttpProxyClient(client, socksPort, username, password)
783800 }
784801 }
785802 } catch (e: Exception ) {
803+ if (e is CancellationException ) throw e
786804 Log .e(TAG , " HTTP proxy error" , e)
787805 VpnManager .appendLog(" HTTP proxy error: ${e.message} " )
788806 }
789807 }
790808 }
791809
792- private suspend fun handleSharingSocksClient (client : java.net.Socket ) {
810+ private fun stopSharingServers () {
811+ sharingSocksJob?.cancel()
812+ sharingHttpJob?.cancel()
813+ runCatching { sharingSocksServer?.close() }
814+ runCatching { sharingHttpServer?.close() }
815+ sharingSocksServer = null
816+ sharingHttpServer = null
817+ }
818+
819+ private suspend fun ensureSharingPortFree (port : Int , coreSocksPort : Int ) {
820+ if (! isLocalPortInUse(port)) return
821+ VpnManager .appendLog(" Sharing port $port is busy; freeing our own resources..." )
822+ // Cancel our sharing jobs and close our servers; never touch the Go core.
823+ stopSharingServers()
824+ repeat(15 ) {
825+ delay(200L )
826+ if (! isLocalPortInUse(port)) {
827+ VpnManager .appendLog(" Sharing port $port is now free" )
828+ return
829+ }
830+ }
831+ if (port == coreSocksPort) {
832+ throw IllegalStateException (
833+ " Sharing port $port is the VPN's internal SOCKS5 port. Pick a different sharing port in Settings."
834+ )
835+ }
836+ throw IllegalStateException (
837+ " Sharing port $port is in use by another app. Change it in Settings."
838+ )
839+ }
840+
841+ private suspend fun handleSharingSocksClient (client : java.net.Socket , coreSocksPort : Int , username : String , password : String ) {
793842 var upstream: java.net.Socket ? = null
794843 try {
795- upstream = java.net.Socket (" 127.0.0.1" , activeLocalSocksPort)
844+ client.soTimeout = 15000
845+ val input = client.getInputStream()
846+ val output = client.getOutputStream()
847+
848+ val authRequired = username.isNotBlank() && password.isNotBlank()
849+
850+ // --- SOCKS5 greeting (RFC 1928) ---
851+ val header = ByteArray (2 )
852+ readFully(input, header, 0 , 2 )
853+ if (header[0 ] != 0x05 .toByte()) return
854+ val nMethods = header[1 ].toInt() and 0xFF
855+ if (nMethods == 0 ) return
856+ val methods = ByteArray (nMethods)
857+ readFully(input, methods, 0 , nMethods)
858+
859+ if (authRequired) {
860+ if (! methods.any { it == 0x02 .toByte() }) {
861+ output.write(byteArrayOf(0x05 , 0xFF .toByte())); output.flush(); return
862+ }
863+ output.write(byteArrayOf(0x05 , 0x02 )); output.flush()
864+ // --- RFC 1929 user/pass sub-negotiation ---
865+ val subVersion = input.read()
866+ if (subVersion != 0x01 ) { output.write(byteArrayOf(0x01 , 0x01 )); output.flush(); return }
867+ val ulen = input.read()
868+ if (ulen < 0 ) return
869+ val ub = ByteArray (ulen)
870+ readFully(input, ub, 0 , ulen)
871+ val plen = input.read()
872+ if (plen < 0 ) return
873+ val pb = ByteArray (plen)
874+ readFully(input, pb, 0 , plen)
875+ val ok = constantTimeEquals(ub, username.toByteArray(Charsets .UTF_8 )) &&
876+ constantTimeEquals(pb, password.toByteArray(Charsets .UTF_8 ))
877+ output.write(byteArrayOf(0x01 , if (ok) 0x00 else 0x01 ))
878+ output.flush()
879+ if (! ok) return
880+ } else {
881+ output.write(byteArrayOf(0x05 , 0x00 )); output.flush()
882+ }
883+
884+ // --- SOCKS5 request ---
885+ val req = ByteArray (4 )
886+ readFully(input, req, 0 , 4 )
887+ if (req[0 ] != 0x05 .toByte()) return
888+ if (req[1 ] != 0x01 .toByte()) {
889+ // 0x07 = command not supported
890+ output.write(byteArrayOf(0x05 , 0x07 , 0x00 )); output.flush(); return
891+ }
892+ val host = when (req[3 ].toInt() and 0xFF ) {
893+ 0x01 -> { val b = ByteArray (4 ); readFully(input, b, 0 , 4 ); b.joinToString(" ." ) { (it.toInt() and 0xFF ).toString() } }
894+ 0x03 -> { val l = input.read(); if (l < 0 ) return ; val b = ByteArray (l); readFully(input, b, 0 , l); String (b, Charsets .UTF_8 ) }
895+ 0x04 -> { val b = ByteArray (16 ); readFully(input, b, 0 , 16 ); java.net.InetAddress .getByAddress(b).hostAddress ? : return }
896+ else -> { output.write(byteArrayOf(0x05 , 0x08 , 0x00 )); output.flush(); return }
897+ }
898+ val portBytes = ByteArray (2 ); readFully(input, portBytes, 0 , 2 )
899+ val port = ((portBytes[0 ].toInt() and 0xFF ) shl 8 ) or (portBytes[1 ].toInt() and 0xFF )
900+
901+ upstream = try { createSocks5Tunnel(coreSocksPort, host, port) } catch (e: Exception ) {
902+ VpnManager .appendLog(" Sharing SOCKS5 upstream to $host :$port failed: ${e.message} " )
903+ output.write(byteArrayOf(0x05 , 0x01 , 0x00 , 0x01 , 0 , 0 , 0 , 0 , 0 , 0 )); output.flush()
904+ return
905+ }
796906 upstream.soTimeout = 30000
907+ // 0x05 0x00 0x00 0x01 + 4-byte bind addr + 2-byte bind port
908+ output.write(byteArrayOf(0x05 , 0x00 , 0x00 , 0x01 , 0 , 0 , 0 , 0 , 0 , 0 )); output.flush()
909+
797910 bridgeBidirectional(client, upstream)
798- } catch (_: Exception ) {
911+ } catch (e: Exception ) {
912+ if (e is CancellationException ) throw e
913+ VpnManager .appendLog(" Sharing SOCKS5 client error: ${e.message} " )
799914 } finally {
800915 runCatching { upstream?.close() }
801916 runCatching { client.close() }
802917 }
803918 }
804919
920+ private fun constantTimeEquals (a : ByteArray , b : ByteArray ): Boolean {
921+ if (a.size != b.size) return false
922+ return java.security.MessageDigest .isEqual(a, b)
923+ }
924+
805925 private fun readLineUnbuffered (input : java.io.InputStream ): String? {
806926 val bytes = ArrayList <Byte >()
807927 while (true ) {
@@ -846,7 +966,7 @@ class GooseRelayVpnService : VpnService() {
846966 }
847967 }
848968
849- val requiresAuth = username.isNotBlank() || password.isNotBlank()
969+ val requiresAuth = username.isNotBlank() && password.isNotBlank()
850970 if (requiresAuth && ! isValidBasicProxyAuth(authHeader, username, password)) {
851971 output.write(
852972 " HTTP/1.1 407 Proxy Authentication Required\r\n " +
@@ -969,16 +1089,16 @@ class GooseRelayVpnService : VpnService() {
9691089 }
9701090
9711091 private fun isValidBasicProxyAuth (header : String? , username : String , password : String ): Boolean {
1092+ // ponytail: both-or-neither enforced at the UI; service treats blank-blank as open.
9721093 if (username.isBlank() && password.isBlank()) return true
9731094 val value = header?.trim().orEmpty()
9741095 if (! value.startsWith(" Basic " , ignoreCase = true )) return false
9751096 val encoded = value.substringAfter(" " , " " ).trim()
9761097 if (encoded.isBlank()) return false
9771098 val decoded = runCatching {
978- val bytes = android.util.Base64 .decode(encoded, android.util.Base64 .DEFAULT )
979- String (bytes, Charsets .UTF_8 )
1099+ android.util.Base64 .decode(encoded, android.util.Base64 .DEFAULT )
9801100 }.getOrNull() ? : return false
981- return decoded == " $username :$password "
1101+ return constantTimeEquals( decoded, " $username :$password " .toByteArray( Charsets . UTF_8 ))
9821102 }
9831103
9841104 private fun readFully (input : java.io.InputStream , buffer : ByteArray , offset : Int , length : Int ) {
0 commit comments