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