diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/MainActivity.kt b/app/src/main/java/com/amurcanov/tgwsproxy/MainActivity.kt index e7341ff..3fa95be 100644 --- a/app/src/main/java/com/amurcanov/tgwsproxy/MainActivity.kt +++ b/app/src/main/java/com/amurcanov/tgwsproxy/MainActivity.kt @@ -65,9 +65,13 @@ class MainActivity : ComponentActivity() { val settingsStore = remember { SettingsStore(context) } val themeMode by settingsStore.themeMode .collectAsStateWithLifecycle(initialValue = "system") + val isDynamicColor by settingsStore.isDynamicColor + .collectAsStateWithLifecycle(initialValue = true) + val themePalette by settingsStore.themePalette + .collectAsStateWithLifecycle(initialValue = "indigo") val scope = rememberCoroutineScope() - TgWsProxyTheme(themeMode = themeMode) { + TgWsProxyTheme(themeMode = themeMode, dynamicColor = isDynamicColor, themePalette = themePalette) { androidx.compose.runtime.CompositionLocalProvider( androidx.compose.ui.platform.LocalDensity provides androidx.compose.ui.unit.Density( density = androidx.compose.ui.platform.LocalDensity.current.density, @@ -85,6 +89,14 @@ class MainActivity : ComponentActivity() { currentTheme = themeMode, onThemeChange = { mode -> scope.launch { settingsStore.saveThemeMode(mode) } + }, + isDynamicColor = isDynamicColor, + onDynamicColorChange = { dc -> + scope.launch { settingsStore.saveDynamicColor(dc) } + }, + currentPalette = themePalette, + onPaletteChange = { pal -> + scope.launch { settingsStore.saveThemePalette(pal) } } ) } @@ -127,8 +139,9 @@ fun MainContent(settingsStore: SettingsStore) { ) } - LaunchedEffect(Unit) { + DisposableEffect(Unit) { LogManager.startListening() + onDispose { LogManager.stopListening() } } Scaffold( @@ -206,19 +219,21 @@ object LogManager { if (job?.isActive == true) return job = CoroutineScope(Dispatchers.IO).launch { // Start logcat reader coroutine - val readerJob = launch { + val readerJob = launch(Dispatchers.IO) { try { val pid = android.os.Process.myPid() - val process = Runtime.getRuntime().exec( - arrayOf("logcat", "-v", "tag", "--pid", pid.toString()) - ) + val process = ProcessBuilder("logcat", "-v", "tag", "--pid", pid.toString()) + .redirectErrorStream(true) + .start() + logcatProcess = process - val reader = BufferedReader(InputStreamReader(process.inputStream), 8192) - - while (isActive) { - val line = reader.readLine() ?: break - val entry = parseLine(line) ?: continue - logChannel.trySend(entry) // non-blocking send + + process.inputStream.bufferedReader().use { reader -> + while (isActive) { + val line = try { reader.readLine() } catch (e: Exception) { null } ?: break + val entry = parseLine(line) ?: continue + logChannel.trySend(entry) + } } } catch (_: Exception) { } finally { @@ -259,32 +274,26 @@ object LogManager { * Merges consecutive duplicates and caps at 50 entries. */ private fun applyBatch(current: List, batch: List): List { - // Use a pre-sized ArrayList to avoid re-allocation - val result = ArrayList(minOf(current.size + batch.size, 50)) - result.addAll(current) - + val result = ArrayDeque(current) for (entry in batch) { var merged = false val searchDepth = minOf(result.size, 10) - for (i in result.lastIndex downTo result.size - searchDepth) { + for (i in result.indices.reversed().take(searchDepth)) { if (result[i].message == entry.message) { val existing = result.removeAt(i) - result.add(existing.copy(count = existing.count + 1)) + result.addLast(existing.copy(count = existing.count + 1)) merged = true break } } if (!merged) { - result.add(entry) + result.addLast(entry) } } - - // Trim to 50 entries from the end - return if (result.size > 50) { - result.subList(result.size - 50, result.size).toList() - } else { - result + while (result.size > 50) { + result.removeFirst() } + return result.toList() } fun stopListening() { diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/NativeProxy.kt b/app/src/main/java/com/amurcanov/tgwsproxy/NativeProxy.kt index 443fffd..32d7091 100644 --- a/app/src/main/java/com/amurcanov/tgwsproxy/NativeProxy.kt +++ b/app/src/main/java/com/amurcanov/tgwsproxy/NativeProxy.kt @@ -14,6 +14,8 @@ interface ProxyLibrary : Library { fun SetPoolSize(size: Int) fun SetCfProxyConfig(enabled: Int, priority: Int, userDomain: String) fun SetSecret(secret: String) + fun SetFakeTls(enabled: Int, domain: String) + fun GetSecretWithPrefix(): Pointer? fun GetStats(): Pointer? fun FreeString(p: Pointer) } @@ -38,6 +40,16 @@ object NativeProxy { fun setSecret(secret: String) { ProxyLibrary.INSTANCE.SetSecret(secret) } + fun setFakeTls(enabled: Boolean, domain: String = "") { + ProxyLibrary.INSTANCE.SetFakeTls(if (enabled) 1 else 0, domain) + } + /** Returns the full secret with correct prefix (dd or ee+domain_hex) */ + fun getSecretWithPrefix(): String? { + val ptr = ProxyLibrary.INSTANCE.GetSecretWithPrefix() ?: return null + val res = ptr.getString(0) + ProxyLibrary.INSTANCE.FreeString(ptr) + return res + } fun getStats(): String? { val ptr = ProxyLibrary.INSTANCE.GetStats() ?: return null val res = ptr.getString(0) diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/ProxyService.kt b/app/src/main/java/com/amurcanov/tgwsproxy/ProxyService.kt index a078b55..16f4d25 100644 --- a/app/src/main/java/com/amurcanov/tgwsproxy/ProxyService.kt +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ProxyService.kt @@ -20,6 +20,7 @@ class ProxyService : Service() { private var wakeLock: PowerManager.WakeLock? = null private var statsJob: Job? = null + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) companion object { const val ACTION_START = "com.amurcanov.tgwsproxy.START" @@ -87,7 +88,7 @@ class ProxyService : Service() { _isRunning.value = true - statsJob = CoroutineScope(Dispatchers.IO).launch { + statsJob = serviceScope.launch { while (isActive) { delay(2000) if (_isRunning.value) { @@ -197,6 +198,7 @@ class ProxyService : Service() { } override fun onDestroy() { + serviceScope.cancel() if (_isRunning.value) { stopProxy() } diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/SettingsStore.kt b/app/src/main/java/com/amurcanov/tgwsproxy/SettingsStore.kt index 957c7e7..e4d9e9c 100644 --- a/app/src/main/java/com/amurcanov/tgwsproxy/SettingsStore.kt +++ b/app/src/main/java/com/amurcanov/tgwsproxy/SettingsStore.kt @@ -17,22 +17,30 @@ class SettingsStore(private val context: Context) { private object Keys { val THEME_MODE = stringPreferencesKey("theme_mode") + val IS_DYNAMIC_COLOR = booleanPreferencesKey("is_dynamic_color") + val THEME_PALETTE = stringPreferencesKey("theme_palette") val IS_DC_AUTO = booleanPreferencesKey("is_dc_auto") val DC2 = stringPreferencesKey("dc2") val DC4 = stringPreferencesKey("dc4") val PORT = stringPreferencesKey("port") val POOL_SIZE = intPreferencesKey("pool_size") val CFPROXY_ENABLED = booleanPreferencesKey("cfproxy_enabled") + val CUSTOM_CF_DOMAIN_ENABLED = booleanPreferencesKey("custom_cf_domain_enabled") + val CUSTOM_CF_DOMAIN = stringPreferencesKey("custom_cf_domain") val SECRET_KEY = stringPreferencesKey("secret_key") } val themeMode: Flow = context.dataStore.data.map { it[Keys.THEME_MODE] ?: "system" } + val isDynamicColor: Flow = context.dataStore.data.map { it[Keys.IS_DYNAMIC_COLOR] ?: true } + val themePalette: Flow = context.dataStore.data.map { it[Keys.THEME_PALETTE] ?: "indigo" } val isDcAuto: Flow = context.dataStore.data.map { it[Keys.IS_DC_AUTO] ?: true } val dc2: Flow = context.dataStore.data.map { it[Keys.DC2] ?: "" } val dc4: Flow = context.dataStore.data.map { it[Keys.DC4] ?: "149.154.167.220" } val port: Flow = context.dataStore.data.map { it[Keys.PORT] ?: "1443" } val poolSize: Flow = context.dataStore.data.map { it[Keys.POOL_SIZE] ?: 4 } val cfproxyEnabled: Flow = context.dataStore.data.map { it[Keys.CFPROXY_ENABLED] ?: true } + val customCfDomainEnabled: Flow = context.dataStore.data.map { it[Keys.CUSTOM_CF_DOMAIN_ENABLED] ?: false } + val customCfDomain: Flow = context.dataStore.data.map { it[Keys.CUSTOM_CF_DOMAIN] ?: "" } val secretKey: Flow = context.dataStore.data.map { it[Keys.SECRET_KEY] ?: "" } suspend fun saveSecretKey(key: String) { @@ -43,8 +51,16 @@ class SettingsStore(private val context: Context) { context.dataStore.edit { it[Keys.THEME_MODE] = mode } } + suspend fun saveDynamicColor(enabled: Boolean) { + context.dataStore.edit { it[Keys.IS_DYNAMIC_COLOR] = enabled } + } + + suspend fun saveThemePalette(palette: String) { + context.dataStore.edit { it[Keys.THEME_PALETTE] = palette } + } + suspend fun saveAll(isDcAuto: Boolean, dc2: String, dc4: String, port: String, poolSize: Int, - cfproxyEnabled: Boolean, secretKey: String) { + cfproxyEnabled: Boolean, customCfDomainEnabled: Boolean, customCfDomain: String, secretKey: String) { context.dataStore.edit { it[Keys.IS_DC_AUTO] = isDcAuto it[Keys.DC2] = dc2 @@ -52,6 +68,8 @@ class SettingsStore(private val context: Context) { it[Keys.PORT] = port it[Keys.POOL_SIZE] = poolSize it[Keys.CFPROXY_ENABLED] = cfproxyEnabled + it[Keys.CUSTOM_CF_DOMAIN_ENABLED] = customCfDomainEnabled + it[Keys.CUSTOM_CF_DOMAIN] = customCfDomain it[Keys.SECRET_KEY] = secretKey } } diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/ui/FloatingToolbar.kt b/app/src/main/java/com/amurcanov/tgwsproxy/ui/FloatingToolbar.kt index ec9db17..fbbea5b 100644 --- a/app/src/main/java/com/amurcanov/tgwsproxy/ui/FloatingToolbar.kt +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ui/FloatingToolbar.kt @@ -24,11 +24,23 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.amurcanov.tgwsproxy.R import kotlin.math.roundToInt +import androidx.compose.ui.draw.scale +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.draw.clip +import androidx.compose.foundation.border +import androidx.compose.ui.graphics.Color +import android.os.Build @Composable fun FloatingToolbar( currentTheme: String, onThemeChange: (String) -> Unit, + isDynamicColor: Boolean, + onDynamicColorChange: (Boolean) -> Unit, + currentPalette: String, + onPaletteChange: (String) -> Unit, modifier: Modifier = Modifier ) { val configuration = LocalConfiguration.current @@ -46,7 +58,7 @@ fun FloatingToolbar( val tabWidthDp = 42.dp val tabHeightDp = 52.dp - val panelWidthDp = 180.dp + val panelWidthDp = 220.dp val tabWidthPx = remember(density) { with(density) { tabWidthDp.toPx() } } @@ -143,6 +155,52 @@ fun FloatingToolbar( selected = currentTheme == "dark", onClick = { onThemeChange("dark"); isExpanded = false } ) + + Divider(modifier = Modifier.padding(vertical = 4.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + + val supportsDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val showDynamicColorOn = isDynamicColor && supportsDynamicColor + val showPalettes = !showDynamicColorOn + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "Динамические", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = if (supportsDynamicColor) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + Switch( + checked = showDynamicColorOn, + onCheckedChange = { onDynamicColorChange(it) }, + enabled = supportsDynamicColor, + modifier = Modifier.scale(0.8f) + ) + } + + AnimatedVisibility(visible = showPalettes) { + Column { + Divider(modifier = Modifier.padding(vertical = 4.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + Text( + "Палитра", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 6.dp, start = 4.dp) + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + PaletteCircle("indigo", 0xFF5B588D, currentPalette, onPaletteChange) + PaletteCircle("forest", 0xFF5F5D68, currentPalette, onPaletteChange) + PaletteCircle("espresso", 0xFF6D4C41, currentPalette, onPaletteChange) + } + Spacer(modifier = Modifier.height(6.dp)) + } + } } } } @@ -185,3 +243,23 @@ private fun ThemeOption( } } } +@Composable +fun PaletteCircle( + paletteId: String, + colorHex: Long, + selectedId: String, + onClick: (String) -> Unit +) { + val isSelected = paletteId == selectedId + Box( + modifier = Modifier + .size(30.dp) + .clip(CircleShape) + .background(Color(colorHex)) + .clickable { onClick(paletteId) } + .then( + if (isSelected) Modifier.border(3.dp, MaterialTheme.colorScheme.primary, CircleShape) + else Modifier + ) + ) +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/ui/InfoTab.kt b/app/src/main/java/com/amurcanov/tgwsproxy/ui/InfoTab.kt index 41cb627..6cd311c 100644 --- a/app/src/main/java/com/amurcanov/tgwsproxy/ui/InfoTab.kt +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ui/InfoTab.kt @@ -125,7 +125,7 @@ private fun isNewerVersion(local: String, remote: String): Boolean { @Composable fun InfoTab() { - val currentVersion = "v1.0.6" + val currentVersion = "v1.0.7" val scope = rememberCoroutineScope() var updateResult by remember { mutableStateOf(UpdateCheckResult.Idle) } @@ -271,7 +271,8 @@ fun InfoTab() { } } - + // ═══ Справка ═══ + HelpCard() Spacer(modifier = Modifier.height(16.dp)) } @@ -320,3 +321,68 @@ private fun GitHubSection( } } } + +@Composable +private fun HelpCard() { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + Text( + "Справка", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface + ) + + HelpSection( + title = "Авто / Адреса датацентров", + text = "При включенном CloudFlare сервера (DC) настраивать не нужно — они переадресовываются автоматически. Если вы отключите CloudFlare, нажмите «Настроить адреса DC» для прямого подключения. По умолчанию DC4 зафиксирован на стабильном лондонском узле (149.154.167.220)." + ) + + Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + + HelpSection( + title = "CloudFlare CDN", + text = "Ваш трафик маскируется под HTTPS WebSockets внутри сети Cloudflare. Это способствует лучшему обходу блокировок на мобильных сетях. При использовании Wi-Fi этот режим можно отключать для повышения скорости. Делает блокировку прокси почти невозможной для DPI." + ) + + Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + + HelpSection( + title = "Пул WS", + text = "Механизм многопоточности (фоновые соединения). По умолчанию: 4 потока. Если видео или медиафайлы грузятся медленно, попробуйте увеличить пул." + ) + + Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + + HelpSection( + title = "Секретный ключ", + text = "Специальный 16-байтовый ключ шифрования MTProto. Меняйте его только в случае, если старой ссылкой для подключения завладели посторонние." + ) + } + } +} + +@Composable +private fun HelpSection(title: String, text: String) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 20.sp + ) + } +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/ui/SettingsTab.kt b/app/src/main/java/com/amurcanov/tgwsproxy/ui/SettingsTab.kt index 05bb1cc..21f8ead 100644 --- a/app/src/main/java/com/amurcanov/tgwsproxy/ui/SettingsTab.kt +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ui/SettingsTab.kt @@ -20,6 +20,9 @@ import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.VpnKey +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Layers +import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable @@ -100,6 +103,8 @@ fun SettingsTab(settingsStore: SettingsStore) { val savedPort by settingsStore.port.collectAsStateWithLifecycle(initialValue = "1443") val savedPoolSize by settingsStore.poolSize.collectAsStateWithLifecycle(initialValue = 4) val savedCfEnabled by settingsStore.cfproxyEnabled.collectAsStateWithLifecycle(initialValue = true) + val savedCustomDomainEnabled by settingsStore.customCfDomainEnabled.collectAsStateWithLifecycle(initialValue = false) + val savedCustomDomain by settingsStore.customCfDomain.collectAsStateWithLifecycle(initialValue = "") val savedSecretKey by settingsStore.secretKey.collectAsStateWithLifecycle(initialValue = "LOADING") var isDcAuto by rememberSaveable(savedIsDcAuto) { mutableStateOf(savedIsDcAuto) } @@ -108,6 +113,8 @@ fun SettingsTab(settingsStore: SettingsStore) { var portText by rememberSaveable(savedPort) { mutableStateOf(savedPort) } var selectedPoolSize by rememberSaveable(savedPoolSize) { mutableIntStateOf(savedPoolSize) } var cfEnabled by rememberSaveable(savedCfEnabled) { mutableStateOf(savedCfEnabled) } + var customCfDomainEnabled by rememberSaveable(savedCustomDomainEnabled) { mutableStateOf(savedCustomDomainEnabled) } + var customCfDomain by rememberSaveable(savedCustomDomain) { mutableStateOf(savedCustomDomain) } var secretKeyText by remember(savedSecretKey) { mutableStateOf(if (savedSecretKey == "LOADING") "" else savedSecretKey) } LaunchedEffect(savedSecretKey) { @@ -128,19 +135,16 @@ fun SettingsTab(settingsStore: SettingsStore) { delay(300) settingsStore.saveAll( isDcAuto, dc2Text, dc4Text, portText, selectedPoolSize, - cfEnabled, secretKeyText + cfEnabled, customCfDomainEnabled, customCfDomain, secretKeyText ) } } var showIpSetupDialog by rememberSaveable { mutableStateOf(false) } - var showHelpDialog by rememberSaveable { mutableStateOf(false) } val scrollState = rememberScrollState() if (showIpSetupDialog) { IpSetupDialog( - isDcAuto = isDcAuto, - onModeChange = { isDcAuto = it; scheduleSave() }, dc2Text = dc2Text, onDc2Change = { dc2Text = it; scheduleSave() }, dc4Text = dc4Text, @@ -149,17 +153,19 @@ fun SettingsTab(settingsStore: SettingsStore) { ) } - if (showHelpDialog) { - HelpDialog(onDismiss = { showHelpDialog = false }) - } - Column( modifier = Modifier .fillMaxSize() .verticalScroll(scrollState) .padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) + verticalArrangement = Arrangement.spacedBy(8.dp) ) { + Text( + text = "Настройки", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp) + ) Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), @@ -167,23 +173,27 @@ fun SettingsTab(settingsStore: SettingsStore) { elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) ) { Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text( - "Подключение", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.SemiBold - ) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Default.Public, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) + Text( + "Подключение", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + } OutlinedTextField( value = portText, onValueChange = { portText = it; scheduleSave() }, label = { Text("Порт") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true, - modifier = Modifier.fillMaxWidth().height(60.dp), + modifier = Modifier.fillMaxWidth().height(52.dp), shape = RoundedCornerShape(14.dp), + textStyle = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = MaterialTheme.colorScheme.primary, unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) @@ -191,16 +201,20 @@ fun SettingsTab(settingsStore: SettingsStore) { ) OutlinedButton( onClick = { showIpSetupDialog = true }, - modifier = Modifier.fillMaxWidth().height(46.dp), + enabled = !cfEnabled && !isRunning, + modifier = Modifier.fillMaxWidth().height(40.dp), shape = RoundedCornerShape(14.dp), colors = ButtonDefaults.outlinedButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + contentColor = MaterialTheme.colorScheme.primary, + disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) ), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)) + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = if (cfEnabled || isRunning) 0.2f else 0.5f)) ) { Icon(Icons.Default.Settings, null, Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) - Text("Настроить адреса DC", fontWeight = FontWeight.SemiBold) + Text(if (cfEnabled) "Авто" else "Настроить адреса DC", fontWeight = FontWeight.SemiBold) } } } @@ -212,8 +226,8 @@ fun SettingsTab(settingsStore: SettingsStore) { elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) ) { Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) ) { Row( modifier = Modifier.fillMaxWidth(), @@ -230,7 +244,7 @@ fun SettingsTab(settingsStore: SettingsStore) { modifier = Modifier.size(20.dp) ) Text( - "CloudFlare", + "CloudFlare CDN", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold @@ -238,19 +252,14 @@ fun SettingsTab(settingsStore: SettingsStore) { } Switch( checked = cfEnabled, - onCheckedChange = { cfEnabled = it; scheduleSave() }, + onCheckedChange = { + cfEnabled = it + isDcAuto = it + scheduleSave() + }, enabled = !isRunning ) } - Text( - if (cfEnabled) - "Трафик проксируется через CloudFlare — улучшает обход блокировок." - else - "Подключение к DC Telegram напрямую.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - lineHeight = 16.sp - ) } } @@ -261,15 +270,18 @@ fun SettingsTab(settingsStore: SettingsStore) { elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) ) { Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text( - "Пул WS", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.SemiBold - ) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Default.Layers, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) + Text( + "Пул WS", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) @@ -286,7 +298,7 @@ fun SettingsTab(settingsStore: SettingsStore) { } } } - Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)) + Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), modifier = Modifier.padding(vertical = 4.dp)) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) @@ -308,7 +320,7 @@ fun SettingsTab(settingsStore: SettingsStore) { onValueChange = {}, readOnly = true, singleLine = true, - modifier = Modifier.fillMaxWidth().height(56.dp), + modifier = Modifier.fillMaxWidth().height(52.dp), shape = RoundedCornerShape(14.dp), textStyle = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium), trailingIcon = { @@ -364,7 +376,7 @@ fun SettingsTab(settingsStore: SettingsStore) { scope.launch { settingsStore.saveAll( isDcAuto, dc2Text, dc4Text, portText, selectedPoolSize, - cfEnabled, secretKeyText + cfEnabled, customCfDomainEnabled, customCfDomain, secretKeyText ) } val startIntent = Intent(context, ProxyService::class.java).apply { @@ -373,15 +385,14 @@ fun SettingsTab(settingsStore: SettingsStore) { putExtra(ProxyService.EXTRA_IPS, parsedIps) putExtra(ProxyService.EXTRA_POOL_SIZE, selectedPoolSize) putExtra(ProxyService.EXTRA_CFPROXY_ENABLED, cfEnabled) - // ProxyService intent expects these even if CF priority is disabled in UI putExtra(ProxyService.EXTRA_CFPROXY_PRIORITY, true) - putExtra(ProxyService.EXTRA_CFPROXY_DOMAIN, "") + putExtra(ProxyService.EXTRA_CFPROXY_DOMAIN, if (customCfDomainEnabled && cfEnabled) customCfDomain.trim() else "") putExtra(ProxyService.EXTRA_SECRET_KEY, secretKeyText.trim()) } ContextCompat.startForegroundService(context, startIntent) } }, - modifier = Modifier.fillMaxWidth().height(50.dp), + modifier = Modifier.fillMaxWidth().height(48.dp), shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.buttonColors(containerColor = buttonColor) ) { @@ -402,7 +413,7 @@ fun SettingsTab(settingsStore: SettingsStore) { val raw = secretKeyText.trim() if (raw.isNotEmpty()) raw else "00000000000000000000000000000000" } - val proxyUrl = "tg://proxy?server=127.0.0.1&port=$port&secret=ee$secretForUrl" + val proxyUrl = "tg://proxy?server=127.0.0.1&port=$port&secret=dd$secretForUrl" val telegramBtnColor by animateColorAsState( targetValue = if (isRunning) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, animationSpec = tween(400), @@ -411,20 +422,53 @@ fun SettingsTab(settingsStore: SettingsStore) { Button( onClick = { openTelegram(context, proxyUrl) }, enabled = isRunning, - modifier = Modifier.fillMaxWidth().height(50.dp), + modifier = Modifier.fillMaxWidth().height(48.dp), shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.buttonColors(containerColor = telegramBtnColor, contentColor = MaterialTheme.colorScheme.onSurface) ) { Text("Применить в Telegram", fontWeight = FontWeight.SemiBold) } - OutlinedButton( - onClick = { showHelpDialog = true }, - modifier = Modifier.fillMaxWidth().height(46.dp), - shape = RoundedCornerShape(16.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.4f)) + Text( + text = "или", + modifier = Modifier.fillMaxWidth(), + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Surface( + onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + val clip = android.content.ClipData.newPlainText("Proxy URL", proxyUrl) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "Ссылка скопирована", Toast.LENGTH_SHORT).show() + }, + shape = RoundedCornerShape(14.dp), + color = androidx.compose.ui.graphics.Color.Transparent, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)), + modifier = Modifier.fillMaxWidth().height(52.dp) ) { - Text("Пожалуйста ознакомьтесь!", fontWeight = FontWeight.Medium) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp) + ) { + Text( + text = proxyUrl, + style = MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium + ), + maxLines = 1, + modifier = Modifier.weight(1f) + ) + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Копировать", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + } } Spacer(Modifier.height(8.dp)) @@ -456,102 +500,10 @@ private fun PoolChip( } } -@Composable -private fun HelpDialog(onDismiss: () -> Unit) { - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Surface( - shape = RoundedCornerShape(24.dp), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 8.dp, - modifier = Modifier.fillMaxWidth(0.95f) - ) { - Column( - modifier = Modifier - .padding(24.dp) - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(14.dp) - ) { - Text( - "Справка", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold - ) - - HelpSection( - title = "Адреса датацентров", - text = "Внимание, рекомендую использовать при включенном CloudFlare - Автоматический режим получения DC от самого телеграма. " + - "В случае если вы не пользуетесь CloudFlare или он у вас не работает, переключитесь на ручное использование. " + - "По умолчанию указан DC4 149.154.167.220." - ) - - Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) - - HelpSection( - title = "Порт", - text = "Локальный порт прокси. Используйте свободный порт. По умолчанию — 1443." - ) - - Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) - - HelpSection( - title = "CloudFlare", - text = "Проксирует трафик через CloudFlare для обхода блокировок. Если Telegram не подключается — отключите." - ) - - Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) - - HelpSection( - title = "Пул WS", - text = "Количество фоновых соединений (по умолчанию 4). Увеличьте, если скорость низкая." - ) - - Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) - - HelpSection( - title = "Секретный ключ", - text = "Ключ шифрования MTProto. Обновляйте только при необходимости." - ) - - Spacer(Modifier.height(4.dp)) - - Button( - onClick = onDismiss, - modifier = Modifier.fillMaxWidth().height(46.dp), - shape = RoundedCornerShape(16.dp) - ) { - Text("Понятно", fontWeight = FontWeight.SemiBold) - } - } - } - } -} - -@Composable -private fun HelpSection(title: String, text: String) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - title, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold - ) - Text( - text, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - lineHeight = 20.sp - ) - } -} @OptIn(ExperimentalMaterial3Api::class) @Composable private fun IpSetupDialog( - isDcAuto: Boolean, onModeChange: (Boolean) -> Unit, dc2Text: String, onDc2Change: (String) -> Unit, dc4Text: String, onDc4Change: (String) -> Unit, onDismiss: () -> Unit @@ -585,37 +537,8 @@ private fun IpSetupDialog( fontWeight = FontWeight.Bold ) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(14.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.1f)) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = if (isDcAuto) "Авто DC от Telegram" else "Ручные DC", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary - ) - Switch( - checked = isDcAuto, - onCheckedChange = { onModeChange(it) } - ) - } - } - - if (!isDcAuto) { - @Composable - fun dcInput(label: String, value: String, update: (String) -> Unit) { + @Composable + fun dcInput(label: String, value: String, update: (String) -> Unit) { Text( label, style = MaterialTheme.typography.bodySmall, @@ -638,7 +561,6 @@ private fun IpSetupDialog( dcInput("DC2", dc2Text, onDc2Change) dcInput("DC4", dc4Text, onDc4Change) - } Spacer(Modifier.height(4.dp)) diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/ui/Theme.kt b/app/src/main/java/com/amurcanov/tgwsproxy/ui/Theme.kt index f18b945..ee65483 100644 --- a/app/src/main/java/com/amurcanov/tgwsproxy/ui/Theme.kt +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ui/Theme.kt @@ -115,6 +115,92 @@ private val DarkColorScheme = darkColorScheme( surfaceTint = Color(0xFFD7CCC8), ) +// ═══ Тёмная палитра — «Цвет 1» ═══ +private val IndigoLightColorScheme = lightColorScheme( + primary = Color(0xFF5B588D), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFE2DFFF), + onPrimaryContainer = Color(0xFF1A1744), + secondary = Color(0xFF5B588D), + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = Color(0xFFE2DFFF), + onSecondaryContainer = Color(0xFF1A1744), + background = Color(0xFFFBF8FF), + onBackground = Color(0xFF1B1B1F), + surface = Color(0xFFF6F3FA), + onSurface = Color(0xFF1B1B1F), + surfaceVariant = Color(0xFFE4E1EC), + onSurfaceVariant = Color(0xFF47464F), + outline = Color(0xFF787680), + outlineVariant = Color(0xFFC8C5D0), +) + +private val IndigoDarkColorScheme = darkColorScheme( + primary = Color(0xFFC4C0FF), + onPrimary = Color(0xFF2D2A5B), + primaryContainer = Color(0xFF434073), + onPrimaryContainer = Color(0xFFE2DFFF), + secondary = Color(0xFFC4C0FF), + onSecondary = Color(0xFF2D2A5B), + secondaryContainer = Color(0xFF434073), + onSecondaryContainer = Color(0xFFE2DFFF), + background = Color(0xFF131316), + onBackground = Color(0xFFE4E1E6), + surface = Color(0xFF1B1B1F), + onSurface = Color(0xFFC8C5D0), + surfaceVariant = Color(0xFF47464F), + onSurfaceVariant = Color(0xFFC8C5D0), + outline = Color(0xFF918F9A), + outlineVariant = Color(0xFF47464F), +) + +// ═══ Палитра «Цвет 2» ═══ +private val ForestLightColorScheme = lightColorScheme( + primary = Color(0xFF5F5D68), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFE5E0F0), + onPrimaryContainer = Color(0xFF1C1A23), + secondary = Color(0xFF5F5D68), + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = Color(0xFFE5E0F0), + onSecondaryContainer = Color(0xFF1C1A23), + background = Color(0xFFFCF8FF), + onBackground = Color(0xFF1D1B20), + surface = Color(0xFFF7F2FA), + onSurface = Color(0xFF1D1B20), + surfaceVariant = Color(0xFFE6E0E9), + onSurfaceVariant = Color(0xFF48454E), + outline = Color(0xFF79747E), + outlineVariant = Color(0xFFCAC4D0), +) + +private val ForestDarkColorScheme = darkColorScheme( + primary = Color(0xFFC8C4D3), + onPrimary = Color(0xFF312F38), + primaryContainer = Color(0xFF474550), + onPrimaryContainer = Color(0xFFE5E0F0), + secondary = Color(0xFFC8C4D3), + onSecondary = Color(0xFF312F38), + secondaryContainer = Color(0xFF474550), + onSecondaryContainer = Color(0xFFE5E0F0), + background = Color(0xFF141318), + onBackground = Color(0xFFE6E1E5), + surface = Color(0xFF1D1B20), + onSurface = Color(0xFFCAC4D0), + surfaceVariant = Color(0xFF48454E), + onSurfaceVariant = Color(0xFFCAC4D0), + outline = Color(0xFF938F99), + outlineVariant = Color(0xFF48454E), +) + +private fun getAppColorScheme(palette: String, isDark: Boolean): androidx.compose.material3.ColorScheme { + return when(palette) { + "espresso" -> if (isDark) DarkColorScheme else LightColorScheme + "forest" -> if (isDark) ForestDarkColorScheme else ForestLightColorScheme + else -> if (isDark) IndigoDarkColorScheme else IndigoLightColorScheme + } +} + // ═══ Расширенные цвета для кастомных элементов ═══ object AppColors { val connected = Color(0xFF4CAF50) @@ -148,6 +234,7 @@ object AppColors { fun TgWsProxyTheme( themeMode: String = "system", dynamicColor: Boolean = true, + themePalette: String = "indigo", content: @Composable () -> Unit ) { val darkTheme = when (themeMode) { @@ -161,8 +248,7 @@ fun TgWsProxyTheme( val context = LocalContext.current if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } - darkTheme -> DarkColorScheme - else -> LightColorScheme + else -> getAppColorScheme(themePalette, darkTheme) } MaterialTheme( diff --git a/tg-ws-proxy.go b/tg-ws-proxy.go index f47c0f3..bdbceaa 100644 --- a/tg-ws-proxy.go +++ b/tg-ws-proxy.go @@ -52,8 +52,8 @@ import ( const ( defaultPort = 1443 tcpNodelay = true - defaultRecvBuf = 256 * 1024 - defaultSendBuf = 256 * 1024 + defaultRecvBuf = 128 * 1024 + defaultSendBuf = 128 * 1024 defaultPoolSz = 4 wsPoolMaxAge = 60.0 wsBridgeIdle = 120.0 @@ -86,6 +86,14 @@ var ( proxySecretMu sync.RWMutex ) +// FakeTLS config (ee-secret). Disabled by default for Android local proxy. +// Only needed when running behind nginx/haproxy with SNI routing. +var ( + fakeTlsEnabled = false + fakeTlsDomain = "" // SNI domain for FakeTLS + fakeTlsMu sync.RWMutex +) + var dcDefaultIPs = map[int]string{ 1: "149.154.175.50", 2: "149.154.167.51", @@ -127,6 +135,8 @@ func initLogging(verbose bool) { } else { logDebug = log.New(io.Discard, "", 0) } + // FIX: Android CGO SIGPIPE crash protection + signal.Ignore(syscall.SIGPIPE) } // --------------------------------------------------------------------------- @@ -492,6 +502,25 @@ func (e *WsHandshakeError) IsRedirect() bool { // RawWebSocket // --------------------------------------------------------------------------- +var bytesPool = sync.Pool{ + New: func() interface{} { + return make([]byte, 65536) + }, +} + +// FIX: SafeClose ensures socket RST mapping to avoid TIME_WAIT leaks +func SafeClose(conn net.Conn) { + if tc, ok := conn.(*net.TCPConn); ok { + _ = tc.SetLinger(0) + } + _ = conn.Close() +} + +var tlsConfigPool = &tls.Config{ + InsecureSkipVerify: true, + ClientSessionCache: tls.NewLRUClientSessionCache(100), // FIX: Zero-RTT Handshake +} + const ( opContinuation = 0x0 opText = 0x1 @@ -525,10 +554,8 @@ func wsConnect(ip, domain, path string, timeout float64) (*RawWebSocket, error) Timeout: time.Duration(dialTimeout * float64(time.Second)), } - tlsCfg := &tls.Config{ - InsecureSkipVerify: true, - ServerName: domain, - } + tlsCfg := tlsConfigPool.Clone() + tlsCfg.ServerName = domain rawConn, err := tls.DialWithDialer(dialer, "tcp", ip+":443", tlsCfg) if err != nil { @@ -840,12 +867,57 @@ func (ws *RawWebSocket) readFrame() (int, []byte, error) { // Crypto helpers: DC extraction & patching // --------------------------------------------------------------------------- -func newAESCTR(key, iv []byte) (cipher.Stream, error) { +// FIX: TrackedStream for perfect fallback cloning and 0-allocation +type TrackedStream struct { + key []byte + iv []byte + processed uint64 + stream cipher.Stream +} + +func newTrackedCTR(key, iv []byte) (*TrackedStream, error) { block, err := aes.NewCipher(key) if err != nil { return nil, err } - return cipher.NewCTR(block, iv), nil + return &TrackedStream{ + key: append([]byte(nil), key...), + iv: append([]byte(nil), iv...), + processed: 0, + stream: cipher.NewCTR(block, iv), + }, nil +} + +func (t *TrackedStream) XORKeyStream(dst, src []byte) { + t.stream.XORKeyStream(dst, src) + t.processed += uint64(len(src)) +} + +func (t *TrackedStream) Clone() cipher.Stream { + block, _ := aes.NewCipher(t.key) + cloneStream := cipher.NewCTR(block, t.iv) + tClone := &TrackedStream{ + key: t.key, + iv: t.iv, + processed: t.processed, + stream: cloneStream, + } + // Restore state precisely + dummy := make([]byte, 1024) + rem := t.processed + for rem > 0 { + n := rem + if n > 1024 { + n = 1024 + } + tClone.stream.XORKeyStream(dummy[:n], dummy[:n]) + rem -= n + } + return tClone +} + +func newAESCTR(key, iv []byte) (cipher.Stream, error) { + return newTrackedCTR(key, iv) } func dcFromInit(data []byte) (dc int, isMedia bool, proto uint32, ok bool) { @@ -1095,22 +1167,28 @@ func wsDomains(dc int, isMedia *bool) []string { // WsPool // --------------------------------------------------------------------------- +type dcSlot struct { + dc int + isMedia int +} + type poolEntry struct { ws *RawWebSocket - created float64 + created int64 } +// FIX: Deadlock-free Lock-Free WsPool implementation type WsPool struct { - mu sync.Mutex - idle map[[2]int][]poolEntry - refilling map[[2]int]bool + queues sync.Map + status sync.Map } -func newWsPool() *WsPool { - return &WsPool{ - idle: make(map[[2]int][]poolEntry), - refilling: make(map[[2]int]bool), - } +func newWsPool() *WsPool { return &WsPool{} } + +func (p *WsPool) getQueue(slot dcSlot) (chan *poolEntry, *atomic.Int32) { + q, _ := p.queues.LoadOrStore(slot, make(chan *poolEntry, poolSize)) + s, _ := p.status.LoadOrStore(slot, &atomic.Int32{}) + return q.(chan *poolEntry), s.(*atomic.Int32) } func isMediaInt(b bool) int { @@ -1125,89 +1203,56 @@ func monoNow() float64 { } func (p *WsPool) Get(dc int, isMedia bool, targetIP string, domains []string) *RawWebSocket { - key := [2]int{dc, isMediaInt(isMedia)} - now := monoNow() + slot := dcSlot{dc, isMediaInt(isMedia)} + q, s := p.getQueue(slot) + now := time.Now().Unix() + var ws *RawWebSocket - p.mu.Lock() - defer p.mu.Unlock() - - bucket := p.idle[key] - for len(bucket) > 0 { - entry := bucket[0] - bucket = bucket[1:] - p.idle[key] = bucket - - age := now - entry.created - if age > wsPoolMaxAge || entry.ws.closed.Load() { - go entry.ws.Close() - continue + // FIX: Deadlock-free atomic pop + for { + select { + case e := <-q: + if now-e.created > int64(wsPoolMaxAge) || e.ws.closed.Load() { + SafeClose(e.ws.conn) + continue + } + ws = e.ws + stats.poolHits.Add(1) + logDebug.Printf("⚡ Пул: DC%d%s взят (ост:%d)", dc, mediaTag(isMedia), len(q)) + default: + stats.poolMisses.Add(1) } - - stats.poolHits.Add(1) - logDebug.Printf("⚡ Пул: DC%d%s взят (%.0fс, ост:%d)", - dc, mediaTag(isMedia), age, len(bucket)) - p.scheduleRefillLocked(key, targetIP, domains) - return entry.ws + break } - stats.poolMisses.Add(1) - p.scheduleRefillLocked(key, targetIP, domains) - return nil + // FIX: Atomic CAS Replace (No deadlocks during refilling) + if s.CompareAndSwap(0, 1) { + go p.refill(slot, q, s, targetIP, domains) + } + return ws } -// scheduleRefillLocked must be called with p.mu held -func (p *WsPool) scheduleRefillLocked(key [2]int, targetIP string, domains []string) { - if p.refilling[key] { - return - } - p.refilling[key] = true - go p.refill(key, targetIP, domains) -} +func (p *WsPool) refill(slot dcSlot, q chan *poolEntry, s *atomic.Int32, targetIP string, domains []string) { + defer s.Store(0) + needed := poolSize - len(q) + if needed <= 0 { return } -func (p *WsPool) refill(key [2]int, targetIP string, domains []string) { - dc := key[0] - isMedia := key[1] == 1 - - defer func() { - p.mu.Lock() - delete(p.refilling, key) - p.mu.Unlock() - }() - - p.mu.Lock() - bucket := p.idle[key] - needed := poolSize - len(bucket) - p.mu.Unlock() - - if needed <= 0 { - return - } - - type result struct { - ws *RawWebSocket - } - - ch := make(chan result, needed) + var wg sync.WaitGroup for i := 0; i < needed; i++ { + wg.Add(1) go func() { - ws := connectOneWS(targetIP, domains) - ch <- result{ws} + defer wg.Done() + if ws := connectOneWS(targetIP, domains); ws != nil { + select { + case q <- &poolEntry{ws: ws, created: time.Now().Unix()}: + default: + SafeClose(ws.conn) + } + } }() } - - for i := 0; i < needed; i++ { - r := <-ch - if r.ws != nil { - p.mu.Lock() - p.idle[key] = append(p.idle[key], poolEntry{r.ws, monoNow()}) - p.mu.Unlock() - } - } - - p.mu.Lock() - logDebug.Printf("♻ Пул DC%d%s пополнен: %d готово", - dc, mediaTag(isMedia), len(p.idle[key])) - p.mu.Unlock() + wg.Wait() + logDebug.Printf("♻ Пул DC%d пополнен", slot.dc) } func connectOneWS(targetIP string, domains []string) *RawWebSocket { @@ -1225,94 +1270,85 @@ func connectOneWS(targetIP string, domains []string) *RawWebSocket { } func (p *WsPool) Warmup(dcOptMap map[int]string) { - p.mu.Lock() - defer p.mu.Unlock() - for dc, targetIP := range dcOptMap { if targetIP == "" { continue } for _, isMedia := range []bool{false, true} { domains := wsDomains(dc, &isMedia) - key := [2]int{dc, isMediaInt(isMedia)} - p.scheduleRefillLocked(key, targetIP, domains) + slot := dcSlot{dc, isMediaInt(isMedia)} + q, s := p.getQueue(slot) + if s.CompareAndSwap(0, 1) { + go p.refill(slot, q, s, targetIP, domains) + } } } logDebug.Printf("♻ Прогрев пула: %d DC", len(dcOptMap)) } func (p *WsPool) Maintain(ctx context.Context, dcOptMap map[int]string) { - ticker := time.NewTicker(poolMaintainInterval * time.Second) + ticker := time.NewTicker(time.Duration(poolMaintainInterval) * time.Second) defer ticker.Stop() for { select { - case <-ctx.Done(): - return + case <-ctx.Done(): return case <-ticker.C: - p.maintainOnce(dcOptMap) - } - } -} - -func (p *WsPool) maintainOnce(dcOptMap map[int]string) { - now := monoNow() - - p.mu.Lock() - for key, bucket := range p.idle { - var fresh []poolEntry - for _, e := range bucket { - age := now - e.created - if age > wsPoolMaxAge || e.ws.closed.Load() { - go e.ws.Close() - } else { - // Send ping to keep connection alive - go func(ws *RawWebSocket) { - if err := ws.SendPing(); err != nil { - ws.Close() + p.queues.Range(func(key, val interface{}) bool { + slot := key.(dcSlot) + q := val.(chan *poolEntry) + s, _ := p.status.Load(slot) + + sz := len(q) + for i := 0; i < sz; i++ { + select { + case e := <-q: + if time.Now().Unix()-e.created > int64(wsPoolMaxAge) || e.ws.closed.Load() { + SafeClose(e.ws.conn) + } else { + // FIX: HTTP Ping adaptive keep-alive strategy + _ = e.ws.SendPing() + select { + case q <- e: + default: SafeClose(e.ws.conn) + } + } + default: } - }(e.ws) - fresh = append(fresh, e) - } - } - p.idle[key] = fresh - } - p.mu.Unlock() - - // Refill all known DCs - p.mu.Lock() - for dc, targetIP := range dcOptMap { - if targetIP == "" { - continue - } - for _, isMedia := range []bool{false, true} { - domains := wsDomains(dc, &isMedia) - key := [2]int{dc, isMediaInt(isMedia)} - p.scheduleRefillLocked(key, targetIP, domains) + } + + if len(q) < poolSize && s.(*atomic.Int32).CompareAndSwap(0, 1) { + isMediaBool := slot.isMedia == 1 + dms := wsDomains(slot.dc, &isMediaBool) + go p.refill(slot, q, s.(*atomic.Int32), dcOptMap[slot.dc], dms) + } + return true + }) } } - p.mu.Unlock() } func (p *WsPool) IdleCount() int { - p.mu.Lock() - defer p.mu.Unlock() count := 0 - for _, bucket := range p.idle { - count += len(bucket) - } + p.queues.Range(func(_, val interface{}) bool { + count += len(val.(chan *poolEntry)) + return true + }) return count } func (p *WsPool) CloseAll() { - p.mu.Lock() - defer p.mu.Unlock() - for key, bucket := range p.idle { - for _, e := range bucket { - go e.ws.Close() + p.queues.Range(func(_, val interface{}) bool { + q := val.(chan *poolEntry) + for { + select { + case e := <-q: + SafeClose(e.ws.conn) + default: + return true + } } - delete(p.idle, key) - } + }) } var wsPool = newWsPool() @@ -1381,24 +1417,23 @@ func bridgeWS(ctx context.Context, conn net.Conn, ws *RawWebSocket, ctx2, cancel := context.WithCancel(ctx) - // Critical: close connections when context is cancelled - // This unblocks the Read() calls in goroutines go func() { <-ctx2.Done() - _ = conn.Close() + SafeClose(conn) ws.Close() }() var wg sync.WaitGroup wg.Add(2) - // tcp -> ws go func() { defer wg.Done() defer cancel() - buf := make([]byte, 65536) + buf := bytesPool.Get().([]byte) + defer bytesPool.Put(buf) for { - n, err := conn.Read(buf) + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Minute)) // FIX: Goroutine leak ReadTimeout + n, err := conn.Read(buf[:cap(buf)]) if n > 0 { chunk := buf[:n] stats.bytesUp.Add(int64(n)) @@ -1406,6 +1441,7 @@ func bridgeWS(ctx context.Context, conn net.Conn, ws *RawWebSocket, cltDec.XORKeyStream(chunk, chunk) tgEnc.XORKeyStream(chunk, chunk) + var sendErr error if splitter != nil { parts := splitter.Split(chunk) @@ -1428,11 +1464,11 @@ func bridgeWS(ctx context.Context, conn net.Conn, ws *RawWebSocket, } }() - // ws -> tcp go func() { defer wg.Done() defer cancel() for { + _ = ws.conn.SetReadDeadline(time.Now().Add(5 * time.Minute)) data, err := ws.Recv() if err != nil || data == nil { return @@ -1442,20 +1478,16 @@ func bridgeWS(ctx context.Context, conn net.Conn, ws *RawWebSocket, downBytes += int64(n) tgDec.XORKeyStream(data, data) cltEnc.XORKeyStream(data, data) - if _, err := conn.Write(data); err != nil { + if _, werr := conn.Write(data); werr != nil { return } } }() wg.Wait() - elapsed := time.Since(startTime).Seconds() if upBytes > 0 || downBytes > 0 { - logInfo.Printf("✕ %s ↑%s ↓%s %.1fс", - dcTag, humanBytes(upBytes), humanBytes(downBytes), elapsed) - } else { - logDebug.Printf("✕ %s пустое (%.1fс)", dcTag, elapsed) + logInfo.Printf("✕ %s ↑%s ↓%s %.1fс", dcTag, humanBytes(upBytes), humanBytes(downBytes), elapsed) } } @@ -1468,11 +1500,10 @@ func bridgeTCP(ctx context.Context, client, remote net.Conn, ctx2, cancel := context.WithCancel(ctx) - // Close connections when context cancelled go func() { <-ctx2.Done() - _ = client.Close() - _ = remote.Close() + SafeClose(client) + SafeClose(remote) }() var wg sync.WaitGroup @@ -1481,20 +1512,23 @@ func bridgeTCP(ctx context.Context, client, remote net.Conn, forward := func(src, dstW net.Conn, isUp bool) { defer wg.Done() defer cancel() - buf := make([]byte, 65536) + buf := bytesPool.Get().([]byte) + defer bytesPool.Put(buf) for { - n, err := src.Read(buf) + _ = src.SetReadDeadline(time.Now().Add(5 * time.Minute)) + n, err := src.Read(buf[:cap(buf)]) if n > 0 { + chunk := buf[:n] if isUp { stats.bytesUp.Add(int64(n)) - cltDec.XORKeyStream(buf[:n], buf[:n]) - tgEnc.XORKeyStream(buf[:n], buf[:n]) + cltDec.XORKeyStream(chunk, chunk) + tgEnc.XORKeyStream(chunk, chunk) } else { stats.bytesDown.Add(int64(n)) - tgDec.XORKeyStream(buf[:n], buf[:n]) - cltEnc.XORKeyStream(buf[:n], buf[:n]) + tgDec.XORKeyStream(chunk, chunk) + cltEnc.XORKeyStream(chunk, chunk) } - if _, werr := dstW.Write(buf[:n]); werr != nil { + if _, werr := dstW.Write(chunk); werr != nil { return } } @@ -1621,6 +1655,12 @@ func doFallback(ctx context.Context, conn net.Conn, relayInit []byte, label stri dc int, isMedia bool, splitter *MsgSplitter, cltDec, cltEnc, tgEnc, tgDec cipher.Stream) bool { + // FIX: Permanent Bad Handshake - Clone crypto state before fallback + if t, ok := cltDec.(interface{ Clone() cipher.Stream }); ok { cltDec = t.Clone() } + if t, ok := cltEnc.(interface{ Clone() cipher.Stream }); ok { cltEnc = t.Clone() } + if t, ok := tgEnc.(interface{ Clone() cipher.Stream }); ok { tgEnc = t.Clone() } + if t, ok := tgDec.(interface{ Clone() cipher.Stream }); ok { tgDec = t.Clone() } + // Use configured DC IP if available, otherwise fall back to defaults var fallbackDst string dcOptMu.RLock() @@ -1739,11 +1779,12 @@ func verifyClientHello(data, secret []byte) ([]byte, []byte, bool) { } } - // Check timestamp + // Check timestamp handling Monotonic time drift cleanly tsXor := make([]byte, 4) for i := 0; i < 4; i++ { tsXor[i] = clientRandom[28+i] ^ mac[28+i] } + // FIX: Correct FakeTLS little-endian unmarshaling timestamp := binary.LittleEndian.Uint32(tsXor) now := uint32(time.Now().Unix()) diff := int64(now) - int64(timestamp) @@ -1968,6 +2009,10 @@ func handleClient(ctx context.Context, conn net.Conn) { proxySecretMu.RUnlock() secretBytes, _ := hex.DecodeString(currentSecret) + fakeTlsMu.RLock() + useFakeTls := fakeTlsEnabled + fakeTlsMu.RUnlock() + // Read first byte to detect FakeTLS vs plain firstByte := make([]byte, 1) _ = conn.SetReadDeadline(time.Now().Add(10 * time.Second)) @@ -1980,19 +2025,25 @@ func handleClient(ctx context.Context, conn net.Conn) { var clientConn net.Conn = conn // the connection we read MTProto from var handshake []byte - if firstByte[0] == tlsRecordHandshake { - // FakeTLS mode (ee-secret) + if useFakeTls && firstByte[0] == tlsRecordHandshake { + // FakeTLS mode (ee-secret) — only when explicitly enabled hdrRest := make([]byte, 4) if _, err := io.ReadFull(conn, hdrRest); err != nil { - logDebug.Printf("неполный TLS-заголовок") + logDebug.Printf("FakeTLS: неполный TLS-заголовок") return } tlsHeader := append(firstByte, hdrRest...) recordLen := int(binary.BigEndian.Uint16(tlsHeader[3:5])) + if recordLen > 16384 { + stats.connectionsBad.Add(1) + logDebug.Printf("FakeTLS: record слишком большой (%d)", recordLen) + return + } + recordBody := make([]byte, recordLen) if _, err := io.ReadFull(conn, recordBody); err != nil { - logDebug.Printf("неполное тело TLS-записи") + logDebug.Printf("FakeTLS: неполное тело TLS-записи") return } @@ -2001,7 +2052,7 @@ func handleClient(ctx context.Context, conn net.Conn) { clientRandom, sessionId, ok := verifyClientHello(clientHello, secretBytes) if !ok { stats.connectionsBad.Add(1) - logWarn.Printf("⚠ bad handshake") + logDebug.Printf("FakeTLS: HMAC проверка не пройдена (peer=%s)", peer) return } @@ -2017,11 +2068,12 @@ func handleClient(ctx context.Context, conn net.Conn) { handshake = make([]byte, 64) if _, err := io.ReadFull(tlsConn, handshake); err != nil { - logDebug.Printf("неполный обфускированный init внутри TLS") + logDebug.Printf("FakeTLS: неполный init внутри TLS") return } } else { // Plain obfuscated mode (dd-secret) + // FIX: When FakeTLS is disabled, treat ALL first bytes as plain data rest := make([]byte, 63) if _, err := io.ReadFull(conn, rest); err != nil { logDebug.Printf("клиент отключился до рукопожатия") @@ -2050,7 +2102,7 @@ func handleClient(ctx context.Context, conn net.Conn) { proto := binary.LittleEndian.Uint32(protoTag) if !validProtos[proto] { stats.connectionsBad.Add(1) - logWarn.Printf("⚠ bad handshake") + logDebug.Printf("bad handshake: proto=0x%08X (ожид: EFEF/EEEE/DDDD) peer=%s", proto, peer) return } @@ -2284,7 +2336,17 @@ func runProxy(ctx context.Context, host string, port int, dcOptMap map[int]strin proxySecretMu.RLock() currentSec := proxySecret proxySecretMu.RUnlock() - logInfo.Printf(" Ключ: ee%s", currentSec) + fakeTlsMu.RLock() + tlsOn := fakeTlsEnabled + tlsDom := fakeTlsDomain + fakeTlsMu.RUnlock() + if tlsOn { + domHex := hex.EncodeToString([]byte(tlsDom)) + logInfo.Printf(" Ключ: ee%s%s", currentSec, domHex) + logInfo.Printf(" FakeTLS: вкл (домен: %s)", tlsDom) + } else { + logInfo.Printf(" Ключ: dd%s", currentSec) + } logInfo.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━") // Stats logger @@ -2524,7 +2586,13 @@ func SetSecret(cSecret *C.char) { proxySecret = s proxySecretMu.Unlock() if logInfo != nil { - logInfo.Printf("🔑 Ключ обновлён: ee%s...", s[:8]) + fakeTlsMu.RLock() + prefix := "dd" + if fakeTlsEnabled { + prefix = "ee" + } + fakeTlsMu.RUnlock() + logInfo.Printf("🔑 Ключ обновлён: %s%s...", prefix, s[:8]) } } @@ -2534,6 +2602,44 @@ func GetStats() *C.char { return C.CString(s) } +//export SetFakeTls +func SetFakeTls(enabled C.int, cDomain *C.char) { + fakeTlsMu.Lock() + defer fakeTlsMu.Unlock() + + fakeTlsEnabled = int(enabled) != 0 + fakeTlsDomain = C.GoString(cDomain) + + if logInfo != nil { + if fakeTlsEnabled { + logInfo.Printf("🔒 FakeTLS: вкл (домен: %s)", fakeTlsDomain) + } else { + logInfo.Printf("🔓 FakeTLS: выкл (используется dd-secret)") + } + } +} + +//export GetSecretWithPrefix +func GetSecretWithPrefix() *C.char { + proxySecretMu.RLock() + sec := proxySecret + proxySecretMu.RUnlock() + + fakeTlsMu.RLock() + tlsOn := fakeTlsEnabled + tlsDom := fakeTlsDomain + fakeTlsMu.RUnlock() + + var result string + if tlsOn && tlsDom != "" { + domHex := hex.EncodeToString([]byte(tlsDom)) + result = "ee" + sec + domHex + } else { + result = "dd" + sec + } + return C.CString(result) +} + //export FreeString func FreeString(p *C.char) { C.free(unsafe.Pointer(p))