mobile-ssh-manager/app/src/main/java/de/tronax/sshmanager/ssh/SshConnectionManager.kt

115 lines
4.7 KiB
Kotlin

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
}
}