feat: add config, auth, fetcher, java modules

- config: OS-specific paths, launcher.json load/save, DefaultSettings with MRIXSCRAFT_SERVER_URL env override
- auth: Yggdrasil client (authenticate, refresh, validate), session persistence in session.json, EnsureValid flow
- fetcher: HTTP download with SHA-1 verification, WorkerPool for concurrent downloads
- java: JRE detection (IsInstalled/Find), platform-specific executable name
- utils: SHA1File, SHA1Bytes, Unzip with zip-slip protection
- cmd/launcher: wire config + auth into main, session restore on startup

Co-Authored-By: OWL <noreply@anthropic.com>
This commit is contained in:
2026-05-26 06:35:09 +03:00
parent 320f009658
commit 070b5c0262
7 changed files with 750 additions and 9 deletions

View File

@@ -1,2 +1,256 @@
// package auth handles Yggdrasil authentication (login, refresh, validate).
// package auth handles Yggdrasil authentication with the backend.
package auth
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/config"
)
// Session represents an authenticated Yggdrasil session stored in session.json.
type Session struct {
AccessToken string `json:"accessToken"`
ClientToken string `json:"clientToken"`
UUID string `json:"uuid"`
Username string `json:"username"`
ExpiresAt time.Time `json:"expiresAt"`
}
// Yggdrasil client endpoints.
type Client struct {
baseURL string
httpClient *http.Client
}
// New creates a new Yggdrasil client bound to the given base URL.
func New(baseURL string) *Client {
return &Client{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 15 * time.Second,
},
}
}
// NewFromConfig creates a client using the server URL from launcher settings.
func NewFromConfig() (*Client, error) {
s, err := config.Load()
if err != nil {
return nil, fmt.Errorf("loading config: %w", err)
}
return New(s.ServerURL), nil
}
// sessionPath returns the file path for session.json under the launcher root.
func sessionPath() (string, error) {
root, err := config.RootDir()
if err != nil {
return "", err
}
return filepath.Join(root, "session.json"), nil
}
// generateClientToken creates a random hex token (16 bytes → 32 hex chars).
func generateClientToken() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// Load reads the session from disk.
func Load() (*Session, error) {
p, err := sessionPath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(p)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("reading session: %w", err)
}
var s Session
if err := json.Unmarshal(data, &s); err != nil {
return nil, fmt.Errorf("parsing session: %w", err)
}
return &s, nil
}
// Save writes the session to disk with restricted permissions.
func (s *Session) Save() error {
p, err := sessionPath()
if err != nil {
return err
}
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return fmt.Errorf("serializing session: %w", err)
}
return os.WriteFile(p, data, 0o600)
}
// Delete removes the session file (logout).
func Delete() error {
p, err := sessionPath()
if err != nil {
return err
}
return os.Remove(p)
}
// Authenticate performs a Yggdrasil /authserver/authenticate request.
func (c *Client) Authenticate(username, password string) (*Session, error) {
body, err := json.Marshal(map[string]interface{}{
"username": username,
"password": password,
})
if err != nil {
return nil, fmt.Errorf("encoding request: %w", err)
}
resp, err := c.httpClient.Post(
c.baseURL+"/authserver/authenticate",
"application/json",
bytes.NewReader(body),
)
if err != nil {
return nil, fmt.Errorf("sending request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("authentication failed (%d): %s", resp.StatusCode, string(msg))
}
var result struct {
AccessToken string `json:"accessToken"`
ClientToken string `json:"clientToken"`
Profile struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"selectedProfile"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decoding response: %w", err)
}
clientToken := result.ClientToken
if clientToken == "" {
clientToken = generateClientToken()
}
return &Session{
AccessToken: result.AccessToken,
ClientToken: clientToken,
UUID: result.Profile.ID,
Username: result.Profile.Name,
ExpiresAt: time.Now().Add(24 * time.Hour),
}, nil
}
// Refresh performs a Yggdrasil /authserver/refresh request.
func (c *Client) Refresh(s *Session) (*Session, error) {
body, err := json.Marshal(map[string]string{
"accessToken": s.AccessToken,
"clientToken": s.ClientToken,
})
if err != nil {
return nil, fmt.Errorf("encoding request: %w", err)
}
resp, err := c.httpClient.Post(
c.baseURL+"/authserver/refresh",
"application/json",
bytes.NewReader(body),
)
if err != nil {
return nil, fmt.Errorf("sending request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("refresh failed (%d): %s", resp.StatusCode, string(msg))
}
var result struct {
AccessToken string `json:"accessToken"`
ClientToken string `json:"clientToken"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decoding response: %w", err)
}
return &Session{
AccessToken: result.AccessToken,
ClientToken: result.ClientToken,
UUID: s.UUID,
Username: s.Username,
ExpiresAt: time.Now().Add(24 * time.Hour),
}, nil
}
// Validate checks if the current session token is still valid.
func (c *Client) Validate(s *Session) error {
body, err := json.Marshal(map[string]string{
"accessToken": s.AccessToken,
"clientToken": s.ClientToken,
})
if err != nil {
return fmt.Errorf("encoding request: %w", err)
}
resp, err := c.httpClient.Post(
c.baseURL+"/authserver/validate",
"application/json",
bytes.NewReader(body),
)
if err != nil {
return fmt.Errorf("sending request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(resp.Body)
return fmt.Errorf("validation failed (%d): %s", resp.StatusCode, string(msg))
}
return nil
}
// EnsureValid tries validate → refresh. Returns a valid session or an error.
func (c *Client) EnsureValid() (*Session, error) {
s, err := Load()
if err != nil {
return nil, err
}
if s == nil {
return nil, nil // not logged in
}
if err := c.Validate(s); err == nil {
return s, nil // still valid
}
// Try refresh.
refreshed, err := c.Refresh(s)
if err != nil {
_ = Delete()
return nil, nil // need to re-login
}
if err := refreshed.Save(); err != nil {
return nil, fmt.Errorf("saving refreshed session: %w", err)
}
return refreshed, nil
}

View File

@@ -1,2 +1,172 @@
// package config manages launcher configuration (launcher.json, system paths).
// package config manages launcher configuration and system paths.
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
)
// AppName is the canonical name for the launcher and its data directory.
const AppName = "MrixsCraft"
// Settings represents the user-configurable launcher settings stored in launcher.json.
type Settings struct {
ServerURL string `json:"server_url"` // Base URL of the backend (e.g. "https://minecraft.mrixs.me")
SelectedPack string `json:"selected_pack"` // Slug of the selected modpack
MemoryMB int `json:"memory_mb"` // Allocated RAM in MB (for -Xms / -Xmx)
ExtraArgs string `json:"extra_args"` // Additional JVM flags
Width int `json:"window_width"` // Main window width
Height int `json:"window_height"` // Main window height
}
// serverEnvURL is the environment variable that overrides the backend base URL.
const serverEnvURL = "MRIXSCRAFT_SERVER_URL"
// defaultServerURL is used when neither the env var nor launcher.json provide a value.
const defaultServerURL = "https://minecraft.mrixs.me"
// DefaultSettings returns sensible defaults.
func DefaultSettings() Settings {
url := os.Getenv(serverEnvURL)
if url == "" {
url = defaultServerURL
}
return Settings{
ServerURL: url,
SelectedPack: "",
MemoryMB: 4096,
ExtraArgs: "",
Width: 900,
Height: 600,
}
}
// RootDir returns the OS-specific data directory for the launcher.
func RootDir() (string, error) {
switch runtime.GOOS {
case "windows":
appData := os.Getenv("APPDATA")
if appData == "" {
return "", fmt.Errorf("APPDATA environment variable is empty")
}
return filepath.Join(appData, AppName), nil
case "darwin":
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolving home directory: %w", err)
}
return filepath.Join(home, "Library", "Application Support", AppName), nil
default: // linux and other unixes
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolving home directory: %w", err)
}
return filepath.Join(home, "."+AppName), nil
}
}
// EnsureRoot creates the root data directory if it does not exist.
func EnsureRoot() (string, error) {
dir, err := RootDir()
if err != nil {
return "", err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", fmt.Errorf("creating root directory %s: %w", dir, err)
}
return dir, nil
}
// Subdirectory returns (and optionally creates) a subdirectory under the root.
func Subdirectory(name string, create bool) (string, error) {
root, err := RootDir()
if err != nil {
return "", err
}
path := filepath.Join(root, name)
if create {
if err := os.MkdirAll(path, 0o755); err != nil {
return "", fmt.Errorf("creating subdirectory %s: %w", path, err)
}
}
return path, nil
}
// JavaDir returns the directory for a specific Java version.
func JavaDir(version int) (string, error) {
return Subdirectory(filepath.Join("Java", fmt.Sprintf("%d", version)), true)
}
// InstancesDir returns the directory containing modpack instances.
func InstancesDir() (string, error) {
return Subdirectory("instances", true)
}
// InstanceDir returns the directory for a specific modpack.
func InstanceDir(slug string) (string, error) {
return Subdirectory(filepath.Join("instances", slug), true)
}
// AssetsDir returns the assets cache directory.
func AssetsDir() (string, error) {
return Subdirectory("assets", true)
}
// LibrariesDir returns the libraries cache directory.
func LibrariesDir() (string, error) {
return Subdirectory("libraries", true)
}
// LauncherJSONPath returns the full path to launcher.json.
func LauncherJSONPath() (string, error) {
root, err := RootDir()
if err != nil {
return "", err
}
return filepath.Join(root, "launcher.json"), nil
}
// Load reads launcher.json from disk, returning defaults if the file does not exist.
func Load() (Settings, error) {
path, err := LauncherJSONPath()
if err != nil {
return Settings{}, err
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return DefaultSettings(), nil
}
return Settings{}, fmt.Errorf("reading %s: %w", path, err)
}
var s Settings
if err := json.Unmarshal(data, &s); err != nil {
return Settings{}, fmt.Errorf("parsing %s: %w", path, err)
}
return s, nil
}
// Save writes the settings to launcher.json.
func Save(s Settings) error {
path, err := LauncherJSONPath()
if err != nil {
return err
}
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return fmt.Errorf("serializing settings: %w", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("writing %s: %w", path, err)
}
return nil
}

View File

@@ -1,2 +1,116 @@
// package fetcher handles HTTP downloads and SHA-1 verification.
package fetcher
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sync"
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/pkg/utils"
)
// Download downloads a URL to dest, optionally verifying its SHA-1 hash.
// onProgress is called with (downloaded, total) bytes; total may be -1 if unknown.
func Download(url, dest, expectedSHA1 string, onProgress func(downloaded, total int64)) error {
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("GET %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s → %d", url, resp.StatusCode)
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return fmt.Errorf("creating parent for %s: %w", dest, err)
}
tmp := dest + ".part"
f, err := os.Create(tmp)
if err != nil {
return fmt.Errorf("creating %s: %w", tmp, err)
}
total := resp.ContentLength
var written int64
buf := make([]byte, 32*1024) // 32 KiB buffer
for {
n, readErr := resp.Body.Read(buf)
if n > 0 {
if _, writeErr := f.Write(buf[:n]); writeErr != nil {
f.Close()
os.Remove(tmp)
return fmt.Errorf("writing %s: %w", tmp, writeErr)
}
written += int64(n)
if onProgress != nil {
onProgress(written, total)
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
f.Close()
os.Remove(tmp)
return fmt.Errorf("reading %s: %w", url, readErr)
}
}
f.Close()
// Verify SHA-1 if expected hash provided.
if expectedSHA1 != "" {
got, err := utils.SHA1File(tmp)
if err != nil {
os.Remove(tmp)
return fmt.Errorf("hashing %s: %w", tmp, err)
}
if got != expectedSHA1 {
os.Remove(tmp)
return fmt.Errorf("SHA-1 mismatch: expected %s, got %s", expectedSHA1, got)
}
}
return os.Rename(tmp, dest)
}
// WorkerPool runs download jobs concurrently with a bounded number of workers.
type WorkerPool struct {
workers int
jobs chan func()
wg sync.WaitGroup
}
// NewWorkerPool creates a pool with the given number of workers.
func NewWorkerPool(workers int) *WorkerPool {
p := &WorkerPool{
workers: workers,
jobs: make(chan func(), 100),
}
for i := 0; i < workers; i++ {
p.wg.Add(1)
go func() {
defer p.wg.Done()
for job := range p.jobs {
job()
}
}()
}
return p
}
// Submit adds a job to the pool.
func (p *WorkerPool) Submit(job func()) {
p.jobs <- job
}
// Wait blocks until all submitted jobs are finished.
func (p *WorkerPool) Wait() {
close(p.jobs)
p.wg.Wait()
}

View File

@@ -1,2 +1,64 @@
// package java manages portable JRE downloads and detection.
package java
import (
"fmt"
"os"
"path/filepath"
"runtime"
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/config"
)
// ExecutableName returns the platform-specific Java executable name.
func ExecutableName() string {
if runtime.GOOS == "windows" {
return "java.exe"
}
return "java"
}
// IsInstalled checks whether the given Java version is available locally.
func IsInstalled(version int) (string, error) {
dir, err := config.JavaDir(version)
if err != nil {
return "", err
}
bin := filepath.Join(dir, "bin", ExecutableName())
if _, err := os.Stat(bin); err != nil {
if os.IsNotExist(err) {
return "", nil // not installed
}
return "", fmt.Errorf("checking %s: %w", bin, err)
}
return bin, nil
}
// Find searches for the required Java version, downloading it if necessary.
// Returns the path to the java binary.
func Find(version int) (string, error) {
bin, err := IsInstalled(version)
if err != nil {
return "", err
}
if bin != "" {
return bin, nil
}
// TODO: Download and extract JRE for the requested version.
// For now, return an error with instructions.
return "", fmt.Errorf(
"Java %d is not installed. Please wait for auto-download feature "+
"(planned) or manually place JRE in %s",
version,
must(config.JavaDir(version)),
)
}
// must panics on error — only used for error messages where the dir is already known valid.
func must(s string, err error) string {
if err != nil {
panic(err)
}
return s
}