diff --git a/app/build.gradle b/app/build.gradle index 5075d2f..bfd9dc7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -9,8 +9,8 @@ android { applicationId "com.daemon.ssh" minSdkVersion 26 targetSdkVersion 36 - versionCode 51 - versionName "2.1.33" + versionCode 52 + versionName "2.1.34" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true } diff --git a/app/src/main/java/com/sshdaemon/MainActivity.java b/app/src/main/java/com/sshdaemon/MainActivity.java index 8928d2a..e0b49bb 100644 --- a/app/src/main/java/com/sshdaemon/MainActivity.java +++ b/app/src/main/java/com/sshdaemon/MainActivity.java @@ -433,6 +433,7 @@ public void generateClicked(View view) { public void passwordSwitchClicked(View passwordAuthenticationEnabled) { var passwordSwitch = (SwitchMaterial) passwordAuthenticationEnabled; enablePasswordAuthentication(true, !passwordSwitch.isActivated()); + handlePublicKeyAuthentication(true); } public void startStopClicked(View view) { diff --git a/app/src/main/java/com/sshdaemon/sshd/AbstractNativeCommand.java b/app/src/main/java/com/sshdaemon/sshd/AbstractNativeCommand.java new file mode 100644 index 0000000..f3fad4a --- /dev/null +++ b/app/src/main/java/com/sshdaemon/sshd/AbstractNativeCommand.java @@ -0,0 +1,233 @@ +package com.sshdaemon.sshd; + +import static com.sshdaemon.util.ShellFinder.findAvailableShell; + +import org.apache.sshd.server.Environment; +import org.apache.sshd.server.ExitCallback; +import org.apache.sshd.server.channel.ChannelSession; +import org.apache.sshd.server.command.Command; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Map; + +/** + * Abstract base class for native Android command implementations + * Contains shared functionality for environment setup and process management + */ +public abstract class AbstractNativeCommand implements Command, Runnable { + private static final Logger logger = LoggerFactory.getLogger(AbstractNativeCommand.class); + + protected final String workingDirectory; + + protected InputStream in; + protected OutputStream out; + protected OutputStream err; + protected ExitCallback callback; + protected Environment environment; + protected Thread commandThread; + protected Process process; + + public AbstractNativeCommand(String workingDirectory) { + this.workingDirectory = workingDirectory != null ? workingDirectory : "/"; + } + + @Override + public void setInputStream(InputStream in) { + this.in = in; + } + + @Override + public void setOutputStream(OutputStream out) { + this.out = out; + } + + @Override + public void setErrorStream(OutputStream err) { + this.err = err; + } + + @Override + public void setExitCallback(ExitCallback callback) { + this.callback = callback; + } + + @Override + public void start(ChannelSession channel, Environment env) throws IOException { + this.environment = env; + this.commandThread = new Thread(this, getThreadName(channel)); + this.commandThread.start(); + } + + @Override + public void destroy(ChannelSession channel) { + if (process != null) { + process.destroy(); + } + if (commandThread != null) { + commandThread.interrupt(); + } + } + + /** + * Sets up common environment variables for Android shell processes + */ + protected void setupEnvironment(ProcessBuilder pb, String shellPath) { + Map processEnv = pb.environment(); + + // Copy SSH environment variables + if (environment != null) { + processEnv.putAll(environment.getEnv()); + } + + // Set common shell environment variables + processEnv.put("HOME", workingDirectory); + processEnv.put("PWD", workingDirectory); + processEnv.put("SHELL", shellPath); + processEnv.put("TERM", processEnv.getOrDefault("TERM", "xterm-256color")); + processEnv.put("USER", processEnv.getOrDefault("USER", "android")); + processEnv.put("LANG", processEnv.getOrDefault("LANG", "en_US.UTF-8")); + + // Set comprehensive PATH for Android including common tool locations + String appBinDir = workingDirectory + "/bin"; + String defaultPath = appBinDir + ":/system/bin:/system/xbin:/vendor/bin:/data/local/tmp:/sbin:/data/data/com.termux/files/usr/bin:/data/data/com.sshdaemon/files/usr/bin"; + String existingPath = processEnv.get("PATH"); + if (existingPath != null && !existingPath.isEmpty()) { + processEnv.put("PATH", existingPath + ":" + defaultPath); + } else { + processEnv.put("PATH", defaultPath); + } + + processEnv.put("ANDROID_DATA", "/data"); + processEnv.put("ANDROID_ROOT", "/system"); + processEnv.put("EXTERNAL_STORAGE", "/sdcard"); + } + + /** + * Finds available shell or handles error if none found + */ + protected String findShellOrExit() { + String shellPath = findAvailableShell(); + if (shellPath == null) { + logger.error("No working shell found for command execution"); + try { + writeError("ERROR: No working shell found on this Android device.\r\n"); + writeError("This may be due to:\r\n"); + writeError("1. Restricted Android environment\r\n"); + writeError("2. Missing shell binaries\r\n"); + writeError("3. Permission restrictions\r\n"); + writeError("\r\nPlease check device configuration or contact administrator.\r\n"); + } catch (IOException e) { + logger.error("Failed to write error message", e); + } + callback.onExit(127); // Command not found + return null; + } + return shellPath; + } + + /** + * Creates standard I/O threads for simple command execution (non-interactive) + */ + protected void createSimpleIOThreads(int bufferSize) { + Thread inputThread = new Thread(() -> { + try { + byte[] buffer = new byte[bufferSize]; + int bytesRead; + while ((bytesRead = in.read(buffer)) != -1) { + if (process != null && process.isAlive()) { + process.getOutputStream().write(buffer, 0, bytesRead); + process.getOutputStream().flush(); + } else { + break; + } + } + } catch (IOException e) { + logger.debug("Input stream closed: {}", e.getMessage()); + } finally { + try { + if (process != null && process.isAlive()) { + process.getOutputStream().close(); + } + } catch (IOException e) { + logger.debug("Error closing process input stream: {}", e.getMessage()); + } + } + }, "Input"); + + Thread outputThread = new Thread(() -> { + try { + byte[] buffer = new byte[bufferSize]; + int bytesRead; + while ((bytesRead = process.getInputStream().read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + out.flush(); + } + } catch (IOException e) { + logger.debug("Output stream closed: {}", e.getMessage()); + } + }, "Output"); + + Thread errorThread = new Thread(() -> { + try { + byte[] buffer = new byte[bufferSize]; + int bytesRead; + while ((bytesRead = process.getErrorStream().read(buffer)) != -1) { + err.write(buffer, 0, bytesRead); + err.flush(); + } + } catch (IOException e) { + logger.debug("Error stream closed: {}", e.getMessage()); + } + }, "Error"); + + // Start I/O threads + inputThread.setDaemon(true); + outputThread.setDaemon(true); + errorThread.setDaemon(true); + + inputThread.start(); + outputThread.start(); + errorThread.start(); + } + + /** + * Waits for process completion and I/O threads to finish + */ + protected int waitForCompletion(int ioTimeout) throws InterruptedException { + int exitCode = process.waitFor(); + + // Wait for I/O threads to finish + Thread[] threads = Thread.getAllStackTraces().keySet().toArray(new Thread[0]); + for (Thread thread : threads) { + if (thread.getName().equals("Input") || thread.getName().equals("Output") || thread.getName().equals("Error")) { + try { + thread.join(ioTimeout); + } catch (InterruptedException e) { + logger.debug("Interrupted while waiting for I/O thread: {}", thread.getName()); + } + } + } + + return exitCode; + } + + protected void writeError(String message) throws IOException { + err.write(message.getBytes()); + err.flush(); + } + + /** + * Get thread name for this command type + */ + protected abstract String getThreadName(ChannelSession channel); + + /** + * Run the specific command implementation + */ + @Override + public abstract void run(); +} diff --git a/app/src/main/java/com/sshdaemon/sshd/NativeCommandFactory.java b/app/src/main/java/com/sshdaemon/sshd/NativeCommandFactory.java new file mode 100644 index 0000000..1535313 --- /dev/null +++ b/app/src/main/java/com/sshdaemon/sshd/NativeCommandFactory.java @@ -0,0 +1,28 @@ +package com.sshdaemon.sshd; + +import org.apache.sshd.server.channel.ChannelSession; +import org.apache.sshd.server.command.Command; +import org.apache.sshd.server.command.CommandFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Command factory that creates commands for execution via SSH + * This enables rsync and other command-line tools to work over SSH + */ +public class NativeCommandFactory implements CommandFactory { + + private static final Logger logger = LoggerFactory.getLogger(NativeCommandFactory.class); + + private final String workingDirectory; + + public NativeCommandFactory(String workingDirectory) { + this.workingDirectory = workingDirectory; + } + + @Override + public Command createCommand(ChannelSession channelSession, String command) { + logger.info("Creating command: {}", command); + return new NativeExecuteCommand(command, workingDirectory); + } +} diff --git a/app/src/main/java/com/sshdaemon/sshd/NativeExecuteCommand.java b/app/src/main/java/com/sshdaemon/sshd/NativeExecuteCommand.java new file mode 100644 index 0000000..cc46347 --- /dev/null +++ b/app/src/main/java/com/sshdaemon/sshd/NativeExecuteCommand.java @@ -0,0 +1,70 @@ +package com.sshdaemon.sshd; + +import org.apache.sshd.server.channel.ChannelSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +/** + * Command implementation that executes native system commands + * This enables rsync and other command-line tools to work over SSH + */ +public class NativeExecuteCommand extends AbstractNativeCommand { + private static final Logger logger = LoggerFactory.getLogger(NativeExecuteCommand.class); + + private final String command; + + public NativeExecuteCommand(String command, String workingDirectory) { + super(workingDirectory); + this.command = command; + } + + @Override + protected String getThreadName(ChannelSession channel) { + return "NativeCommand-" + command.hashCode(); + } + + @Override + public void run() { + try { + logger.info("Executing command: {}", command); + + // Find available shell using shared utility + String shellPath = findShellOrExit(); + if (shellPath == null) { + return; // Error already handled by base class + } + + // Create process builder to execute the command via shell + ProcessBuilder pb = new ProcessBuilder(shellPath, "-c", command); + pb.directory(new java.io.File(workingDirectory)); + pb.redirectErrorStream(false); // Keep stderr separate for proper rsync protocol + + // Set up environment using shared functionality + setupEnvironment(pb, shellPath); + + // Start the process + process = pb.start(); + logger.info("Command process started: {}", command); + + // Create I/O threads using shared functionality (8KB buffer for rsync) + createSimpleIOThreads(8192); + + // Wait for completion using shared functionality + int exitCode = waitForCompletion(2000); + + logger.info("Command completed with exit code: {} - {}", exitCode, command); + callback.onExit(exitCode); + + } catch (Exception e) { + logger.error("Error executing command: " + command, e); + try { + writeError("Command execution error: " + e.getMessage() + "\r\n"); + } catch (IOException ioException) { + logger.error("Failed to write error message", ioException); + } + callback.onExit(1); + } + } +} diff --git a/app/src/main/java/com/sshdaemon/sshd/NativeShellCommand.java b/app/src/main/java/com/sshdaemon/sshd/NativeShellCommand.java new file mode 100644 index 0000000..edc9857 --- /dev/null +++ b/app/src/main/java/com/sshdaemon/sshd/NativeShellCommand.java @@ -0,0 +1,257 @@ +package com.sshdaemon.sshd; + +import org.apache.sshd.server.channel.ChannelSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.Map; +import java.util.Objects; + +/** + * Native shell command that provides access to the Android system shell + */ +public class NativeShellCommand extends AbstractNativeCommand { + private static final Logger logger = LoggerFactory.getLogger(NativeShellCommand.class); + + private TerminalEmulator terminal; + + public NativeShellCommand(String workingDirectory) { + super(workingDirectory); + } + + @Override + protected String getThreadName(ChannelSession channel) { + return "NativeShell-" + channel.toString(); + } + + + @Override + public void run() { + try { + // Find available shell using shared utility + String shellPath = findShellOrExit(); + if (shellPath == null) { + return; // Error already handled by base class + } + + logger.info("Starting native shell: {}", shellPath); + + // Create process builder - use non-interactive shell to avoid TTY issues on Android + ProcessBuilder pb = new ProcessBuilder(shellPath); + pb.directory(new java.io.File(workingDirectory)); + pb.redirectErrorStream(true); // Merge stderr with stdout for simplicity + + // Set up environment using shared functionality + setupEnvironment(pb, shellPath); + + // Initialize terminal emulator + terminal = new TerminalEmulator(out); + + // Set terminal size from SSH environment if available + if (environment != null) { + Map env = environment.getEnv(); + try { + int cols = Integer.parseInt(Objects.requireNonNull(env.getOrDefault("COLUMNS", "80"))); + int rows = Integer.parseInt(Objects.requireNonNull(env.getOrDefault("LINES", "24"))); + terminal.setTerminalSize(cols, rows); + } catch (NumberFormatException e) { + // Use defaults + } + } + + process = pb.start(); + + terminal.write("Current directory: " + workingDirectory + "\r\n"); + String prompt = terminal.createPrompt("android", workingDirectory, "/"); + terminal.write(prompt); + + // Create threads to handle I/O + Thread inputThread = new Thread(() -> { + try { + logger.info("InputThread started, waiting for input..."); + byte[] buffer = new byte[1024]; + int bytesRead; + StringBuilder commandBuffer = new StringBuilder(); // Buffer to track current command line + + while ((bytesRead = in.read(buffer)) != -1) { + logger.info("InputThread: received {} bytes from SSH client", bytesRead); + + // Echo the input back to the client so they can see what they're typing + String input = new String(buffer, 0, bytesRead); + + // Handle special characters + if (input.equals("\r") || input.equals("\n") || input.equals("\r\n")) { + // Enter pressed - send complete command to shell and newline to client + terminal.write("\r\n"); + if (process != null && process.isAlive()) { + // Send the complete command buffer to shell + String command = commandBuffer.toString() + "\n"; + process.getOutputStream().write(command.getBytes()); + process.getOutputStream().flush(); + logger.info("InputThread: sent command to shell: {}", commandBuffer.toString()); + } + // Clear command buffer for next command + commandBuffer.setLength(0); + } else if (input.equals("\u0008") || input.equals("\u007f")) { + // Backspace - handle locally by removing last character from buffer + if (commandBuffer.length() > 0) { + commandBuffer.setLength(commandBuffer.length() - 1); + terminal.write("\b \b"); // Backspace, space, backspace (visual deletion) + logger.info("InputThread: backspace, command buffer now: '{}'", commandBuffer.toString()); + } + } else { + // Regular character - add to command buffer and echo to client + commandBuffer.append(input); + terminal.write(input); + logger.info("InputThread: added to command buffer: '{}', total: '{}'", input, commandBuffer.toString()); + + } + } + } catch (IOException e) { + logger.error("InputThread error: {}", e.getMessage()); + } finally { + logger.info("InputThread ending"); + try { + if (process != null && process.isAlive()) { + process.getOutputStream().close(); + } + } catch (IOException e) { + logger.debug("Error closing process output stream: {}", e.getMessage()); + } + } + }, "ShellInput"); + + Thread outputThread = new Thread(() -> { + try { + logger.info("OutputThread started, waiting for shell output..."); + byte[] buffer = new byte[1024]; + int bytesRead; + StringBuilder commandBuffer = new StringBuilder(); + boolean waitingForPrompt = false; + + while ((bytesRead = process.getInputStream().read(buffer)) != -1) { + logger.info("OutputThread: received {} bytes from shell", bytesRead); + String output = new String(buffer, 0, bytesRead); + + commandBuffer.append(output); + + if (output.endsWith("\n") || output.endsWith("\r\n")) { + String fullOutput = commandBuffer.toString(); + + // Don't echo empty lines or prompt-like output + if (!fullOutput.trim().isEmpty() && + !fullOutput.trim().equals("$") && + !fullOutput.matches("^\\s*$")) { + + // Ensure proper line endings for terminal compatibility + String cleanOutput = fullOutput.replace("\n", "\r\n"); + // Write the accumulated output directly to out stream for better terminal compatibility + out.write(cleanOutput.getBytes()); + out.flush(); + waitingForPrompt = true; + } + + // Clear the buffer + commandBuffer.setLength(0); + + // Add our custom prompt after command output + if (waitingForPrompt) { + String newPrompt = terminal.createPrompt("android", workingDirectory, "/"); + terminal.write(newPrompt); + waitingForPrompt = false; + } + } else { + // For partial output (no newline yet), just write it directly + // This handles interactive programs that don't end with newlines + if (commandBuffer.length() > 0) { + // Write directly to output stream with proper line endings + String cleanOutput = output.replace("\n", "\r\n"); + out.write(cleanOutput.getBytes()); + out.flush(); + commandBuffer.setLength(0); // Clear since we wrote it + } + } + + logger.info("OutputThread: processed and sent output to SSH client"); + } + } catch (IOException e) { + logger.error("OutputThread error: {}", e.getMessage()); + } finally { + logger.info("OutputThread ending"); + } + }, "ShellOutput"); + + Thread errorThread = new Thread(() -> { + try { + byte[] buffer = new byte[1024]; + int bytesRead; + while ((bytesRead = process.getErrorStream().read(buffer)) != -1) { + err.write(buffer, 0, bytesRead); + err.flush(); + } + } catch (IOException e) { + logger.debug("Error stream closed: {}", e.getMessage()); + } + }, "ShellError"); + + // Start I/O threads + inputThread.setDaemon(true); + outputThread.setDaemon(true); + errorThread.setDaemon(true); + + inputThread.start(); + outputThread.start(); + errorThread.start(); + + // Check if shell process is alive and send initial commands + logger.info("Shell process started"); + logger.info("Shell process alive: {}", process.isAlive()); + + try { + Thread.sleep(200); // Give shell time to initialize + + if (process.isAlive()) { + // Set up shell environment - disable shell echo since we handle it + String initCommands = "stty -echo 2>/dev/null || true\nexport PS1=''\n"; + process.getOutputStream().write(initCommands.getBytes()); + process.getOutputStream().flush(); + logger.info("Sent initial commands to shell"); + + // Give time for initial commands to process + Thread.sleep(100); + logger.info("Shell process still alive after init: {}", process.isAlive()); + } else { + logger.error("Shell process died immediately after start!"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + // Wait for process to complete + int exitCode = process.waitFor(); + + // Wait a bit for I/O threads to finish + try { + outputThread.join(1000); + errorThread.join(1000); + } catch (InterruptedException e) { + logger.debug("Interrupted while waiting for I/O threads"); + } + + logger.info("Shell process exited with code: {}", exitCode); + callback.onExit(exitCode); + + } catch (Exception e) { + logger.error("Error in shell execution", e); + try { + writeError("Shell error: " + e.getMessage() + "\r\n"); + } catch (IOException ioException) { + logger.error("Failed to write error message", ioException); + } + callback.onExit(1); + } + } + + +} diff --git a/app/src/main/java/com/sshdaemon/sshd/NativeShellFactory.java b/app/src/main/java/com/sshdaemon/sshd/NativeShellFactory.java new file mode 100644 index 0000000..f4d8b1f --- /dev/null +++ b/app/src/main/java/com/sshdaemon/sshd/NativeShellFactory.java @@ -0,0 +1,26 @@ +package com.sshdaemon.sshd; + +import org.apache.sshd.server.channel.ChannelSession; +import org.apache.sshd.server.command.Command; +import org.apache.sshd.server.shell.ShellFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Factory that creates native Android shell sessions + */ +public class NativeShellFactory implements ShellFactory { + private static final Logger logger = LoggerFactory.getLogger(NativeShellFactory.class); + + private final String workingDirectory; + + public NativeShellFactory(String workingDirectory) { + this.workingDirectory = workingDirectory; + } + + @Override + public Command createShell(ChannelSession channelSession) { + logger.debug("Creating native shell session for channel: {}", channelSession); + return new NativeShellCommand(workingDirectory); + } +} diff --git a/app/src/main/java/com/sshdaemon/sshd/SshDaemon.java b/app/src/main/java/com/sshdaemon/sshd/SshDaemon.java index 5e37ab4..452e424 100644 --- a/app/src/main/java/com/sshdaemon/sshd/SshDaemon.java +++ b/app/src/main/java/com/sshdaemon/sshd/SshDaemon.java @@ -36,7 +36,6 @@ import org.apache.sshd.server.ServerBuilder; import org.apache.sshd.server.SshServer; import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider; -import org.apache.sshd.server.shell.InteractiveProcessShellFactory; import org.apache.sshd.sftp.server.SftpSubsystemFactory; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.slf4j.Logger; @@ -171,10 +170,32 @@ private void init(String selectedInterface, int port, String user, String passwo sshd.setPasswordAuthenticator(new SshPasswordAuthenticator(user, password)); } + // Explicitly disable other authentication methods to ensure security + sshd.setKeyboardInteractiveAuthenticator(null); + sshd.setGSSAuthenticator(null); + sshd.setHostBasedAuthenticator(null); + + // Ensure authentication is required - no anonymous access + if (!authorizedKeyFile.exists() && !passwordAuthEnabled) { + throw new IllegalStateException("No authentication method is enabled. Either enable password authentication or provide authorized keys."); + } + + // Log authentication configuration for debugging + logger.info("Authentication configuration:"); + logger.info(" - Public key auth: {}", authorizedKeyFile.exists()); + logger.info(" - Password auth: {}", passwordAuthEnabled || !authorizedKeyFile.exists()); + logger.info(" - User: {}", user); + var keyProvider = new SimpleGeneratorHostKeyProvider(Paths.get(path + "/ssh_host_rsa_key")); sshd.setKeyPairProvider(keyProvider); - sshd.setShellFactory(new InteractiveProcessShellFactory()); + + // Always use native shell - this is the only supported shell + logger.info("Using native system shell"); + sshd.setShellFactory(new NativeShellFactory(sftpRootPath)); + + // Add command factory to support rsync and other command execution + sshd.setCommandFactory(new NativeCommandFactory(sftpRootPath)); int threadPools = max(THREAD_POOL_SIZE, Runtime.getRuntime().availableProcessors() * 2); logger.info("Thread pool size: {}", threadPools); @@ -227,7 +248,6 @@ public int onStartCommand(Intent intent, int flags, int startId) { "SFTP root path must not be null"); var passwordAuthEnabled = intent.getBooleanExtra(PASSWORD_AUTH_ENABLED, true); var readOnly = intent.getBooleanExtra(READ_ONLY, false); - init(interfaceName, port, user, password, sftpRootPath, passwordAuthEnabled, readOnly); sshd.start(); isServiceRunning = true; diff --git a/app/src/main/java/com/sshdaemon/sshd/TerminalEmulator.java b/app/src/main/java/com/sshdaemon/sshd/TerminalEmulator.java new file mode 100644 index 0000000..df1d638 --- /dev/null +++ b/app/src/main/java/com/sshdaemon/sshd/TerminalEmulator.java @@ -0,0 +1,234 @@ +package com.sshdaemon.sshd; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * Simple terminal emulator that handles basic ANSI escape sequences and terminal features + */ +public class TerminalEmulator { + + // ANSI escape sequences + public static final String RESET = "\u001b[0m"; + public static final String CLEAR_SCREEN = "\u001b[2J\u001b[H"; + public static final String CLEAR_LINE = "\u001b[2K"; + public static final String CURSOR_UP = "\u001b[A"; + public static final String CURSOR_DOWN = "\u001b[B"; + public static final String CURSOR_RIGHT = "\u001b[C"; + public static final String CURSOR_LEFT = "\u001b[D"; + + // Colors + public static final String BLACK = "\u001b[30m"; + public static final String RED = "\u001b[31m"; + public static final String GREEN = "\u001b[32m"; + public static final String YELLOW = "\u001b[33m"; + public static final String BLUE = "\u001b[34m"; + public static final String MAGENTA = "\u001b[35m"; + public static final String CYAN = "\u001b[36m"; + public static final String WHITE = "\u001b[37m"; + + // Bright colors + public static final String BRIGHT_BLACK = "\u001b[90m"; + public static final String BRIGHT_RED = "\u001b[91m"; + public static final String BRIGHT_GREEN = "\u001b[92m"; + public static final String BRIGHT_YELLOW = "\u001b[93m"; + public static final String BRIGHT_BLUE = "\u001b[94m"; + public static final String BRIGHT_MAGENTA = "\u001b[95m"; + public static final String BRIGHT_CYAN = "\u001b[96m"; + public static final String BRIGHT_WHITE = "\u001b[97m"; + + private final OutputStream outputStream; + private final Map environment; + private int columns = 80; + private int rows = 24; + + public TerminalEmulator(OutputStream outputStream) { + this.outputStream = outputStream; + this.environment = new HashMap<>(); + initializeEnvironment(); + } + + private void initializeEnvironment() { + environment.put("TERM", "xterm-256color"); + environment.put("COLUMNS", String.valueOf(columns)); + environment.put("LINES", String.valueOf(rows)); + environment.put("PATH", "/system/bin:/system/xbin"); + environment.put("HOME", System.getProperty("user.home", "/")); + environment.put("USER", System.getProperty("user.name", "android")); + environment.put("SHELL", "/system/bin/sh"); + environment.put("ANDROID_SSH", "1"); + } + + public void setTerminalSize(int columns, int rows) { + this.columns = columns; + this.rows = rows; + environment.put("COLUMNS", String.valueOf(columns)); + environment.put("LINES", String.valueOf(rows)); + } + + public int getColumns() { + return columns; + } + + public int getRows() { + return rows; + } + + public Map getEnvironment() { + return new HashMap<>(environment); + } + + public void write(String text) throws IOException { + outputStream.write(text.getBytes(StandardCharsets.UTF_8)); + outputStream.flush(); + } + + public void writeLine(String text) throws IOException { + write(text + "\r\n"); + } + + public void writeError(String text) throws IOException { + write(RED + text + RESET); + } + + public void writeSuccess(String text) throws IOException { + write(GREEN + text + RESET); + } + + public void writeWarning(String text) throws IOException { + write(YELLOW + text + RESET); + } + + public void writeInfo(String text) throws IOException { + write(CYAN + text + RESET); + } + + public void clearScreen() throws IOException { + write(CLEAR_SCREEN); + } + + public void clearLine() throws IOException { + write(CLEAR_LINE); + } + + public void moveCursor(int row, int col) throws IOException { + write(String.format("\u001b[%d;%dH", row, col)); + } + + public void moveCursorUp(int lines) throws IOException { + write(String.format("\u001b[%dA", lines)); + } + + public void moveCursorDown(int lines) throws IOException { + write(String.format("\u001b[%dB", lines)); + } + + public void moveCursorRight(int columns) throws IOException { + write(String.format("\u001b[%dC", columns)); + } + + public void moveCursorLeft(int columns) throws IOException { + write(String.format("\u001b[%dD", columns)); + } + + public void saveCursor() throws IOException { + write("\u001b[s"); + } + + public void restoreCursor() throws IOException { + write("\u001b[u"); + } + + public void hideCursor() throws IOException { + write("\u001b[?25l"); + } + + public void showCursor() throws IOException { + write("\u001b[?25h"); + } + + public void enableAlternateScreen() throws IOException { + write("\u001b[?1049h"); + } + + public void disableAlternateScreen() throws IOException { + write("\u001b[?1049l"); + } + + public void bold() throws IOException { + write("\u001b[1m"); + } + + public void dim() throws IOException { + write("\u001b[2m"); + } + + public void underline() throws IOException { + write("\u001b[4m"); + } + + public void blink() throws IOException { + write("\u001b[5m"); + } + + public void reverse() throws IOException { + write("\u001b[7m"); + } + + public void reset() throws IOException { + write(RESET); + } + + /** + * Format file permissions in Unix style (e.g., drwxr-xr-x) + */ + public static String formatPermissions(boolean isDirectory, boolean canRead, boolean canWrite, boolean canExecute) { + // Owner, group, others (simplified for Android) + return String.valueOf(isDirectory ? 'd' : '-') + + (canRead ? 'r' : '-') + + (canWrite ? 'w' : '-') + + (canExecute ? 'x' : '-') + + "r--r--"; + } + + /** + * Format file size in human-readable format + */ + public static String formatFileSize(long bytes) { + if (bytes < 1024) return bytes + " B"; + if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0); + if (bytes < 1024 * 1024 * 1024) return String.format("%.1f MB", bytes / (1024.0 * 1024.0)); + return String.format("%.1f GB", bytes / (1024.0 * 1024.0 * 1024.0)); + } + + /** + * Create a colored prompt based on current directory and user + */ + public String createPrompt(String user, String currentDir, String rootDir) { + StringBuilder prompt = new StringBuilder(); + + // User in green + prompt.append(GREEN).append(user).append(RESET); + prompt.append("@"); + + // Host in cyan + prompt.append(CYAN).append("android").append(RESET); + prompt.append(":"); + + // Current directory in blue + String displayDir = currentDir; + if (currentDir.equals(rootDir)) { + displayDir = "~"; + } else if (currentDir.startsWith(rootDir)) { + displayDir = "~" + currentDir.substring(rootDir.length()); + } + prompt.append(BLUE).append(displayDir).append(RESET); + + prompt.append("$ "); + + return prompt.toString(); + } +} diff --git a/app/src/main/java/com/sshdaemon/util/ShellFinder.java b/app/src/main/java/com/sshdaemon/util/ShellFinder.java new file mode 100644 index 0000000..ad81cd4 --- /dev/null +++ b/app/src/main/java/com/sshdaemon/util/ShellFinder.java @@ -0,0 +1,65 @@ +package com.sshdaemon.util; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ShellFinder { + + private static final Logger logger = LoggerFactory.getLogger(ShellFinder.class); + + // Common shell paths to try on Android + public static final String[] SHELL_PATHS = { + "/system/bin/sh", + "/system/xbin/sh", + "/vendor/bin/sh", + "/bin/sh", + "sh" + }; + + public static String findAvailableShell() { + for (String shellPath : SHELL_PATHS) { + try { + // First check if the shell file exists and is executable + java.io.File shellFile = new java.io.File(shellPath); + if (!shellFile.exists()) { + logger.debug("Shell {} does not exist", shellPath); + continue; + } + if (!shellFile.canExecute()) { + logger.debug("Shell {} is not executable", shellPath); + continue; + } + + // Test if shell actually works by running a simple command + ProcessBuilder testPb = new ProcessBuilder(shellPath, "-c", "echo test"); + testPb.redirectErrorStream(true); + Process testProcess = testPb.start(); + + // Wait for the process with a timeout + boolean finished = testProcess.waitFor(5, java.util.concurrent.TimeUnit.SECONDS); + if (finished && testProcess.exitValue() == 0) { + logger.info("Found working shell: {}", shellPath); + return shellPath; + } else { + if (!finished) { + testProcess.destroyForcibly(); + logger.debug("Shell {} test timed out", shellPath); + } else { + logger.debug("Shell {} test failed with exit code: {}", shellPath, testProcess.exitValue()); + } + } + } catch (Exception e) { + logger.debug("Shell {} test failed: {}", shellPath, e.getMessage()); + } + } + + // If no standard shell found, log system information for debugging + logger.warn("No working shell found. System info:"); + logger.warn(" - OS: {}", System.getProperty("os.name", "unknown")); + logger.warn(" - Arch: {}", System.getProperty("os.arch", "unknown")); + logger.warn(" - Java version: {}", System.getProperty("java.version", "unknown")); + + return null; + } + +}