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
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:
@@ -34,6 +34,7 @@ func newAdminAPIHandler() http.HandlerFunc {
|
||||
PluginsList: handleAdminPluginsList,
|
||||
PluginItem: handleAdminPluginItem,
|
||||
PluginAction: handleAdminPluginAction,
|
||||
PluginDraining: handleAdminPluginDraining,
|
||||
PluginDispatch: handleAdminPluginDispatchPlan,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 和低基数运行摘要。
|
||||
|
||||
## 可用性检查点
|
||||
|
||||
阶段结束时必须能做到:
|
||||
|
||||
28
examples/plugins/mc-auth-proxy/README.md
Normal file
28
examples/plugins/mc-auth-proxy/README.md
Normal 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"
|
||||
}
|
||||
```
|
||||
12
examples/plugins/mc-auth-proxy/build.sh
Executable file
12
examples/plugins/mc-auth-proxy/build.sh
Executable 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
|
||||
)
|
||||
84
examples/plugins/mc-auth-proxy/cmd/render-manifest/main.go
Normal file
84
examples/plugins/mc-auth-proxy/cmd/render-manifest/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
7
examples/plugins/mc-auth-proxy/go.mod
Normal file
7
examples/plugins/mc-auth-proxy/go.mod
Normal 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 => ../../..
|
||||
250
examples/plugins/mc-auth-proxy/main.go
Normal file
250
examples/plugins/mc-auth-proxy/main.go
Normal 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)
|
||||
}
|
||||
66
examples/plugins/mc-auth-proxy/main_test.go
Normal file
66
examples/plugins/mc-auth-proxy/main_test.go
Normal 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...)
|
||||
}
|
||||
63
examples/plugins/mc-auth-proxy/manifest.json
Normal file
63
examples/plugins/mc-auth-proxy/manifest.json
Normal 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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
175
protocol/mc.go
175
protocol/mc.go
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
53
protocol/smoke/helper.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user