Skip to content

Commit ede5a60

Browse files
committed
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
1 parent 8158d8a commit ede5a60

File tree

4 files changed

+140
-6
lines changed

4 files changed

+140
-6
lines changed

build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ repositories {
2424
// Dependencies are managed with Gradle version catalog - read more: https://docs.gradle.org/current/userguide/platforms.html#sub:version-catalog
2525
dependencies {
2626
// implementation(libs.annotations)
27+
implementation("org.json:json:20210307")
2728
}
2829

2930
// Set the JVM language level used to build the project.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.coder.jetbrains.matcher
2+
3+
class PortMatcher(private val rule: String) {
4+
private sealed class MatchRule {
5+
data class SinglePort(val port: Int) : MatchRule()
6+
data class PortRange(val start: Int, val end: Int) : MatchRule()
7+
data class RegexPort(val pattern: Regex) : MatchRule()
8+
}
9+
10+
private val parsedRule: MatchRule
11+
12+
init {
13+
parsedRule = parseRule(rule)
14+
}
15+
16+
fun matches(port: Int): Boolean {
17+
return when (parsedRule) {
18+
is MatchRule.SinglePort -> port == parsedRule.port
19+
is MatchRule.PortRange -> port in parsedRule.start..parsedRule.end
20+
is MatchRule.RegexPort -> parsedRule.pattern.matches(port.toString())
21+
}
22+
}
23+
24+
private fun parseRule(rule: String): MatchRule {
25+
// Remove host part if present (e.g., "localhost:3000" -> "3000")
26+
val portPart = rule.substringAfter(':').takeIf { ':' in rule } ?: rule
27+
28+
return when {
29+
// Try parsing as single port
30+
portPart.all { it.isDigit() } -> {
31+
MatchRule.SinglePort(portPart.toInt())
32+
}
33+
// Try parsing as port range (e.g., "40000-55000")
34+
portPart.matches("^\\d+-\\d+$".toRegex()) -> {
35+
val (start, end) = portPart.split('-')
36+
.map { it.trim().toInt() }
37+
require(start <= end) { "Invalid port range: start must be less than or equal to end" }
38+
MatchRule.PortRange(start, end)
39+
}
40+
// If not a single port or range, treat as regex
41+
else -> {
42+
try {
43+
MatchRule.RegexPort(portPart.toRegex())
44+
} catch (e: Exception) {
45+
throw IllegalArgumentException("Invalid port rule format: $rule")
46+
}
47+
}
48+
}
49+
}
50+
}

src/main/kotlin/com/coder/jetbrains/services/CoderPortForwardService.kt

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.coder.jetbrains.services
22

3+
import com.coder.jetbrains.matcher.PortMatcher
34
import com.coder.jetbrains.scanner.listeningPorts
45
import com.intellij.openapi.Disposable
56
import com.intellij.openapi.components.serviceOrNull
@@ -15,6 +16,8 @@ import kotlinx.coroutines.delay
1516
import kotlinx.coroutines.isActive
1617
import kotlinx.coroutines.launch
1718
import kotlinx.coroutines.withContext
19+
import java.io.File
20+
import org.json.JSONObject
1821

1922
/**
2023
* Automatically forward ports that have something listening on them by scanning
@@ -29,15 +32,20 @@ class CoderPortForwardService(
2932
private val logger = thisLogger()
3033
private var poller: Job? = null
3134

32-
// TODO: Make customizable.
35+
private data class PortRule(
36+
val matcher: PortMatcher,
37+
val autoForward: Boolean
38+
)
3339
// TODO: I also see 63342, 57675, and 56830 for JetBrains. Are they static?
3440
// TODO: If you have multiple IDEs, you will see 5991. 5992, etc. Can we
3541
// detect all of these and exclude them?
36-
private val ignoreList = setOf(
37-
22, // SSH
38-
5990, // JetBrains Gateway port.
42+
private val rules = mutableListOf(
43+
PortRule(PortMatcher("22"), false),
44+
PortRule(PortMatcher("5990"), false),
3945
)
4046

47+
private var defaultForward = true
48+
4149
init {
4250
logger.info("initializing port forwarding service")
4351
start()
@@ -48,12 +56,44 @@ class CoderPortForwardService(
4856
}
4957

5058
private fun start() {
51-
logger.info("starting port scanner")
59+
// TODO: make path configurable?
60+
val devcontainerFile = File(System.getProperty("user.home"), ".cache/JetBrains/devcontainer.json")
61+
if (devcontainerFile.exists()) {
62+
try {
63+
val json = devcontainerFile.readText()
64+
val obj = JSONObject(json)
65+
66+
val portsAttributes = obj.optJSONObject("portsAttributes") ?: JSONObject()
67+
portsAttributes.keys().forEach { spec ->
68+
portsAttributes.optJSONObject(spec)?.let { attrs ->
69+
val onAutoForward = attrs.optString("onAutoForward")
70+
if (onAutoForward == "ignore") {
71+
logger.info("found ignored port specification $spec in devcontainer.json")
72+
rules.add(0, PortRule(PortMatcher(spec), false))
73+
} else if (onAutoForward != "") {
74+
logger.info("found auto-forward port specification $spec in devcontainer.json")
75+
rules.add(0, PortRule(PortMatcher(spec), true))
76+
}
77+
}
78+
}
79+
80+
val otherPortsAttributes = obj.optJSONObject("otherPortsAttributes") ?: JSONObject()
81+
if (otherPortsAttributes.optString("onAutoForward") == "ignore") {
82+
logger.info("found ignored setting for otherPortsAttributes in devcontainer.json")
83+
defaultForward = false
84+
}
85+
} catch (e: Exception) {
86+
logger.warn("Failed to parse devcontainer.json", e)
87+
}
88+
}
5289
poller = cs.launch {
5390
while (isActive) {
5491
logger.debug("scanning for ports")
5592
val listeningPorts = withContext(Dispatchers.IO) {
56-
listeningPorts().subtract(ignoreList)
93+
listeningPorts().filter { port ->
94+
val matchedRule = rules.firstOrNull { it.matcher.matches(port) }
95+
matchedRule?.autoForward ?: defaultForward
96+
}.toSet()
5797
}
5898
application.invokeLater {
5999
val manager = serviceOrNull<GlobalPortForwardingManager>()
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package com.coder.jetbrains.matcher
2+
3+
import org.junit.Test
4+
import org.junit.Assert.assertFalse
5+
import org.junit.Assert.assertTrue
6+
7+
class PortMatcherTest {
8+
9+
@Test
10+
fun `test single port`() {
11+
val matcher = PortMatcher("3000")
12+
assertTrue(matcher.matches(3000))
13+
assertFalse(matcher.matches(2999))
14+
assertFalse(matcher.matches(3001))
15+
}
16+
17+
@Test
18+
fun `test host colon port`() {
19+
val matcher = PortMatcher("localhost:3000")
20+
assertTrue(matcher.matches(3000))
21+
assertFalse(matcher.matches(3001))
22+
}
23+
24+
@Test
25+
fun `test port range`() {
26+
val matcher = PortMatcher("40000-55000")
27+
assertFalse(matcher.matches(39999))
28+
assertTrue(matcher.matches(40000))
29+
assertTrue(matcher.matches(50000))
30+
assertTrue(matcher.matches(55000))
31+
assertFalse(matcher.matches(55001))
32+
}
33+
34+
@Test
35+
fun `test regex`() {
36+
val matcher = PortMatcher("800[1-9]")
37+
assertFalse(matcher.matches(8000))
38+
assertTrue(matcher.matches(8001))
39+
assertTrue(matcher.matches(8005))
40+
assertTrue(matcher.matches(8009))
41+
assertFalse(matcher.matches(8010))
42+
}
43+
}

0 commit comments

Comments
 (0)