Skip to content

Commit f6783a2

Browse files
Control automatic port forwarding with a devcontainer.json file (#49)
Control automatic port forwarding with a devcontainer.json file --------- Co-authored-by: Benjamin Peinhardt <[email protected]> Co-authored-by: Benjamin <[email protected]>
1 parent fa65f23 commit f6783a2

File tree

7 files changed

+234
-5
lines changed

7 files changed

+234
-5
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- Allow specifying port forwarding behavior in devcontainer.json
10+
711
## [0.0.4] - 2025-02-21
812

913
### Added

build.gradle.kts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ plugins {
1111
alias(libs.plugins.changelog) // Gradle Changelog Plugin
1212
alias(libs.plugins.qodana) // Gradle Qodana Plugin
1313
alias(libs.plugins.kover) // Gradle Kover Plugin
14+
kotlin("plugin.serialization") version "2.1.10"
1415
}
1516

1617
group = properties("pluginGroup").get()
@@ -24,6 +25,12 @@ repositories {
2425
// Dependencies are managed with Gradle version catalog - read more: https://docs.gradle.org/current/userguide/platforms.html#sub:version-catalog
2526
dependencies {
2627
// implementation(libs.annotations)
28+
// We need kotlinx-serialization-json >= 1.7.0 to support allowComments, as
29+
// devcontainer.json files often have comments and therefore use
30+
// nonstandard JSON syntax. This dependency should be marked compileOnly
31+
// once the minimum IDE version we support (pluginSinceBuild in
32+
// gradle.properties) is at least 251, which includes version 1.7.2.
33+
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
2734
}
2835

2936
// Set the JVM language level used to build the project.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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 = parseRule(rule)
11+
12+
fun matches(port: Int): Boolean {
13+
return when (parsedRule) {
14+
is MatchRule.SinglePort -> port == parsedRule.port
15+
is MatchRule.PortRange -> port in parsedRule.start..parsedRule.end
16+
is MatchRule.RegexPort -> parsedRule.pattern.matches(port.toString())
17+
}
18+
}
19+
20+
private fun parseRule(rule: String): MatchRule {
21+
// Remove host part if present (e.g., "localhost:3000" -> "3000")
22+
val portPart = rule.substringAfter(':').takeIf { ':' in rule } ?: rule
23+
24+
return when {
25+
// Try parsing as single port
26+
portPart.all { it.isDigit() } -> {
27+
val port = portPart.toInt()
28+
validatePort(port)
29+
MatchRule.SinglePort(port)
30+
}
31+
// Try parsing as port range (e.g., "40000-55000")
32+
portPart.matches("^\\d+\\s*-\\s*\\d+$".toRegex()) -> {
33+
val (start, end) = portPart.split('-')
34+
.map { it.trim().toInt() }
35+
validatePortRange(start, end)
36+
MatchRule.PortRange(start, end)
37+
}
38+
// If not a single port or range, treat as regex
39+
else -> {
40+
try {
41+
MatchRule.RegexPort(portPart.toRegex())
42+
} catch (e: Exception) {
43+
throw IllegalArgumentException("Invalid port rule format: $rule")
44+
}
45+
}
46+
}
47+
}
48+
49+
private fun validatePort(port: Int) {
50+
require(port in 0..65535) { "Port number must be between 0 and 65535, got: $port" }
51+
}
52+
53+
private fun validatePortRange(start: Int, end: Int) {
54+
validatePort(start)
55+
validatePort(end)
56+
require(start <= end) { "Invalid port range: start must be less than or equal to end" }
57+
}
58+
}

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

Lines changed: 60 additions & 5 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,10 @@ 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 kotlinx.serialization.json.Json
21+
import kotlinx.serialization.ExperimentalSerializationApi
22+
import com.coder.jetbrains.settings.CoderBackendSettings
1823

1924
/**
2025
* Automatically forward ports that have something listening on them by scanning
@@ -29,15 +34,20 @@ class CoderPortForwardService(
2934
private val logger = thisLogger()
3035
private var poller: Job? = null
3136

32-
// TODO: Make customizable.
37+
private data class PortRule(
38+
val matcher: PortMatcher,
39+
val autoForward: Boolean
40+
)
3341
// TODO: I also see 63342, 57675, and 56830 for JetBrains. Are they static?
3442
// TODO: If you have multiple IDEs, you will see 5991. 5992, etc. Can we
3543
// detect all of these and exclude them?
36-
private val ignoreList = setOf(
37-
22, // SSH
38-
5990, // JetBrains Gateway port.
44+
private val rules = mutableListOf(
45+
PortRule(PortMatcher("22"), false),
46+
PortRule(PortMatcher("5990"), false),
3947
)
4048

49+
private var defaultForward = true
50+
4151
init {
4252
logger.info("initializing port forwarding service")
4353
start()
@@ -47,13 +57,58 @@ class CoderPortForwardService(
4757
poller?.cancel()
4858
}
4959

60+
companion object {
61+
@OptIn(ExperimentalSerializationApi::class)
62+
private val json = Json {
63+
ignoreUnknownKeys = true
64+
allowTrailingComma = true
65+
allowComments = true
66+
}
67+
}
68+
5069
private fun start() {
70+
val devcontainerFile = CoderBackendSettings.getDevcontainerFile()
71+
if (devcontainerFile.exists()) {
72+
try {
73+
val jsonContent = devcontainerFile.readText()
74+
val config = json.decodeFromString<DevContainerConfig>(jsonContent)
75+
76+
// Process port attributes
77+
config.portsAttributes.forEach { (spec, attrs) ->
78+
when (attrs.onAutoForward) {
79+
"ignore" -> {
80+
logger.info("found ignored port specification $spec in devcontainer.json")
81+
rules.add(0, PortRule(PortMatcher(spec), false))
82+
}
83+
"" -> {}
84+
else -> {
85+
logger.info("found auto-forward port specification $spec in devcontainer.json")
86+
rules.add(0, PortRule(PortMatcher(spec), true))
87+
}
88+
}
89+
}
90+
91+
// Process other ports attributes
92+
config.otherPortsAttributes?.let {
93+
if (it.onAutoForward == "ignore") {
94+
logger.info("found ignored setting for otherPortsAttributes in devcontainer.json")
95+
defaultForward = false
96+
}
97+
}
98+
} catch (e: Exception) {
99+
logger.warn("Failed to parse devcontainer.json", e)
100+
}
101+
}
102+
51103
logger.info("starting port scanner")
52104
poller = cs.launch {
53105
while (isActive) {
54106
logger.debug("scanning for ports")
55107
val listeningPorts = withContext(Dispatchers.IO) {
56-
listeningPorts().subtract(ignoreList)
108+
listeningPorts().filter { port ->
109+
val matchedRule = rules.firstOrNull { it.matcher.matches(port) }
110+
matchedRule?.autoForward ?: defaultForward
111+
}.toSet()
57112
}
58113
application.invokeLater {
59114
val manager = serviceOrNull<GlobalPortForwardingManager>()
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.coder.jetbrains.services
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
6+
@Serializable
7+
data class DevContainerConfig(
8+
@SerialName("portsAttributes")
9+
val portsAttributes: Map<String, PortAttributes> = mapOf(),
10+
@SerialName("otherPortsAttributes")
11+
val otherPortsAttributes: OtherPortsAttributes? = null
12+
)
13+
14+
@Serializable
15+
data class PortAttributes(
16+
@SerialName("onAutoForward")
17+
val onAutoForward: String = ""
18+
)
19+
20+
@Serializable
21+
data class OtherPortsAttributes(
22+
@SerialName("onAutoForward")
23+
val onAutoForward: String = ""
24+
)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.coder.jetbrains.settings
2+
3+
import java.io.File
4+
5+
object CoderBackendSettings {
6+
fun getDevcontainerFile(): File {
7+
// TODO: make path configurable?
8+
return File(System.getProperty("user.home"), ".cache/JetBrains/devcontainer.json")
9+
}
10+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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+
import org.junit.Assert.assertThrows
7+
8+
class PortMatcherTest {
9+
10+
@Test
11+
fun `test single port`() {
12+
val matcher = PortMatcher("3000")
13+
assertTrue(matcher.matches(3000))
14+
assertFalse(matcher.matches(2999))
15+
assertFalse(matcher.matches(3001))
16+
}
17+
18+
@Test
19+
fun `test host colon port`() {
20+
val matcher = PortMatcher("localhost:3000")
21+
assertTrue(matcher.matches(3000))
22+
assertFalse(matcher.matches(3001))
23+
}
24+
25+
@Test
26+
fun `test port range`() {
27+
val matcher = PortMatcher("40000-55000")
28+
assertFalse(matcher.matches(39999))
29+
assertTrue(matcher.matches(40000))
30+
assertTrue(matcher.matches(50000))
31+
assertTrue(matcher.matches(55000))
32+
assertFalse(matcher.matches(55001))
33+
}
34+
35+
@Test
36+
fun `test port range with whitespace`() {
37+
val matcher = PortMatcher("20021 - 20024")
38+
assertFalse(matcher.matches(20000))
39+
assertTrue(matcher.matches(20022))
40+
}
41+
42+
@Test
43+
fun `test regex`() {
44+
val matcher = PortMatcher("800[1-9]")
45+
assertFalse(matcher.matches(8000))
46+
assertTrue(matcher.matches(8001))
47+
assertTrue(matcher.matches(8005))
48+
assertTrue(matcher.matches(8009))
49+
assertFalse(matcher.matches(8010))
50+
}
51+
52+
@Test
53+
fun `test invalid port numbers`() {
54+
assertThrows(IllegalArgumentException::class.java) { PortMatcher("65536") }
55+
assertThrows(IllegalArgumentException::class.java) { PortMatcher("0-65536") }
56+
assertThrows(IllegalArgumentException::class.java) { PortMatcher("70000") }
57+
}
58+
59+
@Test
60+
fun `test edge case port numbers`() {
61+
// These should work
62+
PortMatcher("0")
63+
PortMatcher("65535")
64+
PortMatcher("0-65535")
65+
66+
// These combinations should work
67+
val matcher = PortMatcher("0-65535")
68+
assertTrue(matcher.matches(0))
69+
assertTrue(matcher.matches(65535))
70+
}
71+
}

0 commit comments

Comments
 (0)