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 {
@@ -91,7 +97,9 @@ type upstreamHandler struct {
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)
@@ -99,6 +107,38 @@ type upstreamHandler struct {
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
handlerID string
handler *upstreamHandler
client net.Conn
endpoint net.Conn
startedAt time.Time
draining bool
}
type ProxyConnectionHandle struct {
manager *Manager
id uint64
}
type ProxyConnectionStats struct {
BytesToPlugin int64
BytesToClient int64
Duration time.Duration
Err error
}
type Options struct {
@@ -121,6 +161,8 @@ func New(options Options) *Manager {
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,18 +665,27 @@ 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 {
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",
mode: mode,
timeout: timeout,
initialWriteTimeout: initialWriteTimeout,
accept: hook.Acceptor(),
handle: hook.Handler(),
})
@@ -465,7 +698,9 @@ func buildHandlers(pluginRecord PluginRecord, artifact ArtifactRecord, gateway *
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 {
@@ -544,11 +905,20 @@ func handlerSummaries(handlers []*upstreamHandler) []DispatchHandlerSummary {
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,6 +39,7 @@ const (
RuntimeEnabled = "enabled"
RuntimeFailed = "failed"
RuntimeDisabled = "disabled"
RuntimeDraining = "draining"
DefaultPriority = 100
DefaultHandlerTimeout = 3 * time.Second
@@ -44,6 +48,7 @@ const (
DefaultPackageMaxEntries = 2048
DefaultExtractedMaxBytes = 256 * 1024 * 1024
DefaultNonRuntimeMaxBytes = 16 * 1024 * 1024
DefaultInitialWriteTimeout = time.Second
)
var (
@@ -86,6 +91,39 @@ type ExtensionPoint struct {
type RuntimeLimits struct {
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 {
@@ -168,16 +206,30 @@ type DispatchHandlerSummary struct {
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
Mode string
PluginID string
HandlerID string
InitialDataSent bool
Proxied bool
}
type Gateway struct {

View File

@@ -30,6 +30,21 @@ type (
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
}
buf = buf[4:]
host_len := buf[0]
if len(buf) < int(host_len)+1 {
return ""
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{}
}
host := string(buf[1 : host_len+1])
parsed := Handshake{
RawServerHost: host,
ProtocolVersion: protocolVersion,
NextState: nextState,
}
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
}