Skip to content

Control automatic port forwarding with a devcontainer.json file #49

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Feb 27, 2025
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

## [Unreleased]

### Added

- Allow specifying port forwarding behavior in devcontainer.json

## [0.0.4] - 2025-02-21

### Added
Expand Down
7 changes: 7 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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")
Copy link
Collaborator

@fioan89 fioan89 Feb 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for changing this.

Since IntelliJ provides it at runtime as well this should be compileOnly. Another important thing is that here we need to declare the version that is packaged with minimum supported IntelliJ.
We support IntelliJ 2022.3 which uses kotlinx.serialization:1.4.1 so that's the minimum version we should use, and it should only be changed when the minimum supported version of IntelliJ changes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's unfortunate because this version is too old to support allowComments, and devcontainer.json files often include comments (since the reference implementation and VS Code allow them). It's not an issue in my particular case because I'll be stripping comments before injecting this file, but for maximum compatibility it seems like supporting comments would be a good thing.

Could we depend on this newer version and add a comment that the dependency can be changed to compileOnly once the minimum supported IDE version provides kotlinx.serialization >= 1.7.0? It looks like that minimum IDE version will be idea/251.14649.49 (which includes JetBrains/intellij-community@548a3c9).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as far as I know it should be possible to shadow the dependencies but please test it. As a rule of thumb is best not to shadow intellij dependencies to avoid any classloader/memory leak issue.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some context: any jars packaged with the plugin are not exposed to the rest of the IDE or other plugins, the plugin has its own classloader, so it's a good way to make plugin stable in its sandbox

So if we need this serialization library and there's a compatibility issue, it's better to bundle it.

Copy link
Contributor Author

@aaronlehmann aaronlehmann Feb 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added the comment about switching to compileOnly in the future. I've been testing with IntelliJ 242.23339.11 and it seems to work fine, but just tried with EAP 251.22821.72 and it works with that version as well.

Let me know if you think it's better or safer to downgrade the dependency and remove allowComments. I'm also fine with that approach.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the plugin has its own classloader, so it's a good way to make plugin stable in its sandbox

Correct , and intellij platform sdk also allows the plugins to reference classes provided by other plugins in which the classloader for those plugin dependencies will be used. Not really the case here, so I agree with your statement. It should be safe to pack the serialization library.

Let me know if you think it's better or safer to downgrade the dependency and remove allowComments

I agree with @kirillk there is no need for a downgrade for now.

}

// Set the JVM language level used to build the project.
Expand Down
58 changes: 58 additions & 0 deletions src/main/kotlin/com/coder/jetbrains/matcher/PortMatcher.kt
Original file line number Diff line number Diff line change
@@ -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() } -> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non Blocking Nit/Question: Should we validate the port is between 0 and 65535 for the SinglePort and PortRange parsing?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this validation

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" }
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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<DevContainerConfig>(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<GlobalPortForwardingManager>()
Expand Down
24 changes: 24 additions & 0 deletions src/main/kotlin/com/coder/jetbrains/services/DevContainerConfig.kt
Original file line number Diff line number Diff line change
@@ -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<String, PortAttributes> = 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 = ""
)
Original file line number Diff line number Diff line change
@@ -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")
}
}
71 changes: 71 additions & 0 deletions src/test/kotlin/com/coder/jetbrains/matcher/PortMatcherTest.kt
Original file line number Diff line number Diff line change
@@ -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))
}
}