Commit b4c95ae

bryfry <bryon@fryer.io>
2025-01-26 20:58:36
init
cmd/install.go
@@ -0,0 +1,145 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"text/template"
+	"time"
+
+	"github.com/coreos/go-systemd/v22/dbus"
+	"github.com/spf13/cobra"
+)
+
+func InstallCmd() *cobra.Command {
+	return &cobra.Command{
+		Use:   "install",
+		Short: "install as a service",
+		RunE:  install,
+	}
+}
+
+func install(cmd *cobra.Command, args []string) error {
+
+	const (
+		_binaryName     = "mm"
+		_installDir     = "/usr/local/sbin/"
+		_installPath    = _installDir + _binaryName
+		_installDirMode = 0o755
+		_installExeMode = 0o755
+		_configDir      = "/etc/"
+		_configPath     = _configDir + _binaryName + ".conf"
+		_configMode     = 0o400
+	)
+
+	exePath, err := os.Executable()
+	if err != nil {
+		return fmt.Errorf("failed to find self binary: %w", err)
+	}
+	exeBytes, err := os.ReadFile(exePath)
+	if err != nil {
+		return fmt.Errorf("failed to read self binary: %w", err)
+	}
+
+	err = os.MkdirAll(_installDir, _installDirMode)
+	if err != nil {
+		return fmt.Errorf(
+			"failed to create install dir=%q: %w",
+			_installDir, err)
+	}
+	err = os.WriteFile(_installPath, exeBytes, _installExeMode)
+	if err != nil {
+		return fmt.Errorf(
+			"failed to write binary path=%q: %w",
+			_installPath, err)
+	}
+
+	const (
+		_serviceDir  = "/etc/systemd/system/"
+		_serviceName = _binaryName + ".service"
+		_servicePath = _serviceDir + _serviceName
+	)
+
+	unitFile, err := os.Create(_servicePath)
+	if err != nil {
+		return fmt.Errorf(
+			"failed to create service file path=%q: %w",
+			_servicePath, err)
+	}
+	tmpl := template.Must(template.New("unit").Parse(unitTemplate))
+	err = tmpl.Execute(unitFile, struct {
+		Name     string
+		ExecPath string
+		EnvPath  string
+	}{
+		Name:     _binaryName,
+		ExecPath: _installPath,
+		EnvPath:  _configPath,
+	})
+	if err != nil {
+		return fmt.Errorf(
+			"failed to template service file path=%q: %w",
+			_servicePath, err)
+	}
+
+	ctx, cancel := context.WithTimeout(
+		context.Background(), 10*time.Second)
+	defer cancel()
+
+	conn, err := dbus.NewSystemdConnectionContext(ctx)
+	if err != nil {
+		return fmt.Errorf("failed to connect to systemd: %w", err)
+	}
+	defer conn.Close()
+
+	err = conn.ReloadContext(ctx)
+	if err != nil {
+		return fmt.Errorf("failed to reload systemd: %w", err)
+	}
+
+	runtimeOnly := false
+	replaceExisting := true
+	_, _, err = conn.EnableUnitFilesContext(
+		ctx, []string{_serviceName},
+		runtimeOnly, replaceExisting)
+	if err != nil {
+		return fmt.Errorf("failed to enable service name=%q: %w",
+			_serviceName, err)
+	}
+
+	startChan := make(chan string, 1)
+	_, err = conn.StartUnitContext(ctx, _serviceName, "replace", startChan)
+	if err != nil {
+		return fmt.Errorf("failed to start service name=%q: %w",
+			_serviceName, err)
+	}
+
+	select {
+	case <-startChan:
+		break
+	case <-ctx.Done():
+		return fmt.Errorf("timed out starting service")
+	}
+
+	err = os.Remove(exePath)
+	if err != nil {
+		return fmt.Errorf("failed to delete self binary: %w", err)
+	}
+	return nil
+}
+
+var unitTemplate = `
+[Unit]
+Description={{.Name}} service
+After=network.target
+
+[Service]
+Type=simple
+ExecStart={{.ExecPath}}
+EnvironmentFile={{.EnvPath}}
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
+`
cmd/root.go
@@ -0,0 +1,16 @@
+package cmd
+
+import "github.com/spf13/cobra"
+
+func Execute() error {
+
+	rootCmd := &cobra.Command{
+		Use:   "mm",
+		Short: "minecraft manager",
+		RunE:  service, // service.go
+	}
+
+	rootCmd.AddCommand(InstallCmd())
+
+	return rootCmd.Execute()
+}
cmd/service.go
@@ -0,0 +1,60 @@
+package cmd
+
+import (
+	"log/slog"
+	"os"
+
+	"github.com/bryfry/mm/internal/aod"
+	"github.com/bryfry/mm/internal/cf"
+	"github.com/bryfry/mm/internal/iplookup"
+	"github.com/spf13/cobra"
+)
+
+func service(cmd *cobra.Command, args []string) error {
+
+	zoneName, zoneNameExists := os.LookupEnv(cf.EnvZoneName)
+	if !zoneNameExists || len(zoneName) == 0 {
+		return cf.ErrZoneNameRequired
+	}
+	subdomainName, subdomainNameExists := os.LookupEnv(cf.EnvSubdomainName)
+	if !subdomainNameExists || len(subdomainName) == 0 {
+		return cf.ErrSubdomainNameRequired
+	}
+	apiToken, apiTokenExists := os.LookupEnv(cf.EnvAPIToken)
+	if !apiTokenExists || len(apiToken) == 0 {
+		return cf.ErrAPITokenRequired
+	}
+
+	ipDetails, err := iplookup.Discover()
+	if err != nil {
+		return err
+	}
+
+	slog.Info("cfdns: ip discovery",
+		slog.String("ip", ipDetails.Address))
+
+	cfdns, err := cf.New(zoneName, subdomainName, apiToken)
+	if err != nil {
+		return err
+	}
+
+	slog.Info("cfdns: dns discovery",
+		slog.String("domain", subdomainName),
+		slog.String("ip", cfdns.IP()))
+
+	if ipDetails.Address != cfdns.IP() {
+		slog.Info("cfdns: update required")
+
+		err = cfdns.SetIP(ipDetails.Address)
+		if err != nil {
+			return err
+		}
+
+		slog.Info("cfdns: update complete",
+			slog.String("domain", subdomainName),
+			slog.String("ip", cfdns.IP()))
+	}
+
+	// TODO: watch ENV
+	return aod.Watch(subdomainName)
+}
internal/active/main.go
@@ -0,0 +1,37 @@
+package active
+
+import (
+	"fmt"
+
+	"github.com/vishvananda/netlink"
+)
+
+func Connections(port uint16) (int, error) {
+	sockets, err := netlink.SocketDiagTCP(netlink.FAMILY_V4)
+	if err != nil {
+		return 0, fmt.Errorf("failed to read active sockets: %w", err)
+	}
+	flows, err := netlink.ConntrackTableList(
+		netlink.ConntrackTable,
+		netlink.FAMILY_V4)
+	if err != nil {
+		return 0, fmt.Errorf("failed to read conntrack flows: %w", err)
+	}
+
+	activeConnections := 0
+	for _, s := range sockets {
+		if s.ID.DestinationPort == port {
+			activeConnections++
+		}
+	}
+
+	for _, f := range flows {
+		if f.Forward.DstPort == port {
+			activeConnections++
+		}
+	}
+	if activeConnections == 0 {
+		return 0, nil
+	}
+	return activeConnections, nil
+}
internal/aod/main.go
@@ -0,0 +1,76 @@
+package aod
+
+import (
+	"context"
+	"log/slog"
+	"time"
+
+	"github.com/bryfry/mm/internal/minecraft"
+)
+
+// TODO: options
+func Watch(domain string) error {
+
+	const (
+		_targetPort      uint16        = 25565
+		_checkInterval   time.Duration = time.Second * 5
+		_logInterval     time.Duration = time.Minute * 5
+		_inactiveTimeout time.Duration = time.Minute * 55
+	)
+
+	ticker := time.NewTicker(_checkInterval)
+	defer ticker.Stop()
+
+	slog.Info("active or die: starting",
+		slog.String("domain", domain),
+		slog.Int("port", int(_targetPort)))
+
+	// loop variables and timers
+	lastActive := time.Now()
+	lastLog := time.Now()
+	connections := -1 // log first active connection check
+	for {
+		select {
+		case <-ticker.C:
+
+			c, err := minecraft.Connections(domain, _targetPort)
+			if err != nil {
+				return err
+			}
+
+			// trigger logging if changes
+			changed := connections != c
+			connections = c
+
+			// calculate inacivity
+			inactiveDuration := time.Since(lastActive).Round(time.Second)
+			msg := "inactive"
+			details := []slog.Attr{
+				slog.String("duration", inactiveDuration.String()),
+				slog.String("timeout", _inactiveTimeout.String()),
+			}
+
+			// reset if active
+			if connections != 0 {
+				lastActive = time.Now()
+				inactiveDuration = 0
+				msg = "active"
+				details = []slog.Attr{
+					slog.Int("connections", connections),
+				}
+			}
+
+			// log on change or at log interval
+			if changed || time.Since(lastLog) > _logInterval {
+				slog.LogAttrs(context.Background(), slog.LevelInfo, msg, details...)
+				lastLog = time.Now()
+			}
+
+			if inactiveDuration > _inactiveTimeout {
+				msg = "inactive timeout: powering off"
+				slog.LogAttrs(context.Background(), slog.LevelInfo, msg, details...)
+				return poweroff()
+			}
+		}
+	}
+}
internal/aod/poweroff.go
@@ -0,0 +1,32 @@
+package aod
+
+import (
+	"fmt"
+
+	"github.com/godbus/dbus/v5"
+)
+
+func poweroff() error {
+
+	const (
+		_login1      string          = "org.freedesktop.login1"
+		_poweroff    string          = _login1 + ".Manager.PowerOff"
+		_login1Path  dbus.ObjectPath = "/org/freedesktop/login1"
+		_interactive bool            = false
+		_flags       dbus.Flags      = 0
+	)
+
+	conn, err := dbus.SystemBus()
+	if err != nil {
+		return fmt.Errorf("failed to connect to system bus: %w", err)
+	}
+	defer conn.Close()
+
+	obj := conn.Object(_login1, _login1Path)
+	err = obj.Call(_poweroff, _flags, _interactive).Err
+	if err != nil {
+		return fmt.Errorf("failed to power off the system: %w", err)
+	}
+
+	return nil
+}
internal/cf/errors.go
@@ -0,0 +1,19 @@
+package cf
+
+import (
+	"errors"
+	"fmt"
+)
+
+const (
+	EnvZoneName      = "CFDNS_ZONE_NAME"
+	EnvSubdomainName = "CFDNS_SUBDOMAIN_NAME"
+	EnvAPIToken      = "CFDNS_API_TOKEN"
+)
+
+var (
+	_errFmt                  = "required env variable %s not found or empty"
+	ErrZoneNameRequired      = errors.New(fmt.Sprintf(_errFmt, EnvZoneName))
+	ErrSubdomainNameRequired = errors.New(fmt.Sprintf(_errFmt, EnvSubdomainName))
+	ErrAPITokenRequired      = errors.New(fmt.Sprintf(_errFmt, EnvAPIToken))
+)
internal/cf/main.go
@@ -0,0 +1,132 @@
+package cf
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/cloudflare/cloudflare-go"
+)
+
+// CF handles interactions with the Cloudflare API.
+type CF struct {
+	api          *cloudflare.API
+	zone         *cloudflare.ResourceContainer // ZoneID Container
+	record       *cloudflare.DNSRecord
+	recordExists bool
+}
+
+// New creates a new instance of CF with the provided API token.
+func New(
+	zoneName string,
+	subdomainName string,
+	apiToken string,
+) (*CF, error) {
+
+	api, err := cloudflare.NewWithAPIToken(apiToken)
+	if err != nil {
+		return nil, err
+	}
+
+	cf := &CF{
+		api: api,
+	}
+
+	id, err := cf.api.ZoneIDByName(zoneName)
+	if err != nil {
+		return nil, fmt.Errorf(
+			"failed to lookup zone id name=%s: %w",
+			zoneName,
+			err)
+	}
+	cf.zone = cloudflare.ZoneIdentifier(id)
+
+	records, resultInfo, err := cf.api.ListDNSRecords(
+		context.Background(),
+		cf.zone,
+		cloudflare.ListDNSRecordsParams{
+			Type: "A",
+			Name: subdomainName,
+		})
+	if err != nil {
+		err = fmt.Errorf(
+			"failed to lookup dns record name=%s: %w",
+			subdomainName,
+			err)
+		return nil, err
+	}
+
+	if len(records) == 0 || resultInfo.Count == 0 {
+		cf.record = &cloudflare.DNSRecord{
+			Type: "A",
+			Name: subdomainName,
+			TTL:  60,
+		}
+		cf.recordExists = false
+		return cf, nil
+	}
+
+	cf.record = &records[0]
+	cf.recordExists = true
+	return cf, nil
+}
+
+func (cf *CF) IP() string {
+	if cf.zone == nil {
+		return "unknown: lookup failed, no zone"
+	}
+	if cf.record == nil || cf.record.Name == "" {
+		return "unknown: lookup failed, no record"
+	}
+	if !cf.recordExists {
+		return "unset"
+	}
+	return cf.record.Content
+}
+
+func (cf *CF) SetIP(ip string) error {
+
+	if cf.zone == nil {
+		return fmt.Errorf("zone must be set")
+	}
+	if cf.record == nil || cf.record.Name == "" {
+		return fmt.Errorf("dns record must be set")
+	}
+
+	if !cf.recordExists {
+		record, err := cf.api.CreateDNSRecord(
+			context.Background(),
+			cf.zone,
+			cloudflare.CreateDNSRecordParams{
+				Type:    cf.record.Type,
+				Name:    cf.record.Name,
+				Content: ip,
+				TTL:     cf.record.TTL,
+			})
+		if err != nil {
+			return fmt.Errorf("failed to create dns record name=%s: %w",
+				cf.record.Name,
+				err)
+		}
+		cf.record = &record
+		cf.recordExists = true
+		return nil
+	}
+
+	record, err := cf.api.UpdateDNSRecord(
+		context.Background(),
+		cf.zone,
+		cloudflare.UpdateDNSRecordParams{
+			Type:    cf.record.Type,
+			Name:    cf.record.Name,
+			Content: ip,
+			ID:      cf.record.ID,
+			TTL:     cf.record.TTL,
+		})
+	if err != nil {
+		return fmt.Errorf("failed to update dns record name=%s: %w",
+			cf.record.Name,
+			err)
+	}
+	cf.record = &record
+	return nil
+}
internal/cf/prompt-fo.txt
@@ -0,0 +1,26 @@
+Update the functional options to not use with closures (the Uber Style Guide says so).
+
+Here is an example function:
+
+```go
+// Option interface enables functional options on CF
+type Option interface {
+	apply(*CF) error
+}
+
+func ZoneName(name string) Option {
+	return zoneNameOption(name)
+}
+
+type zoneNameOption string
+
+func (zn zoneNameOption) apply(cf *CF) error {
+	name := string(zn)
+	id, err := cf.api.ZoneIDByName(name)
+	if err != nil {
+		return fmt.Errorf("failed to lookup zone id name=%s: %w", name, err)
+	}
+	cf.zoneID = id
+	return nil
+}
+```
internal/cf/prompt.txt
@@ -0,0 +1,8 @@
+Write an internal golang package that 
+
+ - Follows the Uber Golang Style Guide (https://github.com/uber-go/guide/blob/master/style.md)
+ - Uses the `cloudflare-go` library (https://pkg.go.dev/github.com/cloudflare/cloudflare-go) 
+ - Uses a New constructor with api token as the only required parameter
+ - Sets via lookup by Name the ZoneID via a functional option and/or a method
+ - Sets the DNS Record ID by name for a subdomain via a functional option and/or a method
+ - Sets the DNS Record (upsert) to an A record with a provided IP address
internal/iplookup/main.go
@@ -0,0 +1,44 @@
+package iplookup
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+)
+
+type Details struct {
+	Address    string `json:"address"`
+	UserAgent  string `json:"user_agent"`
+	DomainName string `json:"domain_name"`
+}
+
+const (
+	SERVICE_URL = "https://ip.trustme.click"
+)
+
+func Discover() (*Details, error) {
+	resp, err := http.Get(SERVICE_URL)
+	if err != nil {
+		return nil, fmt.Errorf(
+			"failed to fetch ip details url=%s: %w",
+			SERVICE_URL,
+			err)
+	}
+	b, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, fmt.Errorf(
+			"failed to read ip details url=%s: %w",
+			SERVICE_URL,
+			err)
+	}
+	var details Details
+	err = json.Unmarshal(b, &details)
+	if err != nil {
+		return nil, fmt.Errorf(
+			"failed to unmarshal ip details url=%s: %w",
+			SERVICE_URL,
+			err)
+	}
+	return &details, nil
+}
internal/minecraft/server.go
@@ -0,0 +1,106 @@
+package minecraft
+
+import (
+	"bufio"
+	"bytes"
+	"encoding/binary"
+
+	"encoding/json"
+	"fmt"
+	"net"
+	//"zappem.net/pub/debug/xxd"
+)
+
+type status struct {
+	Version struct {
+		Name     string `json:"name"`
+		Protocol int    `json:"protocol"`
+	} `json:"version"`
+	Players struct {
+		Max    int `json:"max"`
+		Online int `json:"online"`
+	} `json:"players"`
+}
+
+// MinecraftPacket has support for writing minecraft varint packed data
+// as well as normal bytes
+type MinecraftPacket struct {
+	bytes.Buffer
+}
+
+// WriteVarInt writes an integer in Minecraft VarInt to the buffer.
+func (b *MinecraftPacket) WriteVarInt(value uint64) error {
+	var buf []byte = make([]byte, binary.MaxVarintLen64)
+	n := binary.PutUvarint(buf[:], value)
+	_, err := b.Write(buf[:n])
+	return err
+}
+
+func (b *MinecraftPacket) Finalize() []byte {
+	length := b.Len()
+	var buf []byte = make([]byte, binary.MaxVarintLen64)
+	n := binary.PutUvarint(buf[:], uint64(length))
+	return append(buf[:n], b.Bytes()...)
+}
+
+func Connections(host string, port uint16) (int, error) {
+
+	address := fmt.Sprintf("%s:%d", host, port)
+	conn, err := net.Dial("tcp", address)
+	if err != nil {
+		return 0, fmt.Errorf("failed to connect to server: %w", err)
+	}
+	defer conn.Close()
+
+	// Protocol Version - also try 0 if it fails
+	protocolVersion := uint64(769)
+	// Server port (BigEndian 2 bytes)
+	portBytes := make([]byte, 2)
+	binary.BigEndian.PutUint16(portBytes, uint16(port))
+
+	h := MinecraftPacket{bytes.Buffer{}}
+	h.WriteVarInt(0)                 // Packet ID
+	h.WriteVarInt(protocolVersion)   // Protocol Version
+	h.WriteVarInt(uint64(len(host))) // Server Addr Len
+	h.Write([]byte(host))            // Server Addr
+	h.Write(portBytes)               // Server Port
+	h.WriteVarInt(1)                 // Next State = 1 - Status
+	handshake := h.Finalize()        // Prepend Packet Len
+
+	_, err = conn.Write(handshake)
+	if err != nil {
+		return 0, fmt.Errorf("failed to connect send handshake: %w", err)
+	}
+
+	sr := MinecraftPacket{bytes.Buffer{}}
+	sr.WriteVarInt(0)              // Status Packet ID
+	statusRequest := sr.Finalize() // Prepend Packet Len
+
+	_, err = conn.Write(statusRequest)
+	if err != nil {
+		return 0, fmt.Errorf("failed to connect send status request: %w", err)
+	}
+
+	byteReader := bufio.NewReader(conn)
+	_, err = binary.ReadUvarint(byteReader) // respLen
+	if err != nil {
+		return 0, fmt.Errorf("failed to read response len: %w", err)
+	}
+	_, err = binary.ReadUvarint(byteReader) // packetID
+	if err != nil {
+		return 0, fmt.Errorf("failed to read paceket id: %w", err)
+	}
+	_, err = binary.ReadUvarint(byteReader) // statusLen
+	if err != nil {
+		return 0, fmt.Errorf("failed to read status len: %w", err)
+	}
+
+	decoder := json.NewDecoder(byteReader)
+	var s status
+	err = decoder.Decode(&s)
+	if err != nil {
+		return 0, fmt.Errorf("failed decoding server status response: %w", err)
+	}
+
+	return s.Players.Online, nil
+}
go.mod
@@ -0,0 +1,24 @@
+module github.com/bryfry/mm
+
+go 1.23.5
+
+require (
+	github.com/coreos/go-systemd/v22 v22.5.0
+	github.com/godbus/dbus/v5 v5.1.0
+	github.com/spf13/cobra v1.8.1
+	github.com/vishvananda/netlink v1.3.0
+)
+
+require (
+	github.com/cloudflare/cloudflare-go v0.114.0 // indirect
+	github.com/goccy/go-json v0.10.4 // indirect
+	github.com/google/go-querystring v1.1.0 // indirect
+	github.com/inconshreveable/mousetrap v1.1.0 // indirect
+	github.com/spf13/pflag v1.0.5 // indirect
+	github.com/vishvananda/netns v0.0.4 // indirect
+	golang.org/x/net v0.34.0 // indirect
+	golang.org/x/sys v0.29.0 // indirect
+	golang.org/x/text v0.21.0 // indirect
+	golang.org/x/time v0.9.0 // indirect
+	zappem.net/pub/debug/xxd v1.0.0 // indirect
+)
go.sum
@@ -0,0 +1,39 @@
+github.com/cloudflare/cloudflare-go v0.114.0 h1:ucoti4/7Exo0XQ+rzpn1H+IfVVe++zgiM+tyKtf0HUA=
+github.com/cloudflare/cloudflare-go v0.114.0/go.mod h1:O7fYfFfA6wKqKFn2QIR9lhj7FDw6VQCGOY6hd2TBtd0=
+github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
+github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
+github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
+github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
+github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQdrZk=
+github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
+github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
+github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
+golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
+golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
+golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
+golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
+golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+zappem.net/pub/debug/xxd v1.0.0 h1:weitvkgR0yOBP4QfGTPyQDD25rxAJxYw6+Dy2wrA3Zs=
+zappem.net/pub/debug/xxd v1.0.0/go.mod h1:7m1I+mBsdwBWcaVp8P0w0YQP9UWRsJEkrXB4OwF6b/o=
main.go
@@ -0,0 +1,7 @@
+package main
+
+import "github.com/bryfry/mm/cmd"
+
+func main() {
+	cmd.Execute()
+}
README.md
@@ -0,0 +1,57 @@
+# minecraft manager (mm)
+
+A minecraft server that lives in a cloud environment to:
+ - update dns for the random cloud IP address it was given
+ - monitor minecraft server usage (player stats) and turn off if idle
+ - make it easy to turn it on on-demand (not yet implemented)
+
+#### Components
+
+- **`cfdns`** is a wrapper around cloudflare dns sdk to check the current running systems' external IP and update a dns domain if needed.
+
+- **Active Or Die** is a lightweight utility for monitoring active connections on a specified port. If no activity exists for a defined amount of time, the server is powered off.
+
+
+### Quickstart 
+
+```bash
+GOOS=linux GOARCH=arm64 go build .
+scp mm <server>:~/
+ssh ubuntu@minecraft.trustme.click sudo ~/mm install
+```
+
+## TODO
+
+ - [] read conf file and prompt for ENV variables on install
+
+## Build
+
+1. Clone the repository:
+
+   ```bash
+   git clone https://github.com/bryfry/mm.git
+   cd mm
+   ```
+
+2. Build the program for your architecture:
+
+   - **For x86_64 (default)**:
+     ```bash
+     go build -o aod main.go
+     ```
+
+   - **For ARM (e.g., Raspberry Pi)**:
+     ```bash
+     GOARCH=arm64 go build -o aod main.go
+     ```
+
+### Required Environment Variables
+
+Place into environment file read by systemd service, for example: `/etc/mm.conf`
+
+```bash
+ CFDNS_ZONE_NAME=""
+ CFDNS_SUBDOMAIN_NAME=""
+ CFDNS_API_TOKEN=""
+ AOD_PORT="25565"
+```