mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-05-22 23:41:44 +03:00
Update v1.0.7
This commit is contained in:
@@ -65,9 +65,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
val settingsStore = remember { SettingsStore(context) }
|
val settingsStore = remember { SettingsStore(context) }
|
||||||
val themeMode by settingsStore.themeMode
|
val themeMode by settingsStore.themeMode
|
||||||
.collectAsStateWithLifecycle(initialValue = "system")
|
.collectAsStateWithLifecycle(initialValue = "system")
|
||||||
|
val isDynamicColor by settingsStore.isDynamicColor
|
||||||
|
.collectAsStateWithLifecycle(initialValue = true)
|
||||||
|
val themePalette by settingsStore.themePalette
|
||||||
|
.collectAsStateWithLifecycle(initialValue = "indigo")
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
TgWsProxyTheme(themeMode = themeMode) {
|
TgWsProxyTheme(themeMode = themeMode, dynamicColor = isDynamicColor, themePalette = themePalette) {
|
||||||
androidx.compose.runtime.CompositionLocalProvider(
|
androidx.compose.runtime.CompositionLocalProvider(
|
||||||
androidx.compose.ui.platform.LocalDensity provides androidx.compose.ui.unit.Density(
|
androidx.compose.ui.platform.LocalDensity provides androidx.compose.ui.unit.Density(
|
||||||
density = androidx.compose.ui.platform.LocalDensity.current.density,
|
density = androidx.compose.ui.platform.LocalDensity.current.density,
|
||||||
@@ -85,6 +89,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
currentTheme = themeMode,
|
currentTheme = themeMode,
|
||||||
onThemeChange = { mode ->
|
onThemeChange = { mode ->
|
||||||
scope.launch { settingsStore.saveThemeMode(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()
|
LogManager.startListening()
|
||||||
|
onDispose { LogManager.stopListening() }
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -206,19 +219,21 @@ object LogManager {
|
|||||||
if (job?.isActive == true) return
|
if (job?.isActive == true) return
|
||||||
job = CoroutineScope(Dispatchers.IO).launch {
|
job = CoroutineScope(Dispatchers.IO).launch {
|
||||||
// Start logcat reader coroutine
|
// Start logcat reader coroutine
|
||||||
val readerJob = launch {
|
val readerJob = launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val pid = android.os.Process.myPid()
|
val pid = android.os.Process.myPid()
|
||||||
val process = Runtime.getRuntime().exec(
|
val process = ProcessBuilder("logcat", "-v", "tag", "--pid", pid.toString())
|
||||||
arrayOf("logcat", "-v", "tag", "--pid", pid.toString())
|
.redirectErrorStream(true)
|
||||||
)
|
.start()
|
||||||
|
|
||||||
logcatProcess = process
|
logcatProcess = process
|
||||||
val reader = BufferedReader(InputStreamReader(process.inputStream), 8192)
|
|
||||||
|
process.inputStream.bufferedReader().use { reader ->
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
val line = reader.readLine() ?: break
|
val line = try { reader.readLine() } catch (e: Exception) { null } ?: break
|
||||||
val entry = parseLine(line) ?: continue
|
val entry = parseLine(line) ?: continue
|
||||||
logChannel.trySend(entry) // non-blocking send
|
logChannel.trySend(entry)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
} finally {
|
} finally {
|
||||||
@@ -259,32 +274,26 @@ object LogManager {
|
|||||||
* Merges consecutive duplicates and caps at 50 entries.
|
* Merges consecutive duplicates and caps at 50 entries.
|
||||||
*/
|
*/
|
||||||
private fun applyBatch(current: List<LogEntry>, batch: List<LogEntry>): List<LogEntry> {
|
private fun applyBatch(current: List<LogEntry>, batch: List<LogEntry>): List<LogEntry> {
|
||||||
// Use a pre-sized ArrayList to avoid re-allocation
|
val result = ArrayDeque(current)
|
||||||
val result = ArrayList<LogEntry>(minOf(current.size + batch.size, 50))
|
|
||||||
result.addAll(current)
|
|
||||||
|
|
||||||
for (entry in batch) {
|
for (entry in batch) {
|
||||||
var merged = false
|
var merged = false
|
||||||
val searchDepth = minOf(result.size, 10)
|
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) {
|
if (result[i].message == entry.message) {
|
||||||
val existing = result.removeAt(i)
|
val existing = result.removeAt(i)
|
||||||
result.add(existing.copy(count = existing.count + 1))
|
result.addLast(existing.copy(count = existing.count + 1))
|
||||||
merged = true
|
merged = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!merged) {
|
if (!merged) {
|
||||||
result.add(entry)
|
result.addLast(entry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
while (result.size > 50) {
|
||||||
// Trim to 50 entries from the end
|
result.removeFirst()
|
||||||
return if (result.size > 50) {
|
|
||||||
result.subList(result.size - 50, result.size).toList()
|
|
||||||
} else {
|
|
||||||
result
|
|
||||||
}
|
}
|
||||||
|
return result.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stopListening() {
|
fun stopListening() {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ interface ProxyLibrary : Library {
|
|||||||
fun SetPoolSize(size: Int)
|
fun SetPoolSize(size: Int)
|
||||||
fun SetCfProxyConfig(enabled: Int, priority: Int, userDomain: String)
|
fun SetCfProxyConfig(enabled: Int, priority: Int, userDomain: String)
|
||||||
fun SetSecret(secret: String)
|
fun SetSecret(secret: String)
|
||||||
|
fun SetFakeTls(enabled: Int, domain: String)
|
||||||
|
fun GetSecretWithPrefix(): Pointer?
|
||||||
fun GetStats(): Pointer?
|
fun GetStats(): Pointer?
|
||||||
fun FreeString(p: Pointer)
|
fun FreeString(p: Pointer)
|
||||||
}
|
}
|
||||||
@@ -38,6 +40,16 @@ object NativeProxy {
|
|||||||
fun setSecret(secret: String) {
|
fun setSecret(secret: String) {
|
||||||
ProxyLibrary.INSTANCE.SetSecret(secret)
|
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? {
|
fun getStats(): String? {
|
||||||
val ptr = ProxyLibrary.INSTANCE.GetStats() ?: return null
|
val ptr = ProxyLibrary.INSTANCE.GetStats() ?: return null
|
||||||
val res = ptr.getString(0)
|
val res = ptr.getString(0)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class ProxyService : Service() {
|
|||||||
|
|
||||||
private var wakeLock: PowerManager.WakeLock? = null
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
private var statsJob: Job? = null
|
private var statsJob: Job? = null
|
||||||
|
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val ACTION_START = "com.amurcanov.tgwsproxy.START"
|
const val ACTION_START = "com.amurcanov.tgwsproxy.START"
|
||||||
@@ -87,7 +88,7 @@ class ProxyService : Service() {
|
|||||||
|
|
||||||
_isRunning.value = true
|
_isRunning.value = true
|
||||||
|
|
||||||
statsJob = CoroutineScope(Dispatchers.IO).launch {
|
statsJob = serviceScope.launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
delay(2000)
|
delay(2000)
|
||||||
if (_isRunning.value) {
|
if (_isRunning.value) {
|
||||||
@@ -197,6 +198,7 @@ class ProxyService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
serviceScope.cancel()
|
||||||
if (_isRunning.value) {
|
if (_isRunning.value) {
|
||||||
stopProxy()
|
stopProxy()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,22 +17,30 @@ class SettingsStore(private val context: Context) {
|
|||||||
|
|
||||||
private object Keys {
|
private object Keys {
|
||||||
val THEME_MODE = stringPreferencesKey("theme_mode")
|
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 IS_DC_AUTO = booleanPreferencesKey("is_dc_auto")
|
||||||
val DC2 = stringPreferencesKey("dc2")
|
val DC2 = stringPreferencesKey("dc2")
|
||||||
val DC4 = stringPreferencesKey("dc4")
|
val DC4 = stringPreferencesKey("dc4")
|
||||||
val PORT = stringPreferencesKey("port")
|
val PORT = stringPreferencesKey("port")
|
||||||
val POOL_SIZE = intPreferencesKey("pool_size")
|
val POOL_SIZE = intPreferencesKey("pool_size")
|
||||||
val CFPROXY_ENABLED = booleanPreferencesKey("cfproxy_enabled")
|
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 SECRET_KEY = stringPreferencesKey("secret_key")
|
||||||
}
|
}
|
||||||
|
|
||||||
val themeMode: Flow<String> = context.dataStore.data.map { it[Keys.THEME_MODE] ?: "system" }
|
val themeMode: Flow<String> = context.dataStore.data.map { it[Keys.THEME_MODE] ?: "system" }
|
||||||
|
val isDynamicColor: Flow<Boolean> = context.dataStore.data.map { it[Keys.IS_DYNAMIC_COLOR] ?: true }
|
||||||
|
val themePalette: Flow<String> = context.dataStore.data.map { it[Keys.THEME_PALETTE] ?: "indigo" }
|
||||||
val isDcAuto: Flow<Boolean> = context.dataStore.data.map { it[Keys.IS_DC_AUTO] ?: true }
|
val isDcAuto: Flow<Boolean> = context.dataStore.data.map { it[Keys.IS_DC_AUTO] ?: true }
|
||||||
val dc2: Flow<String> = context.dataStore.data.map { it[Keys.DC2] ?: "" }
|
val dc2: Flow<String> = context.dataStore.data.map { it[Keys.DC2] ?: "" }
|
||||||
val dc4: Flow<String> = context.dataStore.data.map { it[Keys.DC4] ?: "149.154.167.220" }
|
val dc4: Flow<String> = context.dataStore.data.map { it[Keys.DC4] ?: "149.154.167.220" }
|
||||||
val port: Flow<String> = context.dataStore.data.map { it[Keys.PORT] ?: "1443" }
|
val port: Flow<String> = context.dataStore.data.map { it[Keys.PORT] ?: "1443" }
|
||||||
val poolSize: Flow<Int> = context.dataStore.data.map { it[Keys.POOL_SIZE] ?: 4 }
|
val poolSize: Flow<Int> = context.dataStore.data.map { it[Keys.POOL_SIZE] ?: 4 }
|
||||||
val cfproxyEnabled: Flow<Boolean> = context.dataStore.data.map { it[Keys.CFPROXY_ENABLED] ?: true }
|
val cfproxyEnabled: Flow<Boolean> = context.dataStore.data.map { it[Keys.CFPROXY_ENABLED] ?: true }
|
||||||
|
val customCfDomainEnabled: Flow<Boolean> = context.dataStore.data.map { it[Keys.CUSTOM_CF_DOMAIN_ENABLED] ?: false }
|
||||||
|
val customCfDomain: Flow<String> = context.dataStore.data.map { it[Keys.CUSTOM_CF_DOMAIN] ?: "" }
|
||||||
val secretKey: Flow<String> = context.dataStore.data.map { it[Keys.SECRET_KEY] ?: "" }
|
val secretKey: Flow<String> = context.dataStore.data.map { it[Keys.SECRET_KEY] ?: "" }
|
||||||
|
|
||||||
suspend fun saveSecretKey(key: String) {
|
suspend fun saveSecretKey(key: String) {
|
||||||
@@ -43,8 +51,16 @@ class SettingsStore(private val context: Context) {
|
|||||||
context.dataStore.edit { it[Keys.THEME_MODE] = mode }
|
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,
|
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 {
|
context.dataStore.edit {
|
||||||
it[Keys.IS_DC_AUTO] = isDcAuto
|
it[Keys.IS_DC_AUTO] = isDcAuto
|
||||||
it[Keys.DC2] = dc2
|
it[Keys.DC2] = dc2
|
||||||
@@ -52,6 +68,8 @@ class SettingsStore(private val context: Context) {
|
|||||||
it[Keys.PORT] = port
|
it[Keys.PORT] = port
|
||||||
it[Keys.POOL_SIZE] = poolSize
|
it[Keys.POOL_SIZE] = poolSize
|
||||||
it[Keys.CFPROXY_ENABLED] = cfproxyEnabled
|
it[Keys.CFPROXY_ENABLED] = cfproxyEnabled
|
||||||
|
it[Keys.CUSTOM_CF_DOMAIN_ENABLED] = customCfDomainEnabled
|
||||||
|
it[Keys.CUSTOM_CF_DOMAIN] = customCfDomain
|
||||||
it[Keys.SECRET_KEY] = secretKey
|
it[Keys.SECRET_KEY] = secretKey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,11 +24,23 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.amurcanov.tgwsproxy.R
|
import com.amurcanov.tgwsproxy.R
|
||||||
import kotlin.math.roundToInt
|
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
|
@Composable
|
||||||
fun FloatingToolbar(
|
fun FloatingToolbar(
|
||||||
currentTheme: String,
|
currentTheme: String,
|
||||||
onThemeChange: (String) -> Unit,
|
onThemeChange: (String) -> Unit,
|
||||||
|
isDynamicColor: Boolean,
|
||||||
|
onDynamicColorChange: (Boolean) -> Unit,
|
||||||
|
currentPalette: String,
|
||||||
|
onPaletteChange: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val configuration = LocalConfiguration.current
|
val configuration = LocalConfiguration.current
|
||||||
@@ -46,7 +58,7 @@ fun FloatingToolbar(
|
|||||||
|
|
||||||
val tabWidthDp = 42.dp
|
val tabWidthDp = 42.dp
|
||||||
val tabHeightDp = 52.dp
|
val tabHeightDp = 52.dp
|
||||||
val panelWidthDp = 180.dp
|
val panelWidthDp = 220.dp
|
||||||
|
|
||||||
val tabWidthPx = remember(density) { with(density) { tabWidthDp.toPx() } }
|
val tabWidthPx = remember(density) { with(density) { tabWidthDp.toPx() } }
|
||||||
|
|
||||||
@@ -143,6 +155,52 @@ fun FloatingToolbar(
|
|||||||
selected = currentTheme == "dark",
|
selected = currentTheme == "dark",
|
||||||
onClick = { onThemeChange("dark"); isExpanded = false }
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ private fun isNewerVersion(local: String, remote: String): Boolean {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun InfoTab() {
|
fun InfoTab() {
|
||||||
val currentVersion = "v1.0.6"
|
val currentVersion = "v1.0.7"
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var updateResult by remember { mutableStateOf<UpdateCheckResult>(UpdateCheckResult.Idle) }
|
var updateResult by remember { mutableStateOf<UpdateCheckResult>(UpdateCheckResult.Idle) }
|
||||||
|
|
||||||
@@ -271,7 +271,8 @@ fun InfoTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══ Справка ═══
|
||||||
|
HelpCard()
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import androidx.compose.material.icons.filled.Refresh
|
|||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.filled.Stop
|
import androidx.compose.material.icons.filled.Stop
|
||||||
import androidx.compose.material.icons.filled.VpnKey
|
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.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
@@ -100,6 +103,8 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
|||||||
val savedPort by settingsStore.port.collectAsStateWithLifecycle(initialValue = "1443")
|
val savedPort by settingsStore.port.collectAsStateWithLifecycle(initialValue = "1443")
|
||||||
val savedPoolSize by settingsStore.poolSize.collectAsStateWithLifecycle(initialValue = 4)
|
val savedPoolSize by settingsStore.poolSize.collectAsStateWithLifecycle(initialValue = 4)
|
||||||
val savedCfEnabled by settingsStore.cfproxyEnabled.collectAsStateWithLifecycle(initialValue = true)
|
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")
|
val savedSecretKey by settingsStore.secretKey.collectAsStateWithLifecycle(initialValue = "LOADING")
|
||||||
|
|
||||||
var isDcAuto by rememberSaveable(savedIsDcAuto) { mutableStateOf(savedIsDcAuto) }
|
var isDcAuto by rememberSaveable(savedIsDcAuto) { mutableStateOf(savedIsDcAuto) }
|
||||||
@@ -108,6 +113,8 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
|||||||
var portText by rememberSaveable(savedPort) { mutableStateOf(savedPort) }
|
var portText by rememberSaveable(savedPort) { mutableStateOf(savedPort) }
|
||||||
var selectedPoolSize by rememberSaveable(savedPoolSize) { mutableIntStateOf(savedPoolSize) }
|
var selectedPoolSize by rememberSaveable(savedPoolSize) { mutableIntStateOf(savedPoolSize) }
|
||||||
var cfEnabled by rememberSaveable(savedCfEnabled) { mutableStateOf(savedCfEnabled) }
|
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) }
|
var secretKeyText by remember(savedSecretKey) { mutableStateOf(if (savedSecretKey == "LOADING") "" else savedSecretKey) }
|
||||||
|
|
||||||
LaunchedEffect(savedSecretKey) {
|
LaunchedEffect(savedSecretKey) {
|
||||||
@@ -128,19 +135,16 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
|||||||
delay(300)
|
delay(300)
|
||||||
settingsStore.saveAll(
|
settingsStore.saveAll(
|
||||||
isDcAuto, dc2Text, dc4Text, portText, selectedPoolSize,
|
isDcAuto, dc2Text, dc4Text, portText, selectedPoolSize,
|
||||||
cfEnabled, secretKeyText
|
cfEnabled, customCfDomainEnabled, customCfDomain, secretKeyText
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var showIpSetupDialog by rememberSaveable { mutableStateOf(false) }
|
var showIpSetupDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
var showHelpDialog by rememberSaveable { mutableStateOf(false) }
|
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
if (showIpSetupDialog) {
|
if (showIpSetupDialog) {
|
||||||
IpSetupDialog(
|
IpSetupDialog(
|
||||||
isDcAuto = isDcAuto,
|
|
||||||
onModeChange = { isDcAuto = it; scheduleSave() },
|
|
||||||
dc2Text = dc2Text,
|
dc2Text = dc2Text,
|
||||||
onDc2Change = { dc2Text = it; scheduleSave() },
|
onDc2Change = { dc2Text = it; scheduleSave() },
|
||||||
dc4Text = dc4Text,
|
dc4Text = dc4Text,
|
||||||
@@ -149,17 +153,19 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showHelpDialog) {
|
|
||||||
HelpDialog(onDismiss = { showHelpDialog = false })
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.verticalScroll(scrollState)
|
.verticalScroll(scrollState)
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.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(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = RoundedCornerShape(20.dp),
|
shape = RoundedCornerShape(20.dp),
|
||||||
@@ -167,23 +173,27 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
|||||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(14.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
"Подключение",
|
Icon(Icons.Default.Public, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
|
||||||
style = MaterialTheme.typography.titleSmall,
|
Text(
|
||||||
color = MaterialTheme.colorScheme.primary,
|
"Подключение",
|
||||||
fontWeight = FontWeight.SemiBold
|
style = MaterialTheme.typography.titleSmall,
|
||||||
)
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = portText,
|
value = portText,
|
||||||
onValueChange = { portText = it; scheduleSave() },
|
onValueChange = { portText = it; scheduleSave() },
|
||||||
label = { Text("Порт") },
|
label = { Text("Порт") },
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth().height(60.dp),
|
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||||
shape = RoundedCornerShape(14.dp),
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
textStyle = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium),
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||||
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
|
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
|
||||||
@@ -191,16 +201,20 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
|||||||
)
|
)
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { showIpSetupDialog = true },
|
onClick = { showIpSetupDialog = true },
|
||||||
modifier = Modifier.fillMaxWidth().height(46.dp),
|
enabled = !cfEnabled && !isRunning,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(40.dp),
|
||||||
shape = RoundedCornerShape(14.dp),
|
shape = RoundedCornerShape(14.dp),
|
||||||
colors = ButtonDefaults.outlinedButtonColors(
|
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))
|
Icon(Icons.Default.Settings, null, Modifier.size(18.dp))
|
||||||
Spacer(Modifier.width(8.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)
|
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(14.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -230,7 +244,7 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
|||||||
modifier = Modifier.size(20.dp)
|
modifier = Modifier.size(20.dp)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
"CloudFlare",
|
"CloudFlare CDN",
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold
|
||||||
@@ -238,19 +252,14 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
|||||||
}
|
}
|
||||||
Switch(
|
Switch(
|
||||||
checked = cfEnabled,
|
checked = cfEnabled,
|
||||||
onCheckedChange = { cfEnabled = it; scheduleSave() },
|
onCheckedChange = {
|
||||||
|
cfEnabled = it
|
||||||
|
isDcAuto = it
|
||||||
|
scheduleSave()
|
||||||
|
},
|
||||||
enabled = !isRunning
|
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)
|
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(14.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
"Пул WS",
|
Icon(Icons.Default.Layers, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
|
||||||
style = MaterialTheme.typography.titleSmall,
|
Text(
|
||||||
color = MaterialTheme.colorScheme.primary,
|
"Пул WS",
|
||||||
fontWeight = FontWeight.SemiBold
|
style = MaterialTheme.typography.titleSmall,
|
||||||
)
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
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(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
@@ -308,7 +320,7 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
|||||||
onValueChange = {},
|
onValueChange = {},
|
||||||
readOnly = true,
|
readOnly = true,
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||||
shape = RoundedCornerShape(14.dp),
|
shape = RoundedCornerShape(14.dp),
|
||||||
textStyle = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium),
|
textStyle = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium),
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
@@ -364,7 +376,7 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
settingsStore.saveAll(
|
settingsStore.saveAll(
|
||||||
isDcAuto, dc2Text, dc4Text, portText, selectedPoolSize,
|
isDcAuto, dc2Text, dc4Text, portText, selectedPoolSize,
|
||||||
cfEnabled, secretKeyText
|
cfEnabled, customCfDomainEnabled, customCfDomain, secretKeyText
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val startIntent = Intent(context, ProxyService::class.java).apply {
|
val startIntent = Intent(context, ProxyService::class.java).apply {
|
||||||
@@ -373,15 +385,14 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
|||||||
putExtra(ProxyService.EXTRA_IPS, parsedIps)
|
putExtra(ProxyService.EXTRA_IPS, parsedIps)
|
||||||
putExtra(ProxyService.EXTRA_POOL_SIZE, selectedPoolSize)
|
putExtra(ProxyService.EXTRA_POOL_SIZE, selectedPoolSize)
|
||||||
putExtra(ProxyService.EXTRA_CFPROXY_ENABLED, cfEnabled)
|
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_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())
|
putExtra(ProxyService.EXTRA_SECRET_KEY, secretKeyText.trim())
|
||||||
}
|
}
|
||||||
ContextCompat.startForegroundService(context, startIntent)
|
ContextCompat.startForegroundService(context, startIntent)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth().height(50.dp),
|
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = buttonColor)
|
colors = ButtonDefaults.buttonColors(containerColor = buttonColor)
|
||||||
) {
|
) {
|
||||||
@@ -402,7 +413,7 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
|||||||
val raw = secretKeyText.trim()
|
val raw = secretKeyText.trim()
|
||||||
if (raw.isNotEmpty()) raw else "00000000000000000000000000000000"
|
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(
|
val telegramBtnColor by animateColorAsState(
|
||||||
targetValue = if (isRunning) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
targetValue = if (isRunning) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
||||||
animationSpec = tween(400),
|
animationSpec = tween(400),
|
||||||
@@ -411,20 +422,53 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
|||||||
Button(
|
Button(
|
||||||
onClick = { openTelegram(context, proxyUrl) },
|
onClick = { openTelegram(context, proxyUrl) },
|
||||||
enabled = isRunning,
|
enabled = isRunning,
|
||||||
modifier = Modifier.fillMaxWidth().height(50.dp),
|
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = telegramBtnColor, contentColor = MaterialTheme.colorScheme.onSurface)
|
colors = ButtonDefaults.buttonColors(containerColor = telegramBtnColor, contentColor = MaterialTheme.colorScheme.onSurface)
|
||||||
) {
|
) {
|
||||||
Text("Применить в Telegram", fontWeight = FontWeight.SemiBold)
|
Text("Применить в Telegram", fontWeight = FontWeight.SemiBold)
|
||||||
}
|
}
|
||||||
|
|
||||||
OutlinedButton(
|
Text(
|
||||||
onClick = { showHelpDialog = true },
|
text = "или",
|
||||||
modifier = Modifier.fillMaxWidth().height(46.dp),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = RoundedCornerShape(16.dp),
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
|
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))
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun IpSetupDialog(
|
private fun IpSetupDialog(
|
||||||
isDcAuto: Boolean, onModeChange: (Boolean) -> Unit,
|
|
||||||
dc2Text: String, onDc2Change: (String) -> Unit,
|
dc2Text: String, onDc2Change: (String) -> Unit,
|
||||||
dc4Text: String, onDc4Change: (String) -> Unit,
|
dc4Text: String, onDc4Change: (String) -> Unit,
|
||||||
onDismiss: () -> Unit
|
onDismiss: () -> Unit
|
||||||
@@ -585,37 +537,8 @@ private fun IpSetupDialog(
|
|||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
|
|
||||||
Card(
|
@Composable
|
||||||
modifier = Modifier.fillMaxWidth(),
|
fun dcInput(label: String, value: String, update: (String) -> Unit) {
|
||||||
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) {
|
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@@ -638,7 +561,6 @@ private fun IpSetupDialog(
|
|||||||
|
|
||||||
dcInput("DC2", dc2Text, onDc2Change)
|
dcInput("DC2", dc2Text, onDc2Change)
|
||||||
dcInput("DC4", dc4Text, onDc4Change)
|
dcInput("DC4", dc4Text, onDc4Change)
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,92 @@ private val DarkColorScheme = darkColorScheme(
|
|||||||
surfaceTint = Color(0xFFD7CCC8),
|
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 {
|
object AppColors {
|
||||||
val connected = Color(0xFF4CAF50)
|
val connected = Color(0xFF4CAF50)
|
||||||
@@ -148,6 +234,7 @@ object AppColors {
|
|||||||
fun TgWsProxyTheme(
|
fun TgWsProxyTheme(
|
||||||
themeMode: String = "system",
|
themeMode: String = "system",
|
||||||
dynamicColor: Boolean = true,
|
dynamicColor: Boolean = true,
|
||||||
|
themePalette: String = "indigo",
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val darkTheme = when (themeMode) {
|
val darkTheme = when (themeMode) {
|
||||||
@@ -161,8 +248,7 @@ fun TgWsProxyTheme(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
}
|
}
|
||||||
darkTheme -> DarkColorScheme
|
else -> getAppColorScheme(themePalette, darkTheme)
|
||||||
else -> LightColorScheme
|
|
||||||
}
|
}
|
||||||
|
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
|
|||||||
468
tg-ws-proxy.go
468
tg-ws-proxy.go
@@ -52,8 +52,8 @@ import (
|
|||||||
const (
|
const (
|
||||||
defaultPort = 1443
|
defaultPort = 1443
|
||||||
tcpNodelay = true
|
tcpNodelay = true
|
||||||
defaultRecvBuf = 256 * 1024
|
defaultRecvBuf = 128 * 1024
|
||||||
defaultSendBuf = 256 * 1024
|
defaultSendBuf = 128 * 1024
|
||||||
defaultPoolSz = 4
|
defaultPoolSz = 4
|
||||||
wsPoolMaxAge = 60.0
|
wsPoolMaxAge = 60.0
|
||||||
wsBridgeIdle = 120.0
|
wsBridgeIdle = 120.0
|
||||||
@@ -86,6 +86,14 @@ var (
|
|||||||
proxySecretMu sync.RWMutex
|
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{
|
var dcDefaultIPs = map[int]string{
|
||||||
1: "149.154.175.50",
|
1: "149.154.175.50",
|
||||||
2: "149.154.167.51",
|
2: "149.154.167.51",
|
||||||
@@ -127,6 +135,8 @@ func initLogging(verbose bool) {
|
|||||||
} else {
|
} else {
|
||||||
logDebug = log.New(io.Discard, "", 0)
|
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
|
// 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 (
|
const (
|
||||||
opContinuation = 0x0
|
opContinuation = 0x0
|
||||||
opText = 0x1
|
opText = 0x1
|
||||||
@@ -525,10 +554,8 @@ func wsConnect(ip, domain, path string, timeout float64) (*RawWebSocket, error)
|
|||||||
Timeout: time.Duration(dialTimeout * float64(time.Second)),
|
Timeout: time.Duration(dialTimeout * float64(time.Second)),
|
||||||
}
|
}
|
||||||
|
|
||||||
tlsCfg := &tls.Config{
|
tlsCfg := tlsConfigPool.Clone()
|
||||||
InsecureSkipVerify: true,
|
tlsCfg.ServerName = domain
|
||||||
ServerName: domain,
|
|
||||||
}
|
|
||||||
|
|
||||||
rawConn, err := tls.DialWithDialer(dialer, "tcp", ip+":443", tlsCfg)
|
rawConn, err := tls.DialWithDialer(dialer, "tcp", ip+":443", tlsCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -840,12 +867,57 @@ func (ws *RawWebSocket) readFrame() (int, []byte, error) {
|
|||||||
// Crypto helpers: DC extraction & patching
|
// 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)
|
block, err := aes.NewCipher(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
func dcFromInit(data []byte) (dc int, isMedia bool, proto uint32, ok bool) {
|
||||||
@@ -1095,22 +1167,28 @@ func wsDomains(dc int, isMedia *bool) []string {
|
|||||||
// WsPool
|
// WsPool
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type dcSlot struct {
|
||||||
|
dc int
|
||||||
|
isMedia int
|
||||||
|
}
|
||||||
|
|
||||||
type poolEntry struct {
|
type poolEntry struct {
|
||||||
ws *RawWebSocket
|
ws *RawWebSocket
|
||||||
created float64
|
created int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIX: Deadlock-free Lock-Free WsPool implementation
|
||||||
type WsPool struct {
|
type WsPool struct {
|
||||||
mu sync.Mutex
|
queues sync.Map
|
||||||
idle map[[2]int][]poolEntry
|
status sync.Map
|
||||||
refilling map[[2]int]bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newWsPool() *WsPool {
|
func newWsPool() *WsPool { return &WsPool{} }
|
||||||
return &WsPool{
|
|
||||||
idle: make(map[[2]int][]poolEntry),
|
func (p *WsPool) getQueue(slot dcSlot) (chan *poolEntry, *atomic.Int32) {
|
||||||
refilling: make(map[[2]int]bool),
|
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 {
|
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 {
|
func (p *WsPool) Get(dc int, isMedia bool, targetIP string, domains []string) *RawWebSocket {
|
||||||
key := [2]int{dc, isMediaInt(isMedia)}
|
slot := dcSlot{dc, isMediaInt(isMedia)}
|
||||||
now := monoNow()
|
q, s := p.getQueue(slot)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
var ws *RawWebSocket
|
||||||
|
|
||||||
p.mu.Lock()
|
// FIX: Deadlock-free atomic pop
|
||||||
defer p.mu.Unlock()
|
for {
|
||||||
|
select {
|
||||||
bucket := p.idle[key]
|
case e := <-q:
|
||||||
for len(bucket) > 0 {
|
if now-e.created > int64(wsPoolMaxAge) || e.ws.closed.Load() {
|
||||||
entry := bucket[0]
|
SafeClose(e.ws.conn)
|
||||||
bucket = bucket[1:]
|
continue
|
||||||
p.idle[key] = bucket
|
}
|
||||||
|
ws = e.ws
|
||||||
age := now - entry.created
|
stats.poolHits.Add(1)
|
||||||
if age > wsPoolMaxAge || entry.ws.closed.Load() {
|
logDebug.Printf("⚡ Пул: DC%d%s взят (ост:%d)", dc, mediaTag(isMedia), len(q))
|
||||||
go entry.ws.Close()
|
default:
|
||||||
continue
|
stats.poolMisses.Add(1)
|
||||||
}
|
}
|
||||||
|
break
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stats.poolMisses.Add(1)
|
// FIX: Atomic CAS Replace (No deadlocks during refilling)
|
||||||
p.scheduleRefillLocked(key, targetIP, domains)
|
if s.CompareAndSwap(0, 1) {
|
||||||
return nil
|
go p.refill(slot, q, s, targetIP, domains)
|
||||||
|
}
|
||||||
|
return ws
|
||||||
}
|
}
|
||||||
|
|
||||||
// scheduleRefillLocked must be called with p.mu held
|
func (p *WsPool) refill(slot dcSlot, q chan *poolEntry, s *atomic.Int32, targetIP string, domains []string) {
|
||||||
func (p *WsPool) scheduleRefillLocked(key [2]int, targetIP string, domains []string) {
|
defer s.Store(0)
|
||||||
if p.refilling[key] {
|
needed := poolSize - len(q)
|
||||||
return
|
if needed <= 0 { return }
|
||||||
}
|
|
||||||
p.refilling[key] = true
|
|
||||||
go p.refill(key, targetIP, domains)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *WsPool) refill(key [2]int, targetIP string, domains []string) {
|
var wg sync.WaitGroup
|
||||||
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)
|
|
||||||
for i := 0; i < needed; i++ {
|
for i := 0; i < needed; i++ {
|
||||||
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
ws := connectOneWS(targetIP, domains)
|
defer wg.Done()
|
||||||
ch <- result{ws}
|
if ws := connectOneWS(targetIP, domains); ws != nil {
|
||||||
|
select {
|
||||||
|
case q <- &poolEntry{ws: ws, created: time.Now().Unix()}:
|
||||||
|
default:
|
||||||
|
SafeClose(ws.conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
wg.Wait()
|
||||||
for i := 0; i < needed; i++ {
|
logDebug.Printf("♻ Пул DC%d пополнен", slot.dc)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func connectOneWS(targetIP string, domains []string) *RawWebSocket {
|
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) {
|
func (p *WsPool) Warmup(dcOptMap map[int]string) {
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
|
|
||||||
for dc, targetIP := range dcOptMap {
|
for dc, targetIP := range dcOptMap {
|
||||||
if targetIP == "" {
|
if targetIP == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, isMedia := range []bool{false, true} {
|
for _, isMedia := range []bool{false, true} {
|
||||||
domains := wsDomains(dc, &isMedia)
|
domains := wsDomains(dc, &isMedia)
|
||||||
key := [2]int{dc, isMediaInt(isMedia)}
|
slot := dcSlot{dc, isMediaInt(isMedia)}
|
||||||
p.scheduleRefillLocked(key, targetIP, domains)
|
q, s := p.getQueue(slot)
|
||||||
|
if s.CompareAndSwap(0, 1) {
|
||||||
|
go p.refill(slot, q, s, targetIP, domains)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logDebug.Printf("♻ Прогрев пула: %d DC", len(dcOptMap))
|
logDebug.Printf("♻ Прогрев пула: %d DC", len(dcOptMap))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *WsPool) Maintain(ctx context.Context, dcOptMap map[int]string) {
|
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()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done(): return
|
||||||
return
|
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
p.maintainOnce(dcOptMap)
|
p.queues.Range(func(key, val interface{}) bool {
|
||||||
}
|
slot := key.(dcSlot)
|
||||||
}
|
q := val.(chan *poolEntry)
|
||||||
}
|
s, _ := p.status.Load(slot)
|
||||||
|
|
||||||
func (p *WsPool) maintainOnce(dcOptMap map[int]string) {
|
sz := len(q)
|
||||||
now := monoNow()
|
for i := 0; i < sz; i++ {
|
||||||
|
select {
|
||||||
p.mu.Lock()
|
case e := <-q:
|
||||||
for key, bucket := range p.idle {
|
if time.Now().Unix()-e.created > int64(wsPoolMaxAge) || e.ws.closed.Load() {
|
||||||
var fresh []poolEntry
|
SafeClose(e.ws.conn)
|
||||||
for _, e := range bucket {
|
} else {
|
||||||
age := now - e.created
|
// FIX: HTTP Ping adaptive keep-alive strategy
|
||||||
if age > wsPoolMaxAge || e.ws.closed.Load() {
|
_ = e.ws.SendPing()
|
||||||
go e.ws.Close()
|
select {
|
||||||
} else {
|
case q <- e:
|
||||||
// Send ping to keep connection alive
|
default: SafeClose(e.ws.conn)
|
||||||
go func(ws *RawWebSocket) {
|
}
|
||||||
if err := ws.SendPing(); err != nil {
|
}
|
||||||
ws.Close()
|
default:
|
||||||
}
|
}
|
||||||
}(e.ws)
|
}
|
||||||
fresh = append(fresh, e)
|
|
||||||
}
|
if len(q) < poolSize && s.(*atomic.Int32).CompareAndSwap(0, 1) {
|
||||||
}
|
isMediaBool := slot.isMedia == 1
|
||||||
p.idle[key] = fresh
|
dms := wsDomains(slot.dc, &isMediaBool)
|
||||||
}
|
go p.refill(slot, q, s.(*atomic.Int32), dcOptMap[slot.dc], dms)
|
||||||
p.mu.Unlock()
|
}
|
||||||
|
return true
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p.mu.Unlock()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *WsPool) IdleCount() int {
|
func (p *WsPool) IdleCount() int {
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
count := 0
|
count := 0
|
||||||
for _, bucket := range p.idle {
|
p.queues.Range(func(_, val interface{}) bool {
|
||||||
count += len(bucket)
|
count += len(val.(chan *poolEntry))
|
||||||
}
|
return true
|
||||||
|
})
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *WsPool) CloseAll() {
|
func (p *WsPool) CloseAll() {
|
||||||
p.mu.Lock()
|
p.queues.Range(func(_, val interface{}) bool {
|
||||||
defer p.mu.Unlock()
|
q := val.(chan *poolEntry)
|
||||||
for key, bucket := range p.idle {
|
for {
|
||||||
for _, e := range bucket {
|
select {
|
||||||
go e.ws.Close()
|
case e := <-q:
|
||||||
|
SafeClose(e.ws.conn)
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
delete(p.idle, key)
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var wsPool = newWsPool()
|
var wsPool = newWsPool()
|
||||||
@@ -1381,24 +1417,23 @@ func bridgeWS(ctx context.Context, conn net.Conn, ws *RawWebSocket,
|
|||||||
|
|
||||||
ctx2, cancel := context.WithCancel(ctx)
|
ctx2, cancel := context.WithCancel(ctx)
|
||||||
|
|
||||||
// Critical: close connections when context is cancelled
|
|
||||||
// This unblocks the Read() calls in goroutines
|
|
||||||
go func() {
|
go func() {
|
||||||
<-ctx2.Done()
|
<-ctx2.Done()
|
||||||
_ = conn.Close()
|
SafeClose(conn)
|
||||||
ws.Close()
|
ws.Close()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(2)
|
wg.Add(2)
|
||||||
|
|
||||||
// tcp -> ws
|
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
defer cancel()
|
defer cancel()
|
||||||
buf := make([]byte, 65536)
|
buf := bytesPool.Get().([]byte)
|
||||||
|
defer bytesPool.Put(buf)
|
||||||
for {
|
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 {
|
if n > 0 {
|
||||||
chunk := buf[:n]
|
chunk := buf[:n]
|
||||||
stats.bytesUp.Add(int64(n))
|
stats.bytesUp.Add(int64(n))
|
||||||
@@ -1406,6 +1441,7 @@ func bridgeWS(ctx context.Context, conn net.Conn, ws *RawWebSocket,
|
|||||||
|
|
||||||
cltDec.XORKeyStream(chunk, chunk)
|
cltDec.XORKeyStream(chunk, chunk)
|
||||||
tgEnc.XORKeyStream(chunk, chunk)
|
tgEnc.XORKeyStream(chunk, chunk)
|
||||||
|
|
||||||
var sendErr error
|
var sendErr error
|
||||||
if splitter != nil {
|
if splitter != nil {
|
||||||
parts := splitter.Split(chunk)
|
parts := splitter.Split(chunk)
|
||||||
@@ -1428,11 +1464,11 @@ func bridgeWS(ctx context.Context, conn net.Conn, ws *RawWebSocket,
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// ws -> tcp
|
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
defer cancel()
|
defer cancel()
|
||||||
for {
|
for {
|
||||||
|
_ = ws.conn.SetReadDeadline(time.Now().Add(5 * time.Minute))
|
||||||
data, err := ws.Recv()
|
data, err := ws.Recv()
|
||||||
if err != nil || data == nil {
|
if err != nil || data == nil {
|
||||||
return
|
return
|
||||||
@@ -1442,20 +1478,16 @@ func bridgeWS(ctx context.Context, conn net.Conn, ws *RawWebSocket,
|
|||||||
downBytes += int64(n)
|
downBytes += int64(n)
|
||||||
tgDec.XORKeyStream(data, data)
|
tgDec.XORKeyStream(data, data)
|
||||||
cltEnc.XORKeyStream(data, data)
|
cltEnc.XORKeyStream(data, data)
|
||||||
if _, err := conn.Write(data); err != nil {
|
if _, werr := conn.Write(data); werr != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
elapsed := time.Since(startTime).Seconds()
|
elapsed := time.Since(startTime).Seconds()
|
||||||
if upBytes > 0 || downBytes > 0 {
|
if upBytes > 0 || downBytes > 0 {
|
||||||
logInfo.Printf("✕ %s ↑%s ↓%s %.1fс",
|
logInfo.Printf("✕ %s ↑%s ↓%s %.1fс", dcTag, humanBytes(upBytes), humanBytes(downBytes), elapsed)
|
||||||
dcTag, humanBytes(upBytes), humanBytes(downBytes), elapsed)
|
|
||||||
} else {
|
|
||||||
logDebug.Printf("✕ %s пустое (%.1fс)", dcTag, elapsed)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1468,11 +1500,10 @@ func bridgeTCP(ctx context.Context, client, remote net.Conn,
|
|||||||
|
|
||||||
ctx2, cancel := context.WithCancel(ctx)
|
ctx2, cancel := context.WithCancel(ctx)
|
||||||
|
|
||||||
// Close connections when context cancelled
|
|
||||||
go func() {
|
go func() {
|
||||||
<-ctx2.Done()
|
<-ctx2.Done()
|
||||||
_ = client.Close()
|
SafeClose(client)
|
||||||
_ = remote.Close()
|
SafeClose(remote)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
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) {
|
forward := func(src, dstW net.Conn, isUp bool) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
defer cancel()
|
defer cancel()
|
||||||
buf := make([]byte, 65536)
|
buf := bytesPool.Get().([]byte)
|
||||||
|
defer bytesPool.Put(buf)
|
||||||
for {
|
for {
|
||||||
n, err := src.Read(buf)
|
_ = src.SetReadDeadline(time.Now().Add(5 * time.Minute))
|
||||||
|
n, err := src.Read(buf[:cap(buf)])
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
|
chunk := buf[:n]
|
||||||
if isUp {
|
if isUp {
|
||||||
stats.bytesUp.Add(int64(n))
|
stats.bytesUp.Add(int64(n))
|
||||||
cltDec.XORKeyStream(buf[:n], buf[:n])
|
cltDec.XORKeyStream(chunk, chunk)
|
||||||
tgEnc.XORKeyStream(buf[:n], buf[:n])
|
tgEnc.XORKeyStream(chunk, chunk)
|
||||||
} else {
|
} else {
|
||||||
stats.bytesDown.Add(int64(n))
|
stats.bytesDown.Add(int64(n))
|
||||||
tgDec.XORKeyStream(buf[:n], buf[:n])
|
tgDec.XORKeyStream(chunk, chunk)
|
||||||
cltEnc.XORKeyStream(buf[:n], buf[:n])
|
cltEnc.XORKeyStream(chunk, chunk)
|
||||||
}
|
}
|
||||||
if _, werr := dstW.Write(buf[:n]); werr != nil {
|
if _, werr := dstW.Write(chunk); werr != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1621,6 +1655,12 @@ func doFallback(ctx context.Context, conn net.Conn, relayInit []byte, label stri
|
|||||||
dc int, isMedia bool, splitter *MsgSplitter,
|
dc int, isMedia bool, splitter *MsgSplitter,
|
||||||
cltDec, cltEnc, tgEnc, tgDec cipher.Stream) bool {
|
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
|
// Use configured DC IP if available, otherwise fall back to defaults
|
||||||
var fallbackDst string
|
var fallbackDst string
|
||||||
dcOptMu.RLock()
|
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)
|
tsXor := make([]byte, 4)
|
||||||
for i := 0; i < 4; i++ {
|
for i := 0; i < 4; i++ {
|
||||||
tsXor[i] = clientRandom[28+i] ^ mac[28+i]
|
tsXor[i] = clientRandom[28+i] ^ mac[28+i]
|
||||||
}
|
}
|
||||||
|
// FIX: Correct FakeTLS little-endian unmarshaling
|
||||||
timestamp := binary.LittleEndian.Uint32(tsXor)
|
timestamp := binary.LittleEndian.Uint32(tsXor)
|
||||||
now := uint32(time.Now().Unix())
|
now := uint32(time.Now().Unix())
|
||||||
diff := int64(now) - int64(timestamp)
|
diff := int64(now) - int64(timestamp)
|
||||||
@@ -1968,6 +2009,10 @@ func handleClient(ctx context.Context, conn net.Conn) {
|
|||||||
proxySecretMu.RUnlock()
|
proxySecretMu.RUnlock()
|
||||||
secretBytes, _ := hex.DecodeString(currentSecret)
|
secretBytes, _ := hex.DecodeString(currentSecret)
|
||||||
|
|
||||||
|
fakeTlsMu.RLock()
|
||||||
|
useFakeTls := fakeTlsEnabled
|
||||||
|
fakeTlsMu.RUnlock()
|
||||||
|
|
||||||
// Read first byte to detect FakeTLS vs plain
|
// Read first byte to detect FakeTLS vs plain
|
||||||
firstByte := make([]byte, 1)
|
firstByte := make([]byte, 1)
|
||||||
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
_ = 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 clientConn net.Conn = conn // the connection we read MTProto from
|
||||||
var handshake []byte
|
var handshake []byte
|
||||||
|
|
||||||
if firstByte[0] == tlsRecordHandshake {
|
if useFakeTls && firstByte[0] == tlsRecordHandshake {
|
||||||
// FakeTLS mode (ee-secret)
|
// FakeTLS mode (ee-secret) — only when explicitly enabled
|
||||||
hdrRest := make([]byte, 4)
|
hdrRest := make([]byte, 4)
|
||||||
if _, err := io.ReadFull(conn, hdrRest); err != nil {
|
if _, err := io.ReadFull(conn, hdrRest); err != nil {
|
||||||
logDebug.Printf("неполный TLS-заголовок")
|
logDebug.Printf("FakeTLS: неполный TLS-заголовок")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tlsHeader := append(firstByte, hdrRest...)
|
tlsHeader := append(firstByte, hdrRest...)
|
||||||
recordLen := int(binary.BigEndian.Uint16(tlsHeader[3:5]))
|
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)
|
recordBody := make([]byte, recordLen)
|
||||||
if _, err := io.ReadFull(conn, recordBody); err != nil {
|
if _, err := io.ReadFull(conn, recordBody); err != nil {
|
||||||
logDebug.Printf("неполное тело TLS-записи")
|
logDebug.Printf("FakeTLS: неполное тело TLS-записи")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2001,7 +2052,7 @@ func handleClient(ctx context.Context, conn net.Conn) {
|
|||||||
clientRandom, sessionId, ok := verifyClientHello(clientHello, secretBytes)
|
clientRandom, sessionId, ok := verifyClientHello(clientHello, secretBytes)
|
||||||
if !ok {
|
if !ok {
|
||||||
stats.connectionsBad.Add(1)
|
stats.connectionsBad.Add(1)
|
||||||
logWarn.Printf("⚠ bad handshake")
|
logDebug.Printf("FakeTLS: HMAC проверка не пройдена (peer=%s)", peer)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2017,11 +2068,12 @@ func handleClient(ctx context.Context, conn net.Conn) {
|
|||||||
|
|
||||||
handshake = make([]byte, 64)
|
handshake = make([]byte, 64)
|
||||||
if _, err := io.ReadFull(tlsConn, handshake); err != nil {
|
if _, err := io.ReadFull(tlsConn, handshake); err != nil {
|
||||||
logDebug.Printf("неполный обфускированный init внутри TLS")
|
logDebug.Printf("FakeTLS: неполный init внутри TLS")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Plain obfuscated mode (dd-secret)
|
// Plain obfuscated mode (dd-secret)
|
||||||
|
// FIX: When FakeTLS is disabled, treat ALL first bytes as plain data
|
||||||
rest := make([]byte, 63)
|
rest := make([]byte, 63)
|
||||||
if _, err := io.ReadFull(conn, rest); err != nil {
|
if _, err := io.ReadFull(conn, rest); err != nil {
|
||||||
logDebug.Printf("клиент отключился до рукопожатия")
|
logDebug.Printf("клиент отключился до рукопожатия")
|
||||||
@@ -2050,7 +2102,7 @@ func handleClient(ctx context.Context, conn net.Conn) {
|
|||||||
proto := binary.LittleEndian.Uint32(protoTag)
|
proto := binary.LittleEndian.Uint32(protoTag)
|
||||||
if !validProtos[proto] {
|
if !validProtos[proto] {
|
||||||
stats.connectionsBad.Add(1)
|
stats.connectionsBad.Add(1)
|
||||||
logWarn.Printf("⚠ bad handshake")
|
logDebug.Printf("bad handshake: proto=0x%08X (ожид: EFEF/EEEE/DDDD) peer=%s", proto, peer)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2284,7 +2336,17 @@ func runProxy(ctx context.Context, host string, port int, dcOptMap map[int]strin
|
|||||||
proxySecretMu.RLock()
|
proxySecretMu.RLock()
|
||||||
currentSec := proxySecret
|
currentSec := proxySecret
|
||||||
proxySecretMu.RUnlock()
|
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("━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
logInfo.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||||
|
|
||||||
// Stats logger
|
// Stats logger
|
||||||
@@ -2524,7 +2586,13 @@ func SetSecret(cSecret *C.char) {
|
|||||||
proxySecret = s
|
proxySecret = s
|
||||||
proxySecretMu.Unlock()
|
proxySecretMu.Unlock()
|
||||||
if logInfo != nil {
|
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)
|
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
|
//export FreeString
|
||||||
func FreeString(p *C.char) {
|
func FreeString(p *C.char) {
|
||||||
C.free(unsafe.Pointer(p))
|
C.free(unsafe.Pointer(p))
|
||||||
|
|||||||
Reference in New Issue
Block a user