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:
92
internal/adminhttp/api.go
Normal file
92
internal/adminhttp/api.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package adminhttp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SegmentHandlerFunc func(http.ResponseWriter, *http.Request, string)
|
||||
|
||||
type APIHandlers struct {
|
||||
SetupStatus http.HandlerFunc
|
||||
Setup http.HandlerFunc
|
||||
Login http.HandlerFunc
|
||||
Logout http.HandlerFunc
|
||||
Me http.HandlerFunc
|
||||
Status http.HandlerFunc
|
||||
|
||||
RoutesList http.HandlerFunc
|
||||
RouteItem SegmentHandlerFunc
|
||||
|
||||
ServicesList http.HandlerFunc
|
||||
ServiceItem SegmentHandlerFunc
|
||||
|
||||
Metrics http.HandlerFunc
|
||||
|
||||
UsersList http.HandlerFunc
|
||||
UsersCreate http.HandlerFunc
|
||||
UserItem SegmentHandlerFunc
|
||||
|
||||
AuditLogs http.HandlerFunc
|
||||
}
|
||||
|
||||
func NewAPIHandler(prefix string, handlers APIHandlers) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
path := strings.TrimPrefix(r.URL.Path, prefix)
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
|
||||
switch {
|
||||
case path == "/setup" && r.Method == http.MethodGet:
|
||||
callHandler(w, r, handlers.SetupStatus)
|
||||
case path == "/setup" && r.Method == http.MethodPost:
|
||||
callHandler(w, r, handlers.Setup)
|
||||
case path == "/auth/login" && r.Method == http.MethodPost:
|
||||
callHandler(w, r, handlers.Login)
|
||||
case path == "/auth/logout" && r.Method == http.MethodPost:
|
||||
callHandler(w, r, handlers.Logout)
|
||||
case path == "/me" && r.Method == http.MethodGet:
|
||||
callHandler(w, r, handlers.Me)
|
||||
case path == "/status" && r.Method == http.MethodGet:
|
||||
callHandler(w, r, handlers.Status)
|
||||
case path == "/routes" && r.Method == http.MethodGet:
|
||||
callHandler(w, r, handlers.RoutesList)
|
||||
case strings.HasPrefix(path, "/routes/"):
|
||||
callSegmentHandler(w, r, handlers.RouteItem, strings.TrimPrefix(path, "/routes/"))
|
||||
case path == "/services" && r.Method == http.MethodGet:
|
||||
callHandler(w, r, handlers.ServicesList)
|
||||
case strings.HasPrefix(path, "/services/"):
|
||||
callSegmentHandler(w, r, handlers.ServiceItem, strings.TrimPrefix(path, "/services/"))
|
||||
case path == "/metrics" && r.Method == http.MethodGet:
|
||||
callHandler(w, r, handlers.Metrics)
|
||||
case path == "/users" && r.Method == http.MethodGet:
|
||||
callHandler(w, r, handlers.UsersList)
|
||||
case path == "/users" && r.Method == http.MethodPost:
|
||||
callHandler(w, r, handlers.UsersCreate)
|
||||
case strings.HasPrefix(path, "/users/"):
|
||||
callSegmentHandler(w, r, handlers.UserItem, strings.TrimPrefix(path, "/users/"))
|
||||
case path == "/audit-logs" && r.Method == http.MethodGet:
|
||||
callHandler(w, r, handlers.AuditLogs)
|
||||
default:
|
||||
WriteAPIError(w, http.StatusNotFound, "not found")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func callHandler(w http.ResponseWriter, r *http.Request, handler http.HandlerFunc) {
|
||||
if handler == nil {
|
||||
WriteAPIError(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
handler(w, r)
|
||||
}
|
||||
|
||||
func callSegmentHandler(w http.ResponseWriter, r *http.Request, handler SegmentHandlerFunc, segment string) {
|
||||
if handler == nil {
|
||||
WriteAPIError(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
handler(w, r, segment)
|
||||
}
|
||||
114
internal/adminhttp/api_test.go
Normal file
114
internal/adminhttp/api_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package adminhttp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewAPIHandlerRoutesRequests(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
wantCall string
|
||||
wantSegment string
|
||||
}{
|
||||
{name: "setup status", method: http.MethodGet, path: "/admin/api/setup", wantCall: "setup_status"},
|
||||
{name: "setup", method: http.MethodPost, path: "/admin/api/setup", wantCall: "setup"},
|
||||
{name: "login", method: http.MethodPost, path: "/admin/api/auth/login", wantCall: "login"},
|
||||
{name: "logout", method: http.MethodPost, path: "/admin/api/auth/logout", wantCall: "logout"},
|
||||
{name: "me", method: http.MethodGet, path: "/admin/api/me", wantCall: "me"},
|
||||
{name: "status", method: http.MethodGet, path: "/admin/api/status", wantCall: "status"},
|
||||
{name: "routes list", method: http.MethodGet, path: "/admin/api/routes", wantCall: "routes_list"},
|
||||
{name: "route item", method: http.MethodPut, path: "/admin/api/routes/play.example", wantCall: "route_item", wantSegment: "play.example"},
|
||||
{name: "services list", method: http.MethodGet, path: "/admin/api/services", wantCall: "services_list"},
|
||||
{name: "service restart", method: http.MethodPost, path: "/admin/api/services/kcp/restart", wantCall: "service_item", wantSegment: "kcp/restart"},
|
||||
{name: "metrics", method: http.MethodGet, path: "/admin/api/metrics", wantCall: "metrics"},
|
||||
{name: "users list", method: http.MethodGet, path: "/admin/api/users", wantCall: "users_list"},
|
||||
{name: "users create", method: http.MethodPost, path: "/admin/api/users", wantCall: "users_create"},
|
||||
{name: "user item", method: http.MethodPatch, path: "/admin/api/users/member", wantCall: "user_item", wantSegment: "member"},
|
||||
{name: "audit logs", method: http.MethodGet, path: "/admin/api/audit-logs", wantCall: "audit_logs"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var gotCall, gotSegment string
|
||||
handler := NewAPIHandler("/admin/api", APIHandlers{
|
||||
SetupStatus: recordCall(&gotCall, "setup_status"),
|
||||
Setup: recordCall(&gotCall, "setup"),
|
||||
Login: recordCall(&gotCall, "login"),
|
||||
Logout: recordCall(&gotCall, "logout"),
|
||||
Me: recordCall(&gotCall, "me"),
|
||||
Status: recordCall(&gotCall, "status"),
|
||||
|
||||
RoutesList: recordCall(&gotCall, "routes_list"),
|
||||
RouteItem: recordSegmentCall(&gotCall, &gotSegment, "route_item"),
|
||||
|
||||
ServicesList: recordCall(&gotCall, "services_list"),
|
||||
ServiceItem: recordSegmentCall(&gotCall, &gotSegment, "service_item"),
|
||||
|
||||
Metrics: recordCall(&gotCall, "metrics"),
|
||||
|
||||
UsersList: recordCall(&gotCall, "users_list"),
|
||||
UsersCreate: recordCall(&gotCall, "users_create"),
|
||||
UserItem: recordSegmentCall(&gotCall, &gotSegment, "user_item"),
|
||||
|
||||
AuditLogs: recordCall(&gotCall, "audit_logs"),
|
||||
})
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(tt.method, tt.path, nil)
|
||||
handler.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want %d; body=%s", resp.Code, http.StatusNoContent, resp.Body.String())
|
||||
}
|
||||
if gotCall != tt.wantCall {
|
||||
t.Fatalf("call = %q, want %q", gotCall, tt.wantCall)
|
||||
}
|
||||
if gotSegment != tt.wantSegment {
|
||||
t.Fatalf("segment = %q, want %q", gotSegment, tt.wantSegment)
|
||||
}
|
||||
if got := resp.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("Cache-Control = %q, want no-store", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAPIHandlerWritesNotFound(t *testing.T) {
|
||||
handler := NewAPIHandler("/admin/api", APIHandlers{})
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/api/auth/login", nil)
|
||||
handler.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("wrong method status = %d, want %d", resp.Code, http.StatusNotFound)
|
||||
}
|
||||
if strings.TrimSpace(resp.Body.String()) != `{"error":"not found"}` {
|
||||
t.Fatalf("wrong method body = %q", resp.Body.String())
|
||||
}
|
||||
|
||||
resp = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/api/missing", nil)
|
||||
handler.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing status = %d, want %d", resp.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func recordCall(got *string, call string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
*got = call
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func recordSegmentCall(gotCall, gotSegment *string, call string) SegmentHandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, segment string) {
|
||||
*gotCall = call
|
||||
*gotSegment = segment
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
107
internal/adminhttp/gateway.go
Normal file
107
internal/adminhttp/gateway.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package adminhttp
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
staticIndexFile = "admin_static/index.html"
|
||||
staticCSSFile = "admin_static/app.css"
|
||||
staticJSFile = "admin_static/app.js"
|
||||
)
|
||||
|
||||
type GatewayHandlerOptions struct {
|
||||
AdminPath string
|
||||
AdminAPIPrefix string
|
||||
Assets fs.FS
|
||||
APIHandler http.HandlerFunc
|
||||
WebSocketEnabled bool
|
||||
WebSocketPath string
|
||||
WebSocketHandler http.HandlerFunc
|
||||
}
|
||||
|
||||
func NewGatewayHandler(opts GatewayHandlerOptions) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
registerAdminHandlers(mux, opts)
|
||||
|
||||
if opts.WebSocketEnabled &&
|
||||
opts.WebSocketHandler != nil &&
|
||||
!WebSocketPathConflictsWithAdmin(opts.WebSocketPath, opts.AdminPath, opts.AdminAPIPrefix) {
|
||||
mux.HandleFunc(opts.WebSocketPath, opts.WebSocketHandler)
|
||||
}
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
func WebSocketPathConflictsWithAdmin(webSocketPath, adminPath, adminAPIPrefix string) bool {
|
||||
adminRoot := strings.TrimRight(adminPath, "/")
|
||||
if webSocketPath == adminPath || webSocketPath == adminRoot {
|
||||
return true
|
||||
}
|
||||
if webSocketPath == adminAPIPrefix || strings.HasPrefix(adminAPIPrefix+"/", webSocketPath+"/") {
|
||||
return webSocketPath != "/"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func registerAdminHandlers(mux *http.ServeMux, opts GatewayHandlerOptions) {
|
||||
adminRoot := strings.TrimRight(opts.AdminPath, "/")
|
||||
|
||||
mux.HandleFunc(opts.AdminAPIPrefix, opts.APIHandler)
|
||||
mux.HandleFunc(opts.AdminAPIPrefix+"/", opts.APIHandler)
|
||||
if adminRoot != "" {
|
||||
mux.HandleFunc(adminRoot, func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, opts.AdminPath, http.StatusMovedPermanently)
|
||||
})
|
||||
}
|
||||
mux.HandleFunc(opts.AdminPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
serveAdminStatic(w, r, opts)
|
||||
})
|
||||
}
|
||||
|
||||
func serveAdminStatic(w http.ResponseWriter, r *http.Request, opts GatewayHandlerOptions) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Path == opts.AdminPath {
|
||||
serveAdminIndex(w, opts)
|
||||
return
|
||||
}
|
||||
|
||||
rel := strings.TrimPrefix(r.URL.Path, opts.AdminPath)
|
||||
switch rel {
|
||||
case "app.css":
|
||||
serveAdminFile(w, r, opts.Assets, staticCSSFile, "text/css; charset=utf-8")
|
||||
case "app.js":
|
||||
serveAdminFile(w, r, opts.Assets, staticJSFile, "application/javascript; charset=utf-8")
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func serveAdminIndex(w http.ResponseWriter, opts GatewayHandlerOptions) {
|
||||
data, err := fs.ReadFile(opts.Assets, staticIndexFile)
|
||||
if err != nil {
|
||||
WriteAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
html := strings.ReplaceAll(string(data), "__ADMIN_API_PREFIX__", opts.AdminAPIPrefix)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write([]byte(html))
|
||||
}
|
||||
|
||||
func serveAdminFile(w http.ResponseWriter, r *http.Request, assets fs.FS, name, contentType string) {
|
||||
data, err := fs.ReadFile(assets, name)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
58
internal/adminhttp/http.go
Normal file
58
internal/adminhttp/http.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package adminhttp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func DecodeJSONRequest(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
defer r.Body.Close()
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(dst); err != nil {
|
||||
WriteAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func WriteJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func WriteAPIError(w http.ResponseWriter, status int, message string) {
|
||||
WriteJSON(w, status, map[string]any{
|
||||
"error": message,
|
||||
})
|
||||
}
|
||||
|
||||
func PathSegment(raw string) (string, error) {
|
||||
if raw == "" || strings.Contains(raw, "/") {
|
||||
return "", errors.New("invalid path segment")
|
||||
}
|
||||
value, err := url.PathUnescape(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if value == "" || strings.Contains(value, "/") {
|
||||
return "", errors.New("invalid path segment")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func RequestSourceIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err == nil {
|
||||
return host
|
||||
}
|
||||
if r.RemoteAddr != "" {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
230
internal/adminhttp/http_test.go
Normal file
230
internal/adminhttp/http_test.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package adminhttp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestDecodeJSONRequest(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(`{"port":25565}`))
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
var body map[string]any
|
||||
if !DecodeJSONRequest(resp, req, &body) {
|
||||
t.Fatal("DecodeJSONRequest() = false, want true")
|
||||
}
|
||||
port, ok := body["port"].(json.Number)
|
||||
if !ok {
|
||||
t.Fatalf("port = %#v, want json.Number", body["port"])
|
||||
}
|
||||
if port.String() != "25565" {
|
||||
t.Fatalf("port = %q, want 25565", port.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeJSONRequestWritesError(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(`{`))
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
var body map[string]any
|
||||
if DecodeJSONRequest(resp, req, &body) {
|
||||
t.Fatal("DecodeJSONRequest(invalid) = true, want false")
|
||||
}
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", resp.Code, http.StatusBadRequest)
|
||||
}
|
||||
var errorBody map[string]string
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &errorBody); err != nil {
|
||||
t.Fatalf("Unmarshal(%q) error = %v", resp.Body.String(), err)
|
||||
}
|
||||
if !strings.HasPrefix(errorBody["error"], "invalid JSON: ") {
|
||||
t.Fatalf("error = %q, want invalid JSON prefix", errorBody["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteJSONAndAPIError(t *testing.T) {
|
||||
resp := httptest.NewRecorder()
|
||||
WriteJSON(resp, http.StatusCreated, map[string]any{"ok": true})
|
||||
if resp.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want %d", resp.Code, http.StatusCreated)
|
||||
}
|
||||
if got := resp.Header().Get("Content-Type"); got != "application/json; charset=utf-8" {
|
||||
t.Fatalf("Content-Type = %q", got)
|
||||
}
|
||||
if strings.TrimSpace(resp.Body.String()) != `{"ok":true}` {
|
||||
t.Fatalf("body = %q", resp.Body.String())
|
||||
}
|
||||
|
||||
resp = httptest.NewRecorder()
|
||||
WriteAPIError(resp, http.StatusForbidden, "permission denied")
|
||||
if resp.Code != http.StatusForbidden {
|
||||
t.Fatalf("error status = %d, want %d", resp.Code, http.StatusForbidden)
|
||||
}
|
||||
if strings.TrimSpace(resp.Body.String()) != `{"error":"permission denied"}` {
|
||||
t.Fatalf("error body = %q", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathSegment(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"play.example": "play.example",
|
||||
"play%2Etest": "play.test",
|
||||
}
|
||||
for raw, want := range tests {
|
||||
t.Run(raw, func(t *testing.T) {
|
||||
got, err := PathSegment(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("PathSegment(%q) error = %v", raw, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("PathSegment(%q) = %q, want %q", raw, got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, raw := range []string{"", "a/b", "%2F", "%zz"} {
|
||||
t.Run("invalid "+raw, func(t *testing.T) {
|
||||
if _, err := PathSegment(raw); err == nil {
|
||||
t.Fatalf("PathSegment(%q) error = nil, want error", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestSourceIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
remoteAddr string
|
||||
want string
|
||||
}{
|
||||
{"127.0.0.1:1234", "127.0.0.1"},
|
||||
{"[::1]:1234", "::1"},
|
||||
{"unix-socket", "unix-socket"},
|
||||
{"", "unknown"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.remoteAddr, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = tt.remoteAddr
|
||||
if got := RequestSourceIP(req); got != tt.want {
|
||||
t.Fatalf("RequestSourceIP(%q) = %q, want %q", tt.remoteAddr, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewGatewayHandlerServesAdminAndAPI(t *testing.T) {
|
||||
assets := fstest.MapFS{
|
||||
staticIndexFile: {Data: []byte(`<html data-api-prefix="__ADMIN_API_PREFIX__"></html>`)},
|
||||
staticCSSFile: {Data: []byte(`body{color:red}`)},
|
||||
staticJSFile: {Data: []byte(`console.log("admin")`)},
|
||||
}
|
||||
apiCalled := false
|
||||
handler := NewGatewayHandler(GatewayHandlerOptions{
|
||||
AdminPath: "/ops/",
|
||||
AdminAPIPrefix: "/ops/api",
|
||||
Assets: assets,
|
||||
APIHandler: func(w http.ResponseWriter, r *http.Request) {
|
||||
apiCalled = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
},
|
||||
})
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/ops", nil)
|
||||
handler.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusMovedPermanently {
|
||||
t.Fatalf("redirect status = %d, want %d", resp.Code, http.StatusMovedPermanently)
|
||||
}
|
||||
if got := resp.Header().Get("Location"); got != "/ops/" {
|
||||
t.Fatalf("redirect location = %q, want /ops/", got)
|
||||
}
|
||||
|
||||
resp = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/ops/", nil)
|
||||
handler.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("admin page status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), `data-api-prefix="/ops/api"`) {
|
||||
t.Fatalf("admin page = %q, want API prefix", resp.Body.String())
|
||||
}
|
||||
if got := resp.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("admin page Cache-Control = %q, want no-store", got)
|
||||
}
|
||||
|
||||
resp = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/ops/app.css", nil)
|
||||
handler.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusOK || strings.TrimSpace(resp.Body.String()) != `body{color:red}` {
|
||||
t.Fatalf("css response status=%d body=%q", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
resp = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/ops/", nil)
|
||||
handler.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("POST admin status = %d, want %d", resp.Code, http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
resp = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/ops/api/setup", nil)
|
||||
handler.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusNoContent {
|
||||
t.Fatalf("api status = %d, want %d", resp.Code, http.StatusNoContent)
|
||||
}
|
||||
if !apiCalled {
|
||||
t.Fatal("API handler was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewGatewayHandlerRegistersWebSocketWhenPathDoesNotConflict(t *testing.T) {
|
||||
assets := fstest.MapFS{
|
||||
staticIndexFile: {Data: []byte(``)},
|
||||
}
|
||||
handler := NewGatewayHandler(GatewayHandlerOptions{
|
||||
AdminPath: "/admin/",
|
||||
AdminAPIPrefix: "/admin/api",
|
||||
Assets: assets,
|
||||
APIHandler: func(w http.ResponseWriter, r *http.Request) {},
|
||||
WebSocketEnabled: true,
|
||||
WebSocketPath: "/ws",
|
||||
WebSocketHandler: func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
},
|
||||
})
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/ws", nil)
|
||||
handler.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusAccepted {
|
||||
t.Fatalf("websocket path status = %d, want %d", resp.Code, http.StatusAccepted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketPathConflictsWithAdmin(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
webSocketPath string
|
||||
want bool
|
||||
}{
|
||||
{name: "admin path", webSocketPath: "/admin/", want: true},
|
||||
{name: "admin root", webSocketPath: "/admin", want: true},
|
||||
{name: "api prefix", webSocketPath: "/admin/api", want: true},
|
||||
{name: "api child", webSocketPath: "/admin/api/ws", want: false},
|
||||
{name: "root", webSocketPath: "/", want: false},
|
||||
{name: "separate path", webSocketPath: "/ws", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := WebSocketPathConflictsWithAdmin(tt.webSocketPath, "/admin/", "/admin/api")
|
||||
if got != tt.want {
|
||||
t.Fatalf("WebSocketPathConflictsWithAdmin(%q) = %v, want %v", tt.webSocketPath, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
36
internal/adminhttp/requests.go
Normal file
36
internal/adminhttp/requests.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package adminhttp
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type SetupRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type RouteRequest struct {
|
||||
Upstream string `json:"upstream"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
type ServiceRequest struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Port int `json:"port"`
|
||||
Options map[string]any `json:"options"`
|
||||
}
|
||||
|
||||
type CreateUserRequest struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Password string `json:"password"`
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
type PatchUserRequest struct {
|
||||
Role *string `json:"role"`
|
||||
Password *string `json:"password"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
}
|
||||
55
internal/adminhttp/requests_test.go
Normal file
55
internal/adminhttp/requests_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package adminhttp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRouteRequestKeepsOptionalEnabled(t *testing.T) {
|
||||
var req RouteRequest
|
||||
if err := json.Unmarshal([]byte(`{"upstream":"127.0.0.1:25565","note":"primary"}`), &req); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
t.Fatalf("Enabled = %v, want nil when omitted", *req.Enabled)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(`{"enabled":false}`), &req); err != nil {
|
||||
t.Fatalf("Unmarshal(enabled) error = %v", err)
|
||||
}
|
||||
if req.Enabled == nil || *req.Enabled {
|
||||
t.Fatalf("Enabled = %v, want false pointer", req.Enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchUserRequestKeepsOmittedFieldsNil(t *testing.T) {
|
||||
var req PatchUserRequest
|
||||
if err := json.Unmarshal([]byte(`{"role":"guest"}`), &req); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if req.Role == nil || *req.Role != "guest" {
|
||||
t.Fatalf("Role = %v, want guest pointer", req.Role)
|
||||
}
|
||||
if req.Password != nil {
|
||||
t.Fatal("Password pointer should be nil when omitted")
|
||||
}
|
||||
if req.Disabled != nil {
|
||||
t.Fatal("Disabled pointer should be nil when omitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRequestDecodesOptions(t *testing.T) {
|
||||
var req ServiceRequest
|
||||
if err := json.Unmarshal([]byte(`{"enabled":true,"port":25570,"options":{"path":"/ws"}}`), &req); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if req.Enabled == nil || !*req.Enabled {
|
||||
t.Fatalf("Enabled = %v, want true pointer", req.Enabled)
|
||||
}
|
||||
if req.Port != 25570 {
|
||||
t.Fatalf("Port = %d, want 25570", req.Port)
|
||||
}
|
||||
if req.Options["path"] != "/ws" {
|
||||
t.Fatalf("path option = %#v, want /ws", req.Options["path"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user