-
Notifications
You must be signed in to change notification settings - Fork 3
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
Changes from 1 commit
12f38a1
ae1df2f
933e1d4
cf25cbe
69432d7
3433d28
2da0908
8d86ee7
fa0df63
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
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
There are no files selected for viewing
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() } -> { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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()) -> { | ||
aaronlehmann marked this conversation as resolved.
Show resolved
Hide resolved
|
||
val (start, end) = portPart.split('-') | ||
.map { it.trim().toInt() } | ||
require(start <= end) { "Invalid port range: start must be less than or equal to end" } | ||
aaronlehmann marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 | ||
|
@@ -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 | ||
|
@@ -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() | ||
|
@@ -48,12 +56,46 @@ class CoderPortForwardService( | |
} | ||
|
||
private fun start() { | ||
// TODO: make path configurable? | ||
val devcontainerFile = File(System.getProperty("user.home"), ".cache/JetBrains/devcontainer.json") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
aaronlehmann marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
val portsAttributes = obj.optJSONObject("portsAttributes") ?: JSONObject() | ||
portsAttributes.keys().forEach { spec -> | ||
portsAttributes.optJSONObject(spec)?.let { attrs -> | ||
val onAutoForward = attrs.optString("onAutoForward") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I added validation that the |
||
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>() | ||
|
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)) | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.