Skip to content

Commit 17f5c34

Browse files
authored
Merge pull request #5 from sdjnmxd/feature/upnp
feat: 支持 UPNP 端口映射(启用开关、IPv4 校验、关停清理)
2 parents 73170b9 + 3521c90 commit 17f5c34

File tree

7 files changed

+258
-2
lines changed

7 files changed

+258
-2
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,5 +269,5 @@ MIT License - 参见 LICENSE 文件
269269
- ✅ 文件同步
270270
- ✅ 多种存储后端支持
271271
- ✅ HTTPS 证书管理 (BYOC 和自动获取)
272-
- UPNP 支持 (待实现)
272+
- UPNP 支持 (使用weupnp)
273273

build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ dependencies {
6464
implementation(libs.kotlin.retry)
6565
implementation(libs.bouncycastle.prov)
6666
implementation(libs.bouncycastle.pkix)
67+
implementation("org.bitlet:weupnp:0.1.4")
6768
ksp(libs.koin.ksp.compiler)
6869
testImplementation(libs.ktor.server.test.host)
6970
testImplementation(libs.kotlin.test.junit)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.bangbang93.openbmclapi.agent.nat
2+
3+
import java.net.InetAddress
4+
5+
/**
6+
* NAT 端口映射抽象:封装 UPnP / NAT-PMP / PCP 的最小能力集合。
7+
*/
8+
interface NatMapper {
9+
/** 建立端口映射,返回可用于续期与清理的句柄。 */
10+
fun map(
11+
privatePort: Int,
12+
publicPort: Int = privatePort,
13+
protocol: Protocol = Protocol.TCP,
14+
ttlSeconds: Int = 3600,
15+
description: String = "openbmclapi",
16+
): MappingHandle
17+
18+
/** 刷新现有映射(续期)。返回是否成功。 */
19+
fun refresh(handle: MappingHandle): Boolean
20+
21+
/** 删除映射。返回是否成功。 */
22+
fun unmap(handle: MappingHandle): Boolean
23+
24+
/** 获取外网 IP(若可用)。 */
25+
fun externalIp(): InetAddress
26+
}
27+
28+
enum class Protocol { TCP, UDP }
29+
30+
data class MappingHandle(
31+
val protocol: Protocol,
32+
val privatePort: Int,
33+
val publicPort: Int,
34+
val ttlSeconds: Int,
35+
val description: String,
36+
)
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.bangbang93.openbmclapi.agent.nat
2+
3+
import com.bangbang93.openbmclapi.agent.config.ClusterConfig
4+
import io.github.oshai.kotlinlogging.KotlinLogging
5+
import org.koin.core.annotation.Single
6+
import java.net.Inet4Address
7+
import java.net.InetAddress
8+
9+
private val logger = KotlinLogging.logger {}
10+
11+
@Single
12+
class NatService(
13+
private val config: ClusterConfig,
14+
) {
15+
private val mapper: NatMapper = WeUpnpNatMapper()
16+
@Volatile private var handle: MappingHandle? = null
17+
@Volatile private var external: InetAddress? = null
18+
19+
fun startIfEnabled(): InetAddress? {
20+
if (!config.enableUpnp) return null
21+
22+
val mapped =
23+
mapper.map(
24+
privatePort = config.port,
25+
publicPort = config.clusterPublicPort,
26+
protocol = Protocol.TCP,
27+
ttlSeconds = 3600,
28+
description = "openbmclapi",
29+
)
30+
handle = mapped
31+
external = mapper.externalIp()
32+
33+
val ip = external ?: error("无法获取外网 IP")
34+
validateExternalIp(ip)
35+
logger.info { "UPnP/NAT 端口映射成功,外网 IP: ${ip.hostAddress}" }
36+
37+
// 关闭时清理端口映射
38+
Runtime.getRuntime().addShutdownHook(
39+
Thread(
40+
{
41+
try {
42+
handle?.let { mapper.unmap(it) }
43+
} catch (_: Exception) {
44+
}
45+
},
46+
"nat-unmap",
47+
),
48+
)
49+
50+
return ip
51+
}
52+
53+
/** 返回最近一次建立映射时获取到的外网 IP(可能为空)。 */
54+
fun getExternalIpOrNull(): InetAddress? = external
55+
56+
/** 校验为 IPv4 且不属于私网/回环/组播等地址段。 */
57+
private fun validateExternalIp(ip: InetAddress) {
58+
if (ip !is Inet4Address) {
59+
throw IllegalStateException("不支持ipv6")
60+
}
61+
if (ip.isAnyLocalAddress || ip.isLoopbackAddress || ip.isMulticastAddress || ip.isSiteLocalAddress) {
62+
throw IllegalStateException("无法获取公网IP, UPNP返回的IP位于私有地址段, IP: ${ip.hostAddress}")
63+
}
64+
}
65+
}
66+
67+
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package com.bangbang93.openbmclapi.agent.nat
2+
3+
import io.github.oshai.kotlinlogging.KotlinLogging
4+
import org.bitlet.weupnp.GatewayDevice
5+
import org.bitlet.weupnp.GatewayDiscover
6+
import java.net.InetAddress
7+
8+
private val logger = KotlinLogging.logger {}
9+
10+
class WeUpnpNatMapper : NatMapper {
11+
private var device: GatewayDevice? = null
12+
13+
override fun map(
14+
privatePort: Int,
15+
publicPort: Int,
16+
protocol: Protocol,
17+
ttlSeconds: Int,
18+
description: String,
19+
): MappingHandle {
20+
val discover = GatewayDiscover()
21+
discover.discover()
22+
val gw = discover.validGateway ?: error("未发现可用的 UPnP IGD 网关")
23+
device = gw
24+
25+
val ok =
26+
when (protocol) {
27+
Protocol.TCP ->
28+
gw.addPortMapping(
29+
publicPort,
30+
privatePort,
31+
gw.localAddress.hostAddress,
32+
"TCP",
33+
description,
34+
)
35+
Protocol.UDP ->
36+
gw.addPortMapping(
37+
publicPort,
38+
privatePort,
39+
gw.localAddress.hostAddress,
40+
"UDP",
41+
description,
42+
)
43+
}
44+
if (!ok) error("UPnP 端口映射失败: $protocol $publicPort->$privatePort")
45+
46+
logger.info { "UPnP 映射成功: $protocol $publicPort->$privatePort" }
47+
return MappingHandle(protocol, privatePort, publicPort, ttlSeconds, description)
48+
}
49+
50+
override fun refresh(handle: MappingHandle): Boolean {
51+
// WeUPnP 不支持 TTL 刷新,直接重做一次映射作为 best-effort
52+
return try {
53+
val dev = device ?: return false
54+
when (handle.protocol) {
55+
Protocol.TCP ->
56+
dev.addPortMapping(
57+
handle.publicPort,
58+
handle.privatePort,
59+
dev.localAddress.hostAddress,
60+
"TCP",
61+
handle.description,
62+
)
63+
Protocol.UDP ->
64+
dev.addPortMapping(
65+
handle.publicPort,
66+
handle.privatePort,
67+
dev.localAddress.hostAddress,
68+
"UDP",
69+
handle.description,
70+
)
71+
}
72+
true
73+
} catch (e: Exception) {
74+
logger.error(e) { "UPnP 续期失败" }
75+
false
76+
}
77+
}
78+
79+
override fun unmap(handle: MappingHandle): Boolean {
80+
return try {
81+
val dev = device ?: return true
82+
when (handle.protocol) {
83+
Protocol.TCP -> dev.deletePortMapping(handle.publicPort, "TCP")
84+
Protocol.UDP -> dev.deletePortMapping(handle.publicPort, "UDP")
85+
}
86+
true
87+
} catch (e: Exception) {
88+
logger.warn(e) { "删除映射失败" }
89+
false
90+
}
91+
}
92+
93+
override fun externalIp(): InetAddress {
94+
val dev = device ?: error("尚未建立映射")
95+
return InetAddress.getByName(dev.externalIPAddress)
96+
}
97+
}

src/main/kotlin/com/bangbang93/openbmclapi/agent/service/ClusterService.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import com.bangbang93.openbmclapi.agent.model.FileList
99
import com.bangbang93.openbmclapi.agent.model.OpenbmclapiAgentConfiguration
1010
import com.bangbang93.openbmclapi.agent.model.SyncConfig
1111
import com.bangbang93.openbmclapi.agent.storage.IStorage
12+
import com.bangbang93.openbmclapi.agent.nat.NatService
1213
import com.bangbang93.openbmclapi.agent.util.HashUtil
1314
import com.bangbang93.openbmclapi.agent.util.emitAck
1415
import com.github.avrokotlin.avro4k.Avro
@@ -42,6 +43,7 @@ class ClusterService(
4243
private val storage: IStorage,
4344
private val tokenManager: TokenManager,
4445
private val httpClient: HttpClient,
46+
private val natService: NatService,
4547
) : CoroutineScope by CoroutineScope(Dispatchers.Default) {
4648
lateinit var socket: Socket
4749
var isEnabled = false
@@ -172,9 +174,18 @@ class ClusterService(
172174

173175
logger.trace { "Enabling cluster" }
174176

177+
val upnpIp = try {
178+
natService.startIfEnabled()
179+
} catch (e: Exception) {
180+
logger.error(e) { "UPnP/NAT 端口映射失败" }
181+
throw e
182+
}
183+
184+
val hostForEnable = config.clusterIp ?: upnpIp?.hostAddress
185+
175186
val enableRequest =
176187
EnableRequest(
177-
host = config.clusterIp,
188+
host = hostForEnable,
178189
port = config.clusterPublicPort,
179190
version = AGENT_PROTOCOL_VERSION,
180191
byoc = config.byoc,
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.bangbang93.openbmclapi.agent.nat
2+
3+
import kotlin.test.Test
4+
import kotlin.test.assertTrue
5+
import io.github.oshai.kotlinlogging.KotlinLogging
6+
import org.bitlet.weupnp.GatewayDiscover
7+
import java.net.Inet4Address
8+
9+
class WeUpnpNatMapperTest {
10+
private val logger = KotlinLogging.logger {}
11+
12+
@Test
13+
fun `map and unmap via WeUPnP if gateway available`() {
14+
val discover = GatewayDiscover()
15+
logger.info { "Discovering UPnP gateways..." }
16+
val found = discover.discover()
17+
logger.info { "Discovery result: found=$found, gw=${discover.validGateway}" }
18+
val gw = discover.validGateway ?: run {
19+
logger.info { "No UPnP IGD gateway found - skipping test" }
20+
return
21+
}
22+
23+
val mapper = WeUpnpNatMapper()
24+
val privatePort = 4000
25+
val publicPort = 4000
26+
27+
val handle = mapper.map(
28+
privatePort = privatePort,
29+
publicPort = publicPort,
30+
protocol = Protocol.TCP,
31+
ttlSeconds = 300,
32+
description = "openbmclapi-test",
33+
)
34+
35+
val ip = mapper.externalIp()
36+
logger.info { "External IP: ${ip.hostAddress}" }
37+
assertTrue(ip is Inet4Address, "external ip should be IPv4")
38+
39+
val removed = mapper.unmap(handle)
40+
logger.info { "Unmapped result: $removed" }
41+
}
42+
}
43+
44+

0 commit comments

Comments
 (0)