Create PsAdmin framework

PsAdmin uses a dedicated TCP port to allow for remote queries and
command to be sent to the running World/Login server. Commands are a
single command followed by zero or more arguments.

Commands may require access to the ActorSystem, so they will get their
own dedicated actors to be able to handle the different messages
required that can be sent in response to a query. The return line is in
JSON to allow for easy parsing by applications, such as web servers.
An interactive client is easy as being able to parse json and buffer
command input.

Some basic commands are implemented for now:

* shutdown - kills the actor system
* list_players - gets a list of players on the interstellar cluster
* dump_config - get the running config
* thread_dump - dumps all thread backtraces (useful for prod debugging)

More advanced commands like kick/ban will require additional testing.
This commit is contained in:
Chord 2020-01-26 02:04:17 +01:00
parent ceb58ed39a
commit 82e8840176
16 changed files with 418 additions and 7 deletions

View file

@ -46,9 +46,9 @@ lazy val commonSettings = Seq(
"org.ini4j" % "ini4j" % "0.5.4", "org.ini4j" % "ini4j" % "0.5.4",
"org.scala-graph" %% "graph-core" % "1.12.5", "org.scala-graph" %% "graph-core" % "1.12.5",
"io.kamon" %% "kamon-bundle" % "2.1.0", "io.kamon" %% "kamon-bundle" % "2.1.0",
"io.kamon" %% "kamon-apm-reporter" % "2.1.0" "io.kamon" %% "kamon-apm-reporter" % "2.1.0",
"org.json4s" %% "json4s-native" % "3.6.8",
), ),
classLoaderLayeringStrategy := ClassLoaderLayeringStrategy.Flat
) )
lazy val pscryptoSettings = Seq( lazy val pscryptoSettings = Seq(

View file

@ -190,6 +190,10 @@ trait ConfigParser {
config_map = map config_map = map
} }
def GetRawConfig : Map[String, Any] = {
config_map
}
def FormatErrors(invalidResult : Invalid) : Seq[String] = { def FormatErrors(invalidResult : Invalid) : Seq[String] = {
var count = 0; var count = 0;
@ -209,7 +213,7 @@ trait ConfigParser {
} }
protected def parseSection(sectionIni : org.ini4j.Profile.Section, entry : ConfigEntry, map : Map[String, Any]) : ValidationResult = { protected def parseSection(sectionIni : org.ini4j.Profile.Section, entry : ConfigEntry, map : Map[String, Any]) : ValidationResult = {
var rawValue = sectionIni.get(entry.key) var rawValue = sectionIni.get(entry.key, 0)
val full_key : String = sectionIni.getName + "." + entry.key val full_key : String = sectionIni.getName + "." + entry.key
val value = if (rawValue == null) { val value = if (rawValue == null) {

View file

@ -64,6 +64,16 @@ class InterstellarCluster(zones : List[Zone]) extends Actor {
case None => //zone_number does not exist case None => //zone_number does not exist
sender ! Zone.Lattice.NoValidSpawnPoint(zone_number, None) sender ! Zone.Lattice.NoValidSpawnPoint(zone_number, None)
} }
case InterstellarCluster.ListPlayers() =>
var players : List[String] = List()
for(zone <- zones) {
val zonePlayers = zone.Players
for (player <- zonePlayers) {
players ::= player.name
}
}
sender ! InterstellarCluster.PlayerList(players)
case msg @ Zone.Lattice.RequestSpecificSpawnPoint(zone_number, _, _, _) => case msg @ Zone.Lattice.RequestSpecificSpawnPoint(zone_number, _, _, _) =>
recursiveFindWorldInCluster(zones.iterator, _.Number == zone_number) match { recursiveFindWorldInCluster(zones.iterator, _.Number == zone_number) match {
@ -188,6 +198,9 @@ object InterstellarCluster {
*/ */
final case class GiveWorld(zoneId : String, zone : Zone) final case class GiveWorld(zoneId : String, zone : Zone)
final case class ListPlayers()
final case class PlayerList(players : List[String])
/** /**
* Signal to the cluster that a new client needs to be initialized for all listed `Zone` destinations. * Signal to the cluster that a new client needs to be initialized for all listed `Zone` destinations.
* @see `Zone` * @see `Zone`

View file

@ -119,7 +119,7 @@ ServerType = Released
# Important: Must be different from the worldserver.ListeningPort. Ports below 1024 are # Important: Must be different from the worldserver.ListeningPort. Ports below 1024 are
# privileged on Linux and may require root. # privileged on Linux and may require root.
# Range: [1, 65535] - (UDP port 1, UDP port 65535) # Range: [1, 65535] - (UDP port 1, UDP port 65535)
# Default: 51000 - (Listen on UDP port 5100) # Default: 51000 - (Listen on UDP port 51000)
ListeningPort = 51000 ListeningPort = 51000
@ -134,6 +134,22 @@ ListeningPort = 51000
CreateMissingAccounts = yes CreateMissingAccounts = yes
###################################################################################################
# PSADMIN SETTINGS
###################################################################################################
[psadmin]
# ListeningPort (int)
# Description: The TCP listening port for the server admin interface.
# Important: Must be different from the worldserver and loginserver ListeningPort.
# Ports below 1024 are privileged on Linux and may require root.
# NEVER EXPOSE THIS PORT TO THE INTERNET! CHECK YOUR FIREWALL CONFIG.
# Range: [1, 65535] - (TCP port 1, TCP port 65535)
# Default: 51002 - (Listen on TCP port 51002)
ListeningPort = 51002
################################################################################################### ###################################################################################################
# NETWORK SETTINGS # NETWORK SETTINGS
################################################################################################### ###################################################################################################

View file

@ -1,4 +1,5 @@
// Copyright (c) 2017 PSForever // Copyright (c) 2017 PSForever
import net.psforever.WorldConfig
import com.github.mauricio.async.db.postgresql.PostgreSQLConnection import com.github.mauricio.async.db.postgresql.PostgreSQLConnection
import com.github.mauricio.async.db.{Configuration, QueryResult, RowData, SSLConfiguration} import com.github.mauricio.async.db.{Configuration, QueryResult, RowData, SSLConfiguration}
import scala.util.{Try,Success,Failure} import scala.util.{Try,Success,Failure}

View file

@ -14,6 +14,7 @@ import com.github.mauricio.async.db.{Connection, QueryResult}
import net.psforever.objects.Account import net.psforever.objects.Account
import net.psforever.objects.DefaultCancellable import net.psforever.objects.DefaultCancellable
import net.psforever.types.PlanetSideEmpire import net.psforever.types.PlanetSideEmpire
import net.psforever.WorldConfig
import services.ServiceManager import services.ServiceManager
import services.ServiceManager.Lookup import services.ServiceManager.Lookup
import services.account.{ReceiveIPAddress, RetrieveIPAddress, StoreAccountData} import services.account.{ReceiveIPAddress, RetrieveIPAddress, StoreAccountData}

View file

@ -15,6 +15,8 @@ import net.psforever.config.{Invalid, Valid}
import net.psforever.crypto.CryptoInterface import net.psforever.crypto.CryptoInterface
import net.psforever.objects.zones._ import net.psforever.objects.zones._
import net.psforever.objects.guid.TaskResolver import net.psforever.objects.guid.TaskResolver
import net.psforever.psadmin.PsAdminActor
import net.psforever.WorldConfig
import org.slf4j import org.slf4j
import org.fusesource.jansi.Ansi._ import org.fusesource.jansi.Ansi._
import org.fusesource.jansi.Ansi.Color._ import org.fusesource.jansi.Ansi.Color._
@ -260,6 +262,7 @@ object PsLogin {
val loginServerPort = WorldConfig.Get[Int]("loginserver.ListeningPort") val loginServerPort = WorldConfig.Get[Int]("loginserver.ListeningPort")
val worldServerPort = WorldConfig.Get[Int]("worldserver.ListeningPort") val worldServerPort = WorldConfig.Get[Int]("worldserver.ListeningPort")
val psAdminPort = WorldConfig.Get[Int]("psadmin.ListeningPort")
val netSim : Option[NetworkSimulatorParameters] = WorldConfig.Get[Boolean]("developer.NetSim.Active") match { val netSim : Option[NetworkSimulatorParameters] = WorldConfig.Get[Boolean]("developer.NetSim.Active") match {
case true => case true =>
@ -295,6 +298,8 @@ object PsLogin {
loginListener = system.actorOf(Props(new UdpListener(loginRouter, "login-session-router", LoginConfig.serverIpAddress, loginServerPort, netSim)), "login-udp-endpoint") loginListener = system.actorOf(Props(new UdpListener(loginRouter, "login-session-router", LoginConfig.serverIpAddress, loginServerPort, netSim)), "login-udp-endpoint")
worldListener = system.actorOf(Props(new UdpListener(worldRouter, "world-session-router", LoginConfig.serverIpAddress, worldServerPort, netSim)), "world-udp-endpoint") worldListener = system.actorOf(Props(new UdpListener(worldRouter, "world-session-router", LoginConfig.serverIpAddress, worldServerPort, netSim)), "world-udp-endpoint")
val adminListener = system.actorOf(Props(new TcpListener(classOf[PsAdminActor], "psadmin-client-", InetAddress.getLoopbackAddress, psAdminPort)), "psadmin-tcp-endpoint")
logger.info(s"NOTE: Set client.ini to point to ${LoginConfig.serverIpAddress.getHostAddress}:$loginServerPort") logger.info(s"NOTE: Set client.ini to point to ${LoginConfig.serverIpAddress.getHostAddress}:$loginServerPort")
// Add our shutdown hook (this works for Control+C as well, but not in Cygwin) // Add our shutdown hook (this works for Control+C as well, but not in Cygwin)
@ -321,8 +326,5 @@ object PsLogin {
Locale.setDefault(Locale.US); // to have floats with dots, not comma... Locale.setDefault(Locale.US); // to have floats with dots, not comma...
this.args = args this.args = args
run() run()
// Wait forever until the actor system shuts down
Await.result(system.whenTerminated, Duration.Inf)
} }
} }

View file

@ -9,6 +9,7 @@ import scala.collection.mutable
import akka.actor.SupervisorStrategy.Stop import akka.actor.SupervisorStrategy.Stop
import net.psforever.packet.PacketCoding import net.psforever.packet.PacketCoding
import net.psforever.packet.control.ConnectionClose import net.psforever.packet.control.ConnectionClose
import net.psforever.WorldConfig
import services.ServiceManager import services.ServiceManager
import services.ServiceManager.Lookup import services.ServiceManager.Lookup
import services.account.{IPAddress, StoreIPAddress} import services.account.{IPAddress, StoreIPAddress}

View file

@ -0,0 +1,53 @@
// Copyright (c) 2020 PSForever
import java.net.{InetAddress, InetSocketAddress}
import akka.actor.SupervisorStrategy.Stop
import akka.actor.{Actor, ActorRef, OneForOneStrategy, Props, Terminated}
import akka.io._
import scodec.bits._
import scodec.interop.akka._
import akka.util.ByteString
class TcpListener[T <: Actor](actorClass : Class[T],
nextActorName : String,
listenAddress : InetAddress,
port : Int) extends Actor {
private val log = org.log4s.getLogger(self.path.name)
override def supervisorStrategy = OneForOneStrategy() {
case _ => Stop
}
import context.system
IO(Tcp) ! Tcp.Bind(self, new InetSocketAddress(listenAddress, port))
var sessionId = 0L
var bytesRecevied = 0L
var bytesSent = 0L
var nextActor : ActorRef = Actor.noSender
def receive = {
case Tcp.Bound(local) =>
log.info(s"Now listening on TCP:$local")
context.become(ready(sender()))
case Tcp.CommandFailed(Tcp.Bind(_, address, _, _, _)) =>
log.error("Failed to bind to the network interface: " + address)
context.system.terminate()
case default =>
log.error(s"Unexpected message $default")
}
def ready(socket: ActorRef): Receive = {
case Tcp.Connected(remote, local) =>
val connection = sender()
val session = sessionId
val handler = context.actorOf(Props(actorClass, remote, connection), nextActorName + session)
connection ! Tcp.Register(handler)
sessionId += 1
case Tcp.Unbind => socket ! Tcp.Unbind
case Tcp.Unbound => context.stop(self)
case default => log.error(s"Unhandled message: $default")
}
}

View file

@ -1,4 +1,6 @@
// Copyright (c) 2019 PSForever // Copyright (c) 2019 PSForever
package net.psforever
import scala.util.matching.Regex import scala.util.matching.Regex
import net.psforever.config._ import net.psforever.config._
import scala.concurrent.duration._ import scala.concurrent.duration._
@ -31,6 +33,9 @@ object WorldConfig extends ConfigParser {
ConfigEntryTime("Session.InboundGraceTime", 1 minute, Constraints.min(10 seconds)), ConfigEntryTime("Session.InboundGraceTime", 1 minute, Constraints.min(10 seconds)),
ConfigEntryTime("Session.OutboundGraceTime", 1 minute, Constraints.min(10 seconds)) ConfigEntryTime("Session.OutboundGraceTime", 1 minute, Constraints.min(10 seconds))
), ),
ConfigSection("psadmin",
ConfigEntryInt("ListeningPort", 51002, Constraints.min(1), Constraints.max(65535))
),
ConfigSection("developer", ConfigSection("developer",
ConfigEntryBool ("NetSim.Active", false), ConfigEntryBool ("NetSim.Active", false),
ConfigEntryFloat("NetSim.Loss", 0.02f, Constraints.min(0.0f), Constraints.max(1.0f)), ConfigEntryFloat("NetSim.Loss", 0.02f, Constraints.min(0.0f), Constraints.max(1.0f)),

View file

@ -0,0 +1,30 @@
package net.psforever.psadmin
import net.psforever.WorldConfig
import scala.collection.mutable.Map
object CmdInternal {
def cmdDumpConfig(args : Array[String]) = {
val config = WorldConfig.GetRawConfig
CommandGoodResponse(s"Dump of WorldConfig", config)
}
def cmdThreadDump(args : Array[String]) = {
import scala.collection.JavaConverters._
var data = Map[String,Any]()
val traces = Thread.getAllStackTraces().asScala
var traces_fmt = List[String]()
for ((thread, trace) <- traces) {
val info = s"Thread ${thread.getId} - ${thread.getName}\n"
traces_fmt = traces_fmt ++ List(info + trace.mkString("\n"))
}
data{"trace"} = traces_fmt
CommandGoodResponse(s"Dump of ${traces.size} threads", data)
}
}

View file

@ -0,0 +1,41 @@
// Copyright (c) 2020 PSForever
package net.psforever.psadmin
import java.net.InetAddress
import java.net.InetSocketAddress
import akka.actor.{ActorRef, ActorSystem, Props}
import akka.actor.{Actor, Stash}
import akka.io.Tcp
import scodec.bits._
import scodec.interop.akka._
import scala.collection.mutable.Map
import akka.util.ByteString
import com.typesafe.config.ConfigFactory
import scala.collection.JavaConverters._
import net.psforever.objects.zones.InterstellarCluster
import services.ServiceManager.Lookup
import services._
class CmdListPlayers(args : Array[String], services : Map[String,ActorRef]) extends Actor {
private [this] val log = org.log4s.getLogger(self.path.name)
override def preStart = {
services{"cluster"} ! InterstellarCluster.ListPlayers()
}
override def receive = {
case InterstellarCluster.PlayerList(players) =>
val data = Map[String,Any]()
data{"player_count"} = players.size
data{"player_list"} = Array[String]()
if (players.isEmpty) {
context.parent ! CommandGoodResponse("No players currently online!", data)
} else {
data{"player_list"} = players
context.parent ! CommandGoodResponse(s"${players.length} players online\n", data)
}
case default => log.error(s"Unexpected message $default")
}
}

View file

@ -0,0 +1,17 @@
// Copyright (c) 2020 PSForever
package net.psforever.psadmin
import akka.actor.{Actor,ActorRef}
import scala.collection.mutable.Map
class CmdShutdown(args : Array[String], services : Map[String,ActorRef]) extends Actor {
override def preStart = {
var data = Map[String,Any]()
context.parent ! CommandGoodResponse("Shutting down", data)
context.system.terminate()
}
override def receive = {
case default =>
}
}

View file

@ -0,0 +1,195 @@
// Copyright (c) 2020 PSForever
package net.psforever.psadmin
import java.net.InetAddress
import java.net.InetSocketAddress
import akka.actor.{ActorRef, ActorSystem, Props}
import akka.actor.{Actor, Stash}
import akka.io.Tcp
import scodec.bits._
import scodec.interop.akka._
import scala.collection.mutable.Map
import akka.util.ByteString
import com.typesafe.config.ConfigFactory
import scala.collection.JavaConverters._
import net.psforever.objects.zones.InterstellarCluster
import org.json4s._
import org.json4s.Formats._
import org.json4s.native.Serialization.write
import services.ServiceManager.Lookup
import services._
object PsAdminActor {
val whiteSpaceRegex = """\s+""".r
}
class PsAdminActor(peerAddress : InetSocketAddress, connection : ActorRef) extends Actor with Stash {
private [this] val log = org.log4s.getLogger(self.path.name)
val services = Map[String,ActorRef]()
val servicesToResolve = Array("cluster")
var buffer = ByteString()
implicit val formats = DefaultFormats // for JSON serialization
case class CommandCall(operation : String, args : Array[String])
override def preStart() = {
log.trace(s"PsAdmin connection started $peerAddress")
for (service <- servicesToResolve) {
ServiceManager.serviceManager ! Lookup(service)
}
}
override def receive = ServiceLookup
def ServiceLookup : Receive = {
case ServiceManager.LookupResult(service, endpoint) =>
services{service} = endpoint
if (services.size == servicesToResolve.size) {
unstashAll()
context.become(ReceiveCommand)
}
case default => stash()
}
def ReceiveCommand : Receive = {
case Tcp.Received(data) =>
buffer ++= data
var pos = -1;
var amount = 0
do {
pos = buffer.indexOf('\n')
if (pos != -1) {
val (cmd, rest) = buffer.splitAt(pos)
buffer = rest.drop(1); // drop the newline
// make sure the CN cant crash us
val line = cmd.decodeString("utf-8").trim
if (line != "") {
val tokens = PsAdminActor.whiteSpaceRegex.split(line)
val cmd = tokens.head
val args = tokens.tail
amount += 1
self ! CommandCall(cmd, args)
}
}
} while (pos != -1)
if (amount > 0)
context.become(ProcessCommands)
case Tcp.PeerClosed =>
context.stop(self)
case default =>
log.error(s"Unexpected message $default")
}
/// Process all buffered commands and stash other ones
def ProcessCommands : Receive = {
case c : CommandCall =>
stash()
unstashAll()
context.become(ProcessCommand)
case default =>
stash()
unstashAll()
context.become(ReceiveCommand)
}
/// Process a single command
def ProcessCommand : Receive = {
case CommandCall(cmd, args) =>
val data = Map[String,Any]()
if (cmd == "help" || cmd == "?") {
if (args.size == 0) {
var resp = "PsAdmin command usage\n"
for ((command, info) <- PsAdminCommands.commands) {
resp += s"${command} - ${info.usage}\n"
}
data{"message"} = resp
} else {
if (PsAdminCommands.commands.contains(args(0))) {
val info = PsAdminCommands.commands{args(0)}
data{"message"} = s"${args(0)} - ${info.usage}"
} else {
data{"message"} = s"Unknown command ${args(0)}"
data{"error"} = true
}
}
sendLine(write(data.toMap))
} else if (PsAdminCommands.commands.contains(cmd)) {
val cmd_template = PsAdminCommands.commands{cmd}
cmd_template match {
case PsAdminCommands.Command(usage, handler) =>
context.actorOf(Props(handler, args, services))
case PsAdminCommands.CommandInternal(usage, handler) =>
val resp = handler(args)
resp match {
case CommandGoodResponse(msg, data) =>
data{"message"} = msg
sendLine(write(data.toMap))
case CommandErrorResponse(msg, data) =>
data{"message"} = msg
data{"error"} = true
sendLine(write(data.toMap))
}
context.become(ProcessCommands)
}
} else {
data{"message"} = "Unknown command"
data{"error"} = true
sendLine(write(data.toMap))
context.become(ProcessCommands)
}
case resp : CommandResponse =>
resp match {
case CommandGoodResponse(msg, data) =>
data{"message"} = msg
sendLine(write(data.toMap))
case CommandErrorResponse(msg, data) =>
data{"message"} = msg
data{"error"} = true
sendLine(write(data.toMap))
}
context.become(ProcessCommands)
context.stop(sender())
case default =>
stash()
unstashAll()
context.become(ProcessCommands)
}
def sendLine(line : String) = {
ByteVector.encodeUtf8(line + "\n") match {
case Left(e) =>
log.error(s"Message encoding failure: $e")
case Right(bv) =>
connection ! Tcp.Write(bv.toByteString)
}
}
}

View file

@ -0,0 +1,31 @@
// Copyright (c) 2020 PSForever
package net.psforever.psadmin
import scala.collection.mutable.Map
sealed trait CommandResponse
case class CommandGoodResponse(message : String, data : Map[String,Any]) extends CommandResponse
case class CommandErrorResponse(message : String, data : Map[String,Any]) extends CommandResponse
object PsAdminCommands {
import CmdInternal._
val commands : Map[String,CommandInfo] = Map(
"list_players" -> Command("""Return a list of players connected to the interstellar cluster.""", classOf[CmdListPlayers]),
"dump_config" -> CommandInternal("""Dumps entire running config.""", cmdDumpConfig),
"shutdown" -> Command("""Shuts down the server forcefully.""", classOf[CmdShutdown]),
"thread_dump" -> CommandInternal("""Returns all thread's stack traces.""", cmdThreadDump)
)
sealed trait CommandInfo {
def usage: String
}
/// A command with full access to the ActorSystem and WorldServer services.
/// Spawns an Actor to handle the request and the service queries
case class Command[T](usage : String, handler : Class[T]) extends CommandInfo
/// A command without access to the ActorSystem or any services
case class CommandInternal(usage : String, handler : ((Array[String]) => CommandResponse)) extends CommandInfo
}

View file

@ -3,6 +3,7 @@ import java.io._
import scala.io.Source import scala.io.Source
import org.specs2.mutable._ import org.specs2.mutable._
import net.psforever.config._ import net.psforever.config._
import net.psforever.WorldConfig
import scala.concurrent.duration._ import scala.concurrent.duration._
class ConfigTest extends Specification { class ConfigTest extends Specification {