diff --git a/CHANGELOG.md b/CHANGELOG.md index 482b7fd..2790a67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ## [Unreleased] +### Added + +- Allow specifying port forwarding behavior in devcontainer.json + ## [0.0.4] - 2025-02-21 ### Added diff --git a/build.gradle.kts b/build.gradle.kts index 7cc0417..e9ad197 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,6 +11,7 @@ plugins { alias(libs.plugins.changelog) // Gradle Changelog Plugin alias(libs.plugins.qodana) // Gradle Qodana Plugin alias(libs.plugins.kover) // Gradle Kover Plugin + kotlin("plugin.serialization") version "2.1.10" } group = properties("pluginGroup").get() @@ -24,6 +25,12 @@ repositories { // Dependencies are managed with Gradle version catalog - read more: https://docs.gradle.org/current/userguide/platforms.html#sub:version-catalog dependencies { // implementation(libs.annotations) + // We need kotlinx-serialization-json >= 1.7.0 to support allowComments, as + // devcontainer.json files often have comments and therefore use + // nonstandard JSON syntax. This dependency should be marked compileOnly + // once the minimum IDE version we support (pluginSinceBuild in + // gradle.properties) is at least 251, which includes version 1.7.2. + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0") } // Set the JVM language level used to build the project. diff --git a/src/main/kotlin/com/coder/jetbrains/matcher/PortMatcher.kt b/src/main/kotlin/com/coder/jetbrains/matcher/PortMatcher.kt new file mode 100644 index 0000000..99ffe7c --- /dev/null +++ b/src/main/kotlin/com/coder/jetbrains/matcher/PortMatcher.kt @@ -0,0 +1,58 @@ +package com.coder.jetbrains.matcher + +class PortMatcher(private val rule: String) { + private sealed class MatchRule { + data class SinglePort(val port: Int) : MatchRule() + data class PortRange(val start: Int, val end: Int) : MatchRule() + data class RegexPort(val pattern: Regex) : MatchRule() + } + + private val parsedRule: MatchRule = parseRule(rule) + + fun matches(port: Int): Boolean { + return when (parsedRule) { + is MatchRule.SinglePort -> port == parsedRule.port + is MatchRule.PortRange -> port in parsedRule.start..parsedRule.end + is MatchRule.RegexPort -> parsedRule.pattern.matches(port.toString()) + } + } + + private fun parseRule(rule: String): MatchRule { + // Remove host part if present (e.g., "localhost:3000" -> "3000") + val portPart = rule.substringAfter(':').takeIf { ':' in rule } ?: rule + + return when { + // Try parsing as single port + portPart.all { it.isDigit() } -> { + val port = portPart.toInt() + validatePort(port) + MatchRule.SinglePort(port) + } + // Try parsing as port range (e.g., "40000-55000") + portPart.matches("^\\d+\\s*-\\s*\\d+$".toRegex()) -> { + val (start, end) = portPart.split('-') + .map { it.trim().toInt() } + validatePortRange(start, end) + MatchRule.PortRange(start, end) + } + // If not a single port or range, treat as regex + else -> { + try { + MatchRule.RegexPort(portPart.toRegex()) + } catch (e: Exception) { + throw IllegalArgumentException("Invalid port rule format: $rule") + } + } + } + } + + private fun validatePort(port: Int) { + require(port in 0..65535) { "Port number must be between 0 and 65535, got: $port" } + } + + private fun validatePortRange(start: Int, end: Int) { + validatePort(start) + validatePort(end) + require(start <= end) { "Invalid port range: start must be less than or equal to end" } + } +} diff --git a/src/main/kotlin/com/coder/jetbrains/services/CoderPortForwardService.kt b/src/main/kotlin/com/coder/jetbrains/services/CoderPortForwardService.kt index 84ed196..8a1e376 100644 --- a/src/main/kotlin/com/coder/jetbrains/services/CoderPortForwardService.kt +++ b/src/main/kotlin/com/coder/jetbrains/services/CoderPortForwardService.kt @@ -1,5 +1,6 @@ package com.coder.jetbrains.services +import com.coder.jetbrains.matcher.PortMatcher import com.coder.jetbrains.scanner.listeningPorts import com.intellij.openapi.Disposable import com.intellij.openapi.components.serviceOrNull @@ -15,6 +16,10 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.io.File +import kotlinx.serialization.json.Json +import kotlinx.serialization.ExperimentalSerializationApi +import com.coder.jetbrains.settings.CoderBackendSettings /** * Automatically forward ports that have something listening on them by scanning @@ -29,15 +34,20 @@ class CoderPortForwardService( private val logger = thisLogger() private var poller: Job? = null - // TODO: Make customizable. + private data class PortRule( + val matcher: PortMatcher, + val autoForward: Boolean + ) // TODO: I also see 63342, 57675, and 56830 for JetBrains. Are they static? // TODO: If you have multiple IDEs, you will see 5991. 5992, etc. Can we // detect all of these and exclude them? - private val ignoreList = setOf( - 22, // SSH - 5990, // JetBrains Gateway port. + private val rules = mutableListOf( + PortRule(PortMatcher("22"), false), + PortRule(PortMatcher("5990"), false), ) + private var defaultForward = true + init { logger.info("initializing port forwarding service") start() @@ -47,13 +57,58 @@ class CoderPortForwardService( poller?.cancel() } + companion object { + @OptIn(ExperimentalSerializationApi::class) + private val json = Json { + ignoreUnknownKeys = true + allowTrailingComma = true + allowComments = true + } + } + private fun start() { + val devcontainerFile = CoderBackendSettings.getDevcontainerFile() + if (devcontainerFile.exists()) { + try { + val jsonContent = devcontainerFile.readText() + val config = json.decodeFromString(jsonContent) + + // Process port attributes + config.portsAttributes.forEach { (spec, attrs) -> + when (attrs.onAutoForward) { + "ignore" -> { + logger.info("found ignored port specification $spec in devcontainer.json") + rules.add(0, PortRule(PortMatcher(spec), false)) + } + "" -> {} + else -> { + logger.info("found auto-forward port specification $spec in devcontainer.json") + rules.add(0, PortRule(PortMatcher(spec), true)) + } + } + } + + // Process other ports attributes + config.otherPortsAttributes?.let { + if (it.onAutoForward == "ignore") { + logger.info("found ignored setting for otherPortsAttributes in devcontainer.json") + defaultForward = false + } + } + } catch (e: Exception) { + logger.warn("Failed to parse devcontainer.json", e) + } + } + logger.info("starting port scanner") poller = cs.launch { while (isActive) { logger.debug("scanning for ports") val listeningPorts = withContext(Dispatchers.IO) { - listeningPorts().subtract(ignoreList) + listeningPorts().filter { port -> + val matchedRule = rules.firstOrNull { it.matcher.matches(port) } + matchedRule?.autoForward ?: defaultForward + }.toSet() } application.invokeLater { val manager = serviceOrNull() diff --git a/src/main/kotlin/com/coder/jetbrains/services/DevContainerConfig.kt b/src/main/kotlin/com/coder/jetbrains/services/DevContainerConfig.kt new file mode 100644 index 0000000..a187572 --- /dev/null +++ b/src/main/kotlin/com/coder/jetbrains/services/DevContainerConfig.kt @@ -0,0 +1,24 @@ +package com.coder.jetbrains.services + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DevContainerConfig( + @SerialName("portsAttributes") + val portsAttributes: Map = mapOf(), + @SerialName("otherPortsAttributes") + val otherPortsAttributes: OtherPortsAttributes? = null +) + +@Serializable +data class PortAttributes( + @SerialName("onAutoForward") + val onAutoForward: String = "" +) + +@Serializable +data class OtherPortsAttributes( + @SerialName("onAutoForward") + val onAutoForward: String = "" +) diff --git a/src/main/kotlin/com/coder/jetbrains/settings/CoderBackendSettings.kt b/src/main/kotlin/com/coder/jetbrains/settings/CoderBackendSettings.kt new file mode 100644 index 0000000..dd8e015 --- /dev/null +++ b/src/main/kotlin/com/coder/jetbrains/settings/CoderBackendSettings.kt @@ -0,0 +1,10 @@ +package com.coder.jetbrains.settings + +import java.io.File + +object CoderBackendSettings { + fun getDevcontainerFile(): File { + // TODO: make path configurable? + return File(System.getProperty("user.home"), ".cache/JetBrains/devcontainer.json") + } +} diff --git a/src/test/kotlin/com/coder/jetbrains/matcher/PortMatcherTest.kt b/src/test/kotlin/com/coder/jetbrains/matcher/PortMatcherTest.kt new file mode 100644 index 0000000..6134c03 --- /dev/null +++ b/src/test/kotlin/com/coder/jetbrains/matcher/PortMatcherTest.kt @@ -0,0 +1,71 @@ +package com.coder.jetbrains.matcher + +import org.junit.Test +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.assertThrows + +class PortMatcherTest { + + @Test + fun `test single port`() { + val matcher = PortMatcher("3000") + assertTrue(matcher.matches(3000)) + assertFalse(matcher.matches(2999)) + assertFalse(matcher.matches(3001)) + } + + @Test + fun `test host colon port`() { + val matcher = PortMatcher("localhost:3000") + assertTrue(matcher.matches(3000)) + assertFalse(matcher.matches(3001)) + } + + @Test + fun `test port range`() { + val matcher = PortMatcher("40000-55000") + assertFalse(matcher.matches(39999)) + assertTrue(matcher.matches(40000)) + assertTrue(matcher.matches(50000)) + assertTrue(matcher.matches(55000)) + assertFalse(matcher.matches(55001)) + } + + @Test + fun `test port range with whitespace`() { + val matcher = PortMatcher("20021 - 20024") + assertFalse(matcher.matches(20000)) + assertTrue(matcher.matches(20022)) + } + + @Test + fun `test regex`() { + val matcher = PortMatcher("800[1-9]") + assertFalse(matcher.matches(8000)) + assertTrue(matcher.matches(8001)) + assertTrue(matcher.matches(8005)) + assertTrue(matcher.matches(8009)) + assertFalse(matcher.matches(8010)) + } + + @Test + fun `test invalid port numbers`() { + assertThrows(IllegalArgumentException::class.java) { PortMatcher("65536") } + assertThrows(IllegalArgumentException::class.java) { PortMatcher("0-65536") } + assertThrows(IllegalArgumentException::class.java) { PortMatcher("70000") } + } + + @Test + fun `test edge case port numbers`() { + // These should work + PortMatcher("0") + PortMatcher("65535") + PortMatcher("0-65535") + + // These combinations should work + val matcher = PortMatcher("0-65535") + assertTrue(matcher.matches(0)) + assertTrue(matcher.matches(65535)) + } +}