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
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:
32
cmd/gateway/admin_api.go
Normal file
32
cmd/gateway/admin_api.go
Normal 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,
|
||||
})
|
||||
}
|
||||
281
cmd/gateway/admin_api_test.go
Normal file
281
cmd/gateway/admin_api_test.go
Normal 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
|
||||
}
|
||||
15
cmd/gateway/admin_audit.go
Normal file
15
cmd/gateway/admin_audit.go
Normal 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)
|
||||
}
|
||||
113
cmd/gateway/admin_auth_handlers.go
Normal file
113
cmd/gateway/admin_auth_handlers.go
Normal 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,
|
||||
})
|
||||
}
|
||||
17
cmd/gateway/admin_metric_handlers.go
Normal file
17
cmd/gateway/admin_metric_handlers.go
Normal 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())
|
||||
}
|
||||
62
cmd/gateway/admin_route_handlers.go
Normal file
62
cmd/gateway/admin_route_handlers.go
Normal 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")
|
||||
}
|
||||
}
|
||||
60
cmd/gateway/admin_routes.go
Normal file
60
cmd/gateway/admin_routes.go
Normal 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)
|
||||
}
|
||||
92
cmd/gateway/admin_runtime.go
Normal file
92
cmd/gateway/admin_runtime.go
Normal 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)
|
||||
}
|
||||
74
cmd/gateway/admin_service_handlers.go
Normal file
74
cmd/gateway/admin_service_handlers.go
Normal 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)
|
||||
}
|
||||
85
cmd/gateway/admin_services.go
Normal file
85
cmd/gateway/admin_services.go
Normal 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)
|
||||
}
|
||||
58
cmd/gateway/admin_sessions.go
Normal file
58
cmd/gateway/admin_sessions.go
Normal 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
|
||||
}
|
||||
23
cmd/gateway/admin_static.go
Normal file
23
cmd/gateway/admin_static.go
Normal 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,
|
||||
})
|
||||
}
|
||||
377
cmd/gateway/admin_static/app.css
Normal file
377
cmd/gateway/admin_static/app.css
Normal 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;
|
||||
}
|
||||
}
|
||||
579
cmd/gateway/admin_static/app.js
Normal file
579
cmd/gateway/admin_static/app.js
Normal 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) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"\"": """,
|
||||
"'": "'",
|
||||
}[ch]));
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHTML(value).replace(/`/g, "`");
|
||||
}
|
||||
|
||||
boot();
|
||||
195
cmd/gateway/admin_static/index.html
Normal file
195
cmd/gateway/admin_static/index.html
Normal 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>
|
||||
90
cmd/gateway/admin_user_handlers.go
Normal file
90
cmd/gateway/admin_user_handlers.go
Normal 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
139
cmd/gateway/admin_users.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)()
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -37,6 +37,7 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
gatewayMetrics.WebSocketConnectionStarted()
|
||||
handleRequest(&webSocketConn{Conn: conn})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user