feat: initialize Android SSH manager app with terminal and crypto
This commit is contained in:
commit
66417cc194
55 changed files with 3711 additions and 0 deletions
|
|
@ -0,0 +1,115 @@
|
|||
package de.tronax.sshmanager.ssh
|
||||
|
||||
import de.tronax.sshmanager.data.repo.Host
|
||||
import de.tronax.sshmanager.data.repo.HostRepository
|
||||
import de.tronax.sshmanager.data.repo.SshKeyRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.schmizz.sshj.SSHClient
|
||||
import net.schmizz.sshj.connection.channel.direct.Session
|
||||
import net.schmizz.sshj.transport.verification.PromiscuousVerifier
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Raised when connecting or authenticating fails. */
|
||||
class SshConnectionException(message: String, cause: Throwable? = null) :
|
||||
IOException(message, cause)
|
||||
|
||||
/**
|
||||
* Owns the lifecycle of an [SSHClient] connection and provides a typed handle
|
||||
* ([Connection]) exposing the live shell streams for the terminal layer.
|
||||
*
|
||||
* Host-key verification is currently promiscuous (MVP). TODO: add known-hosts store.
|
||||
*/
|
||||
@Singleton
|
||||
class SshConnectionManager @Inject constructor(
|
||||
private val hosts: HostRepository,
|
||||
private val keys: SshKeyRepository,
|
||||
) {
|
||||
/**
|
||||
* Open and authenticate a connection. Returns a [Connection] whose [Session.Shell]
|
||||
* is ready for the terminal bridge to read/write. Call [Connection.close] when done.
|
||||
*/
|
||||
suspend fun connect(host: Host): Connection = withContext(Dispatchers.IO) {
|
||||
SshSupport.ensureProviders()
|
||||
val client = SSHClient()
|
||||
// TODO: persist + verify known host fingerprints instead of accepting all.
|
||||
client.addHostKeyVerifier(PromiscuousVerifier())
|
||||
client.connectTimeout = CONNECT_TIMEOUT_MS
|
||||
client.timeout = READ_TIMEOUT_MS
|
||||
|
||||
try {
|
||||
client.connect(host.hostName, host.port)
|
||||
authenticate(client, host)
|
||||
} catch (e: Exception) {
|
||||
try { client.close() } catch (_: Exception) {}
|
||||
throw SshConnectionException(
|
||||
"Verbindung zu ${host.userName}@${host.hostName}:${host.port} fehlgeschlagen: ${e.message}",
|
||||
e,
|
||||
)
|
||||
}
|
||||
|
||||
hosts.markConnected(host.id)
|
||||
Connection(client)
|
||||
}
|
||||
|
||||
private suspend fun authenticate(client: SSHClient, host: Host) {
|
||||
val username = host.userName
|
||||
when (host.authType) {
|
||||
de.tronax.sshmanager.domain.model.AuthType.PASSWORD -> {
|
||||
val pw = host.password
|
||||
?: hosts.decryptedPassword(host.id)
|
||||
?: throw SshConnectionException("Kein Passwort gespeichert für '${host.name}'.")
|
||||
client.authPassword(username, pw)
|
||||
}
|
||||
de.tronax.sshmanager.domain.model.AuthType.KEY -> {
|
||||
val keyId = host.keyId ?: throw SshConnectionException("Kein Schlüssel zugeordnet.")
|
||||
val pem = keys.decryptedKeyText(keyId)
|
||||
?: throw SshConnectionException("Schlüssel konnte nicht entschlüsselt werden.")
|
||||
// Re-prompt for passphrase is not supported in this path; keys used for login
|
||||
// must either be unencrypted or — for now — imported without passphrase.
|
||||
val kp = SshSupport.keyProviderFor(pem, null)
|
||||
client.authPublickey(username, kp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Live SSH connection wrapping an [SSHClient] and an open shell [Session.Shell]. */
|
||||
class Connection(internal val client: SSHClient) : AutoCloseable {
|
||||
private var session: Session? = null
|
||||
private var shell: Session.Shell? = null
|
||||
|
||||
/** Allocates a PTY shell (vt220, 80x24 default; the terminal resizes later). */
|
||||
fun startShell(): ShellHandle {
|
||||
val s = client.startSession().also { session = it }
|
||||
s.allocateDefaultPTY() // vt100-ish; termios refined by the emulator feed
|
||||
val sh = s.startShell().also { shell = it }
|
||||
return ShellHandle(
|
||||
remoteOutput = sh.getInputStream(), // bytes coming FROM the server
|
||||
remoteInput = sh.getOutputStream(), // bytes going TO the server
|
||||
close = { close() },
|
||||
)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
try { shell?.close() } catch (_: Exception) {}
|
||||
try { session?.close() } catch (_: Exception) {}
|
||||
try { client.disconnect() } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal, terminal-agnostic handle to the live shell's two streams. */
|
||||
data class ShellHandle(
|
||||
val remoteOutput: InputStream, // bytes coming FROM the server (→ emulator)
|
||||
val remoteInput: OutputStream, // bytes going TO the server (keyboard)
|
||||
val close: () -> Unit,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val CONNECT_TIMEOUT_MS = 15_000
|
||||
const val READ_TIMEOUT_MS = 0 // 0 = no socket read timeout for interactive shell
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue