commit 7c1697660fb18fae2583493d764b17dc73cb0cb7 Author: charles Date: Thu Feb 12 18:59:15 2026 -0800 add: rcon implementation diff --git a/README.md b/README.md new file mode 100644 index 0000000..d7f78fb --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# MC god + +Minecraft (MC) god is a tool that connects to a Minecraft server's RCON port, listens for messages from users by tailing the log, and interacts with players. It has the ability to call some functions to control things like weather, day/night cycle, and difficulty. + +## Layout + +This repository uses the Go standard layout described at https://github.com/golang-standards/project-layout. + +- internal/pkg + - rcon -- contains utilities for interacting with the RCON service +- cmd/mcgod -- contains the main function + +### RCON + +https://github.com/gorcon/rcon is used to connect to the RCON. The system expects the user provide a RCON_ADDRESS, and RCON_PASSWORD environment flag. + +# Handling updates + +Use `go mod tidy` to update go.mod; do not directly modify it. + +# Best practices + +## Only capture environment/flags in main + +To make testing easier, all utility packages should accept arguments that they need. These arguments sometimes get provided by the user, via flags or environment variables. Such variables should generally only be captured in the top-level package (i.e., main), and not helpers. diff --git a/cmd/mcgod/main.go b/cmd/mcgod/main.go new file mode 100644 index 0000000..ca6a104 --- /dev/null +++ b/cmd/mcgod/main.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + "time" + + "tipsy.codes/charles/mc-god/v2/internal/pkg/rcon" +) + +func main() { + // Create a context that will be cancelled on interrupt signals + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Set up signal handling for graceful shutdown + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigChan + log.Println("Received interrupt signal, shutting down...") + cancel() + }() + + // Create RCON client from environment variables + log.Println("Connecting to Minecraft server via RCON...") + client, err := rcon.NewFromEnv() + if err != nil { + log.Fatalf("Failed to create RCON client: %v", err) + } + defer func() { + if err := client.Close(); err != nil { + log.Printf("Error closing RCON connection: %v", err) + } + }() + + // Perform a health check + log.Println("Performing health check...") + if err := client.HealthCheck(); err != nil { + log.Fatalf("Health check failed: %v", err) + } + log.Println("Connected successfully!") + + // Example usage of various RCON functions + log.Println("Executing sample commands...") + + // Get server info + info, err := client.GetServerInfo() + if err != nil { + log.Printf("Failed to get server info: %v", err) + } else { + log.Printf("Server info: %s", info) + } + + // Set weather to clear + if err := client.SetWeather("clear"); err != nil { + log.Printf("Failed to set weather: %v", err) + } else { + log.Println("Weather set to clear") + } + + // Set time to day + if err := client.SetTime("day"); err != nil { + log.Printf("Failed to set time: %v", err) + } else { + log.Println("Time set to day") + } + + // Set difficulty to normal + if err := client.SetDifficulty("normal"); err != nil { + log.Printf("Failed to set difficulty: %v", err) + } else { + log.Println("Difficulty set to normal") + } + + // Example of executing a custom command + response, err := client.ExecuteWithTimeout("list", 5*time.Second) + if err != nil { + log.Printf("Failed to execute 'list' command: %v", err) + } else { + log.Printf("Players list response: %s", response) + } + + // Keep the application running until interrupted + log.Println("MC God is now running. Press Ctrl+C to stop.") + <-ctx.Done() + + log.Println("MC God stopped gracefully.") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..adb9350 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module tipsy.codes/charles/mc-god/v2 + +go 1.25.6 + +require github.com/gorcon/rcon v1.4.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4bcf23d --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/gorcon/rcon v1.4.0 h1:pYwZ8Rhcgfh/LhdPBncecuEo5thoFvPIuMSWovz1FME= +github.com/gorcon/rcon v1.4.0/go.mod h1:M6v6sNmr/NET9YIf+2rq+cIjTBridoy62uzQ58WgC1I= diff --git a/internal/pkg/rcon/README.md b/internal/pkg/rcon/README.md new file mode 100644 index 0000000..cc94524 --- /dev/null +++ b/internal/pkg/rcon/README.md @@ -0,0 +1,60 @@ +# RCON Package + +This package provides an interface for interacting with Minecraft servers via RCON (Remote Console). + +## Features + +- Connect to Minecraft servers using RCON protocol +- Execute commands on the server +- Set weather, time, and difficulty +- Health checking and connection management +- Timeout support for operations +- Environment variable based configuration + +## Usage + +```go +import "tipsy.codes/charles/mc-god/v2/internal/pkg/rcon" + +// Create a new client using environment variables +client, err := rcon.NewFromEnv() +if err != nil { + log.Fatal(err) +} +defer client.Close() + +// Execute a command +response, err := client.Execute("list") +if err != nil { + log.Fatal(err) +} +fmt.Println(response) + +// Set weather to clear +err = client.SetWeather("clear") +if err != nil { + log.Fatal(err) +} +``` + +## Environment Variables + +The package expects these environment variables to be set: + +- `RCON_ADDRESS` - The address of the Minecraft server (e.g., "localhost:25575") +- `RCON_PASSWORD` - The RCON password for authentication + +## Methods + +- `New(address, password)` - Create a new RCON client +- `Execute(command)` - Execute a command on the server +- `SetWeather(weather)` - Set the weather (clear, rain, thunder) +- `SetTime(timeValue)` - Set the time (day, night, noon, midnight, or numeric) +- `SetDifficulty(difficulty)` - Set the difficulty level (peaceful, easy, normal, hard) +- `GetServerInfo()` - Get server version information +- `Close()` - Close the RCON connection +- `HealthCheck()` - Verify the connection is working +- `ExecuteWithTimeout()` - Execute command with timeout +- `ConnectWithTimeout()` - Connect with timeout +- `GetEnvCredentials()` - Get credentials from environment +- `NewFromEnv()` - Create client from environment variables \ No newline at end of file diff --git a/internal/pkg/rcon/rcon.go b/internal/pkg/rcon/rcon.go new file mode 100644 index 0000000..c687050 --- /dev/null +++ b/internal/pkg/rcon/rcon.go @@ -0,0 +1,229 @@ +package rcon + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/gorcon/rcon" +) + +// Client represents an RCON client for Minecraft server interaction +type Client struct { + conn *rcon.Conn +} + +// New creates a new RCON client +func New(address, password string) (*Client, error) { + conn, err := rcon.Dial(address, password) + if err != nil { + return nil, fmt.Errorf("failed to connect to RCON: %w", err) + } + + return &Client{ + conn: conn, + }, nil +} + +// Execute executes a command on the Minecraft server +func (c *Client) Execute(command string) (string, error) { + if c.conn == nil { + return "", fmt.Errorf("RCON connection not established") + } + + response, err := c.conn.Execute(command) + if err != nil { + return "", fmt.Errorf("failed to execute command '%s': %w", command, err) + } + + return response, nil +} + +// Close closes the RCON connection +func (c *Client) Close() error { + if c.conn != nil { + return c.conn.Close() + } + return nil +} + +// Ping checks if the RCON connection is alive +func (c *Client) Ping() error { + _, err := c.Execute("help") + return err +} + +// SetWeather sets the weather in the Minecraft world +func (c *Client) SetWeather(weather string) error { + var command string + switch weather { + case "clear": + command = "weather clear" + case "rain": + command = "weather rain" + case "thunder": + command = "weather thunder" + default: + return fmt.Errorf("invalid weather type: %s", weather) + } + + _, err := c.Execute(command) + return err +} + +// SetTime sets the time in the Minecraft world +func (c *Client) SetTime(timeValue string) error { + var command string + switch timeValue { + case "day": + command = "time set day" + case "night": + command = "time set night" + case "noon": + command = "time set noon" + case "midnight": + command = "time set midnight" + default: + // Assume it's a numeric value + command = fmt.Sprintf("time set %s", timeValue) + } + + _, err := c.Execute(command) + return err +} + +// SetDifficulty sets the difficulty level +func (c *Client) SetDifficulty(difficulty string) error { + var command string + switch difficulty { + case "peaceful": + command = "difficulty peaceful" + case "easy": + command = "difficulty easy" + case "normal": + command = "difficulty normal" + case "hard": + command = "difficulty hard" + default: + return fmt.Errorf("invalid difficulty level: %s", difficulty) + } + + _, err := c.Execute(command) + return err +} + +// GetServerInfo returns basic server information +func (c *Client) GetServerInfo() (string, error) { + return c.Execute("version") +} + +// TailLogs starts tailing the server logs +func (c *Client) TailLogs(ctx context.Context, handler func(string)) error { + // This is a placeholder implementation + // In a real implementation, this would need to be connected to actual log tailing + // For now, we'll just return an error to indicate this is not implemented + return fmt.Errorf("log tailing not implemented in this version") +} + +// ConnectWithTimeout attempts to connect with a timeout +func ConnectWithTimeout(address, password string, timeout time.Duration) (*Client, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + // Create a channel to receive the connection result + connChan := make(chan *Client, 1) + errChan := make(chan error, 1) + + go func() { + client, err := New(address, password) + if err != nil { + errChan <- err + return + } + connChan <- client + }() + + select { + case client := <-connChan: + return client, nil + case err := <-errChan: + return nil, err + case <-ctx.Done(): + return nil, fmt.Errorf("timeout connecting to RCON: %w", ctx.Err()) + } +} + +// GetEnvCredentials returns RCON address and password from environment variables +func GetEnvCredentials() (string, string, error) { + address := os.Getenv("RCON_ADDRESS") + if address == "" { + return "", "", fmt.Errorf("RCON_ADDRESS environment variable not set") + } + + password := os.Getenv("RCON_PASSWORD") + if password == "" { + return "", "", fmt.Errorf("RCON_PASSWORD environment variable not set") + } + + return address, password, nil +} + +// NewFromEnv creates a new RCON client using environment variables +func NewFromEnv() (*Client, error) { + address, password, err := GetEnvCredentials() + if err != nil { + return nil, err + } + + client, err := New(address, password) + if err != nil { + return nil, fmt.Errorf("failed to create RCON client from environment: %w", err) + } + + return client, nil +} + +// HealthCheck verifies the RCON connection is working properly +func (c *Client) HealthCheck() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + select { + case <-ctx.Done(): + return fmt.Errorf("health check timeout") + default: + if err := c.Ping(); err != nil { + return fmt.Errorf("health check failed: %w", err) + } + return nil + } +} + +// ExecuteWithTimeout executes a command with a timeout +func (c *Client) ExecuteWithTimeout(command string, timeout time.Duration) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + // Create a channel to receive the response + responseChan := make(chan string, 1) + errChan := make(chan error, 1) + + go func() { + response, err := c.Execute(command) + if err != nil { + errChan <- err + return + } + responseChan <- response + }() + + select { + case response := <-responseChan: + return response, nil + case err := <-errChan: + return "", err + case <-ctx.Done(): + return "", fmt.Errorf("timeout executing command '%s': %w", command, ctx.Err()) + } +} diff --git a/internal/pkg/rcon/rcon_test.go b/internal/pkg/rcon/rcon_test.go new file mode 100644 index 0000000..d43d1e2 --- /dev/null +++ b/internal/pkg/rcon/rcon_test.go @@ -0,0 +1,23 @@ +package rcon + +import ( + "testing" +) + +func TestNew(t *testing.T) { + // This is a placeholder test since we can't actually connect to a server + // in a test environment without a real Minecraft server + // The actual functionality will be tested with integration tests + t.Log("RCON package tests - placeholder for actual tests") +} + +func TestGetEnvCredentials(t *testing.T) { + // Test that environment variables are properly read + // This test will be skipped in normal execution as environment variables won't be set + t.Log("Testing environment credential reading - placeholder for actual tests") +} + +func TestConnectWithTimeout(t *testing.T) { + // This test would require a mock server or actual server connection + t.Log("Testing connection with timeout - placeholder for actual tests") +}