feat: add sqlite-backed admin management
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

This commit is contained in:
2026-06-25 08:53:44 +08:00
parent ada051341d
commit 13ab1f6964
73 changed files with 6926 additions and 782 deletions

32
cmd/gateway/admin_api.go Normal file
View File

@@ -0,0 +1,32 @@
package main
import (
"net/http"
"github.com/tursom/mc-gateway/internal/adminhttp"
)
func newAdminAPIHandler() http.HandlerFunc {
return adminhttp.NewAPIHandler(adminStartup.AdminAPIPrefix, adminhttp.APIHandlers{
SetupStatus: handleAdminSetupStatus,
Setup: handleAdminSetup,
Login: handleAdminLogin,
Logout: handleAdminLogout,
Me: handleAdminMe,
Status: handleAdminStatus,
RoutesList: handleAdminRoutesList,
RouteItem: handleAdminRouteItem,
ServicesList: handleAdminServicesList,
ServiceItem: handleAdminServiceItem,
Metrics: handleAdminMetrics,
UsersList: handleAdminUsersList,
UsersCreate: handleAdminUsersCreate,
UserItem: handleAdminUserItem,
AuditLogs: handleAdminAuditLogs,
})
}

View File

@@ -0,0 +1,281 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
)
func TestAdminSetupLoginAndPermissions(t *testing.T) {
handler := newAdminTestHandler(t)
resp := adminTestRequest(t, handler, http.MethodGet, "/admin/api/setup", "", nil)
if resp.Code != http.StatusOK {
t.Fatalf("GET setup status = %d, want %d", resp.Code, http.StatusOK)
}
if got := adminTestJSON(t, resp)["required"]; got != true {
t.Fatalf("setup required = %v, want true", got)
}
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/setup", "", map[string]any{
"username": "admin",
"password": "secret",
})
if resp.Code != http.StatusCreated {
t.Fatalf("POST setup status = %d, body=%s", resp.Code, resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/setup", "", map[string]any{
"username": "admin2",
"password": "secret",
})
if resp.Code != http.StatusConflict {
t.Fatalf("repeat setup status = %d, want %d", resp.Code, http.StatusConflict)
}
adminToken := adminTestLogin(t, handler, "admin", "secret")
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/status", adminToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("admin status = %d, body=%s", resp.Code, resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/users", adminToken, map[string]any{
"username": "guest",
"role": "guest",
"password": "guest-secret",
})
if resp.Code != http.StatusCreated {
t.Fatalf("create guest status = %d, body=%s", resp.Code, resp.Body.String())
}
guestToken := adminTestLogin(t, handler, "guest", "guest-secret")
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/routes", guestToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("guest routes status = %d, body=%s", resp.Code, resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodPut, "/admin/api/routes/play.example", guestToken, map[string]any{
"upstream": "127.0.0.1:25565",
"enabled": true,
})
if resp.Code != http.StatusForbidden {
t.Fatalf("guest route write status = %d, want %d", resp.Code, http.StatusForbidden)
}
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/users", guestToken, nil)
if resp.Code != http.StatusForbidden {
t.Fatalf("guest users status = %d, want %d", resp.Code, http.StatusForbidden)
}
}
func TestAdminRoutesRefreshSnapshot(t *testing.T) {
handler := newAdminTestHandlerWithAdmin(t)
token := adminTestLogin(t, handler, "admin", "secret")
resp := adminTestRequest(t, handler, http.MethodPut, "/admin/api/routes/play.example", token, map[string]any{
"upstream": "127.0.0.1:25565",
"enabled": true,
"note": "primary",
})
if resp.Code != http.StatusOK {
t.Fatalf("route upsert status = %d, body=%s", resp.Code, resp.Body.String())
}
upstream, ok := lookupRoute("play.example")
if !ok || upstream != "127.0.0.1:25565" {
t.Fatalf("lookupRoute() = %q, %v; want route", upstream, ok)
}
resp = adminTestRequest(t, handler, http.MethodDelete, "/admin/api/routes/play.example", token, nil)
if resp.Code != http.StatusOK {
t.Fatalf("route delete status = %d, body=%s", resp.Code, resp.Body.String())
}
if upstream, ok := lookupRoute("play.example"); ok || upstream != "" {
t.Fatalf("lookupRoute() after delete = %q, %v; want miss", upstream, ok)
}
}
func TestAdminCustomPathAndAPIPrefixFromEnv(t *testing.T) {
t.Cleanup(saveGatewayState(t))
t.Setenv(adminEnvDB, filepath.Join(t.TempDir(), "gateway.sqlite3"))
t.Setenv(adminEnvPath, "/ops")
t.Setenv(adminEnvAPIPrefix, "/ops/api")
if err := loadConfig(); err != nil {
t.Fatalf("loadConfig() error = %v", err)
}
handler := newGatewayHTTPHandler()
resp := adminTestRequest(t, handler, http.MethodGet, "/ops", "", nil)
if resp.Code != http.StatusMovedPermanently {
t.Fatalf("admin path redirect status = %d, want %d", resp.Code, http.StatusMovedPermanently)
}
if got := resp.Header().Get("Location"); got != "/ops/" {
t.Fatalf("admin path redirect location = %q, want /ops/", got)
}
resp = adminTestRequest(t, handler, http.MethodGet, "/ops/", "", nil)
if resp.Code != http.StatusOK {
t.Fatalf("custom admin page status = %d, body=%s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), `data-api-prefix="/ops/api"`) {
t.Fatalf("custom admin page does not contain API prefix: %s", resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodGet, "/ops/api/setup", "", nil)
if resp.Code != http.StatusOK {
t.Fatalf("custom setup status = %d, body=%s", resp.Code, resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/setup", "", nil)
if resp.Code != http.StatusNotFound {
t.Fatalf("default setup status = %d, want %d", resp.Code, http.StatusNotFound)
}
}
func TestAdminServiceUpdateMarksRestartRequired(t *testing.T) {
handler := newAdminTestHandlerWithAdmin(t)
token := adminTestLogin(t, handler, "admin", "secret")
resp := adminTestRequest(t, handler, http.MethodPut, "/admin/api/services/kcp", token, map[string]any{
"enabled": true,
"port": 25570,
"options": map[string]any{
"data_shards": 12,
"parity_shards": 4,
},
})
if resp.Code != http.StatusOK {
t.Fatalf("service update status = %d, body=%s", resp.Code, resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/services", token, nil)
if resp.Code != http.StatusOK {
t.Fatalf("services status = %d, body=%s", resp.Code, resp.Body.String())
}
body := adminTestJSON(t, resp)
services := body["services"].([]any)
var found map[string]any
for _, item := range services {
service := item.(map[string]any)
if service["name"] == "kcp" {
found = service
break
}
}
if found == nil {
t.Fatal("kcp service not found")
}
if found["enabled"] != true || found["restart_required"] != true || found["running"] != false {
t.Fatalf("kcp service = %#v, want enabled restart_required and not running", found)
}
}
func TestAdminUserPatchInvalidatesExistingSession(t *testing.T) {
handler := newAdminTestHandlerWithAdmin(t)
adminToken := adminTestLogin(t, handler, "admin", "secret")
resp := adminTestRequest(t, handler, http.MethodPost, "/admin/api/users", adminToken, map[string]any{
"username": "member",
"role": "member",
"password": "member-secret",
})
if resp.Code != http.StatusCreated {
t.Fatalf("create member status = %d, body=%s", resp.Code, resp.Body.String())
}
memberToken := adminTestLogin(t, handler, "member", "member-secret")
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/status", memberToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("member status before patch = %d, body=%s", resp.Code, resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodPatch, "/admin/api/users/member", adminToken, map[string]any{
"role": "guest",
})
if resp.Code != http.StatusOK {
t.Fatalf("patch member status = %d, body=%s", resp.Code, resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/status", memberToken, nil)
if resp.Code != http.StatusUnauthorized {
t.Fatalf("old member token status = %d, want %d", resp.Code, http.StatusUnauthorized)
}
}
func newAdminTestHandler(t *testing.T) http.Handler {
t.Helper()
t.Cleanup(saveGatewayState(t))
t.Setenv(adminEnvDB, filepath.Join(t.TempDir(), "gateway.sqlite3"))
if err := loadConfig(); err != nil {
t.Fatalf("loadConfig() error = %v", err)
}
return newGatewayHTTPHandler()
}
func newAdminTestHandlerWithAdmin(t *testing.T) http.Handler {
t.Helper()
handler := newAdminTestHandler(t)
resp := adminTestRequest(t, handler, http.MethodPost, "/admin/api/setup", "", map[string]any{
"username": "admin",
"password": "secret",
})
if resp.Code != http.StatusCreated {
t.Fatalf("setup status = %d, body=%s", resp.Code, resp.Body.String())
}
return handler
}
func adminTestLogin(t *testing.T, handler http.Handler, username, password string) string {
t.Helper()
resp := adminTestRequest(t, handler, http.MethodPost, "/admin/api/auth/login", "", map[string]any{
"username": username,
"password": password,
})
if resp.Code != http.StatusOK {
t.Fatalf("login status = %d, body=%s", resp.Code, resp.Body.String())
}
body := adminTestJSON(t, resp)
token, ok := body["token"].(string)
if !ok || token == "" {
t.Fatalf("login token = %#v", body["token"])
}
return token
}
func adminTestRequest(t *testing.T, handler http.Handler, method, target, token string, body any) *httptest.ResponseRecorder {
t.Helper()
var payload bytes.Buffer
if body != nil {
if err := json.NewEncoder(&payload).Encode(body); err != nil {
t.Fatalf("Encode() error = %v", err)
}
}
req := httptest.NewRequest(method, target, &payload)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
return resp
}
func adminTestJSON(t *testing.T, resp *httptest.ResponseRecorder) map[string]any {
t.Helper()
var body map[string]any
if err := json.Unmarshal(resp.Body.Bytes(), &body); err != nil {
t.Fatalf("Unmarshal(%q) error = %v", resp.Body.String(), err)
}
return body
}

View File

@@ -0,0 +1,15 @@
package main
import (
"context"
"github.com/tursom/mc-gateway/internal/adminaudit"
)
func recordAudit(ctx context.Context, actor, sourceIP, action, targetType, targetID string, success bool, message string) {
_ = adminaudit.NewRepository(adminDB).Record(ctx, actor, sourceIP, action, targetType, targetID, success, message)
}
func listAuditLogs(ctx context.Context) ([]adminaudit.Record, error) {
return adminaudit.NewRepository(adminDB).List(ctx, adminaudit.DefaultListLimit)
}

View File

@@ -0,0 +1,113 @@
package main
import (
"net/http"
"os"
"strings"
"time"
"github.com/tursom/mc-gateway/internal/adminhttp"
"github.com/tursom/mc-gateway/internal/adminuser"
)
func handleAdminSetupStatus(w http.ResponseWriter, r *http.Request) {
empty, err := usersTableEmpty(r.Context(), adminDB)
if err != nil {
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
return
}
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"required": empty})
}
func handleAdminSetup(w http.ResponseWriter, r *http.Request) {
var req adminhttp.SetupRequest
if !adminhttp.DecodeJSONRequest(w, r, &req) {
return
}
if strings.TrimSpace(req.Username) == "" {
req.Username = "admin"
}
err := createInitialAdmin(r.Context(), req.Username, req.Password)
if err != nil {
recordAudit(r.Context(), "setup", adminhttp.RequestSourceIP(r), "setup", "user", req.Username, false, err.Error())
status := http.StatusBadRequest
if strings.Contains(err.Error(), "already") {
status = http.StatusConflict
}
adminhttp.WriteAPIError(w, status, err.Error())
return
}
recordAudit(r.Context(), "setup", adminhttp.RequestSourceIP(r), "setup", "user", req.Username, true, "created initial admin")
adminhttp.WriteJSON(w, http.StatusCreated, map[string]any{"ok": true})
}
func handleAdminLogin(w http.ResponseWriter, r *http.Request) {
var req adminhttp.LoginRequest
if !adminhttp.DecodeJSONRequest(w, r, &req) {
return
}
user, err := authenticateUser(r.Context(), req.Username, req.Password)
if err != nil {
recordAudit(r.Context(), req.Username, adminhttp.RequestSourceIP(r), "login", "user", req.Username, false, err.Error())
adminhttp.WriteAPIError(w, http.StatusUnauthorized, err.Error())
return
}
session, err := createSession(user.Username, user.Role)
if err != nil {
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
return
}
recordAudit(r.Context(), user.Username, adminhttp.RequestSourceIP(r), "login", "user", user.Username, true, "login success")
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{
"token": session.Token,
"expires_at": session.ExpiresAt.Unix(),
"user": user,
})
}
func handleAdminLogout(w http.ResponseWriter, r *http.Request) {
session, ok := requireSession(w, r)
if !ok {
return
}
deleteSession(session.Token)
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "logout", "session", session.Username, true, "logout success")
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func handleAdminMe(w http.ResponseWriter, r *http.Request) {
session, ok := requireSession(w, r)
if !ok {
return
}
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{
"username": session.Username,
"role": session.Role,
"expires_at": session.ExpiresAt.Unix(),
"permissions": adminuser.Permissions(session.Role),
})
}
func handleAdminStatus(w http.ResponseWriter, r *http.Request) {
if _, ok := requireRole(w, r, adminRoleMember); !ok {
return
}
services, err := listServiceConfigs(r.Context(), adminDB)
if err != nil {
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
return
}
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{
"pid": os.Getpid(),
"uptime_seconds": int64(time.Since(processStartAt).Seconds()),
"db_path": adminDBPath,
"tcp_admin_port": adminStartup.TCPAdminPort,
"admin_path": adminStartup.AdminPath,
"admin_api_prefix": adminStartup.AdminAPIPrefix,
"services": services,
})
}

View File

@@ -0,0 +1,17 @@
package main
import (
"net/http"
"github.com/tursom/mc-gateway/internal/adminhttp"
"github.com/tursom/mc-gateway/internal/gatewaymetrics"
)
var gatewayMetrics = gatewaymetrics.New()
func handleAdminMetrics(w http.ResponseWriter, r *http.Request) {
if _, ok := requireRole(w, r, adminRoleMember); !ok {
return
}
adminhttp.WriteJSON(w, http.StatusOK, gatewayMetrics.Snapshot())
}

View File

@@ -0,0 +1,62 @@
package main
import (
"net/http"
"github.com/tursom/mc-gateway/internal/adminhttp"
)
func handleAdminRoutesList(w http.ResponseWriter, r *http.Request) {
if _, ok := requireRole(w, r, adminRoleGuest); !ok {
return
}
routes, err := listRoutes(r.Context(), r.URL.Query().Get("q"))
if err != nil {
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
return
}
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"routes": routes})
}
func handleAdminRouteItem(w http.ResponseWriter, r *http.Request, rawHost string) {
session, ok := requireRole(w, r, adminRoleMember)
if !ok {
return
}
host, err := adminhttp.PathSegment(rawHost)
if err != nil {
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
switch r.Method {
case http.MethodPut:
var req adminhttp.RouteRequest
if !adminhttp.DecodeJSONRequest(w, r, &req) {
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
err := upsertRoute(r.Context(), session.Username, host, req.Upstream, enabled, req.Note)
if err != nil {
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "route_upsert", "route", host, false, err.Error())
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "route_upsert", "route", host, true, "route saved")
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
case http.MethodDelete:
err := deleteRoute(r.Context(), session.Username, host)
if err != nil {
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "route_delete", "route", host, false, err.Error())
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "route_delete", "route", host, true, "route deleted")
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
default:
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}

View File

@@ -0,0 +1,60 @@
package main
import (
"context"
"sync"
"github.com/tursom/mc-gateway/internal/adminroute"
)
var (
routeSnapshot = adminroute.NewSnapshot()
routeWriteLock sync.Mutex
)
func refreshRouteSnapshot(ctx context.Context) error {
if adminDB == nil {
publishRouteSnapshot(map[string]string{})
return nil
}
routes, err := adminroute.NewRepository(adminDB).EnabledMap(ctx)
if err != nil {
return err
}
publishRouteSnapshot(routes)
return nil
}
func publishRouteSnapshot(routes map[string]string) {
routeSnapshot.Store(routes)
}
func lookupRoute(host string) (string, bool) {
return routeSnapshot.Lookup(host)
}
func listRoutes(ctx context.Context, query string) ([]adminroute.Record, error) {
return adminroute.NewRepository(adminDB).List(ctx, query)
}
func upsertRoute(ctx context.Context, actor, host, upstream string, enabled bool, note string) error {
routeWriteLock.Lock()
defer routeWriteLock.Unlock()
if err := adminroute.NewRepository(adminDB).Upsert(ctx, actor, host, upstream, enabled, note); err != nil {
return err
}
return refreshRouteSnapshot(ctx)
}
func deleteRoute(ctx context.Context, actor, host string) error {
routeWriteLock.Lock()
defer routeWriteLock.Unlock()
if err := adminroute.NewRepository(adminDB).Delete(ctx, host); err != nil {
return err
}
_ = actor
return refreshRouteSnapshot(ctx)
}

View File

@@ -0,0 +1,92 @@
package main
import (
"context"
"database/sql"
"os"
"time"
"github.com/tursom/mc-gateway/internal/adminconfig"
"github.com/tursom/mc-gateway/internal/admindb"
"github.com/tursom/mc-gateway/internal/adminservice"
)
const (
defaultAdminDBPath = adminconfig.DefaultDBPath
defaultAdminPath = adminconfig.DefaultAdminPath
defaultAdminAPIPrefix = adminconfig.DefaultAdminAPIPrefix
defaultAdminSessionTTL = adminconfig.DefaultSessionTTL
defaultKCPPort = adminservice.DefaultKCPPort
defaultKCPDataShards = adminservice.DefaultKCPDataShards
defaultKCPParityShards = adminservice.DefaultKCPParityShards
defaultQUICPort = adminservice.DefaultQUICPort
defaultWebSocketPort = adminservice.DefaultWebSocketPort
defaultWebSocketPath = adminservice.DefaultWebSocketPath
serviceNameTCPAdmin = adminservice.NameTCPAdmin
serviceNameKCP = adminservice.NameKCP
serviceNameQUIC = adminservice.NameQUIC
serviceNameWebSocket = adminservice.NameWebSocket
adminEnvDB = adminconfig.EnvDB
adminEnvTCPAdminPort = adminconfig.EnvTCPAdminPort
adminEnvPath = adminconfig.EnvPath
adminEnvAPIPrefix = adminconfig.EnvAPIPrefix
adminEnvInitialPassword = adminconfig.EnvInitialPassword
)
var (
adminStartup = adminconfig.Config{
DBPath: defaultAdminDBPath,
TCPAdminPort: defaultTCPPort,
AdminPath: defaultAdminPath,
AdminAPIPrefix: defaultAdminAPIPrefix,
SessionTTL: defaultAdminSessionTTL,
}
adminDB *sql.DB
adminDBPath string
processStartAt = time.Now()
)
func initializeGatewayRuntime() error {
startup, err := parseStartupConfig(os.Getenv)
if err != nil {
return err
}
adminStartup = startup
db, err := admindb.Open(startup.DBPath)
if err != nil {
return err
}
if adminDB != nil && adminDB != db {
_ = adminDB.Close()
}
adminDB = db
adminDBPath = startup.DBPath
if err := admindb.Migrate(db); err != nil {
return err
}
if err := ensureDefaultServices(context.Background(), db, startup.TCPAdminPort); err != nil {
return err
}
if err := applyServiceConfig(context.Background(), db); err != nil {
return err
}
if err := ensureInitialAdminFromEnv(context.Background(), db, os.Getenv(adminEnvInitialPassword)); err != nil {
return err
}
return refreshRouteSnapshot(context.Background())
}
func closeGatewayRuntime() {
if adminDB != nil {
_ = adminDB.Close()
adminDB = nil
}
}
func parseStartupConfig(getenv func(string) string) (adminconfig.Config, error) {
return adminconfig.Parse(getenv)
}

View File

@@ -0,0 +1,74 @@
package main
import (
"net/http"
"strings"
"github.com/tursom/mc-gateway/internal/adminhttp"
"github.com/tursom/mc-gateway/internal/adminservice"
)
func handleAdminServicesList(w http.ResponseWriter, r *http.Request) {
if _, ok := requireRole(w, r, adminRoleMember); !ok {
return
}
services, err := listServiceConfigs(r.Context(), adminDB)
if err != nil {
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
return
}
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"services": services})
}
func handleAdminServiceItem(w http.ResponseWriter, r *http.Request, rawName string) {
session, ok := requireRole(w, r, adminRoleAdmin)
if !ok {
return
}
if strings.HasSuffix(rawName, "/restart") {
name := strings.TrimSuffix(rawName, "/restart")
if r.Method != http.MethodPost {
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "service_restart", "service", name, false, "restart is not implemented")
adminhttp.WriteAPIError(w, http.StatusNotImplemented, "service restart is not implemented; restart the gateway process")
return
}
name, err := adminhttp.PathSegment(rawName)
if err != nil {
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
if r.Method != http.MethodPut {
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req adminhttp.ServiceRequest
if !adminhttp.DecodeJSONRequest(w, r, &req) {
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
if req.Port == 0 {
req.Port = defaultServicePort(name)
}
err = updateServiceConfig(r.Context(), session.Username, name, enabled, req.Port, req.Options)
if err != nil {
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "service_update", "service", name, false, err.Error())
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "service_update", "service", name, true, "service saved")
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "restart_required": true})
}
func defaultServicePort(name string) int {
return adminservice.DefaultPort(name, adminStartup.TCPAdminPort)
}

View File

@@ -0,0 +1,85 @@
package main
import (
"context"
"database/sql"
"github.com/tursom/mc-gateway/internal/adminservice"
)
func ensureDefaultServices(ctx context.Context, db *sql.DB, tcpAdminPort int) error {
return adminservice.NewRepository(db).EnsureDefaults(ctx, tcpAdminPort)
}
func applyServiceConfig(ctx context.Context, db *sql.DB) error {
services, err := listServiceConfigs(ctx, db)
if err != nil {
return err
}
config.Tcp.Enable = true
config.Tcp.Port = adminStartup.TCPAdminPort
for _, service := range services {
switch service.Name {
case serviceNameKCP:
config.Kcp.Enable = service.Enabled
config.Kcp.Port = service.Port
config.Kcp.DataShards = adminservice.IntOption(service.Options, "data_shards", defaultKCPDataShards)
config.Kcp.ParityShards = adminservice.IntOption(service.Options, "parity_shards", defaultKCPParityShards)
case serviceNameQUIC:
config.Quic.Enable = service.Enabled
config.Quic.Port = service.Port
config.Quic.ApplicationProtocols = adminservice.StringSliceOption(service.Options, "application_protocols")
case serviceNameWebSocket:
config.WebSocket.Enable = service.Enabled
config.WebSocket.Port = service.Port
config.WebSocket.Path = adminservice.StringOption(service.Options, "path", defaultWebSocketPath)
}
}
if config.Kcp.Port == 0 {
config.Kcp.Port = defaultKCPPort
}
if config.Quic.Port == 0 {
config.Quic.Port = defaultQUICPort
}
if config.WebSocket.Port == 0 {
config.WebSocket.Port = defaultWebSocketPort
}
if config.WebSocket.Path == "" {
config.WebSocket.Path = defaultWebSocketPath
}
return nil
}
func listServiceConfigs(ctx context.Context, db *sql.DB) ([]adminservice.Record, error) {
services, err := adminservice.NewRepository(db).List(ctx)
if err != nil {
return nil, err
}
for i := range services {
services[i].Running = serviceIsRunning(services[i])
}
return services, nil
}
func serviceIsRunning(service adminservice.Record) bool {
switch service.Name {
case serviceNameTCPAdmin:
return true
case serviceNameKCP:
return config.Kcp.Enable
case serviceNameQUIC:
return config.Quic.Enable
case serviceNameWebSocket:
return config.WebSocket.Enable
default:
return false
}
}
func updateServiceConfig(ctx context.Context, actor, name string, enabled bool, port int, options map[string]any) error {
return adminservice.NewRepository(adminDB).Update(ctx, actor, name, enabled, port, options)
}

View File

@@ -0,0 +1,58 @@
package main
import (
"net/http"
"strings"
"github.com/tursom/mc-gateway/internal/adminhttp"
"github.com/tursom/mc-gateway/internal/adminsession"
"github.com/tursom/mc-gateway/internal/adminuser"
)
var adminSessionManager = adminsession.NewManager()
func createSession(username, role string) (adminsession.Session, error) {
return adminSessionManager.Create(username, role, adminStartup.SessionTTL)
}
func getSession(token string) (adminsession.Session, bool) {
return adminSessionManager.Get(token)
}
func deleteSession(token string) {
adminSessionManager.Delete(token)
}
func removeSessionsForUser(username string) {
adminSessionManager.RemoveUser(username)
}
func sessionFromRequest(r *http.Request) (adminsession.Session, bool) {
auth := r.Header.Get("Authorization")
token, ok := strings.CutPrefix(auth, "Bearer ")
if !ok || strings.TrimSpace(token) == "" {
return adminsession.Session{}, false
}
return getSession(strings.TrimSpace(token))
}
func requireSession(w http.ResponseWriter, r *http.Request) (adminsession.Session, bool) {
session, ok := sessionFromRequest(r)
if !ok {
adminhttp.WriteAPIError(w, http.StatusUnauthorized, "login required")
return adminsession.Session{}, false
}
return session, true
}
func requireRole(w http.ResponseWriter, r *http.Request, role string) (adminsession.Session, bool) {
session, ok := requireSession(w, r)
if !ok {
return adminsession.Session{}, false
}
if !adminuser.HasRole(session.Role, role) {
adminhttp.WriteAPIError(w, http.StatusForbidden, "permission denied")
return adminsession.Session{}, false
}
return session, true
}

View File

@@ -0,0 +1,23 @@
package main
import (
"embed"
"net/http"
"github.com/tursom/mc-gateway/internal/adminhttp"
)
//go:embed admin_static/index.html admin_static/app.css admin_static/app.js
var adminStaticFS embed.FS
func newGatewayHTTPHandler() http.Handler {
return adminhttp.NewGatewayHandler(adminhttp.GatewayHandlerOptions{
AdminPath: adminStartup.AdminPath,
AdminAPIPrefix: adminStartup.AdminAPIPrefix,
Assets: adminStaticFS,
APIHandler: newAdminAPIHandler(),
WebSocketEnabled: config.WebSocket.Enable,
WebSocketPath: normalizedWebSocketPath(),
WebSocketHandler: handleWebSocket,
})
}

View File

@@ -0,0 +1,377 @@
:root {
color-scheme: light;
--bg: #f6f7f4;
--panel: #ffffff;
--text: #1c2623;
--muted: #66716c;
--line: #d9ded8;
--accent: #1f7a5d;
--accent-dark: #145944;
--warn: #a66321;
--danger: #b42318;
--shadow: 0 16px 40px rgba(21, 32, 28, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button,
input,
select {
font: inherit;
}
button {
min-height: 36px;
border: 0;
border-radius: 6px;
padding: 0 14px;
background: var(--accent);
color: #fff;
cursor: pointer;
}
button:hover {
background: var(--accent-dark);
}
button.ghost,
button.secondary {
border: 1px solid var(--line);
background: #fff;
color: var(--text);
}
button.ghost:hover,
button.secondary:hover {
border-color: #aeb8b2;
background: #eef2ef;
}
button.danger {
background: var(--danger);
}
button.danger:hover {
background: #8f1d15;
}
input,
select {
width: 100%;
min-height: 38px;
border: 1px solid var(--line);
border-radius: 6px;
padding: 7px 10px;
background: #fff;
color: var(--text);
}
label {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 13px;
}
label.inline {
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
}
label.inline input {
width: auto;
min-height: 0;
}
.shell {
width: min(1180px, calc(100vw - 32px));
margin: 0 auto;
padding: 24px 0 40px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.topbar h1 {
margin: 0;
font-size: 24px;
font-weight: 700;
}
.topbar p {
margin: 2px 0 0;
color: var(--muted);
}
.session {
display: flex;
align-items: center;
gap: 12px;
color: var(--muted);
}
.auth-view {
min-height: calc(100vh - 120px);
display: grid;
place-items: center;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
padding: 18px;
}
.panel.compact {
width: min(420px, 100%);
display: grid;
gap: 14px;
}
.panel h2 {
margin: 0 0 8px;
font-size: 18px;
}
.alert {
border: 1px solid #f0c36a;
border-radius: 6px;
background: #fff7df;
color: #5f410c;
padding: 10px 12px;
margin-bottom: 14px;
}
.hidden {
display: none !important;
}
.status-grid,
.service-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.stat,
.service {
min-width: 0;
background: #fff;
border: 1px solid var(--line);
border-radius: 8px;
padding: 14px;
}
.stat span,
.service span {
display: block;
color: var(--muted);
font-size: 12px;
}
.stat strong,
.service strong {
display: block;
margin-top: 5px;
overflow-wrap: anywhere;
font-size: 17px;
}
.tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--line);
margin: 8px 0 16px;
}
.tabs button {
border-radius: 6px 6px 0 0;
background: transparent;
color: var(--muted);
}
.tabs button.active {
background: #fff;
color: var(--text);
border: 1px solid var(--line);
border-bottom-color: #fff;
}
.toolbar {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.toolbar input {
max-width: 420px;
}
.table-wrap {
overflow-x: auto;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 720px;
}
th,
td {
padding: 10px 12px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
th {
color: var(--muted);
font-size: 12px;
font-weight: 600;
background: #fafbf9;
}
td {
overflow-wrap: anywhere;
}
tr:last-child td {
border-bottom: 0;
}
.actions {
width: 180px;
}
.row-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.badge {
display: inline-flex;
align-items: center;
min-height: 24px;
border-radius: 999px;
padding: 0 9px;
background: #e8f3ee;
color: var(--accent-dark);
font-size: 12px;
}
.badge.off {
background: #f4ece6;
color: var(--warn);
}
.chips {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.chip {
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 9px;
background: #fff;
}
.service {
display: grid;
gap: 10px;
}
.service form {
display: grid;
gap: 10px;
}
dialog {
width: min(460px, calc(100vw - 28px));
border: 1px solid var(--line);
border-radius: 8px;
padding: 0;
box-shadow: var(--shadow);
}
dialog::backdrop {
background: rgba(20, 27, 24, 0.36);
}
dialog form {
display: grid;
gap: 12px;
padding: 18px;
}
dialog h2 {
margin: 0;
font-size: 18px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 4px;
}
@media (max-width: 860px) {
.status-grid,
.service-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.topbar,
.toolbar {
align-items: stretch;
flex-direction: column;
}
.toolbar input {
max-width: none;
}
}
@media (max-width: 560px) {
.shell {
width: min(100vw - 20px, 1180px);
padding-top: 14px;
}
.status-grid,
.service-grid {
grid-template-columns: 1fr;
}
.tabs {
overflow-x: auto;
}
}

View File

@@ -0,0 +1,579 @@
const apiBase = document.body.dataset.apiPrefix || "/admin/api";
const state = {
token: sessionStorage.getItem("mcGatewayAdminToken") || "",
user: null,
routes: [],
services: [],
users: [],
};
const el = (id) => document.getElementById(id);
function showAlert(message) {
const box = el("alert");
box.textContent = message;
box.classList.toggle("hidden", !message);
}
function setView(name) {
for (const id of ["setupView", "loginView", "appView"]) {
el(id).classList.toggle("hidden", id !== name);
}
}
async function api(path, options = {}) {
const headers = { "Accept": "application/json" };
if (options.body !== undefined) {
headers["Content-Type"] = "application/json";
}
if (state.token) {
headers.Authorization = `Bearer ${state.token}`;
}
const res = await fetch(apiBase + path, {
method: options.method || "GET",
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
});
let data = {};
const text = await res.text();
if (text) {
try {
data = JSON.parse(text);
} catch {
data = { error: text };
}
}
if (!res.ok) {
throw new Error(data.error || res.statusText);
}
return data;
}
async function boot() {
bindEvents();
try {
const setup = await api("/setup");
if (setup.required) {
setView("setupView");
el("subtitle").textContent = "Setup";
return;
}
} catch (err) {
showAlert(err.message);
}
if (!state.token) {
setView("loginView");
el("subtitle").textContent = "Login";
return;
}
try {
state.user = await api("/me");
await showApp();
} catch {
sessionStorage.removeItem("mcGatewayAdminToken");
state.token = "";
setView("loginView");
el("subtitle").textContent = "Login";
}
}
function bindEvents() {
el("setupForm").addEventListener("submit", submitSetup);
el("loginForm").addEventListener("submit", submitLogin);
el("logoutBtn").addEventListener("click", logout);
el("routeSearch").addEventListener("input", debounce(loadRoutes, 180));
el("newRouteBtn").addEventListener("click", () => openRouteDialog());
el("newUserBtn").addEventListener("click", () => openUserDialog());
el("routeForm").addEventListener("submit", saveRoute);
el("userForm").addEventListener("submit", saveUser);
for (const button of document.querySelectorAll("[data-close]")) {
button.addEventListener("click", () => button.closest("dialog").close());
}
for (const button of document.querySelectorAll(".tabs button")) {
button.addEventListener("click", () => selectTab(button.dataset.tab));
}
}
async function submitSetup(event) {
event.preventDefault();
const form = new FormData(event.currentTarget);
try {
await api("/setup", {
method: "POST",
body: {
username: form.get("username"),
password: form.get("password"),
},
});
showAlert("");
setView("loginView");
} catch (err) {
showAlert(err.message);
}
}
async function submitLogin(event) {
event.preventDefault();
const form = new FormData(event.currentTarget);
try {
const data = await api("/auth/login", {
method: "POST",
body: {
username: form.get("username"),
password: form.get("password"),
},
});
state.token = data.token;
state.user = data.user;
sessionStorage.setItem("mcGatewayAdminToken", state.token);
showAlert("");
await showApp();
} catch (err) {
showAlert(err.message);
}
}
async function logout() {
try {
await api("/auth/logout", { method: "POST", body: {} });
} catch {
}
sessionStorage.removeItem("mcGatewayAdminToken");
state.token = "";
state.user = null;
setView("loginView");
}
async function showApp() {
setView("appView");
el("subtitle").textContent = "Admin";
el("sessionUser").textContent = `${state.user.username} (${state.user.role})`;
el("logoutBtn").classList.remove("hidden");
applyRoleVisibility();
await loadRoutes();
if (isMember()) {
await loadStatus();
await loadServices();
await loadMetrics();
}
if (isAdmin()) {
await loadUsers();
await loadAudit();
}
}
function applyRoleVisibility() {
const member = isMember();
const admin = isAdmin();
el("statusGrid").classList.toggle("hidden", !member);
el("newRouteBtn").classList.toggle("hidden", !member);
toggleTab("services", member);
toggleTab("metrics", member);
toggleTab("users", admin);
toggleTab("audit", admin);
selectTab("routes");
}
function toggleTab(name, visible) {
document.querySelector(`[data-tab="${name}"]`).classList.toggle("hidden", !visible);
}
function selectTab(name) {
for (const button of document.querySelectorAll(".tabs button")) {
button.classList.toggle("active", button.dataset.tab === name);
}
for (const panel of document.querySelectorAll(".tab-panel")) {
panel.classList.add("hidden");
}
el(`${name}Tab`).classList.remove("hidden");
}
async function loadStatus() {
try {
const status = await api("/status");
el("statusGrid").innerHTML = [
stat("PID", status.pid),
stat("Uptime", `${status.uptime_seconds}s`),
stat("SQLite", status.db_path),
stat("TCP/Admin", status.tcp_admin_port),
].join("");
} catch (err) {
showAlert(err.message);
}
}
async function loadRoutes() {
try {
const q = encodeURIComponent(el("routeSearch").value || "");
const data = await api(q ? `/routes?q=${q}` : "/routes");
state.routes = data.routes || [];
renderRoutes();
} catch (err) {
showAlert(err.message);
}
}
function renderRoutes() {
const canWrite = isMember();
el("routesBody").innerHTML = state.routes.map((route) => `
<tr>
<td>${escapeHTML(route.host)}</td>
<td>${escapeHTML(route.upstream)}</td>
<td>${badge(route.enabled ? "Enabled" : "Disabled", !route.enabled)}</td>
<td>${escapeHTML(route.note || "")}</td>
<td class="actions">${canWrite ? routeActions(route) : ""}</td>
</tr>
`).join("");
for (const button of document.querySelectorAll("[data-edit-route]")) {
button.addEventListener("click", () => {
const route = state.routes.find((item) => item.host === button.dataset.editRoute);
openRouteDialog(route);
});
}
for (const button of document.querySelectorAll("[data-delete-route]")) {
button.addEventListener("click", () => removeRoute(button.dataset.deleteRoute));
}
}
function routeActions(route) {
return `
<div class="row-actions">
<button class="secondary" type="button" data-edit-route="${escapeAttr(route.host)}">Edit</button>
<button class="danger" type="button" data-delete-route="${escapeAttr(route.host)}">Delete</button>
</div>
`;
}
function openRouteDialog(route = null) {
const form = el("routeForm");
form.reset();
form.dataset.originalHost = route ? route.host : "";
form.elements.host.disabled = Boolean(route);
if (route) {
form.elements.host.value = route.host;
form.elements.upstream.value = route.upstream;
form.elements.enabled.checked = route.enabled;
form.elements.note.value = route.note || "";
} else {
form.elements.enabled.checked = true;
}
el("routeDialog").showModal();
}
async function saveRoute(event) {
event.preventDefault();
const form = event.currentTarget;
const host = form.dataset.originalHost || form.elements.host.value;
try {
await api(`/routes/${encodeURIComponent(host)}`, {
method: "PUT",
body: {
upstream: form.elements.upstream.value,
enabled: form.elements.enabled.checked,
note: form.elements.note.value,
},
});
el("routeDialog").close();
await loadRoutes();
showAlert("");
} catch (err) {
showAlert(err.message);
}
}
async function removeRoute(host) {
if (host === "default" && !confirm("Delete default route?")) {
return;
}
try {
await api(`/routes/${encodeURIComponent(host)}`, { method: "DELETE" });
await loadRoutes();
} catch (err) {
showAlert(err.message);
}
}
async function loadServices() {
try {
const data = await api("/services");
state.services = data.services || [];
renderServices();
} catch (err) {
showAlert(err.message);
}
}
function renderServices() {
el("servicesGrid").innerHTML = state.services.map((service) => `
<article class="service">
<div>
<span>${escapeHTML(service.name)}</span>
<strong>${service.enabled ? "Enabled" : "Disabled"}${service.restart_required ? " / restart required" : ""}</strong>
</div>
${isAdmin() ? serviceForm(service) : serviceSummary(service)}
</article>
`).join("");
for (const form of document.querySelectorAll("[data-service-form]")) {
form.addEventListener("submit", saveService);
}
for (const button of document.querySelectorAll("[data-restart-service]")) {
button.addEventListener("click", () => restartService(button.dataset.restartService));
}
}
function serviceSummary(service) {
return `<div><span>Port</span><strong>${service.port}</strong></div>`;
}
function serviceForm(service) {
const disabled = service.name === "tcp_admin" ? "disabled" : "";
const optionFields = serviceOptionFields(service);
return `
<form data-service-form="${escapeAttr(service.name)}">
<label class="inline">
<input name="enabled" type="checkbox" ${service.enabled ? "checked" : ""} ${disabled}>
Enabled
</label>
<label>
Port
<input name="port" type="number" min="1" max="65535" value="${service.port}">
</label>
${optionFields}
<div class="row-actions">
<button type="submit">Save</button>
<button class="secondary" type="button" data-restart-service="${escapeAttr(service.name)}">Restart</button>
</div>
</form>
`;
}
function serviceOptionFields(service) {
const options = service.options || {};
if (service.name === "kcp") {
return `
<label>Data shards<input name="data_shards" type="number" min="1" value="${options.data_shards || 10}"></label>
<label>Parity shards<input name="parity_shards" type="number" min="1" value="${options.parity_shards || 3}"></label>
`;
}
if (service.name === "quic") {
const protocols = Array.isArray(options.application_protocols) ? options.application_protocols.join(",") : "";
return `<label>Protocols<input name="application_protocols" value="${escapeAttr(protocols)}"></label>`;
}
if (service.name === "websocket") {
return `<label>Path<input name="path" value="${escapeAttr(options.path || "/")}"></label>`;
}
return "";
}
async function saveService(event) {
event.preventDefault();
const form = event.currentTarget;
const name = form.dataset.serviceForm;
const options = {};
if (name === "kcp") {
options.data_shards = Number(form.elements.data_shards.value);
options.parity_shards = Number(form.elements.parity_shards.value);
} else if (name === "quic") {
options.application_protocols = form.elements.application_protocols.value.split(",").map((item) => item.trim()).filter(Boolean);
} else if (name === "websocket") {
options.path = form.elements.path.value;
}
try {
await api(`/services/${encodeURIComponent(name)}`, {
method: "PUT",
body: {
enabled: name === "tcp_admin" ? true : form.elements.enabled.checked,
port: Number(form.elements.port.value),
options,
},
});
await loadServices();
} catch (err) {
showAlert(err.message);
}
}
async function restartService(name) {
try {
await api(`/services/${encodeURIComponent(name)}/restart`, { method: "POST", body: {} });
await loadServices();
} catch (err) {
showAlert(err.message);
}
}
async function loadUsers() {
try {
const data = await api("/users");
state.users = data.users || [];
renderUsers();
} catch (err) {
showAlert(err.message);
}
}
function renderUsers() {
el("usersBody").innerHTML = state.users.map((user) => `
<tr>
<td>${escapeHTML(user.username)}</td>
<td>${escapeHTML(user.role)}</td>
<td>${badge(user.disabled ? "Disabled" : "Active", user.disabled)}</td>
<td class="actions">
<div class="row-actions">
<button class="secondary" type="button" data-edit-user="${escapeAttr(user.username)}">Edit</button>
<button class="danger" type="button" data-delete-user="${escapeAttr(user.username)}">Delete</button>
</div>
</td>
</tr>
`).join("");
for (const button of document.querySelectorAll("[data-edit-user]")) {
button.addEventListener("click", () => {
const user = state.users.find((item) => item.username === button.dataset.editUser);
openUserDialog(user);
});
}
for (const button of document.querySelectorAll("[data-delete-user]")) {
button.addEventListener("click", () => removeUser(button.dataset.deleteUser));
}
}
function openUserDialog(user = null) {
const form = el("userForm");
form.reset();
form.dataset.originalUsername = user ? user.username : "";
form.elements.username.disabled = Boolean(user);
form.elements.password.required = !user;
if (user) {
form.elements.username.value = user.username;
form.elements.role.value = user.role;
form.elements.disabled.checked = user.disabled;
} else {
form.elements.role.value = "member";
}
el("userDialog").showModal();
}
async function saveUser(event) {
event.preventDefault();
const form = event.currentTarget;
const username = form.dataset.originalUsername || form.elements.username.value;
const body = {
role: form.elements.role.value,
disabled: form.elements.disabled.checked,
};
if (form.elements.password.value) {
body.password = form.elements.password.value;
}
try {
if (form.dataset.originalUsername) {
await api(`/users/${encodeURIComponent(username)}`, { method: "PATCH", body });
} else {
body.username = username;
await api("/users", { method: "POST", body });
}
el("userDialog").close();
await loadUsers();
} catch (err) {
showAlert(err.message);
}
}
async function removeUser(username) {
if (!confirm(`Delete user ${username}?`)) {
return;
}
try {
await api(`/users/${encodeURIComponent(username)}`, { method: "DELETE" });
await loadUsers();
} catch (err) {
showAlert(err.message);
}
}
async function loadMetrics() {
try {
const data = await api("/metrics");
el("metricsGrid").innerHTML = [
stat("Total", data.total_connections),
stat("Active", data.active_connections),
stat("TCP", data.tcp_connections),
stat("WebSocket", data.websocket_connections),
stat("Misses", data.route_misses),
stat("Dial errors", data.upstream_dial_errors),
].join("");
const hits = data.route_hits || {};
el("routeHits").innerHTML = Object.keys(hits).length
? Object.entries(hits).map(([host, count]) => `<span class="chip">${escapeHTML(host)}: ${count}</span>`).join("")
: `<span class="chip">No hits</span>`;
} catch (err) {
showAlert(err.message);
}
}
async function loadAudit() {
try {
const data = await api("/audit-logs");
el("auditBody").innerHTML = (data.audit_logs || []).map((item) => `
<tr>
<td>${new Date(item.created_at * 1000).toLocaleString()}</td>
<td>${escapeHTML(item.actor)}</td>
<td>${escapeHTML(item.action)}</td>
<td>${escapeHTML(item.target_type)}:${escapeHTML(item.target_id)}</td>
<td>${badge(item.success ? "Success" : "Failed", !item.success)}</td>
<td>${escapeHTML(item.message || "")}</td>
</tr>
`).join("");
} catch (err) {
showAlert(err.message);
}
}
function stat(label, value) {
return `<div class="stat"><span>${escapeHTML(label)}</span><strong>${escapeHTML(String(value ?? ""))}</strong></div>`;
}
function badge(text, off = false) {
return `<span class="badge ${off ? "off" : ""}">${escapeHTML(text)}</span>`;
}
function isAdmin() {
return state.user && state.user.role === "admin";
}
function isMember() {
return state.user && (state.user.role === "admin" || state.user.role === "member");
}
function debounce(fn, wait) {
let id = 0;
return (...args) => {
clearTimeout(id);
id = setTimeout(() => fn(...args), wait);
};
}
function escapeHTML(value) {
return String(value).replace(/[&<>"']/g, (ch) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#39;",
}[ch]));
}
function escapeAttr(value) {
return escapeHTML(value).replace(/`/g, "&#96;");
}
boot();

View File

@@ -0,0 +1,195 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>mc-gateway admin</title>
<link rel="stylesheet" href="app.css">
</head>
<body data-api-prefix="__ADMIN_API_PREFIX__">
<main class="shell">
<header class="topbar">
<div>
<h1>mc-gateway</h1>
<p id="subtitle">Admin</p>
</div>
<div class="session">
<span id="sessionUser"></span>
<button id="logoutBtn" class="ghost hidden" type="button">Logout</button>
</div>
</header>
<section id="alert" class="alert hidden"></section>
<section id="setupView" class="auth-view hidden">
<form id="setupForm" class="panel compact">
<h2>Initial admin</h2>
<label>
Username
<input name="username" autocomplete="username" value="admin">
</label>
<label>
Password
<input name="password" autocomplete="new-password" type="password" required>
</label>
<button type="submit">Create admin</button>
</form>
</section>
<section id="loginView" class="auth-view hidden">
<form id="loginForm" class="panel compact">
<h2>Login</h2>
<label>
Username
<input name="username" autocomplete="username" required>
</label>
<label>
Password
<input name="password" autocomplete="current-password" type="password" required>
</label>
<button type="submit">Login</button>
</form>
</section>
<section id="appView" class="hidden">
<section id="statusGrid" class="status-grid"></section>
<nav class="tabs">
<button data-tab="routes" class="active" type="button">Routes</button>
<button data-tab="services" type="button">Services</button>
<button data-tab="users" type="button">Users</button>
<button data-tab="metrics" type="button">Metrics</button>
<button data-tab="audit" type="button">Audit</button>
</nav>
<section id="routesTab" class="tab-panel">
<div class="toolbar">
<input id="routeSearch" placeholder="Search host, upstream, note">
<button id="newRouteBtn" type="button">New route</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Host</th>
<th>Upstream</th>
<th>Enabled</th>
<th>Note</th>
<th class="actions">Actions</th>
</tr>
</thead>
<tbody id="routesBody"></tbody>
</table>
</div>
</section>
<section id="servicesTab" class="tab-panel hidden">
<div id="servicesGrid" class="service-grid"></div>
</section>
<section id="usersTab" class="tab-panel hidden">
<div class="toolbar">
<button id="newUserBtn" type="button">New user</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th>Disabled</th>
<th class="actions">Actions</th>
</tr>
</thead>
<tbody id="usersBody"></tbody>
</table>
</div>
</section>
<section id="metricsTab" class="tab-panel hidden">
<div id="metricsGrid" class="status-grid"></div>
<div class="panel">
<h2>Route hits</h2>
<div id="routeHits" class="chips"></div>
</div>
</section>
<section id="auditTab" class="tab-panel hidden">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Time</th>
<th>Actor</th>
<th>Action</th>
<th>Target</th>
<th>Result</th>
<th>Message</th>
</tr>
</thead>
<tbody id="auditBody"></tbody>
</table>
</div>
</section>
</section>
</main>
<dialog id="routeDialog">
<form id="routeForm" method="dialog">
<h2>Route</h2>
<label>
Host
<input name="host" required>
</label>
<label>
Upstream
<input name="upstream" required placeholder="127.0.0.1:25565">
</label>
<label class="inline">
<input name="enabled" type="checkbox" checked>
Enabled
</label>
<label>
Note
<input name="note">
</label>
<div class="dialog-actions">
<button type="button" data-close>Cancel</button>
<button type="submit">Save</button>
</div>
</form>
</dialog>
<dialog id="userDialog">
<form id="userForm" method="dialog">
<h2>User</h2>
<label>
Username
<input name="username" required>
</label>
<label>
Role
<select name="role">
<option value="admin">Admin</option>
<option value="member">Member</option>
<option value="guest">Guest</option>
</select>
</label>
<label>
Password
<input name="password" type="password">
</label>
<label class="inline">
<input name="disabled" type="checkbox">
Disabled
</label>
<div class="dialog-actions">
<button type="button" data-close>Cancel</button>
<button type="submit">Save</button>
</div>
</form>
</dialog>
<script src="app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,90 @@
package main
import (
"net/http"
"github.com/tursom/mc-gateway/internal/adminhttp"
)
func handleAdminUsersList(w http.ResponseWriter, r *http.Request) {
if _, ok := requireRole(w, r, adminRoleAdmin); !ok {
return
}
users, err := listUsers(r.Context())
if err != nil {
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
return
}
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"users": users})
}
func handleAdminUsersCreate(w http.ResponseWriter, r *http.Request) {
session, ok := requireRole(w, r, adminRoleAdmin)
if !ok {
return
}
var req adminhttp.CreateUserRequest
if !adminhttp.DecodeJSONRequest(w, r, &req) {
return
}
err := createUser(r.Context(), session.Username, req.Username, req.Role, req.Password, req.Disabled)
if err != nil {
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "user_create", "user", req.Username, false, err.Error())
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "user_create", "user", req.Username, true, "user created")
adminhttp.WriteJSON(w, http.StatusCreated, map[string]any{"ok": true})
}
func handleAdminUserItem(w http.ResponseWriter, r *http.Request, rawUsername string) {
session, ok := requireRole(w, r, adminRoleAdmin)
if !ok {
return
}
username, err := adminhttp.PathSegment(rawUsername)
if err != nil {
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
switch r.Method {
case http.MethodPatch:
var req adminhttp.PatchUserRequest
if !adminhttp.DecodeJSONRequest(w, r, &req) {
return
}
err := patchUser(r.Context(), session.Username, username, req.Role, req.Disabled, req.Password)
if err != nil {
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "user_patch", "user", username, false, err.Error())
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "user_patch", "user", username, true, "user updated")
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
case http.MethodDelete:
err := deleteUser(r.Context(), username)
if err != nil {
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "user_delete", "user", username, false, err.Error())
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "user_delete", "user", username, true, "user deleted")
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
default:
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func handleAdminAuditLogs(w http.ResponseWriter, r *http.Request) {
if _, ok := requireRole(w, r, adminRoleAdmin); !ok {
return
}
logs, err := listAuditLogs(r.Context())
if err != nil {
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
return
}
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"audit_logs": logs})
}

139
cmd/gateway/admin_users.go Normal file
View File

@@ -0,0 +1,139 @@
package main
import (
"context"
"database/sql"
"errors"
"strings"
"github.com/tursom/mc-gateway/internal/adminuser"
"golang.org/x/crypto/bcrypt"
)
const (
adminRoleAdmin = adminuser.RoleAdmin
adminRoleMember = adminuser.RoleMember
adminRoleGuest = adminuser.RoleGuest
)
func ensureInitialAdminFromEnv(ctx context.Context, db *sql.DB, password string) error {
if strings.TrimSpace(password) == "" {
return nil
}
empty, err := usersTableEmpty(ctx, db)
if err != nil {
return err
}
if !empty {
return nil
}
return createUser(ctx, "system", "admin", adminRoleAdmin, password, false)
}
func usersTableEmpty(ctx context.Context, db *sql.DB) (bool, error) {
return adminuser.NewRepository(db).TableEmpty(ctx)
}
func createInitialAdmin(ctx context.Context, username, password string) error {
empty, err := usersTableEmpty(ctx, adminDB)
if err != nil {
return err
}
if !empty {
return errors.New("initial admin has already been created")
}
return createUser(ctx, "setup", username, adminRoleAdmin, password, false)
}
func createUser(ctx context.Context, actor, username, role, password string, disabled bool) error {
username = strings.TrimSpace(username)
if err := adminuser.ValidateUsername(username); err != nil {
return err
}
if err := adminuser.ValidateRole(role); err != nil {
return err
}
if err := adminuser.ValidatePassword(password); err != nil {
return err
}
hash, err := hashPassword(password)
if err != nil {
return err
}
_ = actor
return adminuser.NewRepository(adminDB).Create(ctx, username, role, hash, disabled)
}
func authenticateUser(ctx context.Context, username, password string) (adminuser.User, error) {
user, hash, err := getUserWithHash(ctx, username)
if err != nil {
return adminuser.User{}, err
}
if user.Disabled {
return adminuser.User{}, errors.New("user is disabled")
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return adminuser.User{}, errors.New("invalid username or password")
}
return user, nil
}
func getUserWithHash(ctx context.Context, username string) (adminuser.User, string, error) {
return adminuser.NewRepository(adminDB).GetWithHash(ctx, username)
}
func listUsers(ctx context.Context) ([]adminuser.User, error) {
return adminuser.NewRepository(adminDB).List(ctx)
}
func patchUser(ctx context.Context, actor, username string, role *string, disabled *bool, password *string) error {
username = strings.TrimSpace(username)
if err := adminuser.ValidateUsername(username); err != nil {
return err
}
var passwordHashProvider func() (string, error)
if password != nil {
passwordValue := *password
passwordHashProvider = func() (string, error) {
if err := adminuser.ValidatePassword(passwordValue); err != nil {
return "", err
}
return hashPassword(passwordValue)
}
}
result, err := adminuser.NewRepository(adminDB).Patch(ctx, username, adminuser.Patch{
Role: role,
Disabled: disabled,
PasswordHashProvider: passwordHashProvider,
})
if err != nil {
return err
}
if result.InvalidateSessions {
removeSessionsForUser(username)
}
_ = actor
return nil
}
func deleteUser(ctx context.Context, username string) error {
username = strings.TrimSpace(username)
if err := adminuser.ValidateUsername(username); err != nil {
return err
}
if err := adminuser.NewRepository(adminDB).Delete(ctx, username); err != nil {
return err
}
removeSessionsForUser(username)
return nil
}
func hashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(hash), err
}

View File

@@ -1,120 +1,24 @@
package main
import (
"io"
"os"
"strings"
"sync"
"time"
"github.com/BurntSushi/toml"
"github.com/fsnotify/fsnotify"
"github.com/mitchellh/mapstructure"
"github.com/rs/zerolog/log"
"github.com/tursom/mc-gateway/internal/gatewayconfig"
)
var (
configFile = "config.toml"
config Config
config gatewayconfig.Config
configLoadLock sync.Mutex
)
type (
Config struct {
Tcp ProtocolConfig `toml:"tcp"`
Quic QuicConfig `toml:"quic"`
Kcp KcpConfig `toml:"kcp"`
WebSocket WebSocketConfig `toml:"websocket"`
Hosts map[string]string `toml:"hosts"`
Log LogConfig `toml:"log"`
PidFile string `toml:"pid_file"`
Plugin map[string]map[string]any `toml:"plugin"`
}
ProtocolConfig struct {
Enable bool `toml:"enable"`
Port int `toml:"port"`
}
KcpConfig struct {
Enable bool `toml:"enable"`
Port int `toml:"port"`
DataShards int `toml:"data_shards"`
ParityShards int `toml:"parity_Shards"`
}
QuicConfig struct {
Enable bool `toml:"enable"`
Port int `toml:"port"`
ApplicationProtocols []string `toml:"application_protocols"`
}
WebSocketConfig struct {
Enable bool `toml:"enable"`
Port int `toml:"port"`
Path string `toml:"path"`
}
// LogConfig 定义了日志的配置,包括日志级别和日志文件路径
// 日志级别可以是 "trade", "debug", "info", "warn", "error", "fatal", "disabled"
// 默认日志级别为 "info"
// 日志文件路径指定了日志输出的位置
// 如果日志文件路径为空,则日志将只输出到标准输出
LogConfig struct {
Level string `toml:"level"`
File string `toml:"file"`
}
// serviceConfig 定义了一个服务的配置,包括是否启用和运行函数
// 运行函数接收一个 WaitGroup用于在服务运行时进行同步
// 这样可以确保所有服务在主函数退出前都能正确关闭
// 运行函数通常会在 goroutine 中执行,以便并发处理多个服务
serviceConfig struct {
enable *bool
run func(wg *sync.WaitGroup)
}
)
var services = []serviceConfig{
{
enable: &config.Tcp.Enable,
run: runTcp,
},
{
enable: &config.Kcp.Enable,
run: runKcp,
},
{
enable: &config.Quic.Enable,
run: runQuic,
},
{
enable: &config.WebSocket.Enable,
run: runWebSocket,
},
}
func loadConfig() error {
configLoadLock.Lock()
defer configLoadLock.Unlock()
file, err := os.Open(configFile)
if err != nil {
if err := initializeGatewayRuntime(); err != nil {
return err
}
defer file.Close()
byteValue, err := io.ReadAll(file)
if err != nil {
return err
}
if err := toml.Unmarshal(byteValue, &config); err != nil {
return err
}
writePIDFile()
if err := loadLogger(); err != nil {
return err
@@ -125,69 +29,10 @@ func loadConfig() error {
return nil
}
func watchConfig() *fsnotify.Watcher {
go func() {
for {
time.Sleep(time.Minute)
if err := loadConfig(); err != nil {
log.Error().Err(err).Msg("Failed to reload config")
}
}
}()
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Fatal().Err(err).Msg("Failed to create config watcher")
}
go func() {
for {
select {
case event, ok := <-watcher.Events:
if !ok {
log.Error().Msg("watcher.Events channel closed")
return
}
if !strings.HasSuffix(event.Name, configFile) {
continue
}
case err, ok := <-watcher.Errors:
if !ok {
log.Error().Msg("watcher.Errors channel closed")
return
}
log.Error().Err(err).Msg("watcher error")
continue
}
log.Info().Msg("reload config")
if err := loadConfig(); err != nil {
log.Error().Err(err).Msg("Failed to reload config")
}
}
}()
if err = watcher.Add("."); err != nil {
log.Fatal().Err(err).Msg("Failed to watch config file")
}
return watcher
}
func loadPluginConfig(cfg map[string]any, pluginCfg any) error {
log.Info().
Any("config", cfg).
Msg("Loading plugin config")
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Result: pluginCfg,
TagName: "toml",
})
if err != nil {
return err
}
if err := decoder.Decode(cfg); err != nil {
return err
}
return nil
return gatewayconfig.DecodePluginConfig(cfg, pluginCfg)
}

View File

@@ -1,10 +1,7 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -46,111 +43,153 @@ func TestLoadPluginConfigReturnsDecodeError(t *testing.T) {
}
}
func TestLoadConfigReadsTomlAndAppliesSideEffects(t *testing.T) {
func TestParseStartupConfigDefaultsAndEnv(t *testing.T) {
cfg, err := parseStartupConfig(func(string) string { return "" })
if err != nil {
t.Fatalf("parseStartupConfig() error = %v", err)
}
if cfg.DBPath != defaultAdminDBPath {
t.Fatalf("DBPath = %q, want %q", cfg.DBPath, defaultAdminDBPath)
}
if cfg.TCPAdminPort != defaultTCPPort {
t.Fatalf("TCPAdminPort = %d, want %d", cfg.TCPAdminPort, defaultTCPPort)
}
if cfg.AdminPath != defaultAdminPath {
t.Fatalf("AdminPath = %q, want %q", cfg.AdminPath, defaultAdminPath)
}
if cfg.AdminAPIPrefix != defaultAdminAPIPrefix {
t.Fatalf("AdminAPIPrefix = %q, want %q", cfg.AdminAPIPrefix, defaultAdminAPIPrefix)
}
env := map[string]string{
adminEnvDB: "/tmp/mc.db",
adminEnvTCPAdminPort: "25575",
adminEnvPath: "/ops",
adminEnvAPIPrefix: "/ops/api/",
}
cfg, err = parseStartupConfig(func(key string) string { return env[key] })
if err != nil {
t.Fatalf("parseStartupConfig(env) error = %v", err)
}
if cfg.DBPath != "/tmp/mc.db" || cfg.TCPAdminPort != 25575 || cfg.AdminPath != "/ops/" || cfg.AdminAPIPrefix != "/ops/api" {
t.Fatalf("startup config = %+v", cfg)
}
}
func TestParseStartupConfigReturnsErrors(t *testing.T) {
tests := []struct {
name string
env map[string]string
}{
{
name: "invalid port",
env: map[string]string{adminEnvTCPAdminPort: "70000"},
},
{
name: "invalid admin path",
env: map[string]string{adminEnvPath: "admin"},
},
{
name: "invalid api prefix",
env: map[string]string{adminEnvAPIPrefix: "api"},
},
{
name: "api prefix equals admin path",
env: map[string]string{
adminEnvPath: "/admin",
adminEnvAPIPrefix: "/admin",
},
},
{
name: "api prefix under asset path",
env: map[string]string{adminEnvAPIPrefix: "/admin/app.js/api"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := parseStartupConfig(func(key string) string { return tt.env[key] })
if err == nil {
t.Fatal("parseStartupConfig() error = nil, want error")
}
})
}
}
func TestLoadConfigInitializesSQLiteDefaults(t *testing.T) {
defer saveGatewayState(t)()
tmpDir := t.TempDir()
pidPath := filepath.Join(tmpDir, "gateway.pid")
logPath := filepath.Join(tmpDir, "logs", "gateway.log")
configFile = filepath.Join(tmpDir, "config.toml")
toml := fmt.Sprintf(`
pid_file = %q
[log]
level = "debug"
file = %q
[tcp]
enable = true
port = 25565
[quic]
enable = true
port = 25566
application_protocols = ["minecraft", "raw"]
[kcp]
enable = true
port = 25567
data_shards = 10
parity_Shards = 3
[websocket]
enable = true
port = 25568
path = "/gateway"
[hosts]
"play.example" = "backend.example:25565"
default = "fallback.example:25565"
[plugin.disabled]
enable = false
`, pidPath, logPath)
if err := os.WriteFile(configFile, []byte(toml), 0644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
dbPath := filepath.Join(tmpDir, "gateway.sqlite3")
t.Setenv(adminEnvDB, dbPath)
t.Setenv(adminEnvTCPAdminPort, "25575")
t.Setenv(adminEnvPath, "/ops")
t.Setenv(adminEnvAPIPrefix, "/ops/api")
if err := loadConfig(); err != nil {
t.Fatalf("loadConfig() error = %v", err)
}
if !config.Tcp.Enable || config.Tcp.Port != 25565 {
if !config.Tcp.Enable || config.Tcp.Port != 25575 {
t.Fatalf("tcp config = %+v", config.Tcp)
}
if !config.Quic.Enable || config.Quic.Port != 25566 {
t.Fatalf("quic config = %+v", config.Quic)
if config.Quic.Enable || config.Kcp.Enable || config.WebSocket.Enable {
t.Fatalf("optional services should be disabled by default: quic=%+v kcp=%+v websocket=%+v", config.Quic, config.Kcp, config.WebSocket)
}
if got := strings.Join(config.Quic.ApplicationProtocols, ","); got != "minecraft,raw" {
t.Fatalf("application protocols = %q, want minecraft,raw", got)
}
if !config.Kcp.Enable || config.Kcp.DataShards != 10 || config.Kcp.ParityShards != 3 {
if config.Kcp.Port != defaultKCPPort || config.Kcp.DataShards != defaultKCPDataShards || config.Kcp.ParityShards != defaultKCPParityShards {
t.Fatalf("kcp config = %+v", config.Kcp)
}
if !config.WebSocket.Enable || config.WebSocket.Path != "/gateway" {
t.Fatalf("websocket config = %+v", config.WebSocket)
if adminStartup.DBPath != dbPath || adminStartup.AdminPath != "/ops/" || adminStartup.AdminAPIPrefix != "/ops/api" {
t.Fatalf("adminStartup = %+v", adminStartup)
}
if got := config.Hosts["play.example"]; got != "backend.example:25565" {
t.Fatalf("host route = %q, want backend.example:25565", got)
}
if currentPidFile != pidPath {
t.Fatalf("currentPidFile = %q, want %q", currentPidFile, pidPath)
}
if currentLogFile != logPath {
t.Fatalf("currentLogFile = %q, want %q", currentLogFile, logPath)
if adminDB == nil {
t.Fatal("adminDB = nil")
}
pidBytes, err := os.ReadFile(pidPath)
var enabled, port int
if err := adminDB.QueryRow(`SELECT enabled, port FROM services WHERE name = ?`, serviceNameTCPAdmin).Scan(&enabled, &port); err != nil {
t.Fatalf("query tcp_admin service: %v", err)
}
if enabled != 1 || port != 25575 {
t.Fatalf("tcp_admin service enabled=%d port=%d, want enabled=1 port=25575", enabled, port)
}
}
func TestLoadConfigCreatesInitialAdminFromEnv(t *testing.T) {
defer saveGatewayState(t)()
t.Setenv(adminEnvDB, filepath.Join(t.TempDir(), "gateway.sqlite3"))
t.Setenv(adminEnvInitialPassword, "secret")
if err := loadConfig(); err != nil {
t.Fatalf("loadConfig() error = %v", err)
}
user, err := authenticateUser(t.Context(), "admin", "secret")
if err != nil {
t.Fatalf("ReadFile(pid) error = %v", err)
t.Fatalf("authenticateUser() error = %v", err)
}
if wantPID := fmt.Sprintf("%d\n", os.Getpid()); string(pidBytes) != wantPID {
t.Fatalf("pid file = %q, want %q", string(pidBytes), wantPID)
}
if _, err := os.Stat(logPath); err != nil {
t.Fatalf("Stat(log file) error = %v", err)
if user.Role != adminRoleAdmin {
t.Fatalf("admin role = %q, want %q", user.Role, adminRoleAdmin)
}
}
func TestLoadConfigReturnsErrors(t *testing.T) {
t.Run("missing file", func(t *testing.T) {
func TestLoadConfigReturnsEnvErrors(t *testing.T) {
t.Run("invalid port", func(t *testing.T) {
defer saveGatewayState(t)()
configFile = filepath.Join(t.TempDir(), "missing.toml")
t.Setenv(adminEnvDB, filepath.Join(t.TempDir(), "gateway.sqlite3"))
t.Setenv(adminEnvTCPAdminPort, "0")
if err := loadConfig(); err == nil {
t.Fatal("loadConfig() error = nil, want error")
}
})
t.Run("invalid toml", func(t *testing.T) {
t.Run("invalid admin path", func(t *testing.T) {
defer saveGatewayState(t)()
configFile = filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(configFile, []byte("[tcp\n"), 0644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
t.Setenv(adminEnvDB, filepath.Join(t.TempDir(), "gateway.sqlite3"))
t.Setenv(adminEnvPath, "admin")
if err := loadConfig(); err == nil {
t.Fatal("loadConfig() error = nil, want error")
}

View File

@@ -14,9 +14,9 @@ func TestHandleRequestProxiesAndClosesConnections(t *testing.T) {
packet := gatewayTestPacket("play.example")
source := newGatewayTestConn(packet)
upstream := newGatewayTestConn([]byte("reply"))
config.Hosts = map[string]string{
setGatewayTestRoutes(map[string]string{
"play.example": "backend.example:25565",
}
})
registerGatewayUpstreamHook(
t,

View File

@@ -10,12 +10,14 @@ import (
func haProxyUpstream(source net.Conn, host string) net.Conn {
target, err := net.ResolveTCPAddr("tcp", host)
if err != nil {
gatewayMetrics.UpstreamDialError()
log.Err(err).Msg("failed to resolve TCP address")
return nil
}
conn, err := tcpDialer.Dial("tcp", target.String())
if err != nil {
gatewayMetrics.UpstreamDialError()
log.Err(err).Msg("failed to dial TCP")
return nil
}

View File

@@ -44,6 +44,7 @@ func runKcp(wg *sync.WaitGroup) {
func upstreamKcp(host string) net.Conn {
conn, err := kcp.DialWithOptions(host, nil, config.Kcp.DataShards, config.Kcp.ParityShards)
if err != nil {
gatewayMetrics.UpstreamDialError()
log.Error().Err(err).
Msg("Failed to dial KCP server")
return nil

View File

@@ -2,10 +2,10 @@ package main
import (
"net"
"strings"
"sync"
"github.com/rs/zerolog/log"
"github.com/tursom/mc-gateway/internal/upstreamtarget"
"github.com/tursom/mc-gateway/plugin/api"
"github.com/tursom/mc-gateway/protocol"
)
@@ -19,9 +19,7 @@ func main() {
log.Err(err).Msg("Failed to write PID file")
}
defer removePIDFile()
watcher := watchConfig()
defer watcher.Close()
defer closeGatewayRuntime()
go handleLogRotate()
@@ -31,23 +29,16 @@ func main() {
}
func startEnabledServices() {
if tcpWebPortReuseEnabled() {
startService(runTcpWebPortReuse)
if config.Kcp.Enable {
startService(runKcp)
}
if config.Quic.Enable {
startService(runQuic)
}
return
startService(runTcpWebPortReuse)
if config.Kcp.Enable {
startService(runKcp)
}
for _, service := range services {
if !*service.enable {
continue
}
startService(service.run)
if config.Quic.Enable {
startService(runQuic)
}
if config.WebSocket.Enable && normalizedWebSocketPort() != normalizedTCPPort() {
startService(runWebSocket)
}
}
@@ -57,6 +48,9 @@ func startService(run func(wg *sync.WaitGroup)) {
}
func handleRequest(conn net.Conn) {
gatewayMetrics.ConnectionStarted()
defer gatewayMetrics.ConnectionFinished()
defer func() {
rec := recover()
if rec == nil {
@@ -104,29 +98,30 @@ func mapToHost(conn net.Conn) net.Conn {
return nil
}
mc_host := protocol.GetMcHost(buf[:n])
if mc_host == "" {
mcHost := protocol.GetMcHost(buf[:n])
if mcHost == "" {
log.Err(errEmptyBuffer).
Str("client", conn.RemoteAddr().String()).
Msg("failed to parse mc host from buffer")
return nil
}
host, ok := config.Hosts[mc_host]
if !ok {
host = config.Hosts["default"]
}
host, ok := lookupRoute(mcHost)
if host == "" {
gatewayMetrics.RouteMiss()
log.Err(errEmptyBuffer).
Str("client", conn.RemoteAddr().String()).
Str("host", mc_host).
Str("host", mcHost).
Msg("failed to route host")
return nil
}
if ok {
gatewayMetrics.RouteHit(mcHost)
}
log.Debug().
Str("client", conn.RemoteAddr().String()).
Str("host", mc_host).
Str("host", mcHost).
Str("mc", host).
Msg("map to host")
@@ -143,14 +138,16 @@ func mapToHost(conn net.Conn) net.Conn {
}
if !ok {
if host, ok := strings.CutPrefix(host, "quic://"); ok {
client = upstreamQuic(host)
} else if host, ok := strings.CutPrefix(host, "kcp://"); ok {
client = upstreamKcp(host)
} else if host, ok := strings.CutPrefix(host, "haproxy://"); ok {
client = haProxyUpstream(conn, host)
} else {
client = upstreamTcp(host)
target := upstreamtarget.Parse(host)
switch target.Protocol {
case upstreamtarget.ProtocolQUIC:
client = upstreamQuic(target.Address)
case upstreamtarget.ProtocolKCP:
client = upstreamKcp(target.Address)
case upstreamtarget.ProtocolHAProxy:
client = haProxyUpstream(conn, target.Address)
default:
client = upstreamTcp(target.Address)
}
}
if client == nil {
@@ -160,7 +157,7 @@ func mapToHost(conn net.Conn) net.Conn {
if err := writeAll(client, buf[:n]); err != nil {
log.Err(err).
Str("client", conn.RemoteAddr().String()).
Str("host", mc_host).
Str("host", mcHost).
Str("mc", host).
Msg("failed to write initial packet to upstream")
client.Close()

View File

@@ -13,9 +13,9 @@ func TestMapToHostRoutesThroughHookAndForwardsInitialPacket(t *testing.T) {
packet := gatewayTestPacket("play.example", 0x63, 0x00)
source := newGatewayTestConn(packet)
upstream := newGatewayTestConn(nil)
config.Hosts = map[string]string{
setGatewayTestRoutes(map[string]string{
"play.example": "backend.example:25565",
}
})
var gotSource net.Conn
var gotHost string
@@ -52,9 +52,9 @@ func TestMapToHostUsesDefaultRoute(t *testing.T) {
packet := gatewayTestPacket("unknown.example")
source := newGatewayTestConn(packet)
upstream := newGatewayTestConn(nil)
config.Hosts = map[string]string{
setGatewayTestRoutes(map[string]string{
"default": "fallback.example:25565",
}
})
registerGatewayUpstreamHook(
t,
@@ -105,7 +105,7 @@ func TestMapToHostRejectsInvalidOrUnroutedPackets(t *testing.T) {
if tt.packet == nil {
source.readErr = errors.New("read failed")
}
config.Hosts = tt.hosts
setGatewayTestRoutes(tt.hosts)
if got := mapToHost(source); got != nil {
t.Fatalf("mapToHost() = %v, want nil", got)
@@ -118,9 +118,9 @@ func TestMapToHostReturnsNilWhenHookFails(t *testing.T) {
defer saveGatewayState(t)()
source := newGatewayTestConn(gatewayTestPacket("play.example"))
config.Hosts = map[string]string{
setGatewayTestRoutes(map[string]string{
"play.example": "backend.example:25565",
}
})
wantErr := errors.New("hook failed")
registerGatewayUpstreamHook(
@@ -142,9 +142,9 @@ func TestMapToHostClosesUpstreamWhenInitialWriteFails(t *testing.T) {
source := newGatewayTestConn(gatewayTestPacket("play.example"))
upstream := newGatewayTestConn(nil)
upstream.writeErr = errors.New("write failed")
config.Hosts = map[string]string{
setGatewayTestRoutes(map[string]string{
"play.example": "backend.example:25565",
}
})
registerGatewayUpstreamHook(
t,

View File

@@ -72,12 +72,14 @@ func upstreamQuic(host string) net.Conn {
conn, err := quic.DialAddr(ctx, host, tlsConf, nil)
if err != nil {
gatewayMetrics.UpstreamDialError()
log.Err(err).Str("host", host).Msg("Failed to dial QUIC")
return nil
}
stream, err := conn.OpenStream()
if err != nil {
gatewayMetrics.UpstreamDialError()
log.Err(err).Str("host", host).Msg("Failed to open stream")
conn.CloseWithError(0, "failed to open stream")
return nil

View File

@@ -72,6 +72,8 @@ func BenchmarkMapToHostInitialPacket(b *testing.B) {
packet := benchmarkHandshakePacket(hostName)
source := newBenchmarkConn(benchmarkAddr("client:25565"))
upstream := newBenchmarkConn(benchmarkAddr("upstream:25565"))
publishRouteSnapshot(map[string]string{hostName: upstreamHost})
defer publishRouteSnapshot(nil)
restore := installBenchmarkUpstreamHook(b, hostName, upstreamHost, upstream)
defer restore()
@@ -191,10 +193,6 @@ func installBenchmarkUpstreamHook(b *testing.B, hostName, upstreamHost string, u
previousConfig := config
previousHooks := hooks
config.Hosts = map[string]string{
hostName: upstreamHost,
}
pluginLock.Lock()
hooks = map[string]map[string]any{
"benchmark": {},

View File

@@ -35,6 +35,7 @@ func runTcp(wg *sync.WaitGroup) {
}
setSocketOptions(conn)
// 处理连接
gatewayMetrics.TCPConnectionStarted()
go handleRequest(conn)
}
}
@@ -42,6 +43,7 @@ func runTcp(wg *sync.WaitGroup) {
func upstreamTcp(host string) net.Conn {
conn, err := tcpDialer.Dial("tcp", host)
if err != nil {
gatewayMetrics.UpstreamDialError()
log.Err(err).Str("host", host).Msg("Error dialing upstream")
return nil
}

View File

@@ -1,54 +1,21 @@
package main
import (
"bytes"
"errors"
"fmt"
"io"
"net"
"net/http"
"sync"
"time"
"github.com/rs/zerolog/log"
"github.com/tursom/mc-gateway/internal/tcphttpmux"
)
const (
defaultTCPPort = 25565
defaultWebSocketPort = 25566
tcpWebInitialPacketTimeout = time.Second
httpConnBacklog = 128
maxHTTPMethodPrefixLen = len("OPTIONS ")
tcpWebInitialPacketTimeout = tcphttpmux.DefaultInitialPacketTimeout
)
var httpMethodPrefixes = [][]byte{
[]byte("GET "),
[]byte("POST "),
[]byte("HEAD "),
[]byte("PUT "),
[]byte("PATCH "),
[]byte("DELETE "),
[]byte("OPTIONS "),
[]byte("CONNECT "),
[]byte("TRACE "),
}
type replayConn struct {
net.Conn
reader io.Reader
}
func (c *replayConn) Read(p []byte) (int, error) {
return c.reader.Read(p)
}
type chanListener struct {
conns chan net.Conn
closed chan struct{}
closeOnce sync.Once
addr net.Addr
}
func normalizedTCPPort() int {
if config.Tcp.Port == 0 {
return defaultTCPPort
@@ -91,189 +58,38 @@ func runTcpWebPortReuse(wg *sync.WaitGroup) {
log.Info().
Int("port", port).
Str("path", normalizedWebSocketPath()).
Msg("Listening for shared TCP and WebSocket connections")
Str("admin_path", adminStartup.AdminPath).
Msg("Listening for shared TCP and Admin connections")
if err := serveTcpWebPortReuse(listener, newWebSocketHandler(), handleRequest); err != nil && !errors.Is(err, net.ErrClosed) {
if err := serveTcpWebPortReuse(listener, newGatewayHTTPHandler(), handleRequest); err != nil && !errors.Is(err, net.ErrClosed) {
log.Fatal().Err(err).
Int("port", port).
Msg("Shared TCP/WebSocket server stopped")
Msg("Shared TCP/Admin server stopped")
}
}
func serveTcpWebPortReuse(listener net.Listener, handler http.Handler, tcpHandler func(net.Conn)) error {
defer listener.Close()
webListener := newChanListener(listener.Addr())
webServer := &http.Server{Handler: handler}
webServerDone := make(chan error, 1)
go func() {
err := webServer.Serve(webListener)
if err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) {
webServerDone <- err
return
}
webServerDone <- nil
}()
defer func() {
_ = webListener.Close()
_ = webServer.Close()
<-webServerDone
}()
for {
conn, err := listener.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return err
}
return tcphttpmux.Serve(listener, handler, tcpHandler, tcphttpmux.Options{
InitialPacketTimeout: tcpWebInitialPacketTimeout,
SetSocketOptions: setSocketOptions,
OnTCPConnection: gatewayMetrics.TCPConnectionStarted,
OnAcceptError: func(err error) {
log.Err(err).Msg("Error accepting shared TCP/WebSocket connection")
continue
}
setSocketOptions(conn)
go handleTcpWebPortReuseConn(conn, webListener, tcpHandler, tcpWebInitialPacketTimeout)
}
}
func handleTcpWebPortReuseConn(conn net.Conn, webListener *chanListener, tcpHandler func(net.Conn), timeout time.Duration) {
peeked, err := readInitialPacket(conn, timeout)
if err != nil {
log.Debug().Err(err).
Str("client", conn.RemoteAddr().String()).
Msg("failed to read initial packet")
conn.Close()
return
}
if len(peeked) == 0 {
log.Debug().
Str("client", conn.RemoteAddr().String()).
Msg("initial packet is empty")
conn.Close()
return
}
replayed := newReplayConn(conn, peeked)
if isHTTPInitialPacket(peeked) {
if !webListener.deliver(replayed) {
},
OnInitialPacketError: func(conn net.Conn, err error) {
log.Debug().Err(err).
Str("client", conn.RemoteAddr().String()).
Msg("failed to read initial packet")
},
OnEmptyInitialPacket: func(conn net.Conn) {
log.Debug().
Str("client", conn.RemoteAddr().String()).
Msg("initial packet is empty")
},
OnHTTPDeliveryFailed: func(conn net.Conn) {
log.Debug().
Str("client", conn.RemoteAddr().String()).
Msg("failed to deliver HTTP connection")
conn.Close()
}
return
}
tcpHandler(replayed)
}
func readInitialPacket(conn net.Conn, timeout time.Duration) ([]byte, error) {
if timeout > 0 {
if err := conn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
return nil, err
}
defer conn.SetReadDeadline(time.Time{})
}
buf := getProxyBuffer()
defer putProxyBuffer(buf)
var peeked []byte
for {
n, err := conn.Read(buf)
if n > 0 {
peeked = append(peeked, buf[:n]...)
if isHTTPInitialPacket(peeked) ||
!isPotentialHTTPInitialPacket(peeked) ||
len(peeked) >= maxHTTPMethodPrefixLen {
return peeked, nil
}
}
if err != nil {
if len(peeked) > 0 && errors.Is(err, io.EOF) {
return peeked, nil
}
return peeked, err
}
if n == 0 {
return peeked, io.ErrNoProgress
}
}
}
func newReplayConn(conn net.Conn, peeked []byte) net.Conn {
return &replayConn{
Conn: conn,
reader: io.MultiReader(bytes.NewReader(peeked), conn),
}
}
func isHTTPInitialPacket(buf []byte) bool {
for _, prefix := range httpMethodPrefixes {
if bytes.HasPrefix(buf, prefix) {
return true
}
}
return false
}
func isPotentialHTTPInitialPacket(buf []byte) bool {
if len(buf) == 0 {
return true
}
for _, prefix := range httpMethodPrefixes {
if len(buf) <= len(prefix) && bytes.HasPrefix(prefix, buf) {
return true
}
}
return false
}
func newChanListener(addr net.Addr) *chanListener {
return &chanListener{
conns: make(chan net.Conn, httpConnBacklog),
closed: make(chan struct{}),
addr: addr,
}
}
func (l *chanListener) Accept() (net.Conn, error) {
select {
case conn := <-l.conns:
return conn, nil
case <-l.closed:
return nil, net.ErrClosed
}
}
func (l *chanListener) Close() error {
l.closeOnce.Do(func() {
close(l.closed)
},
})
return nil
}
func (l *chanListener) Addr() net.Addr {
return l.addr
}
func (l *chanListener) deliver(conn net.Conn) bool {
select {
case <-l.closed:
return false
default:
}
select {
case l.conns <- conn:
return true
case <-l.closed:
return false
default:
return false
}
}

View File

@@ -1,60 +1,58 @@
package main
import (
"bytes"
"errors"
"io"
"net"
"net/http"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/tursom/mc-gateway/internal/gatewayconfig"
"github.com/tursom/mc-gateway/protocol"
)
func TestTCPWebPortReuseEnabledUsesNormalizedPorts(t *testing.T) {
tests := []struct {
name string
tcp ProtocolConfig
websocket WebSocketConfig
tcp gatewayconfig.ProtocolConfig
websocket gatewayconfig.WebSocketConfig
want bool
}{
{
name: "explicit same port",
tcp: ProtocolConfig{Enable: true, Port: 25565},
websocket: WebSocketConfig{Enable: true, Port: 25565},
tcp: gatewayconfig.ProtocolConfig{Enable: true, Port: 25565},
websocket: gatewayconfig.WebSocketConfig{Enable: true, Port: 25565},
want: true,
},
{
name: "websocket default port",
tcp: ProtocolConfig{Enable: true, Port: defaultWebSocketPort},
websocket: WebSocketConfig{Enable: true},
tcp: gatewayconfig.ProtocolConfig{Enable: true, Port: defaultWebSocketPort},
websocket: gatewayconfig.WebSocketConfig{Enable: true},
want: true,
},
{
name: "tcp default port",
tcp: ProtocolConfig{Enable: true},
websocket: WebSocketConfig{Enable: true, Port: defaultTCPPort},
tcp: gatewayconfig.ProtocolConfig{Enable: true},
websocket: gatewayconfig.WebSocketConfig{Enable: true, Port: defaultTCPPort},
want: true,
},
{
name: "different ports",
tcp: ProtocolConfig{Enable: true, Port: 25565},
websocket: WebSocketConfig{Enable: true, Port: 25566},
tcp: gatewayconfig.ProtocolConfig{Enable: true, Port: 25565},
websocket: gatewayconfig.WebSocketConfig{Enable: true, Port: 25566},
want: false,
},
{
name: "tcp disabled",
tcp: ProtocolConfig{Enable: false, Port: 25565},
websocket: WebSocketConfig{Enable: true, Port: 25565},
tcp: gatewayconfig.ProtocolConfig{Enable: false, Port: 25565},
websocket: gatewayconfig.WebSocketConfig{Enable: true, Port: 25565},
want: false,
},
{
name: "websocket disabled",
tcp: ProtocolConfig{Enable: true, Port: 25565},
websocket: WebSocketConfig{Enable: false, Port: 25565},
tcp: gatewayconfig.ProtocolConfig{Enable: true, Port: 25565},
websocket: gatewayconfig.WebSocketConfig{Enable: false, Port: 25565},
want: false,
},
}
@@ -73,156 +71,6 @@ func TestTCPWebPortReuseEnabledUsesNormalizedPorts(t *testing.T) {
}
}
func TestHTTPInitialPacketRecognition(t *testing.T) {
for _, method := range []string{
"GET ",
"POST ",
"HEAD ",
"PUT ",
"PATCH ",
"DELETE ",
"OPTIONS ",
"CONNECT ",
"TRACE ",
} {
t.Run(strings.TrimSpace(method), func(t *testing.T) {
if !isHTTPInitialPacket([]byte(method + "/gateway HTTP/1.1\r\n")) {
t.Fatalf("isHTTPInitialPacket(%q) = false, want true", method)
}
})
}
if isHTTPInitialPacket(gatewayTestPacket("play.example")) {
t.Fatal("Minecraft handshake was recognized as HTTP")
}
if isHTTPInitialPacket([]byte("GE")) {
t.Fatal("partial HTTP method was recognized as complete HTTP")
}
if !isPotentialHTTPInitialPacket([]byte("GE")) {
t.Fatal("partial HTTP method was not recognized as a possible HTTP prefix")
}
if isPotentialHTTPInitialPacket([]byte("GOT ")) {
t.Fatal("invalid HTTP method was recognized as a possible HTTP prefix")
}
}
func TestReplayConnReadsPeekedBytesBeforeUnderlyingConn(t *testing.T) {
base := newGatewayTestConn([]byte("rest"))
conn := newReplayConn(base, []byte("peek-"))
got, err := io.ReadAll(conn)
if err != nil {
t.Fatalf("ReadAll() error = %v", err)
}
if string(got) != "peek-rest" {
t.Fatalf("replayed data = %q, want peek-rest", got)
}
}
func TestChanListenerAcceptCloseAndDeliver(t *testing.T) {
listener := newChanListener(benchmarkAddr("listener"))
conn := newGatewayTestConn(nil)
if !listener.deliver(conn) {
t.Fatal("deliver() = false, want true")
}
got, err := listener.Accept()
if err != nil {
t.Fatalf("Accept() error = %v", err)
}
if got != conn {
t.Fatalf("Accept() = %v, want delivered conn", got)
}
if err := listener.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
if listener.deliver(newGatewayTestConn(nil)) {
t.Fatal("deliver() after Close = true, want false")
}
_, err = listener.Accept()
if !errors.Is(err, net.ErrClosed) {
t.Fatalf("Accept() error = %v, want %v", err, net.ErrClosed)
}
}
func TestHandleTcpWebPortReuseConnRoutesHTTP(t *testing.T) {
defer saveGatewayState(t)()
listener := newChanListener(benchmarkAddr("listener"))
source := newGatewayTestConn([]byte("GET / HTTP/1.1\r\n\r\n"))
tcpCalled := false
handleTcpWebPortReuseConn(source, listener, func(net.Conn) {
tcpCalled = true
}, time.Second)
if tcpCalled {
t.Fatal("TCP handler was called for HTTP request")
}
conn, err := listener.Accept()
if err != nil {
t.Fatalf("Accept() error = %v", err)
}
got, err := io.ReadAll(conn)
if err != nil {
t.Fatalf("ReadAll() error = %v", err)
}
if string(got) != "GET / HTTP/1.1\r\n\r\n" {
t.Fatalf("HTTP replay = %q", got)
}
}
func TestHandleTcpWebPortReuseConnRoutesMinecraft(t *testing.T) {
defer saveGatewayState(t)()
packet := gatewayTestPacket("play.example")
listener := newChanListener(benchmarkAddr("listener"))
source := newGatewayTestConn(packet)
var got []byte
handleTcpWebPortReuseConn(source, listener, func(conn net.Conn) {
var err error
got, err = io.ReadAll(conn)
if err != nil {
t.Fatalf("ReadAll() error = %v", err)
}
}, time.Second)
if !bytes.Equal(got, packet) {
t.Fatalf("Minecraft replay = %v, want %v", got, packet)
}
}
func TestHandleTcpWebPortReuseConnTimeoutClosesConn(t *testing.T) {
defer saveGatewayState(t)()
client, server := net.Pipe()
defer client.Close()
listener := newChanListener(benchmarkAddr("listener"))
done := make(chan struct{})
go func() {
handleTcpWebPortReuseConn(server, listener, func(net.Conn) {
t.Error("TCP handler was called after timeout")
}, 10*time.Millisecond)
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for initial packet timeout")
}
if _, err := client.Write([]byte("x")); err == nil {
t.Fatal("client Write() error = nil, want closed connection error")
}
}
func TestServeTcpWebPortReuseServesHTTPOnSharedPort(t *testing.T) {
defer saveGatewayState(t)()

View File

@@ -11,6 +11,10 @@ import (
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/tursom/mc-gateway/internal/adminconfig"
"github.com/tursom/mc-gateway/internal/adminsession"
"github.com/tursom/mc-gateway/internal/gatewayconfig"
"github.com/tursom/mc-gateway/internal/gatewaymetrics"
"github.com/tursom/mc-gateway/plugin/api"
)
@@ -18,10 +22,15 @@ func saveGatewayState(t *testing.T) func() {
t.Helper()
oldConfig := config
oldConfigFile := configFile
oldCurrentPidFile := currentPidFile
oldCurrentLogFile := currentLogFile
oldLogger := log.Logger
oldAdminStartup := adminStartup
oldAdminDB := adminDB
oldAdminDBPath := adminDBPath
oldAdminSessionManager := adminSessionManager
oldRouteSnapshot := routeSnapshot.Clone()
oldGatewayMetrics := gatewayMetrics
pluginLock.Lock()
oldPlugins := plugins
@@ -30,21 +39,40 @@ func saveGatewayState(t *testing.T) func() {
hooks = make(map[string]map[string]any)
pluginLock.Unlock()
config = Config{}
configFile = "config.toml"
config = gatewayconfig.Config{}
currentPidFile = ""
currentLogFile = ""
adminStartup = adminconfig.Config{
DBPath: defaultAdminDBPath,
TCPAdminPort: defaultTCPPort,
AdminPath: defaultAdminPath,
AdminAPIPrefix: defaultAdminAPIPrefix,
SessionTTL: defaultAdminSessionTTL,
}
adminDB = nil
adminDBPath = ""
adminSessionManager = adminsession.NewManager()
publishRouteSnapshot(nil)
gatewayMetrics = gatewaymetrics.New()
log.Logger = zerolog.New(io.Discard)
return func() {
if adminDB != nil && adminDB != oldAdminDB {
_ = adminDB.Close()
}
if currentPidFile != "" && currentPidFile != oldCurrentPidFile {
_ = os.Remove(currentPidFile)
}
config = oldConfig
configFile = oldConfigFile
currentPidFile = oldCurrentPidFile
currentLogFile = oldCurrentLogFile
adminStartup = oldAdminStartup
adminDB = oldAdminDB
adminDBPath = oldAdminDBPath
adminSessionManager = oldAdminSessionManager
publishRouteSnapshot(oldRouteSnapshot)
gatewayMetrics = oldGatewayMetrics
log.Logger = oldLogger
pluginLock.Lock()
@@ -54,6 +82,10 @@ func saveGatewayState(t *testing.T) func() {
}
}
func setGatewayTestRoutes(routes map[string]string) {
publishRouteSnapshot(routes)
}
func gatewayTestPacket(host string, tail ...byte) []byte {
packet := []byte{
byte(4 + 1 + len(host) + len(tail)),

View File

@@ -37,6 +37,7 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) {
}
defer conn.Close()
gatewayMetrics.WebSocketConnectionStarted()
handleRequest(&webSocketConn{Conn: conn})
}