package main import ( "bufio" "encoding/json" "flag" "fmt" "io" "log" "os" "regexp" "strings" "sync" "time" "unicode" matrix "git.anarchists.space/meer/pixitrix" mcrcon "github.com/Kelwing/mc-rcon" ) type EventType int const ( ChatEvent EventType = iota JoinEvent LeaveEvent DeathEvent AdvancementEvent PauseEvent ) type MCEvent struct { Type EventType Player string Message string RawLine string Time time.Time } type Minecraft struct { mu sync.Mutex RCON *mcrcon.MCConn addr string password string } type Bridge struct { MC *Minecraft Matrix *matrix.Client TrustedUsers []string RoomID string Events <-chan *MCEvent Players *PlayerSet } type Config struct { LogFile string `json:"logfile"` Minecraft MinecraftConfig `json:"minecraft"` Matrix MatrixConfig `json:"matrix"` } type MinecraftConfig struct { Address string `json:"address"` Password string `json:"password"` } type MatrixConfig struct { Homeserver string `json:"homeserver"` AccessToken string `json:"access_token"` Room string `json:"room"` TrustedUsers []string `json:"trusted_users,omitempty"` Proxy string `json:"proxy,omitempty"` } var ( chatRE = regexp.MustCompile( `^\[[0-9:]+\] \[[^]]+/INFO\]: (?:\[Not Secure\] )?<([^>]+)> (.*)$`, ) joinRE = regexp.MustCompile( `^\[[0-9:]+\] \[[^]]+/INFO\]: ([A-Za-z0-9_]+) joined the game$`, ) leaveRE = regexp.MustCompile( `^\[[0-9:]+\] \[[^]]+/INFO\]: ([A-Za-z0-9_]+) left the game$`, ) advancementRE = regexp.MustCompile( `^\[[0-9:]+\] \[[^]]+/INFO\]: ([A-Za-z0-9_]+) has (?:made the advancement|reached the goal|completed the challenge) \[(.+)\]$`, ) deathRE = regexp.MustCompile( `^\[[0-9:]+\] \[[^]]+/INFO\]: (.+)$`, ) pauseRE = regexp.MustCompile( `^\[[0-9:]+\] \[[^]]+/INFO\]: Server empty for ([0-9]+) seconds, pausing$`, ) ) func LoadConfig(path string, ChmodVerify bool) (*Config, error) { if fi, err := os.Stat(path); err == nil && fi.Mode().Perm()&0o077 != 0 && ChmodVerify { return nil, fmt.Errorf("%s is accessible by other users (mode %o); run: chmod 600 %s", path, fi.Mode().Perm(), path) } b, err := os.ReadFile(path) if err != nil { return nil, err } var cfg Config err = json.Unmarshal(b, &cfg) if err != nil { return nil, err } if v := os.Getenv("MC_RCON_PASSWORD"); v != "" { cfg.Minecraft.Password = v } if v := os.Getenv("MATRIX_ACCESS_TOKEN"); v != "" { cfg.Matrix.AccessToken = v } return &cfg, nil } func main() { configpath := flag.String("config", "config.json", "path for configuration file") ChmodVerify := flag.Bool("chmodcheck", true, "disable or enable exiting on config file not being permission 600") flag.Parse() cfg, err := LoadConfig(*configpath, *ChmodVerify) if err != nil { log.Fatal(err) } mc, err := NewMinecraft( cfg.Minecraft.Address, cfg.Minecraft.Password, ) if err != nil { log.Fatal(err) } mx, err := matrix.New( cfg.Matrix.Homeserver, cfg.Matrix.AccessToken, cfg.Matrix.Proxy, ) if err != nil { log.Fatal(err) } roomID, err := mx.JoinRoom(cfg.Matrix.Room) if err != nil { log.Fatal(err) } events := make(chan *MCEvent, 100) bridge := &Bridge{ MC: mc, Matrix: mx, RoomID: roomID, Events: events, TrustedUsers: cfg.Matrix.TrustedUsers, Players: NewPlayerSet(), } if resp, err := mc.SendCommand("list"); err == nil { bridge.Players.Seed(resp) } go func() { log.Fatalf("log watcher stopped: %v", WatchLog(cfg.LogFile, events)) }() go bridge.RunMCEvents() go RunMatrixBridge( mx, bridge, ) select {} } func dialRCON(addr, password string) (*mcrcon.MCConn, error) { conn := new(mcrcon.MCConn) if err := conn.Open(addr, password); err != nil { return nil, err } if err := conn.Authenticate(); err != nil { conn.Close() return nil, err } return conn, nil } func NewMinecraft(addr, password string) (*Minecraft, error) { conn, err := dialRCON(addr, password) if err != nil { return nil, err } return &Minecraft{RCON: conn, addr: addr, password: password}, nil } const ( maxNickLen = 32 maxMsgLen = 256 ) func sanitize(s string, max int) string { s = strings.ToValidUTF8(s, "") var b strings.Builder n := 0 for _, r := range s { if r == '\n' || r == '\r' || r == '\t' { r = ' ' } if r == '\u00a7' || unicode.IsControl(r) || unicode.Is(unicode.Cf, r) { continue } if n >= max { b.WriteString("…") break } b.WriteRune(r) n++ } return b.String() } func (mc *Minecraft) SendMessage(nick, msg string) error { components := []map[string]any{ {"text": "", "color": "dark_gray"}, {"text": sanitize(nick, maxNickLen), "color": "aqua", "bold": true}, {"text": ": ", "color": "gray"}, {"text": sanitize(msg, maxMsgLen), "color": "white"}, } j, err := json.Marshal(components) if err != nil { return err } _, err = mc.SendCommand("tellraw @a " + string(j)) return err } var playerNameRE = regexp.MustCompile(`^[A-Za-z0-9_]{1,16}$`) var onlineRE = regexp.MustCompile(`players online: ?(.*)$`) type PlayerSet struct { mu sync.Mutex m map[string]struct{} } func NewPlayerSet() *PlayerSet { return &PlayerSet{m: map[string]struct{}{}} } func (p *PlayerSet) Add(n string) { p.mu.Lock(); p.m[n] = struct{}{}; p.mu.Unlock() } func (p *PlayerSet) Remove(n string) { p.mu.Lock(); delete(p.m, n); p.mu.Unlock() } func (p *PlayerSet) Has(n string) bool { p.mu.Lock() defer p.mu.Unlock() _, ok := p.m[n] return ok } func (p *PlayerSet) Seed(listOutput string) { m := onlineRE.FindStringSubmatch(listOutput) if m == nil { return } for _, n := range strings.Split(m[1], ",") { if n = strings.TrimSpace(n); playerNameRE.MatchString(n) { p.Add(n) } } } func ParseLogLine(line string) (*MCEvent, bool) { now := time.Now() if m := chatRE.FindStringSubmatch(line); m != nil { return &MCEvent{ Type: ChatEvent, Player: m[1], Message: m[2], RawLine: line, Time: now, }, true } if m := joinRE.FindStringSubmatch(line); m != nil { return &MCEvent{ Type: JoinEvent, Player: m[1], RawLine: line, Time: now, }, true } if m := leaveRE.FindStringSubmatch(line); m != nil { return &MCEvent{ Type: LeaveEvent, Player: m[1], RawLine: line, Time: now, }, true } if m := advancementRE.FindStringSubmatch(line); m != nil { return &MCEvent{ Type: AdvancementEvent, Player: m[1], Message: m[2], RawLine: line, Time: now, }, true } if m := pauseRE.FindStringSubmatch(line); m != nil { return &MCEvent{ Type: PauseEvent, Message: m[1], RawLine: line, Time: now, }, true } if m := deathRE.FindStringSubmatch(line); m != nil { msg := m[1] deaths := []string{ " was slain by ", " was shot by ", " was blown up", " was doomed to ", " drowned", " died", " blew up", " burned to death", " fell from", " hit the ground too hard", " was killed by ", } for _, d := range deaths { if strings.Contains(msg, d) { player := strings.Fields(msg)[0] if !playerNameRE.MatchString(player) { return nil, false } return &MCEvent{ Type: DeathEvent, Player: player, Message: msg, RawLine: line, Time: now, }, true } } } return nil, false } const maxLogLine = 64 << 10 func WatchLog(path string, events chan<- *MCEvent) error { f, err := os.Open(path) if err != nil { return err } defer func() { f.Close() }() info, err := f.Stat() if err != nil { return err } if _, err = f.Seek(0, io.SeekEnd); err != nil { return err } var ( reader = bufio.NewReaderSize(f, 32<<10) pending []byte overflow bool offset = info.Size() ) for { chunk, err := reader.ReadSlice('\n') offset += int64(len(chunk)) if !overflow { pending = append(pending, chunk...) if len(pending) > maxLogLine { pending, overflow = nil, true } } if err == bufio.ErrBufferFull { continue } if err == nil { if !overflow { line := strings.TrimSpace(string(pending)) if event, ok := ParseLogLine(line); ok { events <- event } } pending, overflow = pending[:0], false continue } if err != io.EOF { return err } if cur, statErr := os.Stat(path); statErr == nil { if !os.SameFile(info, cur) || cur.Size() < offset { log.Printf("%s rotated or truncated, reopening", path) nf, openErr := os.Open(path) if openErr != nil { return openErr } f.Close() f = nf info, err = f.Stat() if err != nil { return err } reader.Reset(f) pending, overflow, offset = nil, false, 0 } } time.Sleep(250 * time.Millisecond) } } func (mc *Minecraft) SendCommand(command string) (string, error) { if strings.ContainsAny(command, "\r\n\x00") { return "", fmt.Errorf("command contains control characters") } mc.mu.Lock() defer mc.mu.Unlock() resp, err := mc.RCON.SendCommand(command) if err == nil { return resp, nil } log.Printf("rcon error (%v), reconnecting", err) conn, derr := dialRCON(mc.addr, mc.password) if derr != nil { return "", fmt.Errorf("rcon reconnect failed: %w", derr) } mc.RCON.Close() mc.RCON = conn return mc.RCON.SendCommand(command) } const ( initialReconnectDelay = 2 * time.Second maxReconnectDelay = 2 * time.Minute ) func RunMatrixBridge( client *matrix.Client, bridge *Bridge, ) { me, err := client.WhoAmI() if err != nil { log.Printf("whoami failed: %v", err) return } log.Printf("logged in as %s", me.UserID) for msg := range client.Messages() { if msg.Sender == me.UserID { continue } if msg.RoomID != bridge.RoomID { continue } if msg.MsgType != "m.text" || msg.Body == "" { continue } //log.Printf("[matrix] %s: (%d bytes)", msg.Sender, len(msg.Body)) if strings.HasPrefix(msg.Body, "!") { handleMatrixCommand( bridge, msg, ) continue } name := msg.Sender profile, err := bridge.Matrix.GetProfile(msg.Sender) if err != nil { log.Printf("profile lookup failed: %v", err) } else if profile.DisplayName != "" { name = profile.DisplayName } if err := bridge.MC.SendMessage(name, msg.Body); err != nil { log.Printf("minecraft send failed: %v", err) } } } func (b *Bridge) SendMatrix( format string, args ...any, ) { text := fmt.Sprintf(format, args...) _, err := b.Matrix.SendMessage( b.RoomID, text, ) if err != nil { log.Printf( "matrix send failed: %v", err, ) } } func dualMsg( bridge *Bridge, roomID string, format string, args ...any, ) { msg := fmt.Sprintf(format, args...) _, err := bridge.Matrix.SendMessage( roomID, msg, ) if err != nil { log.Printf("failed to send matrix notice: %v", err) } err = bridge.MC.SendMessage("global.server", msg) if err != nil { log.Printf("failed to send mc notice: %v", err) } } func handleMatrixCommand( bridge *Bridge, msg matrix.Message, ) { parts := strings.Fields(strings.TrimPrefix(msg.Body, "!")) if len(parts) == 0 { return } switch strings.ToLower(parts[0]) { case "mclist": resp, err := bridge.MC.SendCommand("list") if err != nil { _, _ = bridge.Matrix.SendMessage(msg.RoomID, fmt.Sprintf("list failed: %v", err)) return } _, _ = bridge.Matrix.SendMessage( msg.RoomID, resp, ) case "rcon": if !bridge.IsTrustedUser(msg.Sender) { bridge.Matrix.SendMessage(msg.RoomID, "You are not a whitelisted user to run an RCON command >:E") return } torun := strings.Join(parts[1:], " ") if torun == "" { return } log.Printf("[audit] rcon by %s: %q", msg.Sender, torun) resp, err := bridge.MC.SendCommand(torun) if err != nil { bridge.Matrix.SendMessage(msg.RoomID, fmt.Sprintf("error running rcon command: %s", err.Error())) return } bridge.Matrix.SendMessage(msg.RoomID, fmt.Sprintf("command response: %s", resp)) } } func (b *Bridge) RunMCEvents() { for ev := range b.Events { switch ev.Type { case ChatEvent: b.Players.Add(ev.Player) b.SendMatrix( "%s: %s", sanitize(ev.Player, 64), sanitize(ev.Message, 1024), ) case JoinEvent: b.Players.Add(ev.Player) b.SendMatrix( "%s joined the game :3", ev.Player, ) case LeaveEvent: b.Players.Remove(ev.Player) b.SendMatrix( "%s left the game :<", ev.Player, ) case DeathEvent: if !b.Players.Has(ev.Player) { continue } b.SendMatrix( "%s", sanitize(ev.Message, 512), ) case AdvancementEvent: b.SendMatrix( "%s has made the advancement [%s] :3", ev.Player, sanitize(ev.Message, 128), ) case PauseEvent: b.SendMatrix( "Server paused after %s seconds of inactivity :3", ev.Message, ) } } } func (b *Bridge) IsTrustedUser(mxid string) bool { if b.TrustedUsers == nil { return false } for _, t := range b.TrustedUsers { if mxid == t { return true } } return false }