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
Next Next commit
Control automatic port forwarding with a devcontainer.json file
devcontainer.json has a system for specifying default behavior for
forwarding ports, and also behavior for specific ports and ranges of
ports. Its schema is essentially identical to the settings VS Code uses
to control port forwarding, so supporting this format for port settings
keeps things consistent between VS Code and JetBrains.

See https://containers.dev/implementors/json_reference/ for the spec.
As an example, this will turn off automatic port forwarding except for
ports 7123 and 8100-8150:

    {
      "otherPortsAttributes": {
        "onAutoForward": "ignore"
      },
      "portsAttributes": {
        "7123": {
          "onAutoForward": "notify"
        },
        "8100-8150": {
          "onAutoForward": "notify"
        }
      }
    }

Fixes: #38
  • Loading branch information
aaronlehmann committed Feb 26, 2025
commit 12f38a1d9ce1186b96e2c9ec490bfba0066a2c7d
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ 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)
implementation("org.json:json:20210307")
}

// Set the JVM language level used to build the project.
Expand Down
50 changes: 50 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,50 @@
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

init {
parsedRule = 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

MatchRule.SinglePort(portPart.toInt())
}
// Try parsing as port range (e.g., "40000-55000")
portPart.matches("^\\d+-\\d+$".toRegex()) -> {
val (start, end) = portPart.split('-')
.map { it.trim().toInt() }
require(start <= end) { "Invalid port range: start must be less than or equal to 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")
}
}
}
}
}
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,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import org.json.JSONObject

/**
* Automatically forward ports that have something listening on them by scanning
Expand All @@ -29,15 +32,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 @@ -48,12 +56,46 @@ class CoderPortForwardService(
}

private fun start() {
// TODO: make path configurable?
val devcontainerFile = File(System.getProperty("user.home"), ".cache/JetBrains/devcontainer.json")
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I originally wanted to make this a setting, but this turns out to be a bit complicated because this plugin doesn't have any settings yet, so there's a bit of infrastructure involved. I can come back to this if needed, or we can go with this fixed path for now (or some other path). It's pretty straightforward for other software on the workspace to put the file in the expected place or create a symlink.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think a fixed path for now is fine, and then we'll see if a need for a setting arises.

Nit: Might be worth extracting to a function in some kind of config module just to have a natural place to start moving things that could become settings, but definitely doesn't need to happen in this PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Moved to a settings class, please let me know if the location/naming makes sense.

if (devcontainerFile.exists()) {
try {
val json = devcontainerFile.readText()
val obj = JSONObject(json)

val portsAttributes = obj.optJSONObject("portsAttributes") ?: JSONObject()
portsAttributes.keys().forEach { spec ->
portsAttributes.optJSONObject(spec)?.let { attrs ->
val onAutoForward = attrs.optString("onAutoForward")
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit/Not even sure I want a change here: spec says "onAutoForward" is an an enum of [ "notify", "openBrowser", "openBrowserOnce", "openPreview", "silent", "ignore" ]. Might be worth checking we don't set a PortMatcher('[object Object]') or something to save us a debugging headache in the future, but also see the value of leaving it flexible so we don't have to come back and make an update in the event the spec 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.

I added validation that the onAutoForward value is a string. I think it probably makes sense to accept any non-empty value here except ignore as equivalent to notify, but happy to tweak this as you see fit.

if (onAutoForward == "ignore") {
logger.info("found ignored port specification $spec in devcontainer.json")
rules.add(0, PortRule(PortMatcher(spec), false))
} else if (onAutoForward != "") {
logger.info("found auto-forward port specification $spec in devcontainer.json")
rules.add(0, PortRule(PortMatcher(spec), true))
}
}
}

val otherPortsAttributes = obj.optJSONObject("otherPortsAttributes") ?: JSONObject()
if (otherPortsAttributes.optString("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
43 changes: 43 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,43 @@
package com.coder.jetbrains.matcher

import org.junit.Test
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue

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 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))
}
}