Commit 5aefbe68 authored by Javier Romero's avatar Javier Romero

WIP - Manage host keys

parent a4b64044
Pipeline #29904530 failed with stages
in 7 minutes and 27 seconds
......@@ -111,6 +111,7 @@ android {
ext {
version_answers = '1.4.3'
version_constraint_layout = '1.1.3'
version_crashlytcs = '2.9.5'
version_dagger = '2.16'
version_jsr250 = '1.0'
......@@ -132,6 +133,7 @@ dependencies {
implementation "com.android.support:support-annotations:${version_support}"
implementation "com.android.support:appcompat-v7:${version_support}"
implementation "com.android.support:cardview-v7:${version_support}"
implementation "com.android.support.constraint:constraint-layout:${version_constraint_layout}"
implementation "com.android.support:customtabs:${version_support}"
implementation "com.android.support:design:${version_support}"
implementation "com.android.support:recyclerview-v7:${version_support}"
......
......@@ -43,6 +43,9 @@
android:name="app.devlife.connect2sql.ui.connection.ConnectionInfoEditorActivity"
android:label="@string/activity_configure_connection"
android:theme="@style/Theme.C2SQL.NoActionBar" />
<activity
android:name="app.devlife.connect2sql.ui.hostkeys.HostKeysActivity"
android:label="@string/activity_host_keys" />
<activity
android:name="app.devlife.connect2sql.ui.query.QueryActivity"
android:configChanges="orientation|uiMode|screenSize|smallestScreenSize|keyboardHidden"
......@@ -68,6 +71,7 @@
<meta-data
android:name="io.fabric.ApiKey"
android:value="${fabricApiKey}" />
</application>
</manifest>
</manifest>
\ No newline at end of file
......@@ -3,9 +3,6 @@ package app.devlife.connect2sql;
import android.content.Context;
import android.content.Intent;
/**
*
*/
public class ApplicationUtils {
public static Connect2SqlApplication getApplication(Context context) {
return ((Connect2SqlApplication) context.getApplicationContext());
......
......@@ -3,21 +3,22 @@ package app.devlife.connect2sql;
import android.app.Activity;
import android.app.Application;
import java.lang.ref.WeakReference;
import javax.inject.Inject;
import app.devlife.connect2sql.activity.LaunchActivity;
import app.devlife.connect2sql.data.LockManager;
import app.devlife.connect2sql.di.AnalyticsModule;
import app.devlife.connect2sql.di.ApplicationComponent;
import app.devlife.connect2sql.di.ApplicationModule;
import app.devlife.connect2sql.di.ConnectionModule;
import app.devlife.connect2sql.di.DaggerApplicationComponent;
import app.devlife.connect2sql.di.DatabaseModule;
import app.devlife.connect2sql.di.PreferencesModule;
import app.devlife.connect2sql.di.SecurityModule;
import io.fabric.sdk.android.Fabric;
import app.devlife.connect2sql.activity.LaunchActivity;
import app.devlife.connect2sql.data.LockManager;
import app.devlife.connect2sql.di.*;
import app.devlife.connect2sql.log.EzLogger;
import javax.inject.Inject;
import java.lang.ref.WeakReference;
import io.fabric.sdk.android.Fabric;
/**
*
......@@ -26,21 +27,25 @@ public class Connect2SqlApplication extends Application {
private ApplicationComponent mApplicationComponent;
@Inject ApplicationFocusManager mApplicationFocusManager;
@Inject LockManager mLockManager;
@Inject Fabric mFabric;
@Inject
ApplicationFocusManager mApplicationFocusManager;
@Inject
LockManager mLockManager;
@Inject
Fabric mFabric;
@Override
public void onCreate() {
super.onCreate();
mApplicationComponent = DaggerApplicationComponent.builder()
.analyticsModule(new AnalyticsModule(this))
.applicationModule(new ApplicationModule(this))
.databaseModule(new DatabaseModule(this))
.preferencesModule(new PreferencesModule(this))
.securityModule(new SecurityModule(this))
.build();
.analyticsModule(new AnalyticsModule(this))
.applicationModule(new ApplicationModule(this))
.connectionModule(new ConnectionModule(this))
.databaseModule(new DatabaseModule(this))
.preferencesModule(new PreferencesModule(this))
.securityModule(new SecurityModule(this))
.build();
mApplicationComponent.inject(this);
mApplicationFocusManager.addOnFocusChangeListener(mOnFocusChangeListener);
......@@ -63,8 +68,8 @@ public class Connect2SqlApplication extends Application {
EzLogger.d("Last focused activity: " + activity);
if (!activity.getClass().equals(LaunchActivity.class)) {
if (!mLockManager.isSetLockActivity(activity) &&
!mLockManager.isUnlockActivity(activity) &&
!mLockManager.isForgotLockActivity(activity)) {
!mLockManager.isUnlockActivity(activity) &&
!mLockManager.isForgotLockActivity(activity)) {
mLockManager.startUnlockActivity(activity, 0);
} else {
EzLogger.d("Activity is a lock specific activity.");
......
......@@ -19,13 +19,9 @@ import android.view.MenuItem
import android.view.View
import android.widget.EditText
import android.widget.TextView
import com.crashlytics.android.answers.Answers
import com.crashlytics.android.answers.CustomEvent
import com.gitlab.connect2sql.R
import kotlinx.android.synthetic.main.activity_connections.connections_dashboard
import kotlinx.android.synthetic.main.activity_connections.fab
import app.devlife.connect2sql.ApplicationUtils
import app.devlife.connect2sql.connection.ConnectionAgent
import app.devlife.connect2sql.connection.SshTunnelAgent
import app.devlife.connect2sql.db.model.connection.ConnectionInfo
import app.devlife.connect2sql.db.model.connection.ConnectionInfoSqlModel
import app.devlife.connect2sql.db.repo.ConnectionInfoRepository
......@@ -34,10 +30,17 @@ import app.devlife.connect2sql.sql.DriverType
import app.devlife.connect2sql.ui.connection.ConnectionInfoDriverChooserActivity
import app.devlife.connect2sql.ui.connection.ConnectionInfoEditorActivity
import app.devlife.connect2sql.ui.connection.ConnectionInfoEditorRequest
import app.devlife.connect2sql.ui.hostkeys.HostKeysActivity
import app.devlife.connect2sql.ui.query.QueryActivity
import app.devlife.connect2sql.ui.widget.BlockItem
import app.devlife.connect2sql.ui.widget.Toast
import app.devlife.connect2sql.ui.widget.dialog.ProgressDialog
import com.crashlytics.android.answers.Answers
import com.crashlytics.android.answers.CustomEvent
import com.gitlab.connect2sql.R
import com.jcraft.jsch.JSch
import kotlinx.android.synthetic.main.activity_connections.connections_dashboard
import kotlinx.android.synthetic.main.activity_connections.fab
import rx.Subscriber
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
......@@ -49,6 +52,8 @@ class DashboardActivity : BaseActivity() {
private var actionMode: ActionMode? = null
@Inject
lateinit var jSch: JSch
@Inject
lateinit var connectionAgent: ConnectionAgent
@Inject
......@@ -72,7 +77,9 @@ class DashboardActivity : BaseActivity() {
override fun onResume() {
super.onResume()
supportLoaderManager.restartLoader(LOADER_CONNECTIONS, Bundle(), mConnectionsLoaderCallbacks)
supportLoaderManager.restartLoader(LOADER_CONNECTIONS,
Bundle(),
mConnectionsLoaderCallbacks)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
......@@ -82,9 +89,12 @@ class DashboardActivity : BaseActivity() {
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.host_keys ->
startActivity(HostKeysActivity.newIntent(this))
R.id.rate -> {
try {
val goToMarket = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$packageName"))
val goToMarket = Intent(Intent.ACTION_VIEW,
Uri.parse("market://details?id=$packageName"))
goToMarket.addFlags(
Intent.FLAG_ACTIVITY_NO_HISTORY
or Intent.FLAG_ACTIVITY_NEW_DOCUMENT
......@@ -95,7 +105,8 @@ class DashboardActivity : BaseActivity() {
CustomTabsIntent.Builder()
.setToolbarColor(resources.getColor(R.color.blueBase, theme))
.build()
.launchUrl(this, Uri.parse("http://play.google.com/store/apps/details?id=$packageName"))
.launchUrl(this,
Uri.parse("http://play.google.com/store/apps/details?id=$packageName"))
}
}
R.id.beta -> {
......@@ -115,7 +126,8 @@ class DashboardActivity : BaseActivity() {
}
private fun connect(connectionInfo: ConnectionInfo) {
mAnswers.logCustom(CustomEvent("connect").putCustomAttribute("DriverType", connectionInfo.driverType.toString()))
mAnswers.logCustom(CustomEvent("connect").putCustomAttribute("DriverType",
connectionInfo.driverType.toString()))
// define a progress dialog to display
val progressDialog = ProgressDialog(
......@@ -134,17 +146,36 @@ class DashboardActivity : BaseActivity() {
override fun onError(e: Throwable?) {
progressDialog.dismiss()
val builder = AlertDialog.Builder(this@DashboardActivity)
builder.setTitle("Error")
builder.setMessage("Application Error: ${e?.message}")
builder.setNeutralButton("OK", null)
builder.create().show()
when (e) {
is SshTunnelAgent.UnknownHostException -> AlertDialog.Builder(this@DashboardActivity)
.setTitle(R.string.dialog_add_host_key)
.setMessage(getString(
R.string.dialog_host_fingerprint,
e.hostKey.host,
e.hostKey.getFingerPrint(jSch)
))
.setNegativeButton(R.string.dialog_no, null)
.setPositiveButton(R.string.dialog_yes) { dialog, _ ->
dialog.dismiss()
jSch.hostKeyRepository.add(e.hostKey, null)
connect(connectionInfo)
}
.create()
.show()
else -> AlertDialog.Builder(this@DashboardActivity)
.setTitle(R.string.dialog_error)
.setMessage("Couldn't connect:\n\n${e?.message}")
.setNeutralButton(R.string.dialog_ok, null)
.create()
.show()
}
}
override fun onNext(t: Connection?) {
progressDialog.dismiss()
startActivity(QueryActivity.newIntent(this@DashboardActivity, connectionInfo.id))
startActivity(QueryActivity.newIntent(this@DashboardActivity,
connectionInfo.id))
}
})
......@@ -187,7 +218,7 @@ class DashboardActivity : BaseActivity() {
val activatedItems = ArrayList<BlockItem>()
val totalChildren = connections_dashboard.childCount
for (i in 0..totalChildren - 1) {
for (i in 0 until totalChildren) {
val item = connections_dashboard.getChildAt(i) as BlockItem
if (item.isActivated) {
activatedItems.add(item)
......@@ -247,7 +278,8 @@ class DashboardActivity : BaseActivity() {
val connectionInfo = (item.tag as ConnectionInfo)
if (TextUtils.isEmpty(connectionInfo.password)) {
val promptDialogView = LayoutInflater.from(this@DashboardActivity).inflate(R.layout.dialog_prompt, null)
val promptDialogView = LayoutInflater.from(this@DashboardActivity)
.inflate(R.layout.dialog_prompt, null)
(promptDialogView.findViewById(R.id.textView1) as TextView).visibility = View.GONE
......@@ -302,7 +334,6 @@ class DashboardActivity : BaseActivity() {
private val mActionModeCallback = object : ActionMode.Callback {
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
// TODO Auto-generated method stub
return false
}
......@@ -334,7 +365,8 @@ class DashboardActivity : BaseActivity() {
for (connectionItem in activatedBlockItems) {
val connectionInfo = connectionItem.tag as ConnectionInfo
val request = ConnectionInfoEditorRequest(connectionInfo.id)
val newIntent = ConnectionInfoEditorActivity.newIntent(this@DashboardActivity, request)
val newIntent = ConnectionInfoEditorActivity.newIntent(this@DashboardActivity,
request)
startActivity(newIntent)
break
}
......@@ -345,9 +377,12 @@ class DashboardActivity : BaseActivity() {
for (connectionItem in activatedBlockItems) {
try {
val connectionInfo = connectionItem.tag as ConnectionInfo
connectionInfoRepository.save(connectionInfo.copy(id = -1, name = "Copy: " + connectionInfo.name))
connectionInfoRepository.save(connectionInfo.copy(id = -1,
name = "Copy: " + connectionInfo.name))
} catch (e: CloneNotSupportedException) {
Toast.makeText(this@DashboardActivity, "Failed to create copy", Toast.LENGTH_SHORT).show()
Toast.makeText(this@DashboardActivity,
"Failed to create copy",
Toast.LENGTH_SHORT).show()
e.printStackTrace()
}
}
......
package app.devlife.connect2sql.connection
import app.devlife.connect2sql.db.model.connection.Address
import app.devlife.connect2sql.db.model.connection.BasicAuth
import app.devlife.connect2sql.db.model.connection.ConnectionInfo
import app.devlife.connect2sql.db.model.connection.PrivateKey
import app.devlife.connect2sql.db.model.connection.SshTunnelConfig
import app.devlife.connect2sql.log.EzLogger
import app.devlife.connect2sql.sql.driver.helper.DriverHelperFactory
import com.jcraft.jsch.JSch
import rx.Observable
import java.io.IOException
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
import java.sql.Connection
import java.sql.DriverManager
import java.sql.SQLException
import java.util.Properties
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
class ConnectionAgent {
class ConnectionAgent(private val sshTunnelAgent: SshTunnelAgent) {
private val activeConnections = ConcurrentHashMap<ConnectionInfo, Connection>()
fun connect(connectionInfo: ConnectionInfo): Observable<Connection> {
return if (connectionInfo.sshConfig != null) {
val serviceAddress = Address(connectionInfo.host, connectionInfo.port)
val sshTunnelConfig = SshTunnelConfig(connectionInfo.sshConfig, serviceAddress)
sshTunnelAgent.startSshTunnel(sshTunnelConfig).flatMap { (_, tunnelAddress) ->
doConnect(connectionInfo, tunnelAddress)
}
} else {
doConnect(connectionInfo, null)
}
}
fun disconnect(connection: Connection): Observable<Unit> {
return Observable.create { subscriber ->
try {
if (!connection.isClosed) {
connection.close()
}
subscriber.onNext(Unit)
subscriber.onCompleted()
} catch (e: SQLException) {
subscriber.onError(e)
}
}
}
private fun doConnect(connectionInfo: ConnectionInfo,
tunnelAddress: Address?): Observable<Connection> {
return Observable.create { subscriber ->
try {
if (!activeConnections.containsKey(connectionInfo) || activeConnections[connectionInfo]?.isClosed == true) {
......@@ -37,12 +60,7 @@ class ConnectionAgent {
// determine the final connection info (if tunnel is configured)
val finalConnectionInfo = when {
connectionInfo.sshConfig != null -> {
val tunnelAddress = startSshTunnel(SshTunnelConfig(
connectionInfo.sshConfig,
Address(connectionInfo.host, connectionInfo.port)
))
tunnelAddress != null -> {
connectionInfo.copy(
host = tunnelAddress.host,
port = tunnelAddress.port
......@@ -92,66 +110,4 @@ class ConnectionAgent {
}
}
}
fun disconnect(connection: Connection): Observable<Unit> {
return Observable.create { subscriber ->
try {
if (!connection.isClosed) {
connection.close()
}
subscriber.onNext(Unit)
subscriber.onCompleted()
} catch (e: SQLException) {
subscriber.onError(e)
}
}
}
private fun startSshTunnel(sshTunnelConfig: SshTunnelConfig): Address {
val jSch = JSch()
val sshConfig = sshTunnelConfig.proxy
val serviceAddress = sshTunnelConfig.serviceAddress
val session = jSch.getSession(
sshConfig.authentication.username,
sshConfig.address.host,
sshConfig.address.port)
// FIXME: Ask user to accept host
session.setConfig(Properties().apply {
this["StrictHostKeyChecking"] = "no"
})
when (sshConfig.authentication) {
is PrivateKey ->
jSch.addIdentity(
"${sshConfig.authentication.username}@${sshConfig.address.host}:${sshConfig.address.port}",
sshConfig.authentication.privateKeyContents.toByteArray(),
null,
null
)
is BasicAuth ->
session.setPassword(sshConfig.authentication.password)
}
session.connect()
val boundPort = session.setPortForwardingL(
LOCALHOST,
unusedPort,
serviceAddress.host,
serviceAddress.port)
return Address(LOCALHOST, boundPort)
}
private val unusedPort: Int
get() = ServerSocket(0).let {
val port = it.localPort
it.close()
port
}
companion object {
private const val LOCALHOST = "127.0.0.1"
}
}
package app.devlife.connect2sql.connection
import app.devlife.connect2sql.db.model.connection.Address
import app.devlife.connect2sql.db.model.connection.BasicAuth
import app.devlife.connect2sql.db.model.connection.PrivateKey
import app.devlife.connect2sql.db.model.connection.SshTunnelConfig
import com.jcraft.jsch.HostKey
import com.jcraft.jsch.JSch
import com.jcraft.jsch.JSchException
import com.jcraft.jsch.Session
import rx.Observable
import java.net.ServerSocket
import java.util.concurrent.ConcurrentHashMap
class SshTunnelAgent(private val jSch: JSch) {
private val activeTunnels = ConcurrentHashMap<SshTunnelConfig, Pair<Session, Address>>()
fun startSshTunnel(sshTunnelConfig: SshTunnelConfig): Observable<Pair<Session, Address>> {
return Observable.create { subscriber ->
try {
if (activeTunnels[sshTunnelConfig] == null || activeTunnels[sshTunnelConfig]!!.first.isConnected) {
val sshConfig = sshTunnelConfig.proxy
val serviceAddress = sshTunnelConfig.serviceAddress
val session = jSch.getSession(
sshConfig.authentication.username,
sshConfig.address.host,
sshConfig.address.port)
when (sshConfig.authentication) {
is PrivateKey -> jSch.addIdentity(
"${sshConfig.authentication.username}@${sshConfig.address.host}:${sshConfig.address.port}",
sshConfig.authentication.privateKeyContents.toByteArray(),
null,
null
)
is BasicAuth -> session.setPassword(sshConfig.authentication.password)
}
try {
session.connect()
} catch (e: JSchException) {
throw when {
e.message?.startsWith("UnknownHostKey") == true -> {
UnknownHostException(
session.hostKey)
}
else -> e
}
}
val boundPort = session.setPortForwardingL(
LOCALHOST,
unusedPort,
serviceAddress.host,
serviceAddress.port)
activeTunnels[sshTunnelConfig] = Pair(session, Address(LOCALHOST, boundPort))
}
subscriber.onNext(activeTunnels[sshTunnelConfig])
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
subscriber.onError(e)
}
}
}
private val unusedPort: Int
get() = ServerSocket(0).let {
val port = it.localPort
it.close()
port
}
data class UnknownHostException(val hostKey: HostKey) :
Exception("Unknown host ${hostKey.host}!")
companion object {
private const val LOCALHOST = "127.0.0.1"
}
}
\ No newline at end of file
......@@ -2,42 +2,51 @@ package app.devlife.connect2sql.di;
import javax.inject.Singleton;
import app.devlife.connect2sql.ui.connection.ConnectionInfoEditorActivity;
import app.devlife.connect2sql.ui.history.QueryHistoryActivity;
import app.devlife.connect2sql.ui.lock.SetLockActivity;
import app.devlife.connect2sql.ui.lock.UnlockActivity;
import app.devlife.connect2sql.ui.query.QueryActivity;
import app.devlife.connect2sql.ui.results.ResultsActivity;
import dagger.Component;
import app.devlife.connect2sql.Connect2SqlApplication;
import app.devlife.connect2sql.activity.DashboardActivity;
import app.devlife.connect2sql.activity.LaunchActivity;
import app.devlife.connect2sql.ui.connection.ConnectionInfoEditorActivity;
import app.devlife.connect2sql.ui.history.QueryHistoryActivity;
import app.devlife.connect2sql.ui.hostkeys.HostKeysActivity;
import app.devlife.connect2sql.ui.lock.SetLockActivity;
import app.devlife.connect2sql.ui.lock.UnlockActivity;
import app.devlife.connect2sql.ui.query.QueryActivity;
import app.devlife.connect2sql.ui.results.ResultsActivity;
import app.devlife.connect2sql.ui.savedqueries.SavedQueriesActivity;
import dagger.Component;
/**
*
*/
@Singleton
@Component(modules = {AnalyticsModule.class, ApplicationModule.class, ConnectionModule.class, DatabaseModule.class, PreferencesModule.class, SecurityModule.class})
@Component(modules = {
AnalyticsModule.class,
ApplicationModule.class,
ConnectionModule.class,
DatabaseModule.class,
PreferencesModule.class,
SecurityModule.class
})
public interface ApplicationComponent {
// application
void inject(Connect2SqlApplication application);
// activities
void inject(LaunchActivity activity);
void inject(DashboardActivity activity);
void inject(ConnectionInfoEditorActivity activity);
void inject(DashboardActivity activity);
void inject(HostKeysActivity activity);
void inject(LaunchActivity activity);
void inject(QueryActivity activity);
void inject(QueryHistoryActivity activity);
void inject(ResultsActivity activity);
void inject(UnlockActivity activity);
void inject(SetLockActivity activity);
void inject(SavedQueriesActivity activity);
void inject(UnlockActivity activity);
}
package app.devlife.connect2sql.di;
import javax.inject.Singleton;
import dagger.Module;
import dagger.Provides;
import app.devlife.connect2sql.connection.ConnectionAgent;
import app.devlife.connect2sql.sql.driver.agent.DriverAgent;
/**
*
*/
@Module
public class ConnectionModule {
@Provides
@Singleton
ConnectionAgent provideConnectionAgent() {
return new ConnectionAgent();
}
}
package app.devlife.connect2sql.di
import android.content.Context
import app.devlife.connect2sql.connection.ConnectionAgent
import app.devlife.connect2sql.connection.SshTunnelAgent
import com.jcraft.jsch.JSch
import dagger.Module
import dagger.Provides
import java.io.File
import javax.inject.Singleton
@Module
class ConnectionModule(val context: Context) {
@Provides
@Singleton
internal fun provideConnectionAgent(sshTunnelAgent: SshTunnelAgent): ConnectionAgent {
return ConnectionAgent(sshTunnelAgent)
}
@Provides
@Singleton
internal fun provideSshTunnelAgent(jSch: JSch): SshTunnelAgent {
return SshTunnelAgent(jSch)
}
@Provides
@Singleton
internal fun provideJsch(): JSch {
val jSch = JSch()
File("${context.filesDir.absolutePath}/known_hosts")
.also { it.createNewFile() }
.also { jSch.setKnownHosts(it.absolutePath) }
return jSch
}
}
......@@ -14,12 +14,10 @@ import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import android.widget.TextView
import com.gitlab.connect2sql.R
import com.mobsandgeeks.saripaar.Rule
import com.mobsandgeeks.saripaar.Validator
import app.devlife.connect2sql.ApplicationUtils
import app.devlife.connect2sql.activity.BaseActivity
import app.devlife.connect2sql.connection.ConnectionAgent
import app.devlife.connect2sql.connection.SshTunnelAgent
import app.devlife.connect2sql.db.model.connection.ConnectionInfo
import app.devlife.connect2sql.db.repo.ConnectionInfoRepository
import app.devlife.connect2sql.log.EzLogger
......@@ -33,6 +31,10 @@ import app.devlife.connect2sql.ui.widget.NotifyingScrollView
import app.devlife.connect2sql.ui.widget.Toast
import app.devlife.connect2sql.ui.widget.dialog.ProgressDialog
import app.devlife.connect2sql.util.rx.ActivityAwareSubscriber
import com.gitlab.connect2sql.R
import com.jcraft.jsch.JSch
import com.mobsandgeeks.saripaar.Rule