Skip to content

Commit 6bcef08

Browse files
committed
Rewrite SSH to use native foreground service for persistent sshd
The previous implementation used single-shot runInProot() which can't run daemons — proot uses --kill-on-exit so sshd dies when the command finishes. Also pgrep/hostname -I don't work reliably inside proot. Changes: - Add SshForegroundService.kt: runs sshd -D (foreground mode) in a persistent proot process with wake lock and notification - Add native bridge methods: startSshd, stopSshd, isSshdRunning, getSshdPort, getDeviceIps, setRootPassword - getDeviceIps uses Android NetworkInterface (not proot hostname -I) - setRootPassword runs chpasswd via processManager.runInProotSync - Register SshForegroundService in AndroidManifest.xml - Rewrite ssh_service.dart to use native methods - Update ssh_screen.dart to fetch IPs from Android Co-Authored-By: Mithun Gowda B <[email protected]>
1 parent c2e6f34 commit 6bcef08

File tree

6 files changed

+320
-37
lines changed

6 files changed

+320
-37
lines changed

flutter_app/android/app/src/main/AndroidManifest.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@
6666
android:exported="false"
6767
android:foregroundServiceType="specialUse" />
6868

69+
<service
70+
android:name=".SshForegroundService"
71+
android:exported="false"
72+
android:foregroundServiceType="specialUse" />
73+
6974
<service
7075
android:name=".ScreenCaptureService"
7176
android:exported="false"

flutter_app/android/app/src/main/kotlin/com/nxg/openclawproot/MainActivity.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,50 @@ class MainActivity : FlutterActivity() {
162162
NodeForegroundService.updateStatus(text)
163163
result.success(true)
164164
}
165+
"startSshd" -> {
166+
val port = call.argument<Int>("port") ?: 8022
167+
try {
168+
SshForegroundService.start(applicationContext, port)
169+
result.success(true)
170+
} catch (e: Exception) {
171+
result.error("SERVICE_ERROR", e.message, null)
172+
}
173+
}
174+
"stopSshd" -> {
175+
try {
176+
SshForegroundService.stop(applicationContext)
177+
result.success(true)
178+
} catch (e: Exception) {
179+
result.error("SERVICE_ERROR", e.message, null)
180+
}
181+
}
182+
"isSshdRunning" -> {
183+
result.success(SshForegroundService.isRunning)
184+
}
185+
"getSshdPort" -> {
186+
result.success(SshForegroundService.currentPort)
187+
}
188+
"getDeviceIps" -> {
189+
result.success(SshForegroundService.getDeviceIps())
190+
}
191+
"setRootPassword" -> {
192+
val password = call.argument<String>("password")
193+
if (password != null) {
194+
Thread {
195+
try {
196+
val escaped = password.replace("'", "'\\''")
197+
processManager.runInProotSync(
198+
"echo 'root:$escaped' | chpasswd", 15
199+
)
200+
runOnUiThread { result.success(true) }
201+
} catch (e: Exception) {
202+
runOnUiThread { result.error("SSH_ERROR", e.message, null) }
203+
}
204+
}.start()
205+
} else {
206+
result.error("INVALID_ARGS", "password required", null)
207+
}
208+
}
165209
"requestBatteryOptimization" -> {
166210
try {
167211
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
package com.nxg.openclawproot
2+
3+
import android.app.Notification
4+
import android.app.NotificationChannel
5+
import android.app.NotificationManager
6+
import android.app.PendingIntent
7+
import android.app.Service
8+
import android.content.Context
9+
import android.content.Intent
10+
import android.os.Build
11+
import android.os.IBinder
12+
import android.os.PowerManager
13+
import java.io.BufferedReader
14+
import java.io.InputStreamReader
15+
import java.net.NetworkInterface
16+
17+
/**
18+
* Foreground service that runs sshd in a persistent proot process.
19+
* sshd must run with -D (no daemonize) so proot stays alive.
20+
*/
21+
class SshForegroundService : Service() {
22+
companion object {
23+
const val CHANNEL_ID = "openclaw_ssh"
24+
const val NOTIFICATION_ID = 5
25+
const val EXTRA_PORT = "port"
26+
var isRunning = false
27+
private set
28+
var currentPort = 8022
29+
private set
30+
private var instance: SshForegroundService? = null
31+
32+
fun start(context: Context, port: Int = 8022) {
33+
val intent = Intent(context, SshForegroundService::class.java).apply {
34+
putExtra(EXTRA_PORT, port)
35+
}
36+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
37+
context.startForegroundService(intent)
38+
} else {
39+
context.startService(intent)
40+
}
41+
}
42+
43+
fun stop(context: Context) {
44+
val intent = Intent(context, SshForegroundService::class.java)
45+
context.stopService(intent)
46+
}
47+
48+
/** Get device IP addresses via Android NetworkInterface. */
49+
fun getDeviceIps(): List<String> {
50+
val ips = mutableListOf<String>()
51+
try {
52+
val interfaces = NetworkInterface.getNetworkInterfaces() ?: return ips
53+
for (iface in interfaces) {
54+
if (iface.isLoopback || !iface.isUp) continue
55+
for (addr in iface.inetAddresses) {
56+
if (addr.isLoopbackAddress) continue
57+
val hostAddr = addr.hostAddress ?: continue
58+
// Skip IPv6 link-local
59+
if (hostAddr.contains("%")) continue
60+
// Prefer IPv4, but include IPv6 too
61+
ips.add(hostAddr)
62+
}
63+
}
64+
} catch (_: Exception) {}
65+
return ips
66+
}
67+
}
68+
69+
private var sshdProcess: Process? = null
70+
private var wakeLock: PowerManager.WakeLock? = null
71+
72+
override fun onBind(intent: Intent?): IBinder? = null
73+
74+
override fun onCreate() {
75+
super.onCreate()
76+
createNotificationChannel()
77+
}
78+
79+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
80+
val port = intent?.getIntExtra(EXTRA_PORT, 8022) ?: 8022
81+
currentPort = port
82+
startForeground(NOTIFICATION_ID, buildNotification("Starting SSH on port $port..."))
83+
acquireWakeLock()
84+
startSshd(port)
85+
return START_STICKY
86+
}
87+
88+
override fun onDestroy() {
89+
isRunning = false
90+
instance = null
91+
stopSshd()
92+
releaseWakeLock()
93+
super.onDestroy()
94+
}
95+
96+
private fun startSshd(port: Int) {
97+
isRunning = true
98+
instance = this
99+
100+
Thread {
101+
try {
102+
val filesDir = applicationContext.filesDir.absolutePath
103+
val nativeLibDir = applicationContext.applicationInfo.nativeLibraryDir
104+
val pm = ProcessManager(filesDir, nativeLibDir)
105+
106+
// Ensure directories exist
107+
val bootstrapManager = BootstrapManager(applicationContext, filesDir, nativeLibDir)
108+
bootstrapManager.setupDirectories()
109+
bootstrapManager.writeResolvConf()
110+
111+
// Generate host keys if missing, configure sshd, then run in
112+
// foreground mode (-D) so the proot process stays alive.
113+
// -e logs to stderr instead of syslog (easier debugging).
114+
// PermitRootLogin yes is needed since proot fakes root.
115+
val cmd = "mkdir -p /run/sshd /etc/ssh && " +
116+
"test -f /etc/ssh/ssh_host_rsa_key || ssh-keygen -A && " +
117+
"sed -i 's/^#\\?PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config 2>/dev/null; " +
118+
"grep -q '^PermitRootLogin' /etc/ssh/sshd_config || echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config; " +
119+
"/usr/sbin/sshd -D -e -p $port"
120+
121+
sshdProcess = pm.startProotProcess(cmd)
122+
updateNotification("SSH running on port $port")
123+
124+
// Read stderr for logs
125+
val stderrReader = BufferedReader(InputStreamReader(sshdProcess!!.errorStream))
126+
Thread {
127+
try {
128+
var line: String?
129+
while (stderrReader.readLine().also { line = it } != null) {
130+
// Could log these if needed
131+
}
132+
} catch (_: Exception) {}
133+
}.start()
134+
135+
val exitCode = sshdProcess!!.waitFor()
136+
isRunning = false
137+
updateNotification("SSH stopped (exit $exitCode)")
138+
stopSelf()
139+
} catch (e: Exception) {
140+
isRunning = false
141+
updateNotification("SSH error: ${e.message?.take(50)}")
142+
stopSelf()
143+
}
144+
}.start()
145+
}
146+
147+
private fun stopSshd() {
148+
sshdProcess?.let {
149+
it.destroyForcibly()
150+
sshdProcess = null
151+
}
152+
}
153+
154+
private fun acquireWakeLock() {
155+
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
156+
wakeLock = powerManager.newWakeLock(
157+
PowerManager.PARTIAL_WAKE_LOCK,
158+
"OpenClaw::SshWakeLock"
159+
)
160+
wakeLock?.acquire(24 * 60 * 60 * 1000L)
161+
}
162+
163+
private fun releaseWakeLock() {
164+
wakeLock?.let {
165+
if (it.isHeld) it.release()
166+
}
167+
wakeLock = null
168+
}
169+
170+
private fun createNotificationChannel() {
171+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
172+
val channel = NotificationChannel(
173+
CHANNEL_ID,
174+
"OpenClaw SSH",
175+
NotificationManager.IMPORTANCE_LOW
176+
).apply {
177+
description = "Keeps the SSH server running in the background"
178+
}
179+
val manager = getSystemService(NotificationManager::class.java)
180+
manager.createNotificationChannel(channel)
181+
}
182+
}
183+
184+
private fun buildNotification(text: String): Notification {
185+
val intent = Intent(this, MainActivity::class.java)
186+
val pendingIntent = PendingIntent.getActivity(
187+
this, 0, intent,
188+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
189+
)
190+
191+
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
192+
Notification.Builder(this, CHANNEL_ID)
193+
} else {
194+
@Suppress("DEPRECATION")
195+
Notification.Builder(this)
196+
}
197+
198+
builder.setContentTitle("OpenClaw SSH")
199+
.setContentText(text)
200+
.setSmallIcon(android.R.drawable.ic_lock_lock)
201+
.setContentIntent(pendingIntent)
202+
.setOngoing(true)
203+
204+
return builder.build()
205+
}
206+
207+
private fun updateNotification(text: String) {
208+
try {
209+
val manager = getSystemService(NotificationManager::class.java)
210+
manager.notify(NOTIFICATION_ID, buildNotification(text))
211+
} catch (_: Exception) {}
212+
}
213+
}

flutter_app/lib/screens/ssh_screen.dart

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,11 @@ class _SshScreenState extends State<SshScreen> {
4242
List<String> ips = [];
4343
if (installed) {
4444
running = await SshService.isSshdRunning();
45+
// Always fetch IPs so user can see them before starting
46+
ips = await SshService.getIpAddresses();
4547
if (running) {
46-
ips = await SshService.getIpAddresses();
48+
final port = await SshService.getPort();
49+
if (mounted) _portController.text = port.toString();
4750
}
4851
}
4952
if (mounted) {
@@ -61,9 +64,13 @@ class _SshScreenState extends State<SshScreen> {
6164
try {
6265
if (_running) {
6366
await SshService.stopSshd();
67+
// Give the service a moment to stop
68+
await Future.delayed(const Duration(milliseconds: 500));
6469
} else {
6570
final port = int.tryParse(_portController.text.trim()) ?? 8022;
6671
await SshService.startSshd(port: port);
72+
// Give the service a moment to start
73+
await Future.delayed(const Duration(seconds: 2));
6774
}
6875
await _refresh();
6976
} catch (e) {
@@ -167,7 +174,6 @@ class _SshScreenState extends State<SshScreen> {
167174

168175
Widget _buildInstalledView(ThemeData theme, bool isDark) {
169176
final port = _portController.text.trim();
170-
final iconBg = isDark ? AppColors.darkSurfaceAlt : const Color(0xFFF3F4F6);
171177

172178
return ListView(
173179
padding: const EdgeInsets.all(16),
@@ -306,12 +312,12 @@ class _SshScreenState extends State<SshScreen> {
306312
child: Column(
307313
crossAxisAlignment: CrossAxisAlignment.start,
308314
children: [
309-
_infoRow(theme, iconBg, 'User', 'root'),
315+
_infoRow(theme, 'User', 'root'),
310316
const Divider(height: 24),
311-
_infoRow(theme, iconBg, 'Port', port),
317+
_infoRow(theme, 'Port', port),
312318
if (_ips.isNotEmpty) ...[
313319
const Divider(height: 24),
314-
_infoRow(theme, iconBg, 'IP Addresses', _ips.join(', ')),
320+
_infoRow(theme, 'IP Addresses', _ips.join(', ')),
315321
],
316322
const Divider(height: 24),
317323
Text(
@@ -350,7 +356,7 @@ class _SshScreenState extends State<SshScreen> {
350356
);
351357
}
352358

353-
Widget _infoRow(ThemeData theme, Color iconBg, String label, String value) {
359+
Widget _infoRow(ThemeData theme, String label, String value) {
354360
return Row(
355361
children: [
356362
Expanded(

flutter_app/lib/services/native_bridge.dart

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,4 +157,30 @@ class NativeBridge {
157157
static Future<bool> writeRootfsFile(String path, String content) async {
158158
return await _channel.invokeMethod('writeRootfsFile', {'path': path, 'content': content});
159159
}
160+
161+
// SSH Service
162+
static Future<bool> startSshd({int port = 8022}) async {
163+
return await _channel.invokeMethod('startSshd', {'port': port});
164+
}
165+
166+
static Future<bool> stopSshd() async {
167+
return await _channel.invokeMethod('stopSshd');
168+
}
169+
170+
static Future<bool> isSshdRunning() async {
171+
return await _channel.invokeMethod('isSshdRunning');
172+
}
173+
174+
static Future<int> getSshdPort() async {
175+
return await _channel.invokeMethod('getSshdPort');
176+
}
177+
178+
static Future<List<String>> getDeviceIps() async {
179+
final result = await _channel.invokeMethod('getDeviceIps');
180+
return List<String>.from(result);
181+
}
182+
183+
static Future<bool> setRootPassword(String password) async {
184+
return await _channel.invokeMethod('setRootPassword', {'password': password});
185+
}
160186
}

0 commit comments

Comments
 (0)