feat(plugin): add protocol proxy mode
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
Docker Image / docker (push) Has been cancelled

This commit is contained in:
2026-06-26 10:13:00 +08:00
parent f4a11fb770
commit ae8706f8c8
25 changed files with 1876 additions and 138 deletions

View File

@@ -34,6 +34,7 @@ func newAdminAPIHandler() http.HandlerFunc {
PluginsList: handleAdminPluginsList,
PluginItem: handleAdminPluginItem,
PluginAction: handleAdminPluginAction,
PluginDraining: handleAdminPluginDraining,
PluginDispatch: handleAdminPluginDispatchPlan,
})
}

View File

@@ -205,6 +205,36 @@ func handleAdminPluginAction(w http.ResponseWriter, r *http.Request, rawSegment
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"plugin": plugin})
}
func handleAdminPluginDraining(w http.ResponseWriter, r *http.Request, rawPluginID string) {
session, ok := requireRole(w, r, adminRoleAdmin)
if !ok {
return
}
if pluginsManager == nil {
adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized")
return
}
if r.Method != http.MethodPost {
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
pluginID, err := adminhttp.PathSegment(rawPluginID)
if err != nil {
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
closed, err := pluginsManager.ForceCloseDraining(r.Context(), session.Username, pluginID)
if err != nil {
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_force_close_draining", "plugin", pluginID, false, err.Error())
writePluginManagerError(w, err)
return
}
recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_force_close_draining", "plugin", pluginID, true, "draining protocol-proxy connections force closed", map[string]any{
"closed": closed,
})
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"closed": closed})
}
func handleAdminPluginDispatchPlan(w http.ResponseWriter, r *http.Request) {
if _, ok := requireRole(w, r, adminRoleMember); !ok {
return

View File

@@ -2,10 +2,16 @@ package main
import (
"bytes"
"context"
"errors"
"io"
"net"
"testing"
"time"
"github.com/tursom/mc-gateway/internal/pluginmanager"
"github.com/tursom/mc-gateway/plugin/api"
"github.com/tursom/mc-gateway/protocol"
)
func TestHandleRequestProxiesAndClosesConnections(t *testing.T) {
@@ -42,6 +48,207 @@ func TestHandleRequestProxiesAndClosesConnections(t *testing.T) {
}
}
func TestHandleRequestProtocolProxyReplaysInitialDataOnce(t *testing.T) {
defer saveGatewayState(t)()
packet := gatewayTestPacket("play.example", 0x63, 0x02)
source := newGatewayTestConn(packet)
setGatewayTestRoutes(map[string]string{
"play.example": "backend.example:25565",
})
pluginsManager = pluginmanager.New(pluginmanager.Options{
DB: newGatewayTestPluginDB(t),
ArtifactRoot: t.TempDir(),
Adapter: gatewayTestPluginAdapter{handler: func(req api.UpstreamConnectRequest) (net.Conn, error) {
if req.ServerHost != "play.example" || req.Host != "play.example" {
t.Fatalf("request host = %q/%q, want play.example", req.ServerHost, req.Host)
}
if req.ProtocolVersion != 0x63 || req.NextState != 0x02 {
t.Fatalf("protocol/next state = %d/%d, want 99/2", req.ProtocolVersion, req.NextState)
}
if !bytes.Equal(req.InitialData, packet) {
t.Fatalf("initial data = %v, want %v", req.InitialData, packet)
}
gatewayEnd, pluginEnd := net.Pipe()
go func() {
defer pluginEnd.Close()
buf := make([]byte, len(packet))
if _, err := io.ReadFull(pluginEnd, buf); err != nil {
t.Errorf("plugin endpoint ReadFull() error = %v", err)
return
}
if !bytes.Equal(buf, packet) {
t.Errorf("plugin endpoint initial data = %v, want %v", buf, packet)
return
}
if host := protocol.GetMcHost(buf); host != "play.example" {
t.Errorf("plugin endpoint host = %q, want play.example", host)
return
}
_, _ = pluginEnd.Write([]byte("login rejected"))
}()
return gatewayEnd, nil
}},
})
artifact := uploadGatewayTestArtifactWithCapabilities(t, pluginsManager, "proxy-plugin", `{"upstream_connect":{"mode":"protocol-proxy"}}`)
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "proxy-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
if _, err := pluginsManager.Enable(context.Background(), "admin", "proxy-plugin"); err != nil {
t.Fatalf("Enable() error = %v", err)
}
handleRequest(source)
if got := source.writeBuf.String(); got != "login rejected" {
t.Fatalf("source response = %q, want login rejected", got)
}
plan := pluginsManager.DispatchPlan(context.Background())
if len(plan.Handlers) != 1 {
t.Fatalf("dispatch handlers = %d, want 1", len(plan.Handlers))
}
if plan.Handlers[0].Mode != pluginmanager.UpstreamModeProtocolProxy {
t.Fatalf("handler mode = %q, want protocol-proxy", plan.Handlers[0].Mode)
}
if plan.Handlers[0].ProxyStarted != 1 || plan.Handlers[0].ProxyCompleted != 1 {
t.Fatalf("proxy lifecycle = started %d completed %d, want 1/1", plan.Handlers[0].ProxyStarted, plan.Handlers[0].ProxyCompleted)
}
}
func TestHandleRequestManagedErrBlockedClosesSource(t *testing.T) {
defer saveGatewayState(t)()
source := newGatewayTestConn(gatewayTestPacket("play.example"))
setGatewayTestRoutes(map[string]string{
"play.example": "backend.example:25565",
})
pluginsManager = pluginmanager.New(pluginmanager.Options{
DB: newGatewayTestPluginDB(t),
ArtifactRoot: t.TempDir(),
Adapter: gatewayTestPluginAdapter{handler: func(api.UpstreamConnectRequest) (net.Conn, error) {
return nil, api.ErrBlocked
}},
})
artifact := uploadGatewayTestArtifact(t, pluginsManager, "blocked-plugin")
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "blocked-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
if _, err := pluginsManager.Enable(context.Background(), "admin", "blocked-plugin"); err != nil {
t.Fatalf("Enable() error = %v", err)
}
handleRequest(source)
if !source.closed {
t.Fatal("source was not closed")
}
if source.writeBuf.Len() != 0 {
t.Fatalf("source response length = %d, want 0", source.writeBuf.Len())
}
}
func TestHandleRequestProtocolProxyPanicOnlyFailsCurrentConnection(t *testing.T) {
defer saveGatewayState(t)()
setGatewayTestRoutes(map[string]string{
"play.example": "backend.example:25565",
})
calls := 0
pluginsManager = pluginmanager.New(pluginmanager.Options{
DB: newGatewayTestPluginDB(t),
ArtifactRoot: t.TempDir(),
Adapter: gatewayTestPluginAdapter{handler: func(api.UpstreamConnectRequest) (net.Conn, error) {
calls++
if calls == 1 {
panic("boom")
}
upstream := newGatewayTestConn(nil)
upstream.writeBuf.WriteString("ok")
return upstream, nil
}},
})
artifact := uploadGatewayTestArtifact(t, pluginsManager, "panic-plugin")
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "panic-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
if _, err := pluginsManager.Enable(context.Background(), "admin", "panic-plugin"); err != nil {
t.Fatalf("Enable() error = %v", err)
}
first := newGatewayTestConn(gatewayTestPacket("play.example"))
handleRequest(first)
if !first.closed {
t.Fatal("first connection was not closed")
}
second := newGatewayTestConn(gatewayTestPacket("play.example"))
handleRequest(second)
if !second.closed {
t.Fatal("second connection was not closed")
}
plan := pluginsManager.DispatchPlan(context.Background())
if got := plan.Handlers[0].Panics; got != 1 {
t.Fatalf("panic count = %d, want 1", got)
}
if calls != 2 {
t.Fatalf("handler calls = %d, want 2", calls)
}
}
func TestHandleRequestProtocolProxyDisableSkipsNewConnections(t *testing.T) {
defer saveGatewayState(t)()
setGatewayTestRoutes(map[string]string{
"play.example": "backend.example:25565",
})
proxyCalls := 0
pluginsManager = pluginmanager.New(pluginmanager.Options{
DB: newGatewayTestPluginDB(t),
ArtifactRoot: t.TempDir(),
Adapter: gatewayTestPluginAdapter{handler: func(req api.UpstreamConnectRequest) (net.Conn, error) {
proxyCalls++
gatewayEnd, pluginEnd := net.Pipe()
initialLen := len(req.InitialData)
go func() {
defer pluginEnd.Close()
_, _ = io.ReadFull(pluginEnd, make([]byte, initialLen))
}()
return gatewayEnd, nil
}},
})
artifact := uploadGatewayTestArtifactWithCapabilities(t, pluginsManager, "proxy-plugin", `{"upstream_connect":{"mode":"protocol-proxy"}}`)
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "proxy-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
if _, err := pluginsManager.Enable(context.Background(), "admin", "proxy-plugin"); err != nil {
t.Fatalf("Enable() error = %v", err)
}
first := newGatewayTestConn(gatewayTestPacket("play.example"))
handleRequest(first)
if proxyCalls != 1 {
t.Fatalf("proxy calls after first request = %d, want 1", proxyCalls)
}
if _, err := pluginsManager.Disable(context.Background(), "admin", "proxy-plugin"); err != nil {
t.Fatalf("Disable() error = %v", err)
}
legacyUpstream := newGatewayTestConn(nil)
registerGatewayUpstreamHook(
t,
func(net.Conn, string) bool { return true },
func(net.Conn, string) (net.Conn, error) { return legacyUpstream, nil },
)
second := newGatewayTestConn(gatewayTestPacket("play.example"))
handleRequest(second)
if proxyCalls != 1 {
t.Fatalf("proxy calls after disable = %d, want still 1", proxyCalls)
}
if legacyUpstream.writeBuf.Len() == 0 {
t.Fatal("legacy upstream did not receive second request")
}
}
func TestHandleRequestRecoversAndClosesConnection(t *testing.T) {
defer saveGatewayState(t)()

View File

@@ -2,6 +2,9 @@ package main
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"net"
"os"
"sync"
@@ -104,51 +107,58 @@ func mapToHost(conn net.Conn) net.Conn {
return nil
}
mcHost := protocol.GetMcHost(buf[:n])
if mcHost == "" {
initialData := append([]byte(nil), buf[:n]...)
handshake := protocol.ParseHandshake(initialData)
if handshake.ServerHost == "" {
log.Err(errEmptyBuffer).
Str("client", conn.RemoteAddr().String()).
Msg("failed to parse mc host from buffer")
return nil
}
host, ok := lookupRoute(mcHost)
host, ok := lookupRoute(handshake.ServerHost)
if host == "" {
gatewayMetrics.RouteMiss()
log.Err(errEmptyBuffer).
Str("client", conn.RemoteAddr().String()).
Str("host", mcHost).
Str("host", handshake.ServerHost).
Msg("failed to route host")
return nil
}
if ok {
gatewayMetrics.RouteHit(mcHost)
gatewayMetrics.RouteHit(handshake.ServerHost)
}
log.Debug().
Str("client", conn.RemoteAddr().String()).
Str("host", mcHost).
Str("host", handshake.ServerHost).
Str("mc", host).
Msg("map to host")
var client net.Conn
if pluginsManager != nil {
result, err := pluginsManager.ConnectUpstream(context.Background(), api.UpstreamConnectRequest{
Source: conn,
Host: mcHost,
Upstream: host,
InitialData: append([]byte(nil), buf[:n]...),
})
req := newUpstreamConnectRequest(conn, host, handshake, initialData, ok)
result, err := pluginsManager.ConnectUpstream(context.Background(), req)
if err != nil {
if errors.Is(err, api.ErrBlocked) {
log.Info().
Str("client", conn.RemoteAddr().String()).
Str("host", handshake.ServerHost).
Msg("managed upstream plugin blocked connection")
return nil
}
log.Err(err).
Str("client", conn.RemoteAddr().String()).
Str("host", mcHost).
Str("host", handshake.ServerHost).
Str("mc", host).
Msg("failed to invoke managed upstream plugin")
return nil
}
if result.Handled {
if result.Proxied {
return nil
}
client = result.Conn
}
}
@@ -182,10 +192,10 @@ func mapToHost(conn net.Conn) net.Conn {
return nil
}
if err := writeAll(client, buf[:n]); err != nil {
if err := writeAll(client, initialData); err != nil {
log.Err(err).
Str("client", conn.RemoteAddr().String()).
Str("host", mcHost).
Str("host", handshake.ServerHost).
Str("mc", host).
Msg("failed to write initial packet to upstream")
client.Close()
@@ -194,3 +204,63 @@ func mapToHost(conn net.Conn) net.Conn {
return client
}
func newUpstreamConnectRequest(conn net.Conn, upstream string, handshake protocol.Handshake, initialData []byte, routeHit bool) api.UpstreamConnectRequest {
target := upstreamtarget.Parse(upstream)
transport, serviceName, listenerPort := connectionIngress(conn)
req := api.UpstreamConnectRequest{
Source: conn,
Host: handshake.ServerHost,
Upstream: upstream,
InitialData: append([]byte(nil), initialData...),
Metadata: map[string]string{"route_hit": boolString(routeHit)},
ConnectionID: randomHexID(8),
TraceID: randomHexID(16),
SourceAddr: conn.RemoteAddr().String(),
ServerHost: handshake.ServerHost,
RawServerHost: handshake.RawServerHost,
ProtocolVersion: handshake.ProtocolVersion,
NextState: handshake.NextState,
RouteID: handshake.ServerHost,
RouteTags: []string{},
UpstreamRaw: upstream,
UpstreamProtocol: string(target.Protocol),
UpstreamAddress: target.Address,
Transport: transport,
ServiceName: serviceName,
ListenerPort: listenerPort,
}
return req
}
func connectionIngress(conn net.Conn) (transport string, serviceName string, listenerPort int) {
transport = "tcp"
serviceName = serviceNameTCPAdmin
switch conn.(type) {
case *webSocketConn:
transport = "websocket"
serviceName = serviceNameWebSocket
case quicConn:
transport = "quic"
serviceName = serviceNameQUIC
}
if addr, ok := conn.LocalAddr().(*net.TCPAddr); ok {
listenerPort = addr.Port
}
return transport, serviceName, listenerPort
}
func boolString(value bool) string {
if value {
return "true"
}
return "false"
}
func randomHexID(size int) string {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
return hex.EncodeToString([]byte("fallback"))
}
return hex.EncodeToString(buf)
}

View File

@@ -262,9 +262,14 @@ func newGatewayTestPluginDB(t *testing.T) *sql.DB {
}
func uploadGatewayTestArtifact(t *testing.T, manager *pluginmanager.Manager, pluginID string) pluginmanager.ArtifactRecord {
t.Helper()
return uploadGatewayTestArtifactWithCapabilities(t, manager, pluginID, "")
}
func uploadGatewayTestArtifactWithCapabilities(t *testing.T, manager *pluginmanager.Manager, pluginID string, capabilities string) pluginmanager.ArtifactRecord {
t.Helper()
artifact, err := manager.UploadArtifact(context.Background(), pluginmanager.ArtifactUpload{
SourcePath: writeGatewayTestMCGP(t, pluginID),
SourcePath: writeGatewayTestMCGPWithCapabilities(t, pluginID, capabilities),
FileName: pluginID + ".mcgp",
Actor: "admin",
})
@@ -275,6 +280,10 @@ func uploadGatewayTestArtifact(t *testing.T, manager *pluginmanager.Manager, plu
}
func writeGatewayTestMCGP(t *testing.T, pluginID string) string {
return writeGatewayTestMCGPWithCapabilities(t, pluginID, "")
}
func writeGatewayTestMCGPWithCapabilities(t *testing.T, pluginID string, capabilities string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "plugin.mcgp")
file, err := os.Create(path)
@@ -283,7 +292,7 @@ func writeGatewayTestMCGP(t *testing.T, pluginID string) string {
}
writer := zip.NewWriter(file)
entries := map[string][]byte{
"manifest.json": gatewayTestManifest(t, pluginID),
"manifest.json": gatewayTestManifestWithCapabilities(t, pluginID, capabilities),
"plugin.so": []byte("fake plugin bytes " + pluginID),
}
for name, data := range entries {
@@ -305,7 +314,14 @@ func writeGatewayTestMCGP(t *testing.T, pluginID string) string {
}
func gatewayTestManifest(t *testing.T, pluginID string) []byte {
return gatewayTestManifestWithCapabilities(t, pluginID, "")
}
func gatewayTestManifestWithCapabilities(t *testing.T, pluginID string, capabilities string) []byte {
t.Helper()
if capabilities == "" {
capabilities = `{"extension_points":["upstream.connect/v1"]}`
}
manifest := pluginmanager.Manifest{
SchemaVersion: pluginmanager.SchemaVersion,
ID: pluginID,
@@ -325,6 +341,7 @@ func gatewayTestManifest(t *testing.T, pluginID string) []byte {
Type: "hook",
Key: pluginmanager.ExtensionUpstreamConnect,
}},
Capabilities: json.RawMessage(capabilities),
}
data, err := json.Marshal(manifest)
if err != nil {

View File

@@ -90,16 +90,24 @@ func setGatewayTestRoutes(routes map[string]string) {
}
func gatewayTestPacket(host string, tail ...byte) []byte {
packet := []byte{
byte(4 + 1 + len(host) + len(tail)),
0x00,
0x00,
0x00,
byte(len(host)),
protocolVersion := byte(0x63)
nextState := byte(0x02)
extra := []byte(nil)
if len(tail) > 0 {
protocolVersion = tail[0]
}
packet = append(packet, host...)
packet = append(packet, tail...)
return packet
if len(tail) > 1 {
nextState = tail[1]
}
if len(tail) > 2 {
extra = tail[2:]
}
payload := []byte{0x00, protocolVersion, byte(len(host))}
payload = append(payload, host...)
payload = append(payload, 0x63, 0xdd, nextState)
payload = append(payload, extra...)
packet := []byte{byte(len(payload))}
return append(packet, payload...)
}
type gatewayTestConn struct {

View File

@@ -6,6 +6,8 @@
本阶段使 MC 正版/三方登录插件具备技术可行性登录、身份映射、forwarding 和登录后的协议处理都由插件完成gateway core 只负责连接交接和治理。
阶段 2 实现后gateway core 不解析 login/encryption/session不消费插件内部认证结果也不根据玩家名、UUID、权限或 session 状态改变后续路由。protocol-proxy 插件接管连接后Minecraft 登录业务完全属于插件core 只保留 initial data replay、双向 copy、draining、force close 和低基数运行摘要。
## 可用性检查点
阶段结束时必须能做到:

View File

@@ -0,0 +1,28 @@
# MC Auth Proxy Plugin
This example registers `upstream.connect/v1` in protocol-proxy mode. It receives
the complete Minecraft byte stream from the gateway, reads the handshake and
login start packets, then returns a login disconnect response unless
`fixture_accept` is enabled.
The example is intentionally small: gateway core does not parse authentication
results, identity mapping, forwarding, or play packets. Those responsibilities
belong inside a protocol-proxy plugin.
Build and package:
```sh
./build.sh
```
The package is written to `dist/mc-auth-proxy.mcgp`.
Example config JSON:
```json
{
"match_host": "play.example",
"fixture_accept": false,
"disconnect_message": "Authentication fixture rejected the login"
}
```

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env sh
set -eu
mkdir -p dist
go build -buildmode=plugin -o dist/plugin.so .
go run ./cmd/render-manifest > dist/manifest.json
cp README.md dist/README.md
(
cd dist
rm -f mc-auth-proxy.mcgp
zip -q mc-auth-proxy.mcgp manifest.json plugin.so README.md
)

View File

@@ -0,0 +1,84 @@
package main
import (
"encoding/json"
"os"
"runtime"
)
func main() {
manifest := map[string]any{
"schema_version": "mc-gateway.plugin/v1",
"id": "mc-auth-proxy",
"name": "Minecraft Auth Proxy",
"version": "0.1.0",
"description": "Protocol-proxy example that reads handshake/login start and returns a login disconnect fixture.",
"artifact_type": "binary",
"runtime": map[string]any{
"type": "go-plugin",
"entry": "plugin.so",
"entry_symbol": "Plugin",
"metadata_symbol": "MCGatewayPluginMetadata",
},
"api_version": "plugin-api/v1",
"sdk_module": "github.com/tursom/mc-gateway/plugin/api",
"sdk_module_version": "v0.1.0",
"go_version": runtime.Version(),
"go_os": runtime.GOOS,
"go_arch": runtime.GOARCH,
"extension_points": []map[string]any{
{"type": "hook", "key": "upstream.connect/v1"},
},
"capabilities": map[string]any{
"extension_points": []string{"upstream.connect/v1"},
"upstream_connect": map[string]any{"mode": "protocol-proxy"},
"minecraft": map[string]any{
"protocol_versions": map[string]any{
"min": 760,
"max": 767,
"tested": []int{760, 763, 765, 767},
"unsupported_policy": "kick",
},
"states": map[string]any{
"status": "transparent",
"login": "handled",
"configuration": "transparent",
"play": "transparent",
},
"auth_modes": []string{"fixture"},
"forwarding": map[string]any{
"supported": []string{"none", "velocity-modern"},
"default": "none",
"requires_secret": false,
},
"unsupported_policy": "kick",
"modded": map[string]any{
"forge": "transparent",
"fabric": "transparent",
"unknown": "pass",
},
},
"network": map[string]any{"outbound": []string{"tcp:*:*"}},
"filesystem": map[string]any{"read": []string{}, "write": []string{}},
"env": []string{},
},
"runtime_limits": map[string]any{
"handler_timeout_ms": 3000,
"initial_write_timeout_ms": 1000,
},
"config_schema": map[string]any{
"type": "object",
"properties": map[string]any{
"match_host": map[string]any{"type": "string"},
"fixture_accept": map[string]any{"type": "boolean"},
"disconnect_message": map[string]any{"type": "string"},
"backend": map[string]any{"type": "string"},
},
},
}
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
if err := encoder.Encode(manifest); err != nil {
panic(err)
}
}

View File

@@ -0,0 +1,7 @@
module github.com/tursom/mc-gateway/examples/plugins/mc-auth-proxy
go 1.24.0
require github.com/tursom/mc-gateway v0.0.0
replace github.com/tursom/mc-gateway => ../../..

View File

@@ -0,0 +1,250 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net"
"runtime"
"time"
"github.com/tursom/mc-gateway/plugin/api"
"github.com/tursom/mc-gateway/protocol"
)
type PluginImpl struct {
api.AbstractPlugin
config Config
}
type Config struct {
MatchHost string `json:"match_host"`
FixtureAccept bool `json:"fixture_accept"`
DisconnectMessage string `json:"disconnect_message"`
Backend string `json:"backend"`
}
type loginStart struct {
Username string
}
func Plugin() api.Plugin {
return &PluginImpl{}
}
func MCGatewayPluginMetadata() string {
return manifestJSON
}
func (p *PluginImpl) NewConfigObj() any {
return &Config{}
}
func (p *PluginImpl) ReloadConfig(config any) error {
if cfg, ok := config.(*Config); ok {
p.config = *cfg
}
if p.config.DisconnectMessage == "" {
p.config.DisconnectMessage = "Authentication fixture rejected the login"
}
return nil
}
func (p *PluginImpl) Init(gateway api.Gateway) error {
return api.RegisterHookHandler(
gateway,
api.HookUpstreamConnect,
func(req api.UpstreamConnectRequest) bool {
return p.config.MatchHost == "" || req.Host == p.config.MatchHost
},
func(req api.UpstreamConnectRequest) (net.Conn, error) {
if p.config.MatchHost != "" && req.Host != p.config.MatchHost {
return nil, api.ErrPass
}
gatewayEnd, pluginEnd := net.Pipe()
go p.handleConn(req, pluginEnd)
return gatewayEnd, nil
},
)
}
func (p *PluginImpl) handleConn(req api.UpstreamConnectRequest, conn net.Conn) {
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
handshakePacket, err := readPacketFromConn(conn)
if err != nil {
return
}
handshake := protocol.ParseHandshake(handshakePacket)
if handshake.ServerHost == "" || handshake.NextState != 2 {
_ = writeLoginDisconnect(conn, "Unsupported Minecraft handshake")
return
}
loginPacket, err := readPacketFromConn(conn)
if err != nil {
_ = writeLoginDisconnect(conn, p.config.DisconnectMessage)
return
}
login, err := parseLoginStart(loginPacket)
if err != nil || login.Username == "" {
_ = writeLoginDisconnect(conn, p.config.DisconnectMessage)
return
}
if !p.config.FixtureAccept {
_ = writeLoginDisconnect(conn, p.config.DisconnectMessage)
return
}
if p.config.Backend == "" {
_ = writeLoginDisconnect(conn, "Fixture accepted but no backend is configured")
return
}
backend, err := net.Dial("tcp", p.config.Backend)
if err != nil {
_ = writeLoginDisconnect(conn, "Backend unavailable")
return
}
defer backend.Close()
_, _ = backend.Write(handshakePacket)
_, _ = backend.Write(loginPacket)
copyBoth(conn, backend)
_ = req
}
func readPacketFromConn(conn net.Conn) ([]byte, error) {
length, err := readVarIntFromConn(conn)
if err != nil {
return nil, err
}
if length <= 0 || length > 2*1024*1024 {
return nil, fmt.Errorf("invalid packet length %d", length)
}
packet := make([]byte, length)
if _, err := io.ReadFull(conn, packet); err != nil {
return nil, err
}
out := append(encodeVarInt(length), packet...)
return out, nil
}
func parseLoginStart(packet []byte) (loginStart, error) {
payload, _, err := protocol.ReadPacket(packet)
if err != nil {
return loginStart{}, err
}
packetID, n, err := protocol.ReadVarInt(payload)
if err != nil {
return loginStart{}, err
}
if packetID != 0 {
return loginStart{}, errors.New("not a login start packet")
}
username, _, err := protocol.ReadString(payload[n:])
if err != nil {
return loginStart{}, err
}
return loginStart{Username: username}, nil
}
func writeLoginDisconnect(conn net.Conn, message string) error {
payload := []byte{0x00}
text, _ := json.Marshal(map[string]any{"text": message})
payload = append(payload, encodeVarInt(len(text))...)
payload = append(payload, text...)
packet := append(encodeVarInt(len(payload)), payload...)
_, err := conn.Write(packet)
return err
}
func readVarIntFromConn(r io.Reader) (int, error) {
var value int
var one [1]byte
for i := 0; i < 5; i++ {
if _, err := io.ReadFull(r, one[:]); err != nil {
return 0, err
}
b := one[0]
value |= int(b&0x7f) << (7 * i)
if b&0x80 == 0 {
return value, nil
}
}
return 0, errors.New("varint too long")
}
func encodeVarInt(value int) []byte {
var out []byte
for {
b := byte(value & 0x7f)
value >>= 7
if value != 0 {
b |= 0x80
}
out = append(out, b)
if value == 0 {
return out
}
}
}
func copyBoth(a, b net.Conn) {
done := make(chan struct{}, 2)
go func() {
_, _ = io.Copy(a, b)
done <- struct{}{}
}()
go func() {
_, _ = io.Copy(b, a)
done <- struct{}{}
}()
<-done
}
var manifestJSON = compactJSON(map[string]any{
"schema_version": "mc-gateway.plugin/v1",
"id": "mc-auth-proxy",
"name": "Minecraft Auth Proxy",
"version": "0.1.0",
"description": "Protocol-proxy example that reads handshake/login start and returns a login disconnect fixture.",
"artifact_type": "binary",
"runtime": map[string]any{
"type": "go-plugin",
"entry": "plugin.so",
"entry_symbol": "Plugin",
"metadata_symbol": "MCGatewayPluginMetadata",
},
"api_version": "plugin-api/v1",
"sdk_module": "github.com/tursom/mc-gateway/plugin/api",
"go_version": runtime.Version(),
"go_os": runtime.GOOS,
"go_arch": runtime.GOARCH,
"extension_points": []map[string]any{
{"type": "hook", "key": "upstream.connect/v1"},
},
"capabilities": map[string]any{
"extension_points": []string{"upstream.connect/v1"},
"upstream_connect": map[string]any{"mode": "protocol-proxy"},
"minecraft": map[string]any{
"protocol_versions": map[string]any{"min": 760, "max": 767, "tested": []int{760, 763, 765, 767}, "unsupported_policy": "kick"},
"states": map[string]any{"status": "transparent", "login": "handled", "configuration": "transparent", "play": "transparent"},
"auth_modes": []string{"fixture"},
"forwarding": map[string]any{"supported": []string{"none", "velocity-modern"}, "default": "none", "requires_secret": false},
"unsupported_policy": "kick",
"modded": map[string]any{"forge": "transparent", "fabric": "transparent", "unknown": "pass"},
},
"network": map[string]any{"outbound": []string{"tcp:*:*"}},
},
"runtime_limits": map[string]any{
"handler_timeout_ms": 3000,
"initial_write_timeout_ms": 1000,
},
})
func compactJSON(value any) string {
data, _ := json.Marshal(value)
return string(data)
}

View File

@@ -0,0 +1,66 @@
package main
import (
"bytes"
"net"
"testing"
"github.com/tursom/mc-gateway/plugin/api"
)
func TestFixtureRejectsLoginStart(t *testing.T) {
plugin := &PluginImpl{}
if err := plugin.ReloadConfig(&Config{DisconnectMessage: "fixture rejected"}); err != nil {
t.Fatalf("ReloadConfig() error = %v", err)
}
client, server := net.Pipe()
done := make(chan struct{})
go func() {
plugin.handleConn(upstreamRequestForTest(), server)
close(done)
}()
if _, err := client.Write(mcAuthProxyHandshakePacket("play.example")); err != nil {
t.Fatalf("write handshake error = %v", err)
}
if _, err := client.Write(mcAuthProxyLoginStartPacket("Steve")); err != nil {
t.Fatalf("write login start error = %v", err)
}
response, err := readPacketFromConn(client)
if err != nil {
t.Fatalf("read response error = %v", err)
}
if !bytes.Contains(response, []byte("fixture rejected")) {
t.Fatalf("response = %q, want fixture message", response)
}
_ = client.Close()
<-done
}
func TestParseLoginStart(t *testing.T) {
login, err := parseLoginStart(mcAuthProxyLoginStartPacket("Alex"))
if err != nil {
t.Fatalf("parseLoginStart() error = %v", err)
}
if login.Username != "Alex" {
t.Fatalf("username = %q, want Alex", login.Username)
}
}
func upstreamRequestForTest() api.UpstreamConnectRequest {
return api.UpstreamConnectRequest{}
}
func mcAuthProxyHandshakePacket(host string) []byte {
payload := []byte{0x00, 0x63, byte(len(host))}
payload = append(payload, host...)
payload = append(payload, 0x63, 0xdd, 0x02)
return append(encodeVarInt(len(payload)), payload...)
}
func mcAuthProxyLoginStartPacket(username string) []byte {
payload := []byte{0x00}
payload = append(payload, encodeVarInt(len(username))...)
payload = append(payload, username...)
return append(encodeVarInt(len(payload)), payload...)
}

View File

@@ -0,0 +1,63 @@
{
"schema_version": "mc-gateway.plugin/v1",
"id": "mc-auth-proxy",
"name": "Minecraft Auth Proxy",
"version": "0.1.0",
"description": "Protocol-proxy example that owns Minecraft login handling and returns a stable fixture disconnect.",
"artifact_type": "binary",
"runtime": {
"type": "go-plugin",
"entry": "plugin.so",
"entry_symbol": "Plugin",
"metadata_symbol": "MCGatewayPluginMetadata"
},
"api_version": "plugin-api/v1",
"sdk_module": "github.com/tursom/mc-gateway/plugin/api",
"go_version": "go1.24.0",
"go_os": "linux",
"go_arch": "amd64",
"extension_points": [
{ "type": "hook", "key": "upstream.connect/v1" }
],
"capabilities": {
"upstream_connect": { "mode": "protocol-proxy" },
"minecraft": {
"protocol_versions": {
"min": 47,
"max": 767,
"tested": [47, 760, 763, 767],
"unsupported_policy": "kick"
},
"states": {
"status": "transparent",
"login": "handled",
"configuration": "transparent",
"play": "transparent"
},
"auth_modes": ["fixture"],
"forwarding": {
"supported": ["none", "velocity-modern"],
"default": "none",
"requires_secret": false
},
"unsupported_policy": "kick",
"modded": {
"forge": "transparent",
"fabric": "transparent",
"fml": "unsupported",
"unknown": "pass"
}
}
},
"runtime_limits": {
"handler_timeout_ms": 3000,
"initial_write_timeout_ms": 1000
},
"config_schema": {
"type": "object",
"properties": {
"match_host": { "type": "string" },
"message": { "type": "string" }
}
}
}

View File

@@ -34,6 +34,7 @@ type APIHandlers struct {
PluginsList http.HandlerFunc
PluginItem SegmentHandlerFunc
PluginAction SegmentHandlerFunc
PluginDraining SegmentHandlerFunc
PluginDispatch http.HandlerFunc
}
@@ -86,6 +87,10 @@ func NewAPIHandler(prefix string, handlers APIHandlers) http.HandlerFunc {
callHandler(w, r, handlers.PluginDispatch)
case strings.HasPrefix(path, "/plugins/"):
pluginPath := strings.TrimPrefix(path, "/plugins/")
if strings.HasSuffix(pluginPath, "/draining/force-close") {
callSegmentHandler(w, r, handlers.PluginDraining, strings.TrimSuffix(pluginPath, "/draining/force-close"))
return
}
if strings.Count(pluginPath, "/") == 1 {
callSegmentHandler(w, r, handlers.PluginAction, pluginPath)
return

View File

@@ -35,6 +35,7 @@ func TestNewAPIHandlerRoutesRequests(t *testing.T) {
{name: "plugins list", method: http.MethodGet, path: "/admin/api/plugins", wantCall: "plugins_list"},
{name: "plugin item", method: http.MethodPut, path: "/admin/api/plugins/upstream-rewrite", wantCall: "plugin_item", wantSegment: "upstream-rewrite"},
{name: "plugin action", method: http.MethodPost, path: "/admin/api/plugins/upstream-rewrite/enable", wantCall: "plugin_action", wantSegment: "upstream-rewrite/enable"},
{name: "plugin draining force close", method: http.MethodPost, path: "/admin/api/plugins/mc-auth-proxy/draining/force-close", wantCall: "plugin_draining", wantSegment: "mc-auth-proxy"},
{name: "plugin dispatch", method: http.MethodGet, path: "/admin/api/plugins/dispatch-plan", wantCall: "plugin_dispatch"},
}
@@ -68,6 +69,7 @@ func TestNewAPIHandlerRoutesRequests(t *testing.T) {
PluginsList: recordCall(&gotCall, "plugins_list"),
PluginItem: recordSegmentCall(&gotCall, &gotSegment, "plugin_item"),
PluginAction: recordSegmentCall(&gotCall, &gotSegment, "plugin_action"),
PluginDraining: recordSegmentCall(&gotCall, &gotSegment, "plugin_draining"),
PluginDispatch: recordCall(&gotCall, "plugin_dispatch"),
})

View File

@@ -188,9 +188,9 @@ func (s ArtifactStore) ValidateAndStore(upload ArtifactUpload) (ArtifactRecord,
if err != nil {
return ArtifactRecord{}, err
}
capabilities := manifest.Capabilities
if len(capabilities) == 0 {
capabilities = json.RawMessage(`{}`)
capabilities, err := capabilitiesSummaryJSON(manifest.Capabilities)
if err != nil {
return ArtifactRecord{}, err
}
now := s.now().Unix()
return ArtifactRecord{
@@ -219,6 +219,38 @@ func (s ArtifactStore) ValidateAndStore(upload ArtifactUpload) (ArtifactRecord,
}, nil
}
func capabilitiesSummaryJSON(raw json.RawMessage) ([]byte, error) {
summary := CapabilitySummary{
UpstreamConnect: UpstreamConnectCapability{Mode: UpstreamModeDialer},
}
if len(raw) == 0 {
return json.Marshal(summary)
}
summary.Raw = append(json.RawMessage(nil), raw...)
var caps struct {
UpstreamConnect UpstreamConnectCapability `json:"upstream_connect"`
Minecraft *MinecraftCapability `json:"minecraft"`
}
if err := json.Unmarshal(raw, &caps); err != nil {
return nil, fmt.Errorf("invalid capabilities: %w", err)
}
if caps.UpstreamConnect.Mode != "" {
summary.UpstreamConnect.Mode = caps.UpstreamConnect.Mode
}
if caps.Minecraft != nil {
summary.Minecraft = caps.Minecraft
if summary.Minecraft.UnsupportedPolicy == "" {
summary.Minecraft.UnsupportedPolicy = summary.Minecraft.ProtocolVersions.UnsupportedPolicy
}
}
switch summary.UpstreamConnect.Mode {
case UpstreamModeDialer, UpstreamModeProtocolProxy:
default:
return nil, fmt.Errorf("unsupported upstream_connect.mode %q", summary.UpstreamConnect.Mode)
}
return json.Marshal(summary)
}
func validateManifest(manifest Manifest) error {
switch {
case manifest.SchemaVersion != SchemaVersion:

View File

@@ -91,7 +91,14 @@ func TestArtifactStoreRejectsUnsafePackage(t *testing.T) {
}
func testManifestBytes(t *testing.T, pluginID string) []byte {
return testManifestBytesWithCapabilities(t, pluginID, json.RawMessage(`{"extension_points":["upstream.connect/v1"]}`))
}
func testManifestBytesWithCapabilities(t *testing.T, pluginID string, capabilities json.RawMessage) []byte {
t.Helper()
if len(capabilities) == 0 {
capabilities = json.RawMessage(`{"extension_points":["upstream.connect/v1"]}`)
}
manifest := Manifest{
SchemaVersion: SchemaVersion,
ID: pluginID,
@@ -111,7 +118,7 @@ func testManifestBytes(t *testing.T, pluginID string) []byte {
Type: "hook",
Key: ExtensionUpstreamConnect,
}},
Capabilities: json.RawMessage(`{"extension_points":["upstream.connect/v1"]}`),
Capabilities: capabilities,
ConfigSchema: json.RawMessage(`{"type":"object"}`),
RuntimeLimits: RuntimeLimits{HandlerTimeoutMS: 3000},
}

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net"
stdplugin "plugin"
"reflect"
@@ -76,6 +77,11 @@ type Manager struct {
mu sync.Mutex
loaded map[string]*loadedPlugin
snapshot atomic.Value
proxyMu sync.Mutex
proxySeq uint64
proxyConns map[uint64]*proxyConnection
drainingIDs map[string]bool
}
type loadedPlugin struct {
@@ -87,18 +93,52 @@ type loadedPlugin struct {
}
type upstreamHandler struct {
pluginID string
artifactID string
priority int
handlerID string
mode string
timeout time.Duration
initialWriteTimeout time.Duration
accept func(api.UpstreamConnectRequest) bool
handle func(api.UpstreamConnectRequest) (net.Conn, error)
calls atomic.Uint64
errors atomic.Uint64
panics atomic.Uint64
timeouts atomic.Uint64
blocked atomic.Uint64
activeProxy atomic.Int64
proxyStarted atomic.Uint64
proxyCompleted atomic.Uint64
proxyErrors atomic.Uint64
proxyBytesIn atomic.Uint64
proxyBytesOut atomic.Uint64
proxyDuration atomic.Uint64
}
type proxyConnection struct {
id uint64
pluginID string
artifactID string
priority int
handlerID string
timeout time.Duration
accept func(api.UpstreamConnectRequest) bool
handle func(api.UpstreamConnectRequest) (net.Conn, error)
handler *upstreamHandler
client net.Conn
endpoint net.Conn
startedAt time.Time
draining bool
}
calls atomic.Uint64
errors atomic.Uint64
panics atomic.Uint64
timeouts atomic.Uint64
type ProxyConnectionHandle struct {
manager *Manager
id uint64
}
type ProxyConnectionStats struct {
BytesToPlugin int64
BytesToClient int64
Duration time.Duration
Err error
}
type Options struct {
@@ -115,12 +155,14 @@ func New(options Options) *Manager {
adapter = GoPluginAdapter{}
}
manager := &Manager{
repo: NewRepository(options.DB),
store: NewArtifactStore(options.ArtifactRoot),
adapter: adapter,
handleConn: options.HandleConn,
wg: options.WaitGroup,
loaded: make(map[string]*loadedPlugin),
repo: NewRepository(options.DB),
store: NewArtifactStore(options.ArtifactRoot),
adapter: adapter,
handleConn: options.HandleConn,
wg: options.WaitGroup,
loaded: make(map[string]*loadedPlugin),
proxyConns: make(map[uint64]*proxyConnection),
drainingIDs: make(map[string]bool),
}
manager.publish(nil)
return manager
@@ -207,6 +249,7 @@ func (m *Manager) Enable(ctx context.Context, actor, pluginID string) (PluginRec
if err := m.markEnabled(ctx, loaded); err != nil {
return PluginRecord{}, err
}
m.clearDrainingLocked(pluginID)
m.publish(next)
_ = m.repo.UpdateArtifactStatus(ctx, loaded.artifact.ID, ArtifactStatusLoaded, "")
_ = m.repo.RecordOperation(ctx, pluginID, loaded.artifact.ID, "enable", "succeeded", actor, "plugin enabled", map[string]any{
@@ -229,13 +272,20 @@ func (m *Manager) Disable(ctx context.Context, actor, pluginID string) (PluginRe
return PluginRecord{}, err
}
m.removeFromDispatchLocked(pluginID)
m.markDrainingLocked(pluginID)
if loaded := m.loaded[pluginID]; loaded != nil && loaded.instance != nil {
if err := loaded.instance.Destroy(); err != nil {
_ = m.repo.RecordOperation(ctx, pluginID, loaded.artifact.ID, "disable", "warning", actor, err.Error(), nil)
}
}
runtimeState := RuntimeDisabled
if m.activeProxyCountLocked(pluginID) > 0 {
runtimeState = RuntimeDraining
}
delete(m.loaded, pluginID)
if err := m.repo.MarkRuntime(ctx, pluginID, RuntimeDisabled, "", "", pluginRecord.DesiredGeneration, "", nil, nil); err != nil {
if err := m.repo.MarkRuntime(ctx, pluginID, runtimeState, "", "", pluginRecord.DesiredGeneration, "", map[string]any{
"active_proxy_connections": m.activeProxyCountLocked(pluginID),
}, nil); err != nil {
return PluginRecord{}, err
}
_ = m.repo.RecordOperation(ctx, pluginID, pluginRecord.DesiredArtifactID, "disable", "succeeded", actor, "plugin disabled", nil)
@@ -251,6 +301,7 @@ func (m *Manager) Delete(ctx context.Context, actor, pluginID string) error {
return err
}
m.removeFromDispatchLocked(pluginID)
m.markDrainingLocked(pluginID)
if loaded := m.loaded[pluginID]; loaded != nil && loaded.instance != nil {
_ = loaded.instance.Destroy()
}
@@ -288,6 +339,7 @@ func (m *Manager) Reconcile(ctx context.Context) error {
}
nextByPlugin[pluginRecord.ID] = loaded.handlers
_ = m.markEnabled(ctx, loaded)
m.clearDrainingLocked(pluginRecord.ID)
}
m.publish(flattenHandlers(nextByPlugin))
return nil
@@ -305,6 +357,7 @@ func (m *Manager) ConnectUpstream(ctx context.Context, req api.UpstreamConnectRe
if req.Context == nil {
req.Context = ctx
}
req.InitialData = append([]byte(nil), req.InitialData...)
for _, handler := range handlers {
accepted, err := handler.accepts(req)
if err != nil {
@@ -321,12 +374,54 @@ func (m *Manager) ConnectUpstream(ctx context.Context, req api.UpstreamConnectRe
return UpstreamResult{Handled: true}, err
}
if conn != nil {
return UpstreamResult{Conn: conn, Handled: true}, nil
result := UpstreamResult{
Conn: conn,
Handled: true,
Mode: handler.mode,
PluginID: handler.pluginID,
HandlerID: handler.handlerID,
}
if handler.mode == UpstreamModeProtocolProxy {
return m.startProtocolProxy(ctx, handler, result, req)
}
return result, nil
}
}
return UpstreamResult{}, nil
}
func (m *Manager) startProtocolProxy(ctx context.Context, handler *upstreamHandler, result UpstreamResult, req api.UpstreamConnectRequest) (UpstreamResult, error) {
endpoint := result.Conn
initial := append([]byte(nil), req.InitialData...)
if len(initial) > 0 {
if handler.initialWriteTimeout > 0 {
_ = endpoint.SetWriteDeadline(time.Now().Add(handler.initialWriteTimeout))
defer endpoint.SetWriteDeadline(time.Time{})
}
if err := writeAll(endpoint, initial); err != nil {
handler.proxyErrors.Add(1)
_ = endpoint.Close()
return UpstreamResult{Handled: true, Mode: handler.mode, PluginID: handler.pluginID, HandlerID: handler.handlerID}, fmt.Errorf("plugin %s protocol-proxy initial replay failed: %w", handler.pluginID, err)
}
}
handle := m.TrackProxyConnection(result, req.Source, endpoint)
if handle == nil {
_ = endpoint.Close()
return UpstreamResult{Handled: true, Mode: handler.mode, PluginID: handler.pluginID, HandlerID: handler.handlerID}, fmt.Errorf("plugin %s protocol-proxy tracking failed", handler.pluginID)
}
runProtocolProxy(ctx, handle, req.Source, endpoint)
return UpstreamResult{
Handled: true,
Mode: handler.mode,
PluginID: handler.pluginID,
HandlerID: handler.handlerID,
InitialDataSent: len(initial) > 0,
Proxied: true,
}, nil
}
func (h *upstreamHandler) accepts(req api.UpstreamConnectRequest) (accepted bool, err error) {
if h.accept == nil {
return true, nil
@@ -366,6 +461,106 @@ func (m *Manager) DispatchPlan(ctx context.Context) DispatchPlan {
return plan
}
func (m *Manager) TrackProxyConnection(result UpstreamResult, client, endpoint net.Conn) *ProxyConnectionHandle {
if result.Mode != UpstreamModeProtocolProxy || client == nil || endpoint == nil {
return nil
}
handler := m.findHandler(result.PluginID, result.HandlerID)
if handler == nil {
return nil
}
id := atomic.AddUint64(&m.proxySeq, 1)
proxyConn := &proxyConnection{
id: id,
pluginID: result.PluginID,
artifactID: handler.artifactID,
handlerID: result.HandlerID,
handler: handler,
client: client,
endpoint: endpoint,
startedAt: time.Now(),
}
handler.activeProxy.Add(1)
handler.proxyStarted.Add(1)
m.proxyMu.Lock()
proxyConn.draining = m.drainingIDs[result.PluginID]
m.proxyConns[id] = proxyConn
m.proxyMu.Unlock()
return &ProxyConnectionHandle{manager: m, id: id}
}
func (h *ProxyConnectionHandle) Finish(stats ProxyConnectionStats) {
if h == nil || h.manager == nil {
return
}
h.manager.finishProxyConnection(h.id, stats)
}
func (m *Manager) ForceCloseDraining(ctx context.Context, actor, pluginID string) (int, error) {
_ = ctx
var conns []*proxyConnection
m.proxyMu.Lock()
for _, conn := range m.proxyConns {
if conn.pluginID == pluginID && conn.draining {
conns = append(conns, conn)
}
}
m.proxyMu.Unlock()
for _, conn := range conns {
_ = conn.client.Close()
_ = conn.endpoint.Close()
}
_ = m.repo.RecordOperation(ctx, pluginID, "", "force_close_draining", "succeeded", actor, "draining protocol-proxy connections force closed", map[string]any{
"closed": len(conns),
})
return len(conns), nil
}
func (m *Manager) findHandler(pluginID, handlerID string) *upstreamHandler {
value := m.snapshot.Load()
if handlers, ok := value.([]*upstreamHandler); ok {
for _, handler := range handlers {
if handler.pluginID == pluginID && handler.handlerID == handlerID {
return handler
}
}
}
m.mu.Lock()
defer m.mu.Unlock()
if loaded := m.loaded[pluginID]; loaded != nil {
for _, handler := range loaded.handlers {
if handler.handlerID == handlerID {
return handler
}
}
}
return nil
}
func (m *Manager) finishProxyConnection(id uint64, stats ProxyConnectionStats) {
m.proxyMu.Lock()
proxyConn := m.proxyConns[id]
delete(m.proxyConns, id)
m.proxyMu.Unlock()
if proxyConn == nil || proxyConn.handler == nil {
return
}
proxyConn.handler.activeProxy.Add(-1)
proxyConn.handler.proxyCompleted.Add(1)
if stats.Err != nil {
proxyConn.handler.proxyErrors.Add(1)
}
if stats.BytesToPlugin > 0 {
proxyConn.handler.proxyBytesIn.Add(uint64(stats.BytesToPlugin))
}
if stats.BytesToClient > 0 {
proxyConn.handler.proxyBytesOut.Add(uint64(stats.BytesToClient))
}
if stats.Duration > 0 {
proxyConn.handler.proxyDuration.Add(uint64(stats.Duration.Milliseconds()))
}
}
func (m *Manager) loadLocked(ctx context.Context, pluginRecord PluginRecord) (*loadedPlugin, error) {
if loaded := m.loaded[pluginRecord.ID]; loaded != nil &&
loaded.artifact.ID == pluginRecord.DesiredArtifactID &&
@@ -426,6 +621,35 @@ func (m *Manager) removeFromDispatchLocked(pluginID string) {
m.publish(flattenHandlers(current))
}
func (m *Manager) markDrainingLocked(pluginID string) {
m.proxyMu.Lock()
defer m.proxyMu.Unlock()
m.drainingIDs[pluginID] = true
for _, conn := range m.proxyConns {
if conn.pluginID == pluginID {
conn.draining = true
}
}
}
func (m *Manager) clearDrainingLocked(pluginID string) {
m.proxyMu.Lock()
delete(m.drainingIDs, pluginID)
m.proxyMu.Unlock()
}
func (m *Manager) activeProxyCountLocked(pluginID string) int {
m.proxyMu.Lock()
defer m.proxyMu.Unlock()
count := 0
for _, conn := range m.proxyConns {
if conn.pluginID == pluginID {
count++
}
}
return count
}
func (m *Manager) publish(handlers []*upstreamHandler) {
sort.SliceStable(handlers, func(i, j int) bool {
if handlers[i].priority != handlers[j].priority {
@@ -441,31 +665,42 @@ func (m *Manager) publish(handlers []*upstreamHandler) {
func buildHandlers(pluginRecord PluginRecord, artifact ArtifactRecord, gateway *Gateway) []*upstreamHandler {
timeout := DefaultHandlerTimeout
initialWriteTimeout := DefaultInitialWriteTimeout
var manifest Manifest
if err := json.Unmarshal([]byte(artifact.MetadataJSON), &manifest); err == nil && manifest.RuntimeLimits.HandlerTimeoutMS > 0 {
timeout = time.Duration(manifest.RuntimeLimits.HandlerTimeoutMS) * time.Millisecond
if err := json.Unmarshal([]byte(artifact.MetadataJSON), &manifest); err == nil {
if manifest.RuntimeLimits.HandlerTimeoutMS > 0 {
timeout = time.Duration(manifest.RuntimeLimits.HandlerTimeoutMS) * time.Millisecond
}
if manifest.RuntimeLimits.InitialWriteTimeoutMS > 0 {
initialWriteTimeout = time.Duration(manifest.RuntimeLimits.InitialWriteTimeoutMS) * time.Millisecond
}
}
var handlers []*upstreamHandler
mode := upstreamModeFromArtifact(artifact)
if hook, ok := gateway.UpstreamConnectHandler(); ok {
handlers = append(handlers, &upstreamHandler{
pluginID: pluginRecord.ID,
artifactID: artifact.ID,
priority: pluginRecord.Priority,
handlerID: "upstream.connect/v1",
timeout: timeout,
accept: hook.Acceptor(),
handle: hook.Handler(),
pluginID: pluginRecord.ID,
artifactID: artifact.ID,
priority: pluginRecord.Priority,
handlerID: "upstream.connect/v1",
mode: mode,
timeout: timeout,
initialWriteTimeout: initialWriteTimeout,
accept: hook.Acceptor(),
handle: hook.Handler(),
})
}
if hook, ok := gateway.LegacyUpstreamHandler(); ok {
acceptor := hook.Acceptor()
handler := hook.Handler()
handlers = append(handlers, &upstreamHandler{
pluginID: pluginRecord.ID,
artifactID: artifact.ID,
priority: pluginRecord.Priority,
handlerID: "legacy-upstream",
timeout: timeout,
pluginID: pluginRecord.ID,
artifactID: artifact.ID,
priority: pluginRecord.Priority,
handlerID: "legacy-upstream",
mode: UpstreamModeDialer,
timeout: timeout,
initialWriteTimeout: initialWriteTimeout,
accept: func(req api.UpstreamConnectRequest) bool {
return acceptor(req.Source, req.Upstream)
},
@@ -477,6 +712,19 @@ func buildHandlers(pluginRecord PluginRecord, artifact ArtifactRecord, gateway *
return handlers
}
func upstreamModeFromArtifact(artifact ArtifactRecord) string {
var summary CapabilitySummary
if err := json.Unmarshal([]byte(artifact.CapabilitiesSummaryJSON), &summary); err == nil {
switch summary.UpstreamConnect.Mode {
case UpstreamModeProtocolProxy:
return UpstreamModeProtocolProxy
case UpstreamModeDialer:
return UpstreamModeDialer
}
}
return UpstreamModeDialer
}
func (h *upstreamHandler) invoke(req api.UpstreamConnectRequest) (conn net.Conn, err error) {
h.calls.Add(1)
ctx := req.Context
@@ -508,6 +756,9 @@ func (h *upstreamHandler) invoke(req api.UpstreamConnectRequest) (conn net.Conn,
go closeLateConn(done)
return nil, ctx.Err()
case result := <-done:
if errors.Is(result.err, api.ErrBlocked) {
h.blocked.Add(1)
}
if result.err != nil && !errors.Is(result.err, api.ErrPass) {
h.errors.Add(1)
}
@@ -527,6 +778,116 @@ func closeLateConn(done <-chan result) {
}
}
type proxyCopyResult struct {
toPlugin bool
bytes int64
err error
}
type closeWriter interface {
CloseWrite() error
}
type closeReader interface {
CloseRead() error
}
func runProtocolProxy(ctx context.Context, handle *ProxyConnectionHandle, client, endpoint net.Conn) {
start := time.Now()
defer client.Close()
defer endpoint.Close()
done := make(chan proxyCopyResult, 2)
stopContext := make(chan struct{})
if ctx != nil {
go func() {
select {
case <-ctx.Done():
_ = client.Close()
_ = endpoint.Close()
case <-stopContext:
}
}()
}
go copyProtocolProxy(endpoint, client, true, done)
go copyProtocolProxy(client, endpoint, false, done)
var stats ProxyConnectionStats
for i := 0; i < 2; i++ {
result := <-done
if result.toPlugin {
stats.BytesToPlugin += result.bytes
} else {
stats.BytesToClient += result.bytes
}
if result.err != nil && !errors.Is(result.err, io.EOF) && stats.Err == nil {
stats.Err = result.err
}
}
close(stopContext)
stats.Duration = time.Since(start)
handle.Finish(stats)
}
func copyProtocolProxy(dst io.Writer, src io.Reader, toPlugin bool, done chan<- proxyCopyResult) {
result := proxyCopyResult{toPlugin: toPlugin}
defer func() {
if rec := recover(); rec != nil {
result.err = fmt.Errorf("protocol-proxy copy panic: %v", rec)
}
closeRead(src)
if toPlugin {
closeWriteOnly(dst)
} else {
closeWrite(dst)
}
done <- result
}()
result.bytes, result.err = copyForward(dst, src)
}
func copyForward(dst io.Writer, src io.Reader) (int64, error) {
return io.Copy(dst, src)
}
func writeAll(w io.Writer, buf []byte) error {
for len(buf) > 0 {
n, err := w.Write(buf)
if n > 0 {
buf = buf[n:]
}
if err != nil {
return err
}
if n == 0 {
return io.ErrShortWrite
}
}
return nil
}
func closeWrite(conn any) {
if closer, ok := conn.(closeWriter); ok {
_ = closer.CloseWrite()
return
}
if closer, ok := conn.(io.Closer); ok {
_ = closer.Close()
}
}
func closeWriteOnly(conn any) {
if closer, ok := conn.(closeWriter); ok {
_ = closer.CloseWrite()
}
}
func closeRead(conn any) {
if closer, ok := conn.(closeReader); ok {
_ = closer.CloseRead()
}
}
func flattenHandlers(byPlugin map[string][]*upstreamHandler) []*upstreamHandler {
var handlers []*upstreamHandler
for _, pluginHandlers := range byPlugin {
@@ -539,16 +900,25 @@ func handlerSummaries(handlers []*upstreamHandler) []DispatchHandlerSummary {
summaries := make([]DispatchHandlerSummary, 0, len(handlers))
for _, handler := range handlers {
summaries = append(summaries, DispatchHandlerSummary{
PluginID: handler.pluginID,
ArtifactID: handler.artifactID,
Priority: handler.priority,
HandlerID: handler.handlerID,
ExtensionPoint: ExtensionUpstreamConnect,
TimeoutMS: handler.timeout.Milliseconds(),
Calls: handler.calls.Load(),
Errors: handler.errors.Load(),
Panics: handler.panics.Load(),
Timeouts: handler.timeouts.Load(),
PluginID: handler.pluginID,
ArtifactID: handler.artifactID,
Priority: handler.priority,
HandlerID: handler.handlerID,
ExtensionPoint: ExtensionUpstreamConnect,
Mode: handler.mode,
TimeoutMS: handler.timeout.Milliseconds(),
Calls: handler.calls.Load(),
Errors: handler.errors.Load(),
Panics: handler.panics.Load(),
Timeouts: handler.timeouts.Load(),
Blocked: handler.blocked.Load(),
ActiveProxy: handler.activeProxy.Load(),
ProxyStarted: handler.proxyStarted.Load(),
ProxyCompleted: handler.proxyCompleted.Load(),
ProxyErrors: handler.proxyErrors.Load(),
ProxyBytesIn: handler.proxyBytesIn.Load(),
ProxyBytesOut: handler.proxyBytesOut.Load(),
ProxyDurationMS: handler.proxyDuration.Load(),
})
}
return summaries

View File

@@ -1,12 +1,16 @@
package pluginmanager
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"net"
"path/filepath"
"testing"
"time"
"github.com/tursom/mc-gateway/internal/admindb"
"github.com/tursom/mc-gateway/plugin/api"
@@ -109,6 +113,46 @@ func TestManagerErrPassContinuesToNextHandler(t *testing.T) {
}
}
func TestManagerErrBlockedStopsDispatch(t *testing.T) {
adapter := &fakeAdapter{
handlers: map[string]api.UpstreamConnectHandler{
"plugin-a": func(api.UpstreamConnectRequest) (net.Conn, error) {
return nil, api.ErrBlocked
},
"plugin-b": func(api.UpstreamConnectRequest) (net.Conn, error) {
return newMemoryConn(), nil
},
},
}
manager := newManagerForTest(t, adapter)
artifactA := uploadTestArtifact(t, manager, "plugin-a")
artifactB := uploadTestArtifact(t, manager, "plugin-b")
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-a", artifactA.ID, DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired(a) error = %v", err)
}
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-b", artifactB.ID, DesiredEnabled, `{}`, 20); err != nil {
t.Fatalf("SetDesired(b) error = %v", err)
}
if err := manager.Reconcile(context.Background()); err != nil {
t.Fatalf("Reconcile() error = %v", err)
}
result, err := manager.ConnectUpstream(context.Background(), api.UpstreamConnectRequest{Host: "play.example", Upstream: "backend"})
if !errors.Is(err, api.ErrBlocked) {
t.Fatalf("ConnectUpstream() error = %v, want ErrBlocked", err)
}
if !result.Handled {
t.Fatalf("ConnectUpstream() = %+v, want handled", result)
}
plan := manager.DispatchPlan(context.Background())
if got := plan.Handlers[0].Blocked; got != 1 {
t.Fatalf("blocked count = %d, want 1", got)
}
if got := plan.Handlers[1].Calls; got != 0 {
t.Fatalf("second handler calls = %d, want 0", got)
}
}
func TestManagerPanicDoesNotReplaceExistingDispatch(t *testing.T) {
adapter := &fakeAdapter{
handlers: map[string]api.UpstreamConnectHandler{
@@ -240,6 +284,176 @@ func TestAcceptorPanicIsRecovered(t *testing.T) {
}
}
func TestProtocolProxyTrackDrainAndForceClose(t *testing.T) {
clientGateway, clientSide := net.Pipe()
defer clientSide.Close()
pluginGateway, pluginSide := net.Pipe()
defer pluginSide.Close()
handlerReturned := make(chan struct{})
adapter := &fakeAdapter{
handlers: map[string]api.UpstreamConnectHandler{
"plugin-a": func(req api.UpstreamConnectRequest) (net.Conn, error) {
go func() {
buf := make([]byte, len(req.InitialData))
if _, err := io.ReadFull(pluginSide, buf); err != nil {
t.Errorf("plugin side initial read error = %v", err)
}
close(handlerReturned)
_, _ = pluginSide.Read(make([]byte, 1))
}()
return pluginGateway, nil
},
},
}
manager := newManagerForTest(t, adapter)
artifact := uploadTestArtifactWithCapabilities(t, manager, "plugin-a", json.RawMessage(`{"upstream_connect":{"mode":"protocol-proxy"}}`))
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-a", artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
if _, err := manager.Enable(context.Background(), "admin", "plugin-a"); err != nil {
t.Fatalf("Enable() error = %v", err)
}
errCh := make(chan error, 1)
go func() {
_, err := manager.ConnectUpstream(context.Background(), api.UpstreamConnectRequest{
Host: "play.example",
Upstream: "backend",
Source: clientGateway,
InitialData: []byte("hello"),
})
errCh <- err
}()
<-handlerReturned
waitForPluginManagerTest(t, func() bool {
return manager.DispatchPlan(context.Background()).Handlers[0].ActiveProxy == 1
})
plan := manager.DispatchPlan(context.Background())
if got := plan.Handlers[0].ActiveProxy; got != 1 {
t.Fatalf("active proxy = %d, want 1", got)
}
if _, err := manager.Disable(context.Background(), "admin", "plugin-a"); err != nil {
t.Fatalf("Disable() error = %v", err)
}
closed, err := manager.ForceCloseDraining(context.Background(), "admin", "plugin-a")
if err != nil {
t.Fatalf("ForceCloseDraining() error = %v", err)
}
if closed != 1 {
t.Fatalf("ForceCloseDraining() = %d, want 1", closed)
}
if err := <-errCh; err != nil {
t.Fatalf("ConnectUpstream() error = %v", err)
}
waitForPluginManagerTest(t, func() bool {
return manager.activeProxyCountLocked("plugin-a") == 0
})
}
func TestProtocolProxyReplaysInitialAndForwardsClientBytes(t *testing.T) {
clientGateway, clientSide := net.Pipe()
defer clientSide.Close()
initial := []byte("initial-handshake")
next := []byte("login-start")
pluginRead := make(chan []byte, 1)
adapter := &fakeAdapter{
handlers: map[string]api.UpstreamConnectHandler{
"plugin-a": func(api.UpstreamConnectRequest) (net.Conn, error) {
gatewayEnd, pluginEnd := net.Pipe()
go func() {
defer pluginEnd.Close()
buf := make([]byte, len(initial)+len(next))
if _, err := io.ReadFull(pluginEnd, buf); err != nil {
t.Errorf("plugin read error = %v", err)
return
}
pluginRead <- buf
}()
return gatewayEnd, nil
},
},
}
manager := newManagerForTest(t, adapter)
enableProtocolProxyTestPlugin(t, manager, "plugin-a")
errCh := make(chan error, 1)
go func() {
_, err := manager.ConnectUpstream(context.Background(), api.UpstreamConnectRequest{
Source: clientGateway,
InitialData: initial,
})
errCh <- err
}()
if _, err := clientSide.Write(next); err != nil {
t.Fatalf("client write error = %v", err)
}
got := <-pluginRead
if !bytes.Equal(got, append(append([]byte(nil), initial...), next...)) {
t.Fatalf("plugin bytes = %q, want initial+next", got)
}
_ = clientSide.Close()
if err := <-errCh; err != nil {
t.Fatalf("ConnectUpstream() error = %v", err)
}
plan := manager.DispatchPlan(context.Background())
if got := plan.Handlers[0].ProxyBytesIn; got != uint64(len(next)) {
t.Fatalf("proxy bytes in = %d, want %d", got, len(next))
}
}
func TestProtocolProxyInitialWriteTimeoutClosesUnreadableConn(t *testing.T) {
reader, writer := net.Pipe()
defer reader.Close()
adapter := &fakeAdapter{
handlers: map[string]api.UpstreamConnectHandler{
"plugin-a": func(api.UpstreamConnectRequest) (net.Conn, error) {
return writer, nil
},
},
}
manager := newManagerForTest(t, adapter)
artifact := uploadTestArtifactWithCapabilities(t, manager, "plugin-a", json.RawMessage(`{"upstream_connect":{"mode":"protocol-proxy"}}`))
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-a", artifact.ID, DesiredEnabled, `{"unused":true}`, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
if _, err := manager.Enable(context.Background(), "admin", "plugin-a"); err != nil {
t.Fatalf("Enable() error = %v", err)
}
sourceGateway, sourceClient := net.Pipe()
defer sourceGateway.Close()
defer sourceClient.Close()
done := make(chan error, 1)
go func() {
initial := bytes.Repeat([]byte("x"), 2*1024*1024)
_, err := manager.ConnectUpstream(context.Background(), api.UpstreamConnectRequest{
Source: sourceGateway,
InitialData: initial,
})
done <- err
}()
select {
case err := <-done:
if err == nil {
t.Fatal("ConnectUpstream() error = nil, want initial replay failure")
}
case <-time.After(2 * time.Second):
t.Fatal("ConnectUpstream() did not return after initial write deadline")
}
}
func enableProtocolProxyTestPlugin(t *testing.T, manager *Manager, pluginID string) ArtifactRecord {
t.Helper()
artifact := uploadTestArtifactWithCapabilities(t, manager, pluginID, json.RawMessage(`{"upstream_connect":{"mode":"protocol-proxy"}}`))
if _, err := manager.SetDesired(context.Background(), "admin", pluginID, artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
if _, err := manager.Enable(context.Background(), "admin", pluginID); err != nil {
t.Fatalf("Enable() error = %v", err)
}
return artifact
}
func newManagerForTest(t *testing.T, adapter RuntimeAdapter) *Manager {
t.Helper()
db := openPluginManagerTestDB(t)
@@ -264,9 +478,14 @@ func openPluginManagerTestDB(t *testing.T) *sql.DB {
}
func uploadTestArtifact(t *testing.T, manager *Manager, pluginID string) ArtifactRecord {
t.Helper()
return uploadTestArtifactWithCapabilities(t, manager, pluginID, nil)
}
func uploadTestArtifactWithCapabilities(t *testing.T, manager *Manager, pluginID string, capabilities json.RawMessage) ArtifactRecord {
t.Helper()
packagePath := writeTestMCGP(t, map[string][]byte{
"manifest.json": testManifestBytes(t, pluginID),
"manifest.json": testManifestBytesWithCapabilities(t, pluginID, capabilities),
"plugin.so": []byte("fake plugin bytes " + pluginID),
})
artifact, err := manager.UploadArtifact(context.Background(), ArtifactUpload{
@@ -280,6 +499,23 @@ func uploadTestArtifact(t *testing.T, manager *Manager, pluginID string) Artifac
return artifact
}
func waitForPluginManagerTest(t *testing.T, done func() bool) {
t.Helper()
deadline := time.After(2 * time.Second)
ticker := time.NewTicker(time.Millisecond)
defer ticker.Stop()
for {
select {
case <-deadline:
t.Fatal("timed out waiting for plugin manager condition")
case <-ticker.C:
if done() {
return
}
}
}
}
type fakeAdapter struct {
loads int
handlers map[string]api.UpstreamConnectHandler

View File

@@ -20,6 +20,9 @@ const (
ExtensionUpstreamConnect = "upstream.connect/v1"
UpstreamModeDialer = "dialer"
UpstreamModeProtocolProxy = "protocol-proxy"
ArtifactStatusUploaded = "uploaded"
ArtifactStatusValidated = "validated"
ArtifactStatusLoadable = "loadable"
@@ -36,14 +39,16 @@ const (
RuntimeEnabled = "enabled"
RuntimeFailed = "failed"
RuntimeDisabled = "disabled"
RuntimeDraining = "draining"
DefaultPriority = 100
DefaultHandlerTimeout = 3 * time.Second
DefaultManifestMaxBytes = 256 * 1024
DefaultPackageMaxBytes = 64 * 1024 * 1024
DefaultPackageMaxEntries = 2048
DefaultExtractedMaxBytes = 256 * 1024 * 1024
DefaultNonRuntimeMaxBytes = 16 * 1024 * 1024
DefaultPriority = 100
DefaultHandlerTimeout = 3 * time.Second
DefaultManifestMaxBytes = 256 * 1024
DefaultPackageMaxBytes = 64 * 1024 * 1024
DefaultPackageMaxEntries = 2048
DefaultExtractedMaxBytes = 256 * 1024 * 1024
DefaultNonRuntimeMaxBytes = 16 * 1024 * 1024
DefaultInitialWriteTimeout = time.Second
)
var (
@@ -85,7 +90,40 @@ type ExtensionPoint struct {
}
type RuntimeLimits struct {
HandlerTimeoutMS int `json:"handler_timeout_ms"`
HandlerTimeoutMS int `json:"handler_timeout_ms"`
InitialWriteTimeoutMS int `json:"initial_write_timeout_ms"`
}
type CapabilitySummary struct {
UpstreamConnect UpstreamConnectCapability `json:"upstream_connect,omitempty"`
Minecraft *MinecraftCapability `json:"minecraft,omitempty"`
Raw json.RawMessage `json:"raw,omitempty"`
}
type UpstreamConnectCapability struct {
Mode string `json:"mode,omitempty"`
}
type MinecraftCapability struct {
ProtocolVersions MinecraftProtocolVersions `json:"protocol_versions,omitempty"`
States map[string]string `json:"states,omitempty"`
AuthModes []string `json:"auth_modes,omitempty"`
Forwarding MinecraftForwarding `json:"forwarding,omitempty"`
UnsupportedPolicy string `json:"unsupported_policy,omitempty"`
Modded map[string]string `json:"modded,omitempty"`
}
type MinecraftProtocolVersions struct {
Min int `json:"min,omitempty"`
Max int `json:"max,omitempty"`
Tested []int `json:"tested,omitempty"`
UnsupportedPolicy string `json:"unsupported_policy,omitempty"`
}
type MinecraftForwarding struct {
Supported []string `json:"supported,omitempty"`
Default string `json:"default,omitempty"`
RequiresSecret bool `json:"requires_secret,omitempty"`
}
type ArtifactRecord struct {
@@ -163,21 +201,35 @@ type DispatchPlan struct {
}
type DispatchHandlerSummary struct {
PluginID string `json:"plugin_id"`
ArtifactID string `json:"artifact_id"`
Priority int `json:"priority"`
HandlerID string `json:"handler_id"`
ExtensionPoint string `json:"extension_point"`
TimeoutMS int64 `json:"timeout_ms"`
Calls uint64 `json:"calls"`
Errors uint64 `json:"errors"`
Panics uint64 `json:"panics"`
Timeouts uint64 `json:"timeouts"`
PluginID string `json:"plugin_id"`
ArtifactID string `json:"artifact_id"`
Priority int `json:"priority"`
HandlerID string `json:"handler_id"`
ExtensionPoint string `json:"extension_point"`
Mode string `json:"mode"`
TimeoutMS int64 `json:"timeout_ms"`
Calls uint64 `json:"calls"`
Errors uint64 `json:"errors"`
Panics uint64 `json:"panics"`
Timeouts uint64 `json:"timeouts"`
Blocked uint64 `json:"blocked"`
ActiveProxy int64 `json:"active_proxy_connections"`
ProxyStarted uint64 `json:"proxy_connections_started"`
ProxyCompleted uint64 `json:"proxy_connections_completed"`
ProxyErrors uint64 `json:"proxy_errors"`
ProxyBytesIn uint64 `json:"proxy_bytes_in"`
ProxyBytesOut uint64 `json:"proxy_bytes_out"`
ProxyDurationMS uint64 `json:"proxy_duration_ms"`
}
type UpstreamResult struct {
Conn net.Conn
Handled bool
Conn net.Conn
Handled bool
Mode string
PluginID string
HandlerID string
InitialDataSent bool
Proxied bool
}
type Gateway struct {

View File

@@ -24,12 +24,27 @@ type (
}
UpstreamConnectRequest struct {
Context context.Context
Source net.Conn
Host string
Upstream string
InitialData []byte
Metadata map[string]string
Context context.Context
Source net.Conn
Host string
Upstream string
InitialData []byte
Metadata map[string]string
ConnectionID string
TraceID string
SourceAddr string
ServerHost string
RawServerHost string
ProtocolVersion int
NextState int
RouteID string
RouteTags []string
UpstreamRaw string
UpstreamProtocol string
UpstreamAddress string
Transport string
ServiceName string
ListenerPort int
}
UpstreamConnectAcceptor func(UpstreamConnectRequest) bool

View File

@@ -2,59 +2,172 @@ package protocol
import (
"bytes"
"errors"
"strings"
)
type Handshake struct {
RawServerHost string
ServerHost string
ProtocolVersion int
NextState int
}
// ReplaceMcHost 替换 Minecraft 主机名
// 必须是连接的第一个数据包
func ReplaceMcHost(buf []byte, host string) []byte {
if len(buf) < 5 {
packet, consumed, err := readPacket(buf)
if err != nil || len(packet) == 0 {
return nil
}
packetID, n, err := readVarInt(packet)
if err != nil || packetID != 0 {
return nil
}
prefixEnd := n
if _, n, err = readVarInt(packet[prefixEnd:]); err != nil {
return nil
}
prefixEnd += n
rawHost, n, err := readString(packet[prefixEnd:])
if err != nil {
return nil
}
hostEnd := prefixEnd + n
if spliterIndex := strings.IndexRune(rawHost, 0); spliterIndex != -1 {
host = host + rawHost[spliterIndex:]
}
var payload bytes.Buffer
payload.Write(packet[:prefixEnd])
payload.Write(encodeVarInt(len(host)))
payload.WriteString(host)
payload.Write(packet[hostEnd:])
var out bytes.Buffer
head := buf[:4]
buf = buf[4:]
host_len := buf[0]
if len(buf) < int(host_len)+1 {
return nil
}
raw_host := string(buf[1 : host_len+1])
if spliterIndex := strings.IndexRune(raw_host, 0); spliterIndex != -1 {
host = host + raw_host[spliterIndex:]
}
// 修改标识数据包长度的字节
head[0] += byte(len(host) - len(raw_host))
out.Write(head) // 保留前四个字节
out.WriteByte(byte(len(host))) // 写入主机名长度
out.Write([]byte(host)) // 写入主机名
out.Write(buf[host_len+1:]) // 写入剩余数据
out.Write(encodeVarInt(payload.Len()))
out.Write(payload.Bytes())
out.Write(buf[consumed:])
return out.Bytes()
}
// GetMcHost 通过第一个数据包获取 Minecraft 主机名
func GetMcHost(buf []byte) string {
if len(buf) < 5 {
return ""
return ParseHandshake(buf).ServerHost
}
func ParseHandshake(buf []byte) Handshake {
packet, _, err := readPacket(buf)
if err != nil || len(packet) == 0 {
return Handshake{}
}
packetID, n, err := readVarInt(packet)
if err != nil || packetID != 0 {
return Handshake{}
}
packet = packet[n:]
protocolVersion, n, err := readVarInt(packet)
if err != nil {
return Handshake{}
}
packet = packet[n:]
host, n, err := readString(packet)
if err != nil {
return Handshake{}
}
packet = packet[n:]
if _, n, err = readUnsignedShort(packet); err != nil {
return Handshake{}
}
packet = packet[n:]
nextState, _, err := readVarInt(packet)
if err != nil {
return Handshake{}
}
buf = buf[4:]
host_len := buf[0]
if len(buf) < int(host_len)+1 {
return ""
parsed := Handshake{
RawServerHost: host,
ProtocolVersion: protocolVersion,
NextState: nextState,
}
host := string(buf[1 : host_len+1])
if spliterIndex := strings.IndexRune(host, 0); spliterIndex != -1 {
return host[0:spliterIndex]
parsed.ServerHost = host[0:spliterIndex]
} else {
return host
parsed.ServerHost = host
}
return parsed
}
func ReadPacket(buf []byte) ([]byte, int, error) {
return readPacket(buf)
}
func ReadVarInt(buf []byte) (int, int, error) {
return readVarInt(buf)
}
func ReadString(buf []byte) (string, int, error) {
return readString(buf)
}
func readPacket(buf []byte) ([]byte, int, error) {
length, n, err := readVarInt(buf)
if err != nil {
return nil, 0, err
}
if length < 0 || len(buf[n:]) < length {
return nil, 0, errors.New("incomplete packet")
}
return buf[n : n+length], n + length, nil
}
func readVarInt(buf []byte) (int, int, error) {
var value int
for i := 0; i < 5; i++ {
if i >= len(buf) {
return 0, 0, errors.New("incomplete varint")
}
b := buf[i]
value |= int(b&0x7f) << (7 * i)
if b&0x80 == 0 {
return value, i + 1, nil
}
}
return 0, 0, errors.New("varint too long")
}
func readString(buf []byte) (string, int, error) {
length, n, err := readVarInt(buf)
if err != nil {
return "", 0, err
}
if length < 0 || len(buf[n:]) < length {
return "", 0, errors.New("incomplete string")
}
return string(buf[n : n+length]), n + length, nil
}
func readUnsignedShort(buf []byte) (int, int, error) {
if len(buf) < 2 {
return 0, 0, errors.New("incomplete unsigned short")
}
return int(buf[0])<<8 | int(buf[1]), 2, nil
}
func encodeVarInt(value int) []byte {
var out []byte
for {
b := byte(value & 0x7f)
value >>= 7
if value != 0 {
b |= 0x80
}
out = append(out, b)
if value == 0 {
return out
}
}
}

View File

@@ -92,14 +92,22 @@ func TestReplaceMcHost(t *testing.T) {
}
func mcTestPacket(host string, tail ...byte) []byte {
packet := []byte{
byte(4 + 1 + len(host) + len(tail)),
0x00,
0x00,
0x00,
byte(len(host)),
protocolVersion := byte(0x63)
nextState := byte(0x02)
extra := []byte(nil)
if len(tail) > 0 {
protocolVersion = tail[0]
}
packet = append(packet, host...)
packet = append(packet, tail...)
return packet
if len(tail) > 1 {
nextState = tail[1]
}
if len(tail) > 2 {
extra = tail[2:]
}
payload := []byte{0x00, protocolVersion, byte(len(host))}
payload = append(payload, host...)
payload = append(payload, 0x63, 0xdd, nextState)
payload = append(payload, extra...)
packet := []byte{byte(len(payload))}
return append(packet, payload...)
}

53
protocol/smoke/helper.go Normal file
View File

@@ -0,0 +1,53 @@
package smoke
import (
"bytes"
"io"
"net"
"time"
)
type Result struct {
Request []byte
Response []byte
}
func RunProtocolProxyFixture(initial []byte, handler func(net.Conn)) (Result, error) {
gatewayEnd, pluginEnd := net.Pipe()
done := make(chan struct{})
go func() {
defer close(done)
handler(pluginEnd)
}()
defer gatewayEnd.Close()
if err := gatewayEnd.SetDeadline(time.Now().Add(2 * time.Second)); err != nil {
return Result{}, err
}
if _, err := gatewayEnd.Write(initial); err != nil {
return Result{}, err
}
var response bytes.Buffer
readDone := make(chan error, 1)
go func() {
buf := make([]byte, 4096)
n, err := gatewayEnd.Read(buf)
if n > 0 {
_, _ = response.Write(buf[:n])
}
if err == io.EOF {
err = nil
}
readDone <- err
}()
<-done
_ = gatewayEnd.Close()
if err := <-readDone; err != nil {
return Result{}, err
}
return Result{
Request: append([]byte(nil), initial...),
Response: response.Bytes(),
}, nil
}