115 lines
4.7 KiB
Kotlin
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
|
|
}
|
|
}
|