Compare commits
9 Commits
Author | SHA1 | Date | |
---|---|---|---|
48dc10df19 | |||
5d3cbdd6b4 | |||
e5d3f243fb | |||
3e8ccf3df1 | |||
207d5b32e0 | |||
46fe9b66c4 | |||
0661fb9cc0 | |||
5cdf8777dc | |||
935e445510 |
32
README.md
32
README.md
@ -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.
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user