diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 8f265fe..62fe4bb 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -204,4 +204,8 @@ class CoderRemoteEnvironment( * Companion to equals, for sets. */ override fun hashCode(): Int = id.hashCode() + + override fun toString(): String { + return "CoderRemoteEnvironment(name='$name')" + } } diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index fd0ad57..c9acc57 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -20,13 +20,16 @@ import com.jetbrains.toolbox.api.remoteDev.RemoteProvider import com.jetbrains.toolbox.api.remoteDev.RemoteProviderEnvironment import com.jetbrains.toolbox.api.ui.actions.ActionDescription import com.jetbrains.toolbox.api.ui.components.UiPage +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job -import kotlinx.coroutines.delay +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.selects.onTimeout +import kotlinx.coroutines.selects.select import okhttp3.OkHttpClient import java.net.URI import java.net.URL @@ -35,18 +38,20 @@ import kotlin.time.Duration.Companion.seconds import com.jetbrains.toolbox.api.ui.components.AccountDropdownField as DropDownMenu import com.jetbrains.toolbox.api.ui.components.AccountDropdownField as dropDownFactory +@OptIn(ExperimentalCoroutinesApi::class) class CoderRemoteProvider( private val context: CoderToolboxContext, private val httpClient: OkHttpClient, ) : RemoteProvider("Coder") { // Current polling job. private var pollJob: Job? = null - private var lastEnvironments: Set<CoderRemoteEnvironment>? = null + private val lastEnvironments = mutableSetOf<CoderRemoteEnvironment>() - private val cSettings = context.settingsStore.readOnly() + private val settings = context.settingsStore.readOnly() // Create our services from the Toolbox ones. - private val settingsPage: CoderSettingsPage = CoderSettingsPage(context) + private val triggerSshConfig = Channel<Boolean>(Channel.CONFLATED) + private val settingsPage: CoderSettingsPage = CoderSettingsPage(context, triggerSshConfig) private val dialogUi = DialogUi(context) // The REST client, if we are signed in @@ -92,7 +97,7 @@ class CoderRemoteProvider( }?.map { agent -> // If we have an environment already, update that. val env = CoderRemoteEnvironment(context, client, ws, agent) - lastEnvironments?.firstOrNull { it == env }?.let { + lastEnvironments.firstOrNull { it == env }?.let { it.update(ws, agent) it } ?: env @@ -107,9 +112,7 @@ class CoderRemoteProvider( // Reconfigure if a new environment is found. // TODO@JB: Should we use the add/remove listeners instead? - val newEnvironments = lastEnvironments - ?.let { resolvedEnvironments.subtract(it) } - ?: resolvedEnvironments + val newEnvironments = resolvedEnvironments.subtract(lastEnvironments) if (newEnvironments.isNotEmpty()) { context.logger.info("Found new environment(s), reconfiguring CLI: $newEnvironments") cli.configSsh(newEnvironments.map { it.name }.toSet()) @@ -124,8 +127,10 @@ class CoderRemoteProvider( true } } - - lastEnvironments = resolvedEnvironments + lastEnvironments.apply { + clear() + addAll(resolvedEnvironments) + } } catch (_: CancellationException) { context.logger.debug("${client.url} polling loop canceled") break @@ -136,7 +141,17 @@ class CoderRemoteProvider( break } // TODO: Listening on a web socket might be better? - delay(5.seconds) + select<Unit> { + onTimeout(5.seconds) { + context.logger.trace("workspace poller waked up by the 5 seconds timeout") + } + triggerSshConfig.onReceive { shouldTrigger -> + if (shouldTrigger) { + context.logger.trace("workspace poller waked up because it should reconfigure the ssh configurations") + cli.configSsh(lastEnvironments.map { it.name }.toSet()) + } + } + } } } @@ -178,7 +193,7 @@ class CoderRemoteProvider( override fun close() { pollJob?.cancel() client?.close() - lastEnvironments = null + lastEnvironments.clear() environments.value = LoadableState.Value(emptyList()) isInitialized.update { false } } @@ -270,7 +285,7 @@ class CoderRemoteProvider( var autologinEx: Exception? = null context.secrets.lastToken.let { lastToken -> context.secrets.lastDeploymentURL.let { lastDeploymentURL -> - if (autologin && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !cSettings.requireTokenAuth)) { + if (autologin && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) { try { return createConnectPage(URL(lastDeploymentURL), lastToken) } catch (ex: Exception) { @@ -342,7 +357,7 @@ class CoderRemoteProvider( if (it.isNotBlank() && context.secrets.lastDeploymentURL == deploymentURL.toString()) { it to SettingSource.LAST_USED } else { - cSettings.token(deploymentURL) + settings.token(deploymentURL) } } diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt index d112c18..d97caf8 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt @@ -6,7 +6,7 @@ import com.coder.toolbox.cli.ex.ResponseException import com.coder.toolbox.cli.ex.SSHConfigFormatException import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent -import com.coder.toolbox.settings.CoderSettings +import com.coder.toolbox.settings.ReadOnlyCoderSettings import com.coder.toolbox.util.CoderHostnameVerifier import com.coder.toolbox.util.InvalidVersionException import com.coder.toolbox.util.OS @@ -125,7 +125,7 @@ class CoderCLIManager( private val deploymentURL: URL, private val logger: Logger, // Plugin configuration. - private val settings: CoderSettings, + private val settings: ReadOnlyCoderSettings, // If the binary directory is not writable, this can be used to force the // manager to download to the data directory instead. forceDownloadToData: Boolean = false, @@ -267,21 +267,21 @@ class CoderCLIManager( "--url", escape(deploymentURL.toString()), if (!settings.headerCommand.isNullOrBlank()) "--header-command" else null, - if (!settings.headerCommand.isNullOrBlank()) escapeSubcommand(settings.headerCommand) else null, + if (!settings.headerCommand.isNullOrBlank()) escapeSubcommand(settings.headerCommand!!) else null, "ssh", "--stdio", if (settings.disableAutostart && feats.disableAutostart) "--disable-autostart" else null, ) val proxyArgs = baseArgs + listOfNotNull( if (!settings.sshLogDirectory.isNullOrBlank()) "--log-dir" else null, - if (!settings.sshLogDirectory.isNullOrBlank()) escape(settings.sshLogDirectory) else null, + if (!settings.sshLogDirectory.isNullOrBlank()) escape(settings.sshLogDirectory!!) else null, if (feats.reportWorkspaceUsage) "--usage-app=jetbrains" else null, ) val backgroundProxyArgs = baseArgs + listOfNotNull(if (feats.reportWorkspaceUsage) "--usage-app=disable" else null) val extraConfig = if (!settings.sshConfigOptions.isNullOrBlank()) { - "\n" + settings.sshConfigOptions.prependIndent(" ") + "\n" + settings.sshConfigOptions!!.prependIndent(" ") } else { "" } diff --git a/src/main/kotlin/com/coder/toolbox/settings/CoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/CoderSettings.kt deleted file mode 100644 index 867159c..0000000 --- a/src/main/kotlin/com/coder/toolbox/settings/CoderSettings.kt +++ /dev/null @@ -1,244 +0,0 @@ -package com.coder.toolbox.settings - -import com.coder.toolbox.util.expand -import com.coder.toolbox.util.safeHost -import com.coder.toolbox.util.toURL -import com.coder.toolbox.util.withPath -import java.net.URL -import java.nio.file.Files -import java.nio.file.Path - -data class CoderSettings( - val defaultURL: String?, - - /** - * Used to download the Coder CLI which is necessary to proxy SSH - * connections. The If-None-Match header will be set to the SHA1 of the CLI - * and can be used for caching. Absolute URLs will be used as-is; otherwise - * this value will be resolved against the deployment domain. Defaults to - * the plugin's data directory. - */ - val binarySource: String?, - - /** - * Directories are created here that store the CLI for each domain to which - * the plugin connects. Defaults to the data directory. - */ - val binaryDirectory: String?, - - val defaultCliBinaryNameByOsAndArch: String, - - /** - * Configurable CLI binary name with extension, dependent on OS and arch - */ - val binaryName: String, - - /** - * Where to save plugin data like the Coder binary (if not configured with - * binaryDirectory) and the deployment URL and session token. - */ - val dataDirectory: String?, - - /** - * Coder plugin's global data directory. - */ - val globalDataDirectory: String, - - /** - * Coder plugin's global config dir - */ - val globalConfigDir: String, - - /** - * Whether to allow the plugin to download the CLI if the current one is out - * of date or does not exist. - */ - val enableDownloads: Boolean, - - /** - * Whether to allow the plugin to fall back to the data directory when the - * CLI directory is not writable. - */ - val enableBinaryDirectoryFallback: Boolean, - - /** - * An external command that outputs additional HTTP headers added to all - * requests. The command must output each header as `key=value` on its own - * line. The following environment variables will be available to the - * process: CODER_URL. - */ - val headerCommand: String?, - - /** - * Optional TLS settings - */ - val tls: CTLSSettings, - - /** - * Whether login should be done with a token - */ - val requireTokenAuth: Boolean = tls.certPath.isNullOrBlank() || tls.keyPath.isNullOrBlank(), - - /** - * Whether to add --disable-autostart to the proxy command. This works - * around issues on macOS where it periodically wakes and Gateway - * reconnects, keeping the workspace constantly up. - */ - val disableAutostart: Boolean, - - val isSshWildcardConfigEnabled: Boolean, - - /** - * The location of the SSH config. Defaults to ~/.ssh/config. - */ - val sshConfigPath: String, - - /** - * Value for --log-dir. - */ - val sshLogDirectory: String?, - - /** - * Extra SSH config options - */ - val sshConfigOptions: String?, -) { - - /** - * Given a deployment URL, try to find a token for it if required. - */ - fun token(deploymentURL: URL): Pair<String, SettingSource>? { - // No need to bother if we do not need token auth anyway. - if (!requireTokenAuth) { - return null - } - // Try the deployment's config directory. This could exist if someone - // has entered a URL that they are not currently connected to, but have - // connected to in the past. - val (_, deploymentToken) = readConfig(dataDir(deploymentURL).resolve("config")) - if (!deploymentToken.isNullOrBlank()) { - return deploymentToken to SettingSource.DEPLOYMENT_CONFIG - } - // Try the global config directory, in case they previously set up the - // CLI with this URL. - val (configUrl, configToken) = readConfig(Path.of(globalConfigDir)) - if (configUrl == deploymentURL.toString() && !configToken.isNullOrBlank()) { - return configToken to SettingSource.CONFIG - } - return null - } - - /** - * Where the specified deployment should put its data. - */ - fun dataDir(url: URL): Path { - dataDirectory.let { - val dir = - if (it.isNullOrBlank()) { - Path.of(globalDataDirectory) - } else { - Path.of(expand(it)) - } - return withHost(dir, url).toAbsolutePath() - } - } - - /** - * From where the specified deployment should download the binary. - */ - fun binSource(url: URL): URL { - binarySource.let { - return if (it.isNullOrBlank()) { - url.withPath("/bin/$defaultCliBinaryNameByOsAndArch") - } else { - try { - it.toURL() - } catch (_: Exception) { - url.withPath(it) // Assume a relative path. - } - } - } - } - - /** - * To where the specified deployment should download the binary. - */ - fun binPath( - url: URL, - forceDownloadToData: Boolean = false, - ): Path { - binaryDirectory.let { - val dir = - if (forceDownloadToData || it.isNullOrBlank()) { - dataDir(url) - } else { - withHost(Path.of(expand(it)), url) - } - return dir.resolve(binaryName).toAbsolutePath() - } - } - - /** - * Return the URL and token from the config, if they exist. - */ - fun readConfig(dir: Path): Pair<String?, String?> { -// logger.info("Reading config from $dir") - return try { - Files.readString(dir.resolve("url")) - } catch (e: Exception) { - // SSH has not been configured yet, or using some other authorization mechanism. - null - } to - try { - Files.readString(dir.resolve("session")) - } catch (e: Exception) { - // SSH has not been configured yet, or using some other authorization mechanism. - null - } - } - - /** - * Append the host to the path. For example, foo/bar could become - * foo/bar/dev.coder.com-8080. - */ - private fun withHost( - path: Path, - url: URL, - ): Path { - val host = if (url.port > 0) "${url.safeHost()}-${url.port}" else url.safeHost() - return path.resolve(host) - } - -} - -/** - * Consolidated TLS settings. - */ -data class CTLSSettings( - /** - * Optionally set this to the path of a certificate to use for TLS - * connections. The certificate should be in X.509 PEM format. - */ - val certPath: String?, - - /** - * Optionally set this to the path of the private key that corresponds to - * the above cert path to use for TLS connections. The key should be in - * X.509 PEM format. - */ - val keyPath: String?, - - /** - * Optionally set this to the path of a file containing certificates for an - * alternate certificate authority used to verify TLS certs returned by the - * Coder service. The file should be in X.509 PEM format. - */ - val caPath: String?, - - /** - * Optionally set this to an alternate hostname used for verifying TLS - * connections. This is useful when the hostname used to connect to the - * Coder service does not match the hostname in the TLS certificate. - */ - val altHostname: String?, -) \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt new file mode 100644 index 0000000..25568d3 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -0,0 +1,174 @@ +package com.coder.toolbox.settings + +import java.net.URL +import java.nio.file.Path + +/** + * Read-only interface for accessing Coder settings + */ +interface ReadOnlyCoderSettings { + /** + * The default URL to show in the connection window. + */ + val defaultURL: String? + + /** + * Used to download the Coder CLI which is necessary to proxy SSH + * connections. The If-None-Match header will be set to the SHA1 of the CLI + * and can be used for caching. Absolute URLs will be used as-is; otherwise + * this value will be resolved against the deployment domain. Defaults to + * the plugin's data directory. + */ + val binarySource: String? + + /** + * Directories are created here that store the CLI for each domain to which + * the plugin connects. Defaults to the data directory. + */ + val binaryDirectory: String? + + /** + * Default CLI binary name based on OS and architecture + */ + val defaultCliBinaryNameByOsAndArch: String + + /** + * Configurable CLI binary name with extension, dependent on OS and arch + */ + val binaryName: String + + /** + * Where to save plugin data like the Coder binary (if not configured with + * binaryDirectory) and the deployment URL and session token. + */ + val dataDirectory: String? + + /** + * Coder plugin's global data directory. + */ + val globalDataDirectory: String + + /** + * Coder plugin's global config dir + */ + val globalConfigDir: String + + /** + * Whether to allow the plugin to download the CLI if the current one is out + * of date or does not exist. + */ + val enableDownloads: Boolean + + /** + * Whether to allow the plugin to fall back to the data directory when the + * CLI directory is not writable. + */ + val enableBinaryDirectoryFallback: Boolean + + /** + * An external command that outputs additional HTTP headers added to all + * requests. The command must output each header as `key=value` on its own + * line. The following environment variables will be available to the + * process: CODER_URL. + */ + val headerCommand: String? + + /** + * Optional TLS settings + */ + val tls: ReadOnlyTLSSettings + + /** + * Whether login should be done with a token + */ + val requireTokenAuth: Boolean + + /** + * Whether to add --disable-autostart to the proxy command. This works + * around issues on macOS where it periodically wakes and Gateway + * reconnects, keeping the workspace constantly up. + */ + val disableAutostart: Boolean + + /** + * Whether SSH wildcard config is enabled + */ + val isSshWildcardConfigEnabled: Boolean + + /** + * The location of the SSH config. Defaults to ~/.ssh/config. + */ + val sshConfigPath: String + + /** + * Value for --log-dir. + */ + val sshLogDirectory: String? + + /** + * Extra SSH config options + */ + val sshConfigOptions: String? + + /** + * The default URL to show in the connection window. + */ + fun defaultURL(): Pair<String, SettingSource>? + + /** + * Given a deployment URL, try to find a token for it if required. + */ + fun token(deploymentURL: URL): Pair<String, SettingSource>? + + /** + * Where the specified deployment should put its data. + */ + fun dataDir(url: URL): Path + + /** + * From where the specified deployment should download the binary. + */ + fun binSource(url: URL): URL + + /** + * To where the specified deployment should download the binary. + */ + fun binPath(url: URL, forceDownloadToData: Boolean = false): Path + + /** + * Return the URL and token from the config, if they exist. + */ + fun readConfig(dir: Path): Pair<String?, String?> +} + +/** + * Read-only interface for TLS settings + */ +interface ReadOnlyTLSSettings { + /** + * Optionally set this to the path of a certificate to use for TLS + * connections. The certificate should be in X.509 PEM format. + */ + val certPath: String? + + /** + * Optionally set this to the path of the private key that corresponds to + * the above cert path to use for TLS connections. The key should be in + * X.509 PEM format. + */ + val keyPath: String? + + /** + * Optionally set this to the path of a file containing certificates for an + * alternate certificate authority used to verify TLS certs returned by the + * Coder service. The file should be in X.509 PEM format. + */ + val caPath: String? + + /** + * Optionally set this to an alternate hostname used for verifying TLS + * connections. This is useful when the hostname used to connect to the + * Coder service does not match the hostname in the TLS certificate. + */ + val altHostname: String? +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index e5b96a1..92c08d0 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -1,62 +1,82 @@ package com.coder.toolbox.store -import com.coder.toolbox.settings.CTLSSettings -import com.coder.toolbox.settings.CoderSettings import com.coder.toolbox.settings.Environment +import com.coder.toolbox.settings.ReadOnlyCoderSettings +import com.coder.toolbox.settings.ReadOnlyTLSSettings import com.coder.toolbox.settings.SettingSource import com.coder.toolbox.util.Arch import com.coder.toolbox.util.OS +import com.coder.toolbox.util.expand import com.coder.toolbox.util.getArch import com.coder.toolbox.util.getOS +import com.coder.toolbox.util.safeHost +import com.coder.toolbox.util.toURL +import com.coder.toolbox.util.withPath import com.jetbrains.toolbox.api.core.PluginSettingsStore import com.jetbrains.toolbox.api.core.diagnostics.Logger +import java.net.URL +import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths + class CoderSettingsStore( private val store: PluginSettingsStore, private val env: Environment = Environment(), private val logger: Logger -) { - private var backingSettings = CoderSettings( - defaultURL = store[DEFAULT_URL], - binarySource = store[BINARY_SOURCE], - binaryDirectory = store[BINARY_DIRECTORY], - defaultCliBinaryNameByOsAndArch = getCoderCLIForOS(getOS(), getArch()), - binaryName = store[BINARY_NAME] ?: getCoderCLIForOS(getOS(), getArch()), - dataDirectory = store[DATA_DIRECTORY], - globalDataDirectory = getDefaultGlobalDataDir().normalize().toString(), - globalConfigDir = getDefaultGlobalConfigDir().normalize().toString(), - enableDownloads = store[ENABLE_DOWNLOADS]?.toBooleanStrictOrNull() ?: true, - enableBinaryDirectoryFallback = store[ENABLE_BINARY_DIR_FALLBACK]?.toBooleanStrictOrNull() ?: false, - headerCommand = store[HEADER_COMMAND], - tls = CTLSSettings( +) : ReadOnlyCoderSettings { + + // Internal TLS settings implementation + private class TLSSettings( + override val certPath: String?, + override val keyPath: String?, + override val caPath: String?, + override val altHostname: String? + ) : ReadOnlyTLSSettings + + // Properties implementation + override val defaultURL: String? get() = store[DEFAULT_URL] + override val binarySource: String? get() = store[BINARY_SOURCE] + override val binaryDirectory: String? get() = store[BINARY_DIRECTORY] + override val defaultCliBinaryNameByOsAndArch: String get() = getCoderCLIForOS(getOS(), getArch()) + override val binaryName: String get() = store[BINARY_NAME] ?: getCoderCLIForOS(getOS(), getArch()) + override val dataDirectory: String? get() = store[DATA_DIRECTORY] + override val globalDataDirectory: String get() = getDefaultGlobalDataDir().normalize().toString() + override val globalConfigDir: String get() = getDefaultGlobalConfigDir().normalize().toString() + override val enableDownloads: Boolean get() = store[ENABLE_DOWNLOADS]?.toBooleanStrictOrNull() ?: true + override val enableBinaryDirectoryFallback: Boolean + get() = store[ENABLE_BINARY_DIR_FALLBACK]?.toBooleanStrictOrNull() ?: false + override val headerCommand: String? get() = store[HEADER_COMMAND] + override val tls: ReadOnlyTLSSettings + get() = TLSSettings( certPath = store[TLS_CERT_PATH], keyPath = store[TLS_KEY_PATH], caPath = store[TLS_CA_PATH], altHostname = store[TLS_ALTERNATE_HOSTNAME] - ), - disableAutostart = store[DISABLE_AUTOSTART]?.toBooleanStrictOrNull() ?: (getOS() == OS.MAC), - isSshWildcardConfigEnabled = store[ENABLE_SSH_WILDCARD_CONFIG]?.toBooleanStrictOrNull() ?: true, - sshConfigPath = store[SSH_CONFIG_PATH].takeUnless { it.isNullOrEmpty() } - ?: Path.of(System.getProperty("user.home")).resolve(".ssh/config").normalize().toString(), - sshLogDirectory = store[SSH_LOG_DIR], - sshConfigOptions = store[SSH_CONFIG_OPTIONS].takeUnless { it.isNullOrEmpty() } ?: env.get( - CODER_SSH_CONFIG_OPTIONS ) - ) + override val requireTokenAuth: Boolean get() = tls.certPath.isNullOrBlank() || tls.keyPath.isNullOrBlank() + override val disableAutostart: Boolean + get() = store[DISABLE_AUTOSTART]?.toBooleanStrictOrNull() ?: (getOS() == OS.MAC) + override val isSshWildcardConfigEnabled: Boolean + get() = store[ENABLE_SSH_WILDCARD_CONFIG]?.toBooleanStrictOrNull() ?: true + override val sshConfigPath: String + get() = store[SSH_CONFIG_PATH].takeUnless { it.isNullOrEmpty() } + ?: Path.of(System.getProperty("user.home")).resolve(".ssh/config").normalize().toString() + override val sshLogDirectory: String? get() = store[SSH_LOG_DIR] + override val sshConfigOptions: String? + get() = store[SSH_CONFIG_OPTIONS].takeUnless { it.isNullOrEmpty() } ?: env.get(CODER_SSH_CONFIG_OPTIONS) /** * The default URL to show in the connection window. */ - fun defaultURL(): Pair<String, SettingSource>? { + override fun defaultURL(): Pair<String, SettingSource>? { val envURL = env.get(CODER_URL) - if (!backingSettings.defaultURL.isNullOrEmpty()) { - return backingSettings.defaultURL!! to SettingSource.SETTINGS + if (!defaultURL.isNullOrEmpty()) { + return defaultURL!! to SettingSource.SETTINGS } else if (envURL.isNotBlank()) { return envURL to SettingSource.ENVIRONMENT } else { - val (configUrl, _) = backingSettings.readConfig(Path.of(backingSettings.globalConfigDir)) + val (configUrl, _) = readConfig(Path.of(globalConfigDir)) if (!configUrl.isNullOrBlank()) { return configUrl to SettingSource.CONFIG } @@ -65,77 +85,154 @@ class CoderSettingsStore( } /** - * Read-only access to the settings + * Given a deployment URL, try to find a token for it if required. */ - fun readOnly(): CoderSettings = backingSettings + override fun token(deploymentURL: URL): Pair<String, SettingSource>? { + // No need to bother if we do not need token auth anyway. + if (!requireTokenAuth) { + return null + } + // Try the deployment's config directory. This could exist if someone + // has entered a URL that they are not currently connected to, but have + // connected to in the past. + val (_, deploymentToken) = readConfig(dataDir(deploymentURL).resolve("config")) + if (!deploymentToken.isNullOrBlank()) { + return deploymentToken to SettingSource.DEPLOYMENT_CONFIG + } + // Try the global config directory, in case they previously set up the + // CLI with this URL. + val (configUrl, configToken) = readConfig(Path.of(globalConfigDir)) + if (configUrl == deploymentURL.toString() && !configToken.isNullOrBlank()) { + return configToken to SettingSource.CONFIG + } + return null + } + /** + * Where the specified deployment should put its data. + */ + override fun dataDir(url: URL): Path { + dataDirectory.let { + val dir = + if (it.isNullOrBlank()) { + Path.of(globalDataDirectory) + } else { + Path.of(expand(it)) + } + return withHost(dir, url).toAbsolutePath() + } + } + + /** + * From where the specified deployment should download the binary. + */ + override fun binSource(url: URL): URL { + binarySource.let { + return if (it.isNullOrBlank()) { + url.withPath("/bin/$defaultCliBinaryNameByOsAndArch") + } else { + try { + it.toURL() + } catch (_: Exception) { + url.withPath(it) // Assume a relative path. + } + } + } + } + + /** + * To where the specified deployment should download the binary. + */ + override fun binPath( + url: URL, + forceDownloadToData: Boolean, + ): Path { + binaryDirectory.let { + val dir = + if (forceDownloadToData || it.isNullOrBlank()) { + dataDir(url) + } else { + withHost(Path.of(expand(it)), url) + } + return dir.resolve(binaryName).toAbsolutePath() + } + } + + /** + * Return the URL and token from the config, if they exist. + */ + override fun readConfig(dir: Path): Pair<String?, String?> { + return try { + Files.readString(dir.resolve("url")) + } catch (e: Exception) { + // SSH has not been configured yet, or using some other authorization mechanism. + null + } to + try { + Files.readString(dir.resolve("session")) + } catch (e: Exception) { + // SSH has not been configured yet, or using some other authorization mechanism. + null + } + } + + // a readonly cast + fun readOnly(): ReadOnlyCoderSettings = this + + // Write operations fun updateBinarySource(source: String) { - backingSettings = backingSettings.copy(binarySource = source) store[BINARY_SOURCE] = source } fun updateBinaryDirectory(dir: String) { - backingSettings = backingSettings.copy(binaryDirectory = dir) store[BINARY_DIRECTORY] = dir } fun updateDataDirectory(dir: String) { - backingSettings = backingSettings.copy(dataDirectory = dir) store[DATA_DIRECTORY] = dir } fun updateEnableDownloads(shouldEnableDownloads: Boolean) { - backingSettings = backingSettings.copy(enableDownloads = shouldEnableDownloads) store[ENABLE_DOWNLOADS] = shouldEnableDownloads.toString() } fun updateBinaryDirectoryFallback(shouldEnableBinDirFallback: Boolean) { - backingSettings = backingSettings.copy(enableBinaryDirectoryFallback = shouldEnableBinDirFallback) store[ENABLE_BINARY_DIR_FALLBACK] = shouldEnableBinDirFallback.toString() } fun updateHeaderCommand(cmd: String) { - backingSettings = backingSettings.copy(headerCommand = cmd) store[HEADER_COMMAND] = cmd } fun updateCertPath(path: String) { - backingSettings = backingSettings.copy(tls = backingSettings.tls.copy(certPath = path)) store[TLS_CERT_PATH] = path } fun updateKeyPath(path: String) { - backingSettings = backingSettings.copy(tls = backingSettings.tls.copy(keyPath = path)) store[TLS_KEY_PATH] = path } fun updateCAPath(path: String) { - backingSettings = backingSettings.copy(tls = backingSettings.tls.copy(caPath = path)) store[TLS_CA_PATH] = path } fun updateAltHostname(hostname: String) { - backingSettings = backingSettings.copy(tls = backingSettings.tls.copy(altHostname = hostname)) store[TLS_ALTERNATE_HOSTNAME] = hostname } fun updateDisableAutostart(shouldDisableAutostart: Boolean) { - backingSettings = backingSettings.copy(disableAutostart = shouldDisableAutostart) store[DISABLE_AUTOSTART] = shouldDisableAutostart.toString() } fun updateEnableSshWildcardConfig(enable: Boolean) { - backingSettings = backingSettings.copy(isSshWildcardConfigEnabled = enable) store[ENABLE_SSH_WILDCARD_CONFIG] = enable.toString() } fun updateSshLogDir(path: String) { - backingSettings = backingSettings.copy(sshLogDirectory = path) store[SSH_LOG_DIR] = path } fun updateSshConfigOptions(options: String) { - backingSettings = backingSettings.copy(sshConfigOptions = options) store[SSH_CONFIG_OPTIONS] = options } @@ -210,4 +307,16 @@ class CoderSettingsStore( } } } -} + + /** + * Append the host to the path. For example, foo/bar could become + * foo/bar/dev.coder.com-8080. + */ + private fun withHost( + path: Path, + url: URL, + ): Path { + val host = if (url.port > 0) "${url.safeHost()}-${url.port}" else url.safeHost() + return path.resolve(host) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/util/TLS.kt b/src/main/kotlin/com/coder/toolbox/util/TLS.kt index 17952df..dac816e 100644 --- a/src/main/kotlin/com/coder/toolbox/util/TLS.kt +++ b/src/main/kotlin/com/coder/toolbox/util/TLS.kt @@ -1,6 +1,6 @@ package com.coder.toolbox.util -import com.coder.toolbox.settings.CTLSSettings +import com.coder.toolbox.settings.ReadOnlyTLSSettings import okhttp3.internal.tls.OkHostnameVerifier import java.io.File import java.io.FileInputStream @@ -81,7 +81,7 @@ fun sslContextFromPEMs( return sslContext } -fun coderSocketFactory(settings: CTLSSettings): SSLSocketFactory { +fun coderSocketFactory(settings: ReadOnlyTLSSettings): SSLSocketFactory { val sslContext = sslContextFromPEMs(settings.certPath, settings.keyPath, settings.caPath) if (settings.altHostname.isNullOrBlank()) { return sslContext.socketFactory diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index d4ac2c8..ff86c42 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -6,8 +6,11 @@ import com.jetbrains.toolbox.api.ui.components.CheckboxField import com.jetbrains.toolbox.api.ui.components.TextField import com.jetbrains.toolbox.api.ui.components.TextType import com.jetbrains.toolbox.api.ui.components.UiField +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedSendChannelException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch /** * A page for modifying Coder settings. @@ -16,7 +19,8 @@ import kotlinx.coroutines.flow.StateFlow * TODO@JB: There is no scroll, and our settings do not fit. As a consequence, * I have not been able to test this page. */ -class CoderSettingsPage(context: CoderToolboxContext) : CoderPage(context, context.i18n.ptrl("Coder Settings"), false) { +class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel<Boolean>) : + CoderPage(context, context.i18n.ptrl("Coder Settings"), false) { private val settings = context.settingsStore.readOnly() // TODO: Copy over the descriptions, holding until I can test this page. @@ -87,7 +91,18 @@ class CoderSettingsPage(context: CoderToolboxContext) : CoderPage(context, conte context.settingsStore.updateCAPath(tlsCAPathField.textState.value) context.settingsStore.updateAltHostname(tlsAlternateHostnameField.textState.value) context.settingsStore.updateDisableAutostart(disableAutostartField.checkedState.value) + val oldIsSshWildcardConfigEnabled = settings.isSshWildcardConfigEnabled context.settingsStore.updateEnableSshWildcardConfig(enableSshWildCardConfig.checkedState.value) + + if (enableSshWildCardConfig.checkedState.value != oldIsSshWildcardConfigEnabled) { + context.cs.launch { + try { + triggerSshConfig.send(true) + context.logger.info("Wildcard settings have been modified from $oldIsSshWildcardConfigEnabled to ${!oldIsSshWildcardConfigEnabled}, ssh config is going to be regenerated...") + } catch (_: ClosedSendChannelException) { + } + } + } context.settingsStore.updateSshLogDir(sshLogDirField.textState.value) context.settingsStore.updateSshConfigOptions(sshExtraArgs.textState.value) } diff --git a/src/main/kotlin/com/coder/toolbox/views/EnvironmentView.kt b/src/main/kotlin/com/coder/toolbox/views/EnvironmentView.kt index 4f41eee..89ef3dd 100644 --- a/src/main/kotlin/com/coder/toolbox/views/EnvironmentView.kt +++ b/src/main/kotlin/com/coder/toolbox/views/EnvironmentView.kt @@ -3,7 +3,7 @@ package com.coder.toolbox.views import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent -import com.coder.toolbox.settings.CoderSettings +import com.coder.toolbox.settings.ReadOnlyCoderSettings import com.jetbrains.toolbox.api.remoteDev.environments.SshEnvironmentContentsView import com.jetbrains.toolbox.api.remoteDev.ssh.SshConnectionInfo import java.net.URL @@ -17,7 +17,7 @@ import java.net.URL * SSH must be configured before this will work. */ class EnvironmentView( - private val settings: CoderSettings, + private val settings: ReadOnlyCoderSettings, private val url: URL, private val workspace: Workspace, private val agent: WorkspaceAgent,