fix(tunnel): resolve UDP stream interruption and add KCP protocol support

- Increase UDP listener default TTL from 5s to 30s to prevent idle disconnect
- Add mux keepalive config (15s interval, 45s timeout) to tunnel relay handler
- Add KCP as tunnel chain transport protocol with keepalive and UDP mode default
- Add KCP protocol option to tunnel UI (frontend)
- Remove generic 'tcp' fallback key from KCP metadata to prevent false TCP mode
- Simplify forward service config by removing unused tunnelTLSProtocol parameter
This commit is contained in:
sagitchu
2026-04-20 23:57:43 +08:00
parent 7f14bd30fa
commit 630e012ec1
11 changed files with 195 additions and 55 deletions
@@ -185,14 +185,6 @@ func (h *Handler) listForwardPorts(forwardID int64) ([]forwardPortRecord, error)
return h.repo.ListForwardPorts(forwardID)
}
func (h *Handler) isTunnelSelectedTLSProtocol(tunnelID int64) (bool, error) {
protocol, err := h.repo.GetTunnelOutProtocol(tunnelID)
if err != nil {
return false, err
}
return isTLSTunnelProtocol(protocol), nil
}
func (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) {
n, err := h.repo.GetNodeRecord(nodeID)
if err != nil {
@@ -272,10 +264,6 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
}
serviceBase := buildForwardServiceBaseWithResolvedUserTunnel(forward.ID, forward.UserID, userTunnelID)
tunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)
if err != nil {
return nil, err
}
for _, fp := range ports {
if limiterID != nil && speed != nil {
@@ -298,7 +286,7 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
if err != nil {
return nil, err
}
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID, tunnelTLSProtocol)
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID)
_, err = h.sendNodeCommand(node.ID, method, services, true, false)
if err != nil && allowFallbackAdd && method == "UpdateService" {
if isNotFoundError(err) {
@@ -313,7 +301,7 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
}
if err != nil && strings.EqualFold(strings.TrimSpace(method), "UpdateService") && isCannotAssignRequestedAddressError(err) {
var warning string
warning, err = h.fallbackForwardPortToDefaultBind(forward, tunnel, node, fp, serviceBase, limiterID, tunnelTLSProtocol)
warning, err = h.fallbackForwardPortToDefaultBind(forward, tunnel, node, fp, serviceBase, limiterID)
if err == nil && warning != "" {
warnings = append(warnings, warning)
}
@@ -339,7 +327,7 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
return warnings, nil
}
func (h *Handler) fallbackForwardPortToDefaultBind(forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, fp forwardPortRecord, serviceBase string, limiterID *int64, tunnelTLSProtocol bool) (string, error) {
func (h *Handler) fallbackForwardPortToDefaultBind(forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, fp forwardPortRecord, serviceBase string, limiterID *int64) (string, error) {
if h == nil || forward == nil || tunnel == nil || node == nil {
return "", errors.New("invalid bind fallback context")
}
@@ -356,7 +344,7 @@ func (h *Handler) fallbackForwardPortToDefaultBind(forward *forwardRecord, tunne
}
time.Sleep(150 * time.Millisecond)
defaultServices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, "", limiterID, tunnelTLSProtocol)
defaultServices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, "", limiterID)
if _, err := h.sendNodeCommand(node.ID, "AddService", defaultServices, true, false); err != nil {
return "", err
}
@@ -1561,7 +1549,7 @@ func compactErrorMessage(msg string) string {
return strings.Join(strings.Fields(strings.ToLower(msg)), "")
}
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64) []map[string]interface{} {
protocols := []string{"tcp", "udp"}
services := make([]map[string]interface{}, 0, 2)
targets := splitRemoteTargets(forward.RemoteAddr)
@@ -1605,9 +1593,9 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
},
}
if protocol == "udp" {
listenerMetadata := map[string]interface{}{"keepAlive": true}
if tunnelTLSProtocol {
listenerMetadata["ttl"] = "10s"
listenerMetadata := map[string]interface{}{
"keepAlive": true,
"ttl": "30s",
}
service["listener"].(map[string]interface{})["metadata"] = listenerMetadata
}
@@ -378,7 +378,7 @@ func TestRetryTunnelServiceAddWithCleanupReturnsCleanupError(t *testing.T) {
func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) {
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22000, "10.9.8.7", nil, false)
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22000, "10.9.8.7", nil)
if len(services) != 2 {
t.Fatalf("expected 2 services, got %d", len(services))
}
@@ -393,7 +393,7 @@ func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) {
func TestBuildForwardServiceConfigs_DefaultListenAddrWhenBindIPEmpty(t *testing.T) {
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
node := &nodeRecord{TCPListenAddr: "0.0.0.0", UDPListenAddr: "[::]"}
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22001, "", nil, false)
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22001, "", nil)
if len(services) != 2 {
t.Fatalf("expected 2 services, got %d", len(services))
}
@@ -409,7 +409,7 @@ func TestBuildForwardServiceConfigs_DefaultListenAddrWhenBindIPEmpty(t *testing.
func TestBuildForwardServiceConfigs_BindIPAlreadyContainsPort(t *testing.T) {
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 55555, "3.3.3.3:12345", nil, false)
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 55555, "3.3.3.3:12345", nil)
if len(services) != 2 {
t.Fatalf("expected 2 services, got %d", len(services))
}
@@ -464,7 +464,7 @@ func TestBuildForwardServiceConfigs_IPv6BindIP(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, tt.port, tt.bindIP, nil, false)
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, tt.port, tt.bindIP, nil)
if len(services) != 2 {
t.Fatalf("expected 2 services, got %d", len(services))
}
+26 -12
View File
@@ -144,17 +144,24 @@ type remoteUsageNodeItem struct {
func buildFederationServiceConfig(serviceName, addr, protocol, role, chainName string, targetCount int, interfaceName string) map[string]interface{} {
service := map[string]interface{}{
"name": serviceName,
"addr": addr,
"name": serviceName,
"addr": addr,
"handler": map[string]interface{}{
"type": "relay",
},
"listener": map[string]interface{}{
"type": protocol,
},
"listener": buildTunnelListenerConfig(protocol),
}
if isTLSTunnelProtocol(protocol) {
service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{"nodelay": true}
if isTCPTunnelProtocol(protocol) {
service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{
"nodelay": true,
"mux.keepaliveInterval": "15s",
"mux.keepaliveTimeout": "45s",
}
}
if isKCPTunnelProtocol(protocol) {
service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{
"connectTimeout": "30s",
}
}
if role == "middle" {
service["handler"].(map[string]interface{})["chain"] = chainName
@@ -1095,16 +1102,23 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
connector := map[string]interface{}{
"type": "relay",
}
if isTLSTunnelProtocol(targetProtocol) {
connector["metadata"] = map[string]interface{}{"nodelay": true}
if isTCPTunnelProtocol(targetProtocol) {
connector["metadata"] = map[string]interface{}{
"nodelay": true,
"mux.keepaliveInterval": "15s",
"mux.keepaliveTimeout": "45s",
}
}
if isKCPTunnelProtocol(targetProtocol) {
connector["metadata"] = map[string]interface{}{
"connectTimeout": "30s",
}
}
nodeItems = append(nodeItems, map[string]interface{}{
"name": fmt.Sprintf("node_%d", i+1),
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, target.Port)),
"connector": connector,
"dialer": map[string]interface{}{
"type": targetProtocol,
},
"dialer": buildTunnelDialerConfig(targetProtocol),
})
}
+62 -13
View File
@@ -3452,16 +3452,23 @@ func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRu
connector := map[string]interface{}{
"type": "relay",
}
if isTLSTunnelProtocol(protocol) {
connector["metadata"] = map[string]interface{}{"nodelay": true}
connectorMetadata := map[string]interface{}{}
if isTCPTunnelProtocol(protocol) {
connectorMetadata["nodelay"] = true
connectorMetadata["mux.keepaliveInterval"] = "15s"
connectorMetadata["mux.keepaliveTimeout"] = "45s"
}
if isKCPTunnelProtocol(protocol) {
connectorMetadata["connectTimeout"] = "30s"
}
if len(connectorMetadata) > 0 {
connector["metadata"] = connectorMetadata
}
nodeItems = append(nodeItems, map[string]interface{}{
"name": fmt.Sprintf("node_%d", idx+1),
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, port)),
"connector": connector,
"dialer": map[string]interface{}{
"type": protocol,
},
"dialer": buildTunnelDialerConfig(protocol),
})
}
@@ -3493,19 +3500,26 @@ func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode,
handlerCfg := map[string]interface{}{
"type": "relay",
}
if isTLSTunnelProtocol(protocol) {
handlerCfg["metadata"] = map[string]interface{}{"nodelay": true}
handlerMetadata := map[string]interface{}{}
if isTCPTunnelProtocol(protocol) {
handlerMetadata["nodelay"] = true
handlerMetadata["mux.keepaliveInterval"] = "15s"
handlerMetadata["mux.keepaliveTimeout"] = "45s"
}
if isKCPTunnelProtocol(protocol) {
handlerMetadata["connectTimeout"] = "30s"
}
if len(handlerMetadata) > 0 {
handlerCfg["metadata"] = handlerMetadata
}
if nextHopCandidateCount > 1 {
handlerCfg["retries"] = nextHopCandidateCount - 1
}
service := map[string]interface{}{
"name": fmt.Sprintf("%d_tls", tunnelID),
"addr": processServerAddress(fmt.Sprintf("%s:%d", defaultString(strings.TrimSpace(chainNode.ConnectIP), node.TCPListenAddr), chainNode.Port)),
"handler": handlerCfg,
"listener": map[string]interface{}{
"type": protocol,
},
"name": fmt.Sprintf("%d_%s", tunnelID, protocol),
"addr": processServerAddress(fmt.Sprintf("%s:%d", defaultString(strings.TrimSpace(chainNode.ConnectIP), node.TCPListenAddr), chainNode.Port)),
"handler": handlerCfg,
"listener": buildTunnelListenerConfig(protocol),
}
if chainNode.ChainType == 2 {
service["handler"].(map[string]interface{})["chain"] = fmt.Sprintf("chains_%d", tunnelID)
@@ -3588,10 +3602,45 @@ func nodeDisplayName(node *nodeRecord) string {
return fmt.Sprintf("node_%d", node.ID)
}
func isTCPTunnelProtocol(protocol string) bool {
p := strings.ToLower(strings.TrimSpace(defaultString(protocol, "tls")))
return p == "tls" || p == "mtls" || p == "mtcp"
}
func isKCPTunnelProtocol(protocol string) bool {
return strings.EqualFold(strings.TrimSpace(protocol), "kcp")
}
func isTLSTunnelProtocol(protocol string) bool {
return strings.EqualFold(strings.TrimSpace(defaultString(protocol, "tls")), "tls")
}
func buildTunnelDialerConfig(protocol string) map[string]interface{} {
dialer := map[string]interface{}{
"type": protocol,
}
if isKCPTunnelProtocol(protocol) {
dialer["metadata"] = map[string]interface{}{
"kcp.keepalive": 10,
"kcp.tcp": false,
}
}
return dialer
}
func buildTunnelListenerConfig(protocol string) map[string]interface{} {
listener := map[string]interface{}{
"type": protocol,
}
if isKCPTunnelProtocol(protocol) {
listener["metadata"] = map[string]interface{}{
"kcp.keepalive": 10,
"kcp.tcp": false,
}
}
return listener
}
func nodeSupportsV4(node *nodeRecord) bool {
if node == nil {
return false