Compare commits

...

9 Commits
v0.01 ... main

3 changed files with 146 additions and 92 deletions

View File

@ -1,6 +1,6 @@
# openttd-admin # openttd-admin
This is a Golang interface to an OpenTTD server, and a standalone 'openttd_multitool' binary This is a Golang interface to an OpenTTD server, and a standalone 'openttd_multitool' binary
for simple, periodic server operations. for simple, periodic server operations.
The latter might include periodically: The latter might include periodically:
@ -9,12 +9,21 @@ The latter might include periodically:
* saving the game to a custom, datestamped save * saving the game to a custom, datestamped save
* generating datestamped screenshots * generating datestamped screenshots
## admin.go library
At the moment the library has limited support for anything except managing
responses to date changes. Please tell me your use case and I will be happy
to look at extending it further. Or, pull requests accepted :-)
## openttd_multitool ## openttd_multitool
The openttd_multitool connects to the OpenTTD Admin port (default 3977) The openttd_multitool connects to the OpenTTD Admin port (default 3977)
and stays connected. It monitors the game date, and performs your custom commands and stays connected. It monitors the game date, and performs your custom commands
at periodic intervals. at periodic intervals.
It supports string substitution of commands to embed the current (game) date
into commands or filenames.
Possible intervals are: Possible intervals are:
* daily * daily
@ -23,6 +32,20 @@ Possible intervals are:
These intervals are obviously in game time! These intervals are obviously in game time!
Possible string substitutions are:
* %Y - 4 digit year
* %M - 2 digit month
* %D - 2 digit day of month
## running
If you don't want to build from source, grab a binary release from
https://github.com/tardisx/openttd-admin/releases for your architecture.
The tool is a command line driven executable. It does not require installation.
Just copy it somewhere and run it.
You can configure the tool to send any command that you would type at the OpenTTD You can configure the tool to send any command that you would type at the OpenTTD
console. Here are a few examples: console. Here are a few examples:
@ -40,10 +63,9 @@ This saves the game once per month, with a filename like "mygame-2020-11.sav".
This generates a screenshot once per year of the entire map, with a name like "screenshot-20201121.png". This generates a screenshot once per year of the entire map, with a name like "screenshot-20201121.png".
NOTE that your OpenTTD server needs to support generating screenshots (some dedicated servers compiled without NOTE that your OpenTTD server needs to support generating screenshots (some dedicated servers compiled without
graphics will not work) and the appropriate graphics packs need to also be installed. graphics will not work) and the appropriate graphics packs need to also be installed.
Additionally, when using the "screenshot giant" command, the entire server will freeze for that time, almost Additionally, when using the "screenshot giant" command, the entire server will freeze for that time, almost
certainly kicking off all clients, unless your map is very small or your server is very very fast! certainly kicking off all clients, unless your map is very small or your server is very very fast!
No harm is done in that case, they can just reconnect after the screenshot is finished. No harm is done in that case, they can just reconnect after the screenshot is finished.

View File

@ -1,90 +1,85 @@
package main package main
import ( import (
"github.com/tardisx/openttd-admin/pkg" "flag"
"flag" "github.com/tardisx/openttd-admin/pkg/admin"
"strings" "os"
"os" "strings"
) )
const currentVersion = "0.02"
// ./openttd_multitool --monthly "say \"hi it is a new month\"" --daily "say \"wow a new day %D"
const currentVersion = "0.01"
type dailyFlags []string type dailyFlags []string
type monthlyFlags []string type monthlyFlags []string
type yearlyFlags []string type yearlyFlags []string
func (i *dailyFlags) String() string { func (i *dailyFlags) String() string {
// change this, this is just can example to satisfy the interface // change this, this is just can example to satisfy the interface
return "my string representation" return "my string representation"
} }
func (i *dailyFlags) Set(value string) error { func (i *dailyFlags) Set(value string) error {
*i = append(*i, strings.TrimSpace(value)) *i = append(*i, strings.TrimSpace(value))
return nil return nil
} }
func (i *monthlyFlags) String() string { func (i *monthlyFlags) String() string {
// change this, this is just can example to satisfy the interface // change this, this is just can example to satisfy the interface
return "my string representation" return "my string representation"
} }
func (i *monthlyFlags) Set(value string) error { func (i *monthlyFlags) Set(value string) error {
*i = append(*i, strings.TrimSpace(value)) *i = append(*i, strings.TrimSpace(value))
return nil return nil
} }
func (i *yearlyFlags) String() string { func (i *yearlyFlags) String() string {
// change this, this is just can example to satisfy the interface // change this, this is just can example to satisfy the interface
return "my string representation" return "my string representation"
} }
func (i *yearlyFlags) Set(value string) error { func (i *yearlyFlags) Set(value string) error {
*i = append(*i, strings.TrimSpace(value)) *i = append(*i, strings.TrimSpace(value))
return nil return nil
} }
func main() { func main() {
var daily dailyFlags var daily dailyFlags
var monthly monthlyFlags var monthly monthlyFlags
var yearly yearlyFlags var yearly yearlyFlags
flag.Var(&daily, "daily", "An RCON command to run daily - may be repeated") flag.Var(&daily, "daily", "An RCON command to run daily - may be repeated")
flag.Var(&monthly, "monthly", "An RCON command to run monthly - may be repeated") flag.Var(&monthly, "monthly", "An RCON command to run monthly - may be repeated")
flag.Var(&yearly, "yearly", "An RCON command to run yearly - may be repeated") flag.Var(&yearly, "yearly", "An RCON command to run yearly - may be repeated")
var hostname string var hostname string
var password string var password string
var port int var port int
flag.StringVar(&hostname, "hostname", "localhost", "The hostname (or IP address) of the OpenTTD server to connect to") flag.StringVar(&hostname, "hostname", "localhost", "The hostname (or IP address) of the OpenTTD server to connect to")
flag.StringVar(&password, "password", "", "The password for the admin interface ('admin_password' in openttd.cfg)") flag.StringVar(&password, "password", "", "The password for the admin interface ('admin_password' in openttd.cfg)")
flag.IntVar(&port, "port", 3977, "The port number of the admin interface (default is 3977)") flag.IntVar(&port, "port", 3977, "The port number of the admin interface (default is 3977)")
flag.Parse() flag.Parse()
if password == "" { if password == "" {
println("ERROR: You must supply a password") println("ERROR: You must supply a password")
os.Exit(1) os.Exit(1)
}
server := admin.OpenTTDServer{}
for _, value := range daily {
server.RegisterDateChange("daily", value)
} }
for _, value := range monthly { server := admin.OpenTTDServer{}
server.RegisterDateChange("monthly", value)
}
for _, value := range yearly { for _, value := range daily {
server.RegisterDateChange("yearly", value) server.RegisterDateChange("daily", value)
} }
// this blocks forever for _, value := range monthly {
server.Connect(hostname, port, password, "openttd-multitool", currentVersion) server.RegisterDateChange("monthly", value)
}
for _, value := range yearly {
server.RegisterDateChange("yearly", value)
}
// this blocks forever
server.Connect(hostname, port, password, "openttd-multitool", currentVersion)
} }

View File

@ -1,3 +1,5 @@
// Package admin provides an interface to connect to and manage a running
// OpenTTD dedicated server.
package admin package admin
// references // references
@ -9,21 +11,32 @@ package admin
import ( import (
"encoding/binary" "encoding/binary"
"fmt" "fmt"
"log"
"net" "net"
"strings" "strings"
"time" "time"
"log"
) )
// OpenTTDServer - an object representing the server connection // OpenTTDServer - an object representing the server connection
type OpenTTDServer struct { type OpenTTDServer struct {
connection net.Conn connection net.Conn
serverName string ServerName string
ServerVersion string
ServerDedicated bool // is this a dedicated server?
MapName string
MapSeed uint32
MapLandscape byte
MapX uint16
MapY uint16
MapCreationDate time.Time
rconDaily []string rconDaily []string
rconMonthly []string rconMonthly []string
rconYearly []string rconYearly []string
connected chan bool connected chan bool
disconnected chan bool disconnected chan bool
frequencies [adminUpdateEND]uint16
} }
const ( const (
@ -88,7 +101,9 @@ const (
adminFrequencyAUTOMATIC = 0x40 ///< The admin gets information about this when it changes. adminFrequencyAUTOMATIC = 0x40 ///< The admin gets information about this when it changes.
) )
// Connect to the OpenTTD server on the admin port // Connect to the OpenTTD server on the admin port. Requires that the server
// is listening on the admin port (admin_password must be specified in the config).
// This method will block, and automatically attempt to reconnect if disconnected.
func (server *OpenTTDServer) Connect(host string, port int, password string, botName string, botVersion string) { func (server *OpenTTDServer) Connect(host string, port int, password string, botName string, botVersion string) {
for { for {
@ -160,7 +175,11 @@ func (server *OpenTTDServer) Connect(host string, port int, password string, bot
} }
// RegisterDateChange to send a command periodically // RegisterDateChange sends an arbitrary number of rcon commands when certain
// (game time) date changes occur. The possible periods are 'daily', 'monthly'
// and 'yearly'.
//
// Note that this must be called before Connect.
func (server *OpenTTDServer) RegisterDateChange(period string, command string) { func (server *OpenTTDServer) RegisterDateChange(period string, command string) {
if period == "daily" { if period == "daily" {
server.rconDaily = append(server.rconDaily, command) server.rconDaily = append(server.rconDaily, command)
@ -213,23 +232,17 @@ func processCommand(command string, dt time.Time) string {
} }
func (server *OpenTTDServer) sendSocket(protocol int, data []byte) { func (server *OpenTTDServer) sendSocket(protocol int, data []byte) {
// fmt.Printf("Going to send using protocol %v this data: %v\n", protocol, data)
toSend := make([]byte, 3) // start with 3 bytes for the length and protocol toSend := make([]byte, 3) // start with 3 bytes for the length and protocol
size := uint16(len(data) + 3) // size 2 bytes, plus protocol size := uint16(len(data) + 3) // size 2 bytes, plus protocol
binary.LittleEndian.PutUint16(toSend, size) binary.LittleEndian.PutUint16(toSend, size)
// toSend = append(toSend[:],
toSend[2] = byte(protocol) toSend[2] = byte(protocol)
toSend = append(toSend, data...) toSend = append(toSend, data...)
// fmt.Printf("Going to send this: %v\n", toSend)
server.connection.Write(toSend) server.connection.Write(toSend)
} }
func (server *OpenTTDServer) listenSocket() { func (server *OpenTTDServer) listenSocket() {
// fmt.Println("waiting for connection...")
// fmt.Printf("Listening to socket...\n")
<-server.connected <-server.connected
var chunk []byte var chunk []byte
SocketLoop: SocketLoop:
@ -273,23 +286,54 @@ SocketLoop:
packetSize := binary.LittleEndian.Uint16(chunk[0:2]) packetSize := binary.LittleEndian.Uint16(chunk[0:2])
// if we don't have enough bytes yet, just loop around // if we don't have enough bytes yet, just loop around
// fmt.Printf("current chunk %d bytes, indicated packet size %d\n", len(chunk), packetSize)
if packetSize > uint16(len(chunk)) { if packetSize > uint16(len(chunk)) {
// fmt.Printf("incomplete data, waiting for more from socket\n")
continue SocketLoop continue SocketLoop
} }
// so we are good to continue processing // so we are good to continue processing
packetType := int(chunk[2]) packetType := int(chunk[2])
packetData := chunk[3:packetSize] packetData := chunk[3:packetSize]
// fmt.Printf("packet type %d and size is %v bytes, I read %d from socket\n", packetType, len(packetData), s)
// figure out what type of packet this is and process it
if packetType == adminPacketServerPROTOCOL { if packetType == adminPacketServerPROTOCOL {
// fmt.Print(" - Got a adminPacketServerPROTOCOL packet\n") log.Println("received protocol packet")
adminUpdateDataStart := 1
var frequencies [adminUpdateEND]uint16
for i := 0; i < adminUpdateEND; i++ {
indexPos := adminUpdateDataStart+(i*5)+1
index := binary.LittleEndian.Uint16(packetData[indexPos:indexPos+2])
freqPos := adminUpdateDataStart+(i*5)+3
allowedFreq := binary.LittleEndian.Uint16(packetData[freqPos:freqPos+2])
frequencies[index] = allowedFreq
}
server.frequencies = frequencies
} else if packetType == adminPacketServerWELCOME { } else if packetType == adminPacketServerWELCOME {
log.Println("received welcome packet") log.Println("received welcome packet")
server.serverName = extractString(packetData[0:]) var next int
// fmt.Printf(" * server name: %s\n", serverName) server.ServerName, next = extractString(packetData[:], 0)
server.ServerVersion, next = extractString(packetData[:], next)
if packetData[next] == 0000 {
server.ServerDedicated = false
} else if packetData[next] == 0001 {
server.ServerDedicated = true
} else {
panic(fmt.Sprintf("not bool %v?\n", packetData[next]))
}
server.MapName, next = extractString(packetData[:], next+1)
server.MapSeed = binary.LittleEndian.Uint32(packetData[next : next+4])
server.MapLandscape = packetData[next+4]
creationDate := binary.LittleEndian.Uint32(packetData[next+5:next+9])
epochDate := time.Date(0, time.January, 1, 0, 0, 0, 0, time.UTC)
dt := epochDate.AddDate(0, 0, int(creationDate))
server.MapCreationDate = dt
server.MapX = binary.LittleEndian.Uint16(packetData[next+9 : next+11])
server.MapY = binary.LittleEndian.Uint16(packetData[next+11 : next+13])
log.Printf("server: %v\n", server)
} else if packetType == adminPacketServerSHUTDOWN { } else if packetType == adminPacketServerSHUTDOWN {
log.Println("server shutting down - will try to reconnect") log.Println("server shutting down - will try to reconnect")
server.connection = nil server.connection = nil
@ -297,39 +341,32 @@ SocketLoop:
return return
} else if packetType == adminPacketServerDATE { } else if packetType == adminPacketServerDATE {
// [[7 0 107 84 252 10 0 0 0
date := binary.LittleEndian.Uint32(packetData[0:4]) date := binary.LittleEndian.Uint32(packetData[0:4])
epochDate := time.Date(0, time.January, 1, 0, 0, 0, 0, time.UTC) epochDate := time.Date(0, time.January, 1, 0, 0, 0, 0, time.UTC)
dt := epochDate.AddDate(0, 0, int(date)) dt := epochDate.AddDate(0, 0, int(date))
// fmt.Printf(" * Date is %v\n", dt)
server.dateChanged(dt) server.dateChanged(dt)
// uint32
} else if packetType == adminPacketServerCHAT { } else if packetType == adminPacketServerCHAT {
// fmt.Printf(" - Got a chat packet:\n%v", packetData)
// [3 0 3 0 0 0 98 105 116 104 99 105 110 103 0 0 0 0 0 0 0 0 0]
chatAction := int8(packetData[0]) chatAction := int8(packetData[0])
chatDestType := int8(packetData[1]) chatDestType := int8(packetData[1])
chatClientID := binary.LittleEndian.Uint32(packetData[2:6]) chatClientID := binary.LittleEndian.Uint32(packetData[2:6])
chatMsg := extractString(packetData[6:]) chatMsg, _ := extractString(packetData[:], 6)
chatData := binary.LittleEndian.Uint64(packetData[len(packetData)-8:]) chatData := binary.LittleEndian.Uint64(packetData[len(packetData)-8:])
log.Printf("chat message: action %v desttype %v, client id %v msg %v data %v\n", chatAction, chatDestType, chatClientID, string(chatMsg), chatData) log.Printf("chat message: action %v desttype %v, client id %v msg %v data %v\n", chatAction, chatDestType, chatClientID, string(chatMsg), chatData)
} else if packetType == adminPacketServerRCON { } else if packetType == adminPacketServerRCON {
colour := binary.LittleEndian.Uint16(packetData[0:2]) colour := binary.LittleEndian.Uint16(packetData[0:2])
string := extractString(packetData[2:]) rconRecvString, _ := extractString(packetData[:], 2)
log.Printf("rcon: colour %v : %s\n", colour, string) log.Printf("rcon: colour %v : %s\n", colour, rconRecvString)
} else if packetType == adminPacketServerRCON_END { } else if packetType == adminPacketServerRCON_END {
string := extractString(packetData[0:]) rconEndRecvString, _ := extractString(packetData[:], 0)
log.Printf("rcon end : %s\n", string) log.Printf("rcon end : %s\n", rconEndRecvString)
} else { } else {
log.Printf("unknown packet received from server: %v [%v]\n", string(packetData), packetData) log.Printf("unknown packet received from server: %v [%v]\n", string(packetData), packetData)
} }
// fmt.Printf("removing the chunk we have processed\n")
chunk = chunk[packetSize:] chunk = chunk[packetSize:]
} }
// check if there is data left to process in the current data // check if there is data left to process in the current data
// fmt.Printf("remaining in chunk %d bytes", len(chunk))
if len(chunk) < 3 { if len(chunk) < 3 {
// we don't even have enough for a length and protocol type, so may // we don't even have enough for a length and protocol type, so may
// as well go sit on the socket // as well go sit on the socket
@ -340,13 +377,13 @@ SocketLoop:
} }
func extractString(bytes []byte) string { func extractString(bytes []byte, start int) (string, int) {
var str []byte var buildString []byte
for i := 0; i <= len(bytes); i++ { for i := start; i <= len(bytes); i++ {
if bytes[i] == 0 { if bytes[i] == 0 {
return string(str) return string(buildString), i + 1
} }
str = append(str, bytes[i]) buildString = append(buildString, bytes[i])
} }
return "" return "", -1
} }