diff --git a/cmd/server/main.go b/cmd/server/main.go index cc8a074..c914b35 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,26 +1,77 @@ package main import ( - "fmt" + "context" "log" "net/http" "os" + "os/signal" + "strconv" + "syscall" + "time" + + "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/auth" + "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config" + "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database" ) func main() { - port := os.Getenv("SERVER_PORT") - if port == "" { - port = "8080" + ctx := context.Background() + + cfg, err := config.Load() + if err != nil { + log.Fatalf("Failed to load config: %v", err) } + db, err := database.Open(ctx, cfg.DatabaseURL) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + defer db.Close() + mux := http.NewServeMux() - mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + + // Health check. + mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) - fmt.Fprintln(w, "ok") + w.Write([]byte("ok")) }) - log.Printf("MrixsCraft Server starting on :%s", port) - if err := http.ListenAndServe(":"+port, mux); err != nil { - log.Fatal(err) + // Yggdrasil API. + authHandler := auth.NewHandler(db, cfg) + authHandler.RegisterRoutes(mux) + + // TODO: register API, Admin, CAS routes. + + addr := ":" + itoa(cfg.Port) + srv := &http.Server{ + Addr: addr, + Handler: mux, } + + // Graceful shutdown. + done := make(chan os.Signal, 1) + signal.Notify(done, syscall.SIGINT, syscall.SIGTERM) + + go func() { + log.Printf("MrixsCraft Server starting on %s", addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Server error: %v", err) + } + }() + + <-done + log.Println("Shutting down…") + + shutdownCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Printf("Shutdown error: %v", err) + } + log.Println("Stopped.") +} + +// itoa converts int to string (stdlib alias to avoid fmt import just for this). +func itoa(n int) string { + return strconv.Itoa(n) } diff --git a/go.mod b/go.mod index 22d04eb..b2c73c0 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,14 @@ -module github.com/Mrixs/MrixsCraft-server +module gitea.mrixs.me/Mrixs/MrixsCraft-server go 1.22 + +require github.com/jackc/pgx/v5 v5.6.0 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 0283f6c..e2493b5 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -1,2 +1,288 @@ -// Package auth implements Yggdrasil authentication protocol. +// package auth implements the Yggdrasil authentication protocol. package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config" + "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database" +) + +// Handler serves Yggdrasil endpoints. +type Handler struct { + db *database.DB + cfg *config.Config +} + +// NewHandler creates a new auth handler. +func NewHandler(db *database.DB, cfg *config.Config) *Handler { + return &Handler{db: db, cfg: cfg} +} + +// RegisterRoutes mounts the Yggdrasil endpoints on the given mux. +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /authserver/authenticate", h.authenticate) + mux.HandleFunc("POST /authserver/refresh", h.refresh) + mux.HandleFunc("POST /authserver/validate", h.validate) +} + +// ── Request / Response types ────────────────────────────────── + +type authenticateRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type authenticateResponse struct { + AccessToken string `json:"accessToken"` + ClientToken string `json:"clientToken"` + AvailableProfile []profile `json:"availableProfiles"` + SelectedProfile *profile `json:"selectedProfile,omitempty"` + User *userProperties `json:"user,omitempty"` +} + +type profile struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type userProperties struct { + ID string `json:"id"` + Properties []property `json:"properties"` +} + +type property struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type refreshRequest struct { + AccessToken string `json:"accessToken"` + ClientToken string `json:"clientToken"` +} + +type refreshResponse struct { + AccessToken string `json:"accessToken"` + ClientToken string `json:"clientToken"` + SelectedProfile *profile `json:"selectedProfile"` +} + +type errorResponse struct { + Error string `json:"error"` + ErrorMessage string `json:"errorMessage"` +} + +// ── Handlers ────────────────────────────────────────────────── + +func (h *Handler) authenticate(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + writeError(w, http.StatusBadRequest, "Bad Request", "Cannot read body") + return + } + + var req authenticateRequest + if err := json.Unmarshal(body, &req); err != nil { + writeError(w, http.StatusBadRequest, "Bad Request", "Invalid JSON") + return + } + + // Look up user by username or email. + user, err := h.findUser(r.Context(), req.Username) + if err != nil { + writeError(w, http.StatusUnauthorized, "Forbidden", "Invalid credentials") + return + } + + // Verify password (SHA-256 hex comparison). + if !verifyPassword(req.Password, user.PasswordHash) { + writeError(w, http.StatusUnauthorized, "Forbidden", "Invalid credentials") + return + } + + // Generate tokens. + accessToken := generateToken() + clientToken := generateToken() + + // Store session. + expiresAt := time.Now().Add(24 * time.Hour) + _, err = h.db.Pool().Exec(r.Context(), + `INSERT INTO yggdrasil_sessions (client_token, access_token, user_id, expires_at) + VALUES ($1, $2, $3, $4)`, + clientToken, accessToken, user.ID, expiresAt, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Internal Error", "Failed to create session") + return + } + + resp := authenticateResponse{ + AccessToken: accessToken, + ClientToken: clientToken, + SelectedProfile: &profile{ + ID: user.UUID, + Name: user.Username, + }, + AvailableProfile: []profile{{ + ID: user.UUID, + Name: user.Username, + }}, + } + + writeJSON(w, http.StatusOK, resp) +} + +func (h *Handler) refresh(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + writeError(w, http.StatusBadRequest, "Bad Request", "Cannot read body") + return + } + + var req refreshRequest + if err := json.Unmarshal(body, &req); err != nil { + writeError(w, http.StatusBadRequest, "Bad Request", "Invalid JSON") + return + } + + // Look up session. + var userID int + var expiresAt time.Time + err = h.db.Pool().QueryRow(r.Context(), + `SELECT user_id, expires_at FROM yggdrasil_sessions + WHERE access_token = $1 AND client_token = $2`, + req.AccessToken, req.ClientToken, + ).Scan(&userID, &expiresAt) + + if err != nil { + writeError(w, http.StatusUnauthorized, "Forbidden", "Invalid token") + return + } + + if time.Now().After(expiresAt) { + writeError(w, http.StatusUnauthorized, "Forbidden", "Token expired") + return + } + + // Rotate access token. + newAccessToken := generateToken() + _, err = h.db.Pool().Exec(r.Context(), + `UPDATE yggdrasil_sessions SET access_token = $1, expires_at = $2 + WHERE access_token = $3`, + newAccessToken, time.Now().Add(24*time.Hour), req.AccessToken, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Internal Error", "Failed to refresh") + return + } + + // Get user info. + var username, uuid string + err = h.db.Pool().QueryRow(r.Context(), + `SELECT username, uuid FROM users WHERE id = $1`, userID, + ).Scan(&username, &uuid) + if err != nil { + writeError(w, http.StatusInternalServerError, "Internal Error", "User not found") + return + } + + writeJSON(w, http.StatusOK, refreshResponse{ + AccessToken: newAccessToken, + ClientToken: req.ClientToken, + SelectedProfile: &profile{ + ID: uuid, + Name: username, + }, + }) +} + +func (h *Handler) validate(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusNoContent) + return + } + + var req refreshRequest + if err := json.Unmarshal(body, &req); err != nil { + w.WriteHeader(http.StatusNoContent) + return + } + + var expiresAt time.Time + err = h.db.Pool().QueryRow(r.Context(), + `SELECT expires_at FROM yggdrasil_sessions + WHERE access_token = $1 AND client_token = $2`, + req.AccessToken, req.ClientToken, + ).Scan(&expiresAt) + + if err != nil || time.Now().After(expiresAt) { + w.WriteHeader(http.StatusNoContent) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// ── Helpers ─────────────────────────────────────────────────── + +func (h *Handler) findUser(ctx context.Context, login string) (*database.User, error) { + var user database.User + err := h.db.Pool().QueryRow(ctx, + `SELECT id, username, email, password_hash, uuid, role FROM users + WHERE username = $1 OR email = $1`, login, + ).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.UUID, &user.Role) + if err != nil { + return nil, err + } + return &user, nil +} + +func verifyPassword(password, hash string) bool { + h := sha256.Sum256([]byte(password)) + return subtle.ConstantTimeCompare([]byte(hex.EncodeToString(h[:])), []byte(hash)) == 1 +} + +func generateToken() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, err, msg string) { + writeJSON(w, status, errorResponse{ + Error: err, + ErrorMessage: msg, + }) +} + +// HashPassword returns the SHA-256 hex of a password for storage. +func HashPassword(password string) string { + h := sha256.Sum256([]byte(password)) + return hex.EncodeToString(h[:]) +} + +// GenerateUUID creates a random UUID v4-like string. +func GenerateUUID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + b[6] = (b[6] & 0x0f) | 0x40 // version 4 + b[8] = (b[8] & 0x3f) | 0x80 // variant + return fmt.Sprintf("%x-%x-%x-%x-%x", + b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} diff --git a/internal/config/config.go b/internal/config/config.go index d912805..957679f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,2 +1,62 @@ -// package config handles server configuration (env vars, config files). +// package config handles server configuration from environment variables. package config + +import ( + "fmt" + "os" + "strconv" +) + +// Config holds all server configuration. +type Config struct { + // HTTP + Port int `json:"port"` + + // Database + DatabaseURL string `json:"database_url"` + + // Storage + CASDir string `json:"cas_dir"` // Content-Addressable Storage root + SkinsDir string `json:"skins_dir"` // Uploaded skins + + // Auth + JWTSecret string `json:"jwt_secret"` + CIsecret string `json:"ci_secret"` // Token for CI/CD launcher release endpoint + + // Public + BaseURL string `json:"base_url"` // External URL for CDN links +} + +// Load reads configuration from environment variables with sensible defaults. +func Load() (*Config, error) { + port, err := strconv.Atoi(getEnv("SERVER_PORT", "8080")) + if err != nil { + return nil, fmt.Errorf("invalid SERVER_PORT: %w", err) + } + + cfg := &Config{ + Port: port, + DatabaseURL: getEnv("DATABASE_URL", ""), + CASDir: getEnv("CAS_DIR", "/var/www/cdn/files"), + SkinsDir: getEnv("SKINS_DIR", "/var/www/cdn/skins"), + JWTSecret: getEnv("JWT_SECRET", ""), + CIsecret: getEnv("CI_SECRET", ""), + BaseURL: getEnv("BASE_URL", "https://minecraft.mrixs.me"), + } + + if cfg.DatabaseURL == "" { + return nil, fmt.Errorf("DATABASE_URL is required") + } + if cfg.JWTSecret == "" { + return nil, fmt.Errorf("JWT_SECRET is required") + } + + return cfg, nil +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/internal/database/database.go b/internal/database/database.go index 28b927f..39579b5 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -1,2 +1,87 @@ -// package database manages PostgreSQL connections, migrations, and data models. +// package database manages PostgreSQL connections and data models. package database + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// DB wraps pgxpool.Pool with application-specific helpers. +type DB struct { + pool *pgxpool.Pool +} + +// Open creates a new database connection pool. +func Open(ctx context.Context, databaseURL string) (*DB, error) { + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return nil, fmt.Errorf("connecting to database: %w", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("pinging database: %w", err) + } + return &DB{pool: pool}, nil +} + +// Close shuts down the connection pool. +func (db *DB) Close() { + db.pool.Close() +} + +// Pool returns the underlying pool for direct queries. +func (db *DB) Pool() *pgxpool.Pool { + return db.pool +} + +// ── Models ───────────────────────────────────────────────────── + +// User represents a registered player. +type User struct { + ID int `db:"id"` + Username string `db:"username"` + Email string `db:"email"` + PasswordHash string `db:"password_hash"` + UUID string `db:"uuid"` + Role string `db:"role"` +} + +// YggdrasilSession represents an active authentication session. +type YggdrasilSession struct { + ClientToken string `db:"client_token"` + AccessToken string `db:"access_token"` + UserID int `db:"user_id"` + ExpiresAt string `db:"expires_at"` +} + +// Modpack represents a game server / modpack configuration. +type Modpack struct { + ID int `db:"id"` + Slug string `db:"slug"` + Name string `db:"name"` + MinecraftVersion string `db:"minecraft_version"` + JavaVersion int `db:"java_version"` + ServerIP string `db:"server_ip"` + IsActive bool `db:"is_active"` +} + +// GlobalFile is a CAS entry. +type GlobalFile struct { + SHA1 string `db:"sha1"` + Size int64 `db:"size_bytes"` + FileName string `db:"file_name"` +} + +// LauncherRelease tracks published launcher binaries. +type LauncherRelease struct { + ID int `db:"id"` + Version string `db:"version"` + OS string `db:"os"` + Arch string `db:"arch"` + SHA256 string `db:"sha256"` + FilePath string `db:"file_path"` + IsActive bool `db:"is_active"` + IsMandatory bool `db:"is_mandatory"` +}