rearrange Ui, prep for ssl support, removed iptables and resorting to use syscalls to allow ports bwlo 1024

This commit is contained in:
2026-02-06 01:53:31 +05:00
parent 23122d32a9
commit 4142606728
5 changed files with 345 additions and 67 deletions

View File

@@ -98,4 +98,52 @@ object RootManager {
}
} ?: Pair(false, "Timeout")
}
/**
* Allow binding to privileged ports by lowering the unprivileged port start.
* This uses sysctl to set net.ipv4.ip_unprivileged_port_start to the specified port.
*/
suspend fun allowPrivilegedPort(port: Int): Boolean = withContext(Dispatchers.IO) {
withTimeoutOrNull(5000L) {
try {
val command = "sysctl -w net.ipv4.ip_unprivileged_port_start=$port"
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
val completed = process.waitFor(4, TimeUnit.SECONDS)
if (completed) {
val success = process.exitValue() == 0
if (success) {
android.util.Log.i("RootManager", "Allowed binding to ports >= $port")
}
success
} else {
process.destroyForcibly()
false
}
} catch (e: Exception) {
android.util.Log.e("RootManager", "Failed to allow privileged port", e)
false
}
} ?: false
}
/**
* Reset the unprivileged port start back to default (1024).
*/
suspend fun resetPrivilegedPorts(): Boolean = withContext(Dispatchers.IO) {
withTimeoutOrNull(5000L) {
try {
val command = "sysctl -w net.ipv4.ip_unprivileged_port_start=1024"
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
val completed = process.waitFor(4, TimeUnit.SECONDS)
if (completed) {
process.exitValue() == 0
} else {
process.destroyForcibly()
false
}
} catch (e: Exception) {
false
}
} ?: false
}
}

View File

@@ -37,7 +37,9 @@ class TextpipeService : Service() {
private const val NOTIFICATION_ID = 1
private const val PREFS_NAME = "textpipe_settings"
private const val KEY_PORT = "server_port"
private const val KEY_REDIRECT_PORT = "redirect_port"
private const val KEY_SSL_ENABLED = "ssl_enabled"
private const val KEY_SSL_CERT_PATH = "ssl_cert_path"
private const val KEY_SSL_KEY_PATH = "ssl_key_path"
private const val DEFAULT_PORT = 8080
private const val ACTION_STOP = "sh.sar.textpipe.STOP"
@@ -70,14 +72,34 @@ class TextpipeService : Service() {
prefs.edit().putInt(KEY_PORT, port).apply()
}
fun getRedirectPort(context: Context): Int {
fun isSslEnabled(context: Context): Boolean {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getInt(KEY_REDIRECT_PORT, 0)
return prefs.getBoolean(KEY_SSL_ENABLED, false)
}
fun setRedirectPort(context: Context, port: Int) {
fun setSslEnabled(context: Context, enabled: Boolean) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putInt(KEY_REDIRECT_PORT, port).apply()
prefs.edit().putBoolean(KEY_SSL_ENABLED, enabled).apply()
}
fun getSslCertPath(context: Context): String {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getString(KEY_SSL_CERT_PATH, "") ?: ""
}
fun setSslCertPath(context: Context, path: String) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putString(KEY_SSL_CERT_PATH, path).apply()
}
fun getSslKeyPath(context: Context): String {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getString(KEY_SSL_KEY_PATH, "") ?: ""
}
fun setSslKeyPath(context: Context, path: String) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putString(KEY_SSL_KEY_PATH, path).apply()
}
}
@@ -86,8 +108,7 @@ class TextpipeService : Service() {
private var textpipeServer: TextpipeServer? = null
private lateinit var smsRepository: SmsRepository
private lateinit var smsSender: SmsSender
private var redirectPort: Int = 0
private var usedPrivilegedPort = false
override fun onCreate() {
super.onCreate()
@@ -125,14 +146,18 @@ class TextpipeService : Service() {
_serverPort.value = port
serviceScope.launch(Dispatchers.IO) {
// Setup port redirect if configured
redirectPort = getRedirectPort(this@TextpipeService)
if (redirectPort > 0 && redirectPort != port) {
val success = RootManager.setupPortRedirect(redirectPort, port)
if (!success) {
Log.w(TAG, "Failed to setup port redirect from $redirectPort to $port")
setRedirectPort(this@TextpipeService, 0)
redirectPort = 0
// If port < 1024, we need root to allow binding
if (port < 1024) {
Log.i(TAG, "Port $port requires root access")
val allowed = RootManager.allowPrivilegedPort(port)
if (allowed) {
usedPrivilegedPort = true
Log.i(TAG, "Privileged port access granted for port $port")
} else {
updateNotification("Error: Root required for port $port")
showToast("Root access required for port $port")
Log.e(TAG, "Failed to get root access for port $port")
return@launch
}
}
@@ -140,9 +165,8 @@ class TextpipeService : Service() {
when (val result = textpipeServer?.start(port)) {
is ServerStartResult.Success -> {
_isRunning.value = true
val displayPort = if (redirectPort > 0) redirectPort else port
updateNotification("Running on port $displayPort")
showToast("Server started on port $displayPort")
updateNotification("Running on port $port")
showToast("Server started on port $port")
Log.i(TAG, "Server started successfully on port $port")
}
is ServerStartResult.Error -> {
@@ -164,12 +188,12 @@ class TextpipeService : Service() {
override fun onDestroy() {
Log.i(TAG, "Service onDestroy")
// Clear port redirect asynchronously (fire and forget to avoid ANR)
if (redirectPort > 0) {
val port = textpipeServer?.getPort() ?: 8080
// Reset privileged port setting if we used it
if (usedPrivilegedPort) {
GlobalScope.launch(Dispatchers.IO) {
RootManager.clearPortRedirect(redirectPort, port)
RootManager.resetPrivilegedPorts()
}
usedPrivilegedPort = false
}
textpipeServer?.stop()

View File

@@ -1,7 +1,10 @@
package sh.sar.textpipe.ui
import android.app.Activity
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -47,6 +50,7 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import sh.sar.textpipe.sim.SimSlotInfo
@@ -57,9 +61,11 @@ fun MainScreen(viewModel: MainViewModel) {
val simSlots by viewModel.simSlots.collectAsState()
val isRootAvailable by viewModel.isRootAvailable.collectAsState()
val portInput by viewModel.portInput.collectAsState()
val redirectPortInput by viewModel.redirectPortInput.collectAsState()
val autoStartEnabled by viewModel.autoStartEnabled.collectAsState()
val isBatteryOptimized by viewModel.isBatteryOptimized.collectAsState()
val sslEnabled by viewModel.sslEnabled.collectAsState()
val sslCertPath by viewModel.sslCertPath.collectAsState()
val sslKeyPath by viewModel.sslKeyPath.collectAsState()
val context = LocalContext.current
val activity = context as? Activity
@@ -116,9 +122,9 @@ fun MainScreen(viewModel: MainViewModel) {
}
}
// Settings
// Server Configuration
Text(
text = "Settings",
text = "Server Configuration",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
@@ -126,14 +132,31 @@ fun MainScreen(viewModel: MainViewModel) {
// Port Configuration Card
PortConfigCard(
portInput = portInput,
redirectPortInput = redirectPortInput,
isRootAvailable = isRootAvailable,
isRunning = isRunning,
onPortChange = { viewModel.setPort(it) },
onRedirectPortChange = { viewModel.setRedirectPort(it) },
onApply = { viewModel.applyPortSettings() }
)
// SSL Configuration Card
SslConfigCard(
sslEnabled = sslEnabled,
certPath = sslCertPath,
keyPath = sslKeyPath,
isRunning = isRunning,
onSslEnabledChange = { viewModel.setSslEnabled(it) },
onCertPathChange = { viewModel.setSslCertPath(it) },
onKeyPathChange = { viewModel.setSslKeyPath(it) },
onApply = { viewModel.applySslSettings() }
)
// Settings
Text(
text = "Settings",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
// Other Settings Card
SettingsCard(
autoStartEnabled = autoStartEnabled,
@@ -144,6 +167,15 @@ fun MainScreen(viewModel: MainViewModel) {
},
onRefreshBatteryStatus = { viewModel.refreshBatteryOptimizationStatus() }
)
// About
Text(
text = "About",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
AboutCard()
}
}
}
@@ -212,13 +244,14 @@ private fun ServerStatusCard(
@Composable
private fun PortConfigCard(
portInput: String,
redirectPortInput: String,
isRootAvailable: Boolean,
isRunning: Boolean,
onPortChange: (String) -> Unit,
onRedirectPortChange: (String) -> Unit,
onApply: () -> Unit
) {
val port = portInput.toIntOrNull() ?: 0
val needsRoot = port in 1..1023
Card(
modifier = Modifier.fillMaxWidth()
) {
@@ -226,7 +259,7 @@ private fun PortConfigCard(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Port Configuration",
text = "Port",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
@@ -237,28 +270,25 @@ private fun PortConfigCard(
value = portInput,
onValueChange = { onPortChange(it.filter { c -> c.isDigit() }) },
label = { Text("Server Port") },
placeholder = { Text("e.g., 80, 443, 8080") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
if (isRootAvailable) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = redirectPortInput,
onValueChange = { onRedirectPortChange(it.filter { c -> c.isDigit() }) },
label = { Text("Redirect Port (requires root)") },
placeholder = { Text("e.g., 80 or 443") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
if (needsRoot) {
Text(
text = "Ports below 1024 (like 80, 443) require root. Uses iptables to redirect traffic to server port.",
text = if (isRootAvailable) {
"Port $port requires root access (available)"
} else {
"Port $port requires root access (not available)"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = if (isRootAvailable) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
},
modifier = Modifier.padding(top = 4.dp)
)
}
@@ -462,3 +492,135 @@ private fun SettingsCard(
}
}
}
@Composable
private fun SslConfigCard(
sslEnabled: Boolean,
certPath: String,
keyPath: String,
isRunning: Boolean,
onSslEnabledChange: (Boolean) -> Unit,
onCertPathChange: (String) -> Unit,
onKeyPathChange: (String) -> Unit,
onApply: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "SSL / HTTPS",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Enable SSL",
style = MaterialTheme.typography.bodyLarge
)
Text(
text = "Coming soon",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = sslEnabled,
onCheckedChange = onSslEnabledChange,
enabled = false
)
}
if (sslEnabled) {
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = certPath,
onValueChange = onCertPathChange,
label = { Text("Certificate Path") },
placeholder = { Text("/sdcard/cert.pem") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = keyPath,
onValueChange = onKeyPathChange,
label = { Text("Private Key Path") },
placeholder = { Text("/sdcard/key.pem") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Text(
text = "Provide paths to PEM-encoded certificate and private key files",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = onApply,
modifier = Modifier.fillMaxWidth(),
enabled = certPath.isNotBlank() && keyPath.isNotBlank()
) {
Text(if (isRunning) "Apply & Restart" else "Save")
}
}
}
}
}
@Composable
private fun AboutCard() {
val context = LocalContext.current
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Textpipe",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "SMS API Gateway for Android. Send and receive SMS messages via REST API with dual SIM support.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "git.shihaam.dev/shihaam/textpipe",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
modifier = Modifier.clickable {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://git.shihaam.dev/shihaam/textpipe"))
context.startActivity(intent)
}
)
}
}
}

View File

@@ -5,11 +5,15 @@ import android.app.Application
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.provider.Settings
import android.widget.Toast
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -36,26 +40,27 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
private val _portInput = MutableStateFlow(TextpipeService.getConfiguredPort(application).toString())
val portInput: StateFlow<String> = _portInput.asStateFlow()
private val _redirectPortInput = MutableStateFlow("")
val redirectPortInput: StateFlow<String> = _redirectPortInput.asStateFlow()
private val _autoStartEnabled = MutableStateFlow(BootReceiver.isAutoStartEnabled(application))
val autoStartEnabled: StateFlow<Boolean> = _autoStartEnabled.asStateFlow()
private val _isBatteryOptimized = MutableStateFlow(true)
val isBatteryOptimized: StateFlow<Boolean> = _isBatteryOptimized.asStateFlow()
private val _sslEnabled = MutableStateFlow(TextpipeService.isSslEnabled(application))
val sslEnabled: StateFlow<Boolean> = _sslEnabled.asStateFlow()
private val _sslCertPath = MutableStateFlow(TextpipeService.getSslCertPath(application))
val sslCertPath: StateFlow<String> = _sslCertPath.asStateFlow()
private val _sslKeyPath = MutableStateFlow(TextpipeService.getSslKeyPath(application))
val sslKeyPath: StateFlow<String> = _sslKeyPath.asStateFlow()
init {
viewModelScope.launch {
refreshSimInfo()
}
checkRootAvailability()
checkBatteryOptimization()
val redirectPort = TextpipeService.getRedirectPort(application)
if (redirectPort > 0) {
_redirectPortInput.value = redirectPort.toString()
}
}
fun refreshSimInfo() {
@@ -82,39 +87,47 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
_portInput.value = port
}
fun setRedirectPort(port: String) {
_redirectPortInput.value = port
}
fun applyPortSettings() {
val port = _portInput.value.toIntOrNull() ?: return
if (port !in 1..65535) return
TextpipeService.setConfiguredPort(getApplication(), port)
val redirectPort = _redirectPortInput.value.toIntOrNull() ?: 0
if (redirectPort in 1..65535 && redirectPort != port) {
TextpipeService.setRedirectPort(getApplication(), redirectPort)
} else {
TextpipeService.setRedirectPort(getApplication(), 0)
}
// Restart service if running
// Restart service if running, otherwise just save
if (isServerRunning.value) {
stopServer()
startServer()
// Small delay to let service stop before restarting
viewModelScope.launch {
delay(500)
TextpipeService.start(getApplication())
showToast("Settings applied, server restarting...")
}
} else {
showToast("Settings saved")
}
}
fun startServer() {
applyPortSettings()
savePortSettings()
TextpipeService.start(getApplication())
}
private fun savePortSettings() {
val port = _portInput.value.toIntOrNull() ?: return
if (port !in 1..65535) return
TextpipeService.setConfiguredPort(getApplication(), port)
}
fun stopServer() {
TextpipeService.stop(getApplication())
}
private fun showToast(message: String) {
Handler(Looper.getMainLooper()).post {
Toast.makeText(getApplication(), message, Toast.LENGTH_SHORT).show()
}
}
fun toggleServer() {
if (isServerRunning.value) {
stopServer()
@@ -147,5 +160,35 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun regenerateApiKey(slotIndex: Int) {
app.simManager.regenerateApiKey(slotIndex)
refreshSimInfo()
showToast("API key regenerated for SIM ${slotIndex + 1}")
}
fun setSslEnabled(enabled: Boolean) {
_sslEnabled.value = enabled
TextpipeService.setSslEnabled(getApplication(), enabled)
}
fun setSslCertPath(path: String) {
_sslCertPath.value = path
TextpipeService.setSslCertPath(getApplication(), path)
}
fun setSslKeyPath(path: String) {
_sslKeyPath.value = path
TextpipeService.setSslKeyPath(getApplication(), path)
}
fun applySslSettings() {
// Restart service if running to apply SSL changes
if (isServerRunning.value) {
stopServer()
viewModelScope.launch {
delay(500)
TextpipeService.start(getApplication())
showToast("SSL settings applied, server restarting...")
}
} else {
showToast("SSL settings saved")
}
}
}