Compare commits

...

9 Commits
v0.01 ... main

3 changed files with 146 additions and 92 deletions

View File

@ -1,6 +1,6 @@
# 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.
The latter might include periodically:
@ -9,12 +9,21 @@ The latter might include periodically:
* saving the game to a custom, datestamped save
* 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
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
at periodic intervals.
It supports string substitution of commands to embed the current (game) date
into commands or filenames.
Possible intervals are:
* daily
@ -23,6 +32,20 @@ Possible intervals are:
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
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".
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
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.

View File

@ -1,90 +1,85 @@
package main
import (
"github.com/tardisx/openttd-admin/pkg"
"flag"
"strings"
"os"
"flag"
"github.com/tardisx/openttd-admin/pkg/admin"
"os"
"strings"
)
// ./openttd_multitool --monthly "say \"hi it is a new month\"" --daily "say \"wow a new day %D"
const currentVersion = "0.01"
const currentVersion = "0.02"
type dailyFlags []string
type monthlyFlags []string
type yearlyFlags []string
func (i *dailyFlags) String() string {
// change this, this is just can example to satisfy the interface
return "my string representation"
// change this, this is just can example to satisfy the interface
return "my string representation"
}
func (i *dailyFlags) Set(value string) error {
*i = append(*i, strings.TrimSpace(value))
return nil
*i = append(*i, strings.TrimSpace(value))
return nil
}
func (i *monthlyFlags) String() string {
// change this, this is just can example to satisfy the interface
return "my string representation"
// change this, this is just can example to satisfy the interface
return "my string representation"
}
func (i *monthlyFlags) Set(value string) error {
*i = append(*i, strings.TrimSpace(value))
return nil
*i = append(*i, strings.TrimSpace(value))
return nil
}
func (i *yearlyFlags) String() string {
// change this, this is just can example to satisfy the interface
return "my string representation"
// change this, this is just can example to satisfy the interface
return "my string representation"
}
func (i *yearlyFlags) Set(value string) error {
*i = append(*i, strings.TrimSpace(value))
return nil
*i = append(*i, strings.TrimSpace(value))
return nil
}
func main() {
var daily dailyFlags
var monthly monthlyFlags
var yearly yearlyFlags
var daily dailyFlags
var monthly monthlyFlags
var yearly yearlyFlags
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(&yearly, "yearly", "An RCON command to run yearly - 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(&yearly, "yearly", "An RCON command to run yearly - may be repeated")
var hostname string
var password string
var port int
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.IntVar(&port, "port", 3977, "The port number of the admin interface (default is 3977)")
flag.Parse()
var hostname string
var password string
var port int
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.IntVar(&port, "port", 3977, "The port number of the admin interface (default is 3977)")
flag.Parse()
if password == "" {
println("ERROR: You must supply a password")
os.Exit(1)
}
server := admin.OpenTTDServer{}
for _, value := range daily {
server.RegisterDateChange("daily", value)
if password == "" {
println("ERROR: You must supply a password")
os.Exit(1)
}
for _, value := range monthly {
server.RegisterDateChange("monthly", value)
}
server := admin.OpenTTDServer{}
for _, value := range yearly {
server.RegisterDateChange("yearly", value)
}
for _, value := range daily {
server.RegisterDateChange("daily", value)
}
// this blocks forever
server.Connect(hostname, port, password, "openttd-multitool", currentVersion)
for _, value := range monthly {
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
// references
@ -9,21 +11,32 @@ package admin
import (
"encoding/binary"
"fmt"
"log"
"net"
"strings"
"time"
"log"
)
// OpenTTDServer - an object representing the server connection
type OpenTTDServer struct {
connection net.Conn
serverName string
connection net.Conn
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
rconMonthly []string
rconYearly []string
connected chan bool
disconnected chan bool
frequencies [adminUpdateEND]uint16
}
const (
@ -88,7 +101,9 @@ const (
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) {
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) {
if period == "daily" {
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) {
// 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
size := uint16(len(data) + 3) // size 2 bytes, plus protocol
binary.LittleEndian.PutUint16(toSend, size)
// toSend = append(toSend[:],
toSend[2] = byte(protocol)
toSend = append(toSend, data...)
// fmt.Printf("Going to send this: %v\n", toSend)
server.connection.Write(toSend)
}
func (server *OpenTTDServer) listenSocket() {
// fmt.Println("waiting for connection...")
// fmt.Printf("Listening to socket...\n")
<-server.connected
var chunk []byte
SocketLoop:
@ -273,23 +286,54 @@ SocketLoop:
packetSize := binary.LittleEndian.Uint16(chunk[0:2])
// 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)) {
// fmt.Printf("incomplete data, waiting for more from socket\n")
continue SocketLoop
}
// so we are good to continue processing
packetType := int(chunk[2])
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 {
// 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 {
log.Println("received welcome packet")
server.serverName = extractString(packetData[0:])
// fmt.Printf(" * server name: %s\n", serverName)
var next int
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 {
log.Println("server shutting down - will try to reconnect")
server.connection = nil
@ -297,39 +341,32 @@ SocketLoop:
return
} else if packetType == adminPacketServerDATE {
// [[7 0 107 84 252 10 0 0 0
date := binary.LittleEndian.Uint32(packetData[0:4])
epochDate := time.Date(0, time.January, 1, 0, 0, 0, 0, time.UTC)
dt := epochDate.AddDate(0, 0, int(date))
// fmt.Printf(" * Date is %v\n", dt)
server.dateChanged(dt)
// uint32
} 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])
chatDestType := int8(packetData[1])
chatClientID := binary.LittleEndian.Uint32(packetData[2:6])
chatMsg := extractString(packetData[6:])
chatMsg, _ := extractString(packetData[:], 6)
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)
} else if packetType == adminPacketServerRCON {
colour := binary.LittleEndian.Uint16(packetData[0:2])
string := extractString(packetData[2:])
log.Printf("rcon: colour %v : %s\n", colour, string)
rconRecvString, _ := extractString(packetData[:], 2)
log.Printf("rcon: colour %v : %s\n", colour, rconRecvString)
} else if packetType == adminPacketServerRCON_END {
string := extractString(packetData[0:])
log.Printf("rcon end : %s\n", string)
rconEndRecvString, _ := extractString(packetData[:], 0)
log.Printf("rcon end : %s\n", rconEndRecvString)
} else {
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:]
}
// 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 {
// we don't even have enough for a length and protocol type, so may
// as well go sit on the socket
@ -340,13 +377,13 @@ SocketLoop:
}
func extractString(bytes []byte) string {
var str []byte
for i := 0; i <= len(bytes); i++ {
func extractString(bytes []byte, start int) (string, int) {
var buildString []byte
for i := start; i <= len(bytes); i++ {
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
}