PSF-LoginServer/pslogin/src/main/scala/LoginSessionActor.scala
Chord 82e8840176 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.
2020-05-11 04:18:29 +02:00

391 lines
16 KiB
Scala

// Copyright (c) 2017 PSForever
import java.net.InetSocketAddress
import java.net.InetAddress
import akka.actor.{Actor, ActorRef, Cancellable, MDCContextAware}
import net.psforever.packet.{PlanetSideGamePacket, _}
import net.psforever.packet.control._
import net.psforever.packet.game._
import org.log4s.MDC
import scodec.bits._
import MDCContextAware.Implicits._
import com.github.mauricio.async.db.general.ArrayRowData
import com.github.mauricio.async.db.{Connection, QueryResult}
import net.psforever.objects.Account
import net.psforever.objects.DefaultCancellable
import net.psforever.types.PlanetSideEmpire
import net.psforever.WorldConfig
import services.ServiceManager
import services.ServiceManager.Lookup
import services.account.{ReceiveIPAddress, RetrieveIPAddress, StoreAccountData}
import com.github.t3hnar.bcrypt._
import net.psforever.packet.game.LoginRespMessage.{LoginError, StationError, StationSubscriptionStatus}
import scala.concurrent.duration._
import scala.util.{Failure, Success}
case class StartAccountAuthentication(connection: Option[Connection], username: String, password: String, newToken: String, queryResult: Any)
case class FinishAccountLogin(connection: Option[Connection], username: String, newToken: String, isSuccessfulLogin: Boolean, isInactive:Boolean = false)
case class CreateNewAccount(connection: Option[Connection], username: String, password: String, newToken: String)
case class LogTheLoginOccurrence(connection: Option[Connection], username: String, newToken: String, isSuccessfulLogin: Boolean, accountId: Int)
class LoginSessionActor extends Actor with MDCContextAware {
private[this] val log = org.log4s.getLogger
import scala.concurrent.ExecutionContext.Implicits.global
private case class UpdateServerList()
var sessionId : Long = 0
var leftRef : ActorRef = ActorRef.noSender
var rightRef : ActorRef = ActorRef.noSender
var accountIntermediary : ActorRef = Actor.noSender
var updateServerListTask : Cancellable = DefaultCancellable.obj
var ipAddress : String = ""
var hostName : String = ""
var canonicalHostName : String = ""
var port : Int = 0
val serverName = WorldConfig.Get[String]("worldserver.ServerName")
// This MUST be an IP address. The client DOES NOT do name resolution
var serverHost : String = if (WorldConfig.Get[String]("worldserver.Hostname") != "")
InetAddress.getByName(WorldConfig.Get[String]("worldserver.Hostname")).getHostAddress
else
LoginConfig.serverIpAddress.getHostAddress
val serverAddress = new InetSocketAddress(serverHost, WorldConfig.Get[Int]("worldserver.ListeningPort"))
// Reference: https://stackoverflow.com/a/50470009
private val numBcryptPasses = 10
override def postStop() = {
if(updateServerListTask != null)
updateServerListTask.cancel()
}
def receive = Initializing
def Initializing : Receive = {
case HelloFriend(aSessionId, pipe) =>
this.sessionId = aSessionId
leftRef = sender()
if(pipe.hasNext) {
rightRef = pipe.next
rightRef !> HelloFriend(aSessionId, pipe)
} else {
rightRef = sender()
}
context.become(Started)
ServiceManager.serviceManager ! Lookup("accountIntermediary")
case _ =>
log.error("Unknown message")
context.stop(self)
}
def Started : Receive = {
case ServiceManager.LookupResult("accountIntermediary", endpoint) =>
accountIntermediary = endpoint
case ReceiveIPAddress(address) =>
ipAddress = address.Address
hostName = address.HostName
canonicalHostName = address.CanonicalHostName
port = address.Port
case UpdateServerList() =>
updateServerList()
case ControlPacket(_, ctrl) =>
handleControlPkt(ctrl)
case GamePacket(_, _, game) =>
handleGamePkt(game)
case default => failWithError(s"Invalid packet class received: $default")
}
def handleControlPkt(pkt : PlanetSideControlPacket) = {
pkt match {
/// TODO: figure out what this is what what it does for the PS client
/// I believe it has something to do with reliable packet transmission and resending
case sync @ ControlSync(diff, _, _, _, _, _, fa, fb) =>
log.trace(s"SYNC: $sync")
val serverTick = Math.abs(System.nanoTime().toInt) // limit the size to prevent encoding error
sendResponse(PacketCoding.CreateControlPacket(ControlSyncResp(diff, serverTick, fa, fb, fb, fa)))
case TeardownConnection(_) =>
sendResponse(DropSession(sessionId, "client requested session termination"))
case default =>
log.error(s"Unhandled ControlPacket $default")
}
}
def handleGamePkt(pkt : PlanetSideGamePacket) = pkt match {
case LoginMessage(majorVersion, minorVersion, buildDate, username, password, token, revision) =>
// TODO: prevent multiple LoginMessages from being processed in a row!! We need a state machine
val clientVersion = s"Client Version: $majorVersion.$minorVersion.$revision, $buildDate"
accountIntermediary ! RetrieveIPAddress(sessionId)
if(token.isDefined)
log.info(s"New login UN:$username Token:${token.get}. $clientVersion")
else {
// log.info(s"New login UN:$username PW:$password. $clientVersion")
log.info(s"New login UN:$username. $clientVersion")
}
startAccountLogin(username, password.get)
case ConnectToWorldRequestMessage(name, _, _, _, _, _, _) =>
log.info(s"Connect to world request for '$name'")
val response = ConnectToWorldMessage(serverName, serverAddress.getHostString, serverAddress.getPort)
sendResponse(PacketCoding.CreateGamePacket(0, response))
sendResponse(DropSession(sessionId, "user transferring to world"))
case _ =>
log.debug(s"Unhandled GamePacket $pkt")
}
def startAccountLogin(username: String, password: String) : Unit = {
val newToken = this.generateToken()
Database.getConnection.connect.onComplete {
case Success(connection) =>
Database.query(connection.sendPreparedStatement(
"SELECT id, passhash, inactive, gm FROM accounts where username=?", Array(username)
)).onComplete {
case Success(queryResult) =>
context.become(startAccountAuthentication)
self ! StartAccountAuthentication(Some(connection), username, password, newToken, queryResult)
case Failure(e) =>
log.error("Failed account lookup query " + e.getMessage)
connection.disconnect
context.become(finishAccountLogin)
self ! FinishAccountLogin(Some(connection), username, newToken, false)
}
case Failure(e) =>
log.error("Failed connecting to database for account lookup " + e.getMessage)
context.become(finishAccountLogin)
self ! FinishAccountLogin(None, username, newToken, false)
}
}
def startAccountAuthentication : Receive = {
case StartAccountAuthentication(connection, username, password, newToken, queryResult) =>
queryResult match {
// If we got a row from the database
case row: ArrayRowData =>
val (isSuccessfulLogin, accountId) = authenticateExistingAccount(connection.get, username, password, newToken, row)
if(isSuccessfulLogin) { // login OK
context.become(logTheLoginOccurrence)
self ! LogTheLoginOccurrence(connection, username, newToken, isSuccessfulLogin, accountId)
} else {
if (accountId == 0) { // Bad password
context.become(finishAccountLogin)
self ! FinishAccountLogin(connection, username, newToken, isSuccessfulLogin)
} else { // Account inactive
context.become(finishAccountLogin)
self ! FinishAccountLogin(connection, username, newToken, isSuccessfulLogin, true)
}
}
// If the account didn't exist in the database
case errorCode: Int => errorCode match {
case Database.EMPTY_RESULT =>
if (WorldConfig.Get[Boolean]("loginserver.CreateMissingAccounts")) {
self ! CreateNewAccount(connection, username, password, newToken)
context.become(createNewAccount)
} else {
context.become(finishAccountLogin)
self ! FinishAccountLogin(connection, username, newToken, false)
}
case _ =>
log.error(s"Issue retrieving result set from database for account $username")
context.become(finishAccountLogin)
self ! FinishAccountLogin(connection, username, newToken, false)
}
}
case default =>
failWithError(s"Invalid message '$default' received in startAccountAuthentication")
}
def createNewAccount : Receive = {
case CreateNewAccount(connection, username, password, newToken) =>
log.info(s"Account $username does not exist, creating new account...")
val bcryptPassword : String = password.bcrypt(numBcryptPasses)
connection.get.inTransaction {
c => c.sendPreparedStatement(
"INSERT INTO accounts (username, passhash) VALUES(?,?) RETURNING id",
Array(username, bcryptPassword)
)
}.onComplete {
case Success(insertResult) =>
insertResult match {
case result: QueryResult =>
if (result.rows.nonEmpty) {
val accountId = result.rows.get.head(0).asInstanceOf[Int]
accountIntermediary ! StoreAccountData(newToken, new Account(accountId, username))
log.info(s"Successfully created new account for $username")
context.become(logTheLoginOccurrence)
self ! LogTheLoginOccurrence(connection, username, newToken, true, accountId)
} else {
log.error(s"No result from account create insert for $username")
context.become(finishAccountLogin)
self ! FinishAccountLogin(connection, username, newToken, false)
}
case default =>
log.error(s"Error creating new account for $username - $default")
context.become(finishAccountLogin)
self ! FinishAccountLogin(connection, username, newToken, false)
}
case Failure(e : com.github.mauricio.async.db.postgresql.exceptions.GenericDatabaseException) =>
log.error(s"Error creating new account - ${e.errorMessage.message}")
context.become(finishAccountLogin)
self ! FinishAccountLogin(connection, username, newToken, false)
case Failure(e : java.sql.SQLException) =>
log.error(s"Error creating new account - ${e.getMessage}")
context.become(finishAccountLogin)
self ! FinishAccountLogin(connection, username, newToken, false)
case _ =>
failWithError(s"Something to do?")
}
case default =>
failWithError(s"Invalid message '$default' received in createNewAccount")
}
// Essentially keeps a record of this individual login occurrence
def logTheLoginOccurrence : Receive = {
case LogTheLoginOccurrence(connection, username, newToken, isSuccessfulLogin, accountId) =>
connection.get.inTransaction {
c => c.sendPreparedStatement(
"INSERT INTO logins (account_id, login_time, ip_address, canonical_hostName, hostname, port) VALUES(?,?,?,?,?,?)",
Array(accountId, new java.sql.Timestamp(System.currentTimeMillis), ipAddress, canonicalHostName, hostName, port)
)
}.onComplete {
_ =>
context.become(finishAccountLogin)
self ! FinishAccountLogin(connection, username, newToken, isSuccessfulLogin)
}
case default => failWithError(s"Invalid message '$default' received in logTheLoginOccurrence")
}
def finishAccountLogin : Receive = {
case FinishAccountLogin(connection, username, newToken, isSuccessfulLogin, isInactive) =>
if(isSuccessfulLogin) { // Login OK
loginSuccessfulResponse(username, newToken)
updateServerListTask = context.system.scheduler.schedule(0 seconds, 2 seconds, self, UpdateServerList())
} else {
if (!isInactive && connection.nonEmpty) { // Bad Password
loginPwdFailureResponse(username, newToken)
} else if (connection.nonEmpty) { // Account inactive
loginAccountFailureResponse(username, newToken)
} else {
loginFailureResponse(username, newToken)
}
}
if(connection.nonEmpty) {
connection.get.disconnect
}
context.become(Started)
case default =>
failWithError(s"Invalid message '$default' received in finishAccountLogin")
}
def authenticateExistingAccount(
connection: Connection, username: String, password: String, newToken: String, row: ArrayRowData
) : (Boolean, Int) = {
val accountId : Int = row(0).asInstanceOf[Int]
val dbPassHash : String = row(1).asInstanceOf[String]
val inactive : Boolean = row(2).asInstanceOf[Boolean]
val gm : Boolean = row(3).asInstanceOf[Boolean]
if (password.isBcrypted(dbPassHash)) {
if (!inactive) {
log.info(s"Account password correct for $username!")
accountIntermediary ! StoreAccountData(newToken, new Account(accountId, username, gm))
return (true, accountId)
} else {
log.info(s"Account password correct for $username but account inactive !")
return (false, accountId)
}
} else {
log.info(s"Account password incorrect for $username")
}
(false, 0)
}
def loginSuccessfulResponse(username: String, newToken: String) = {
sendResponse(PacketCoding.CreateGamePacket(0, LoginRespMessage(
newToken, LoginError.Success, StationError.AccountActive,
StationSubscriptionStatus.Active, 0, username, 10001
)))
}
def loginPwdFailureResponse(username: String, newToken: String) = {
log.info(s"Failed login to account $username")
sendResponse(PacketCoding.CreateGamePacket(0, LoginRespMessage(
newToken, LoginError.BadUsernameOrPassword, StationError.AccountActive,
StationSubscriptionStatus.Active, 685276011, username, 10001
)))
}
def loginFailureResponse(username: String, newToken: String) = {
log.info("DB problem")
sendResponse(PacketCoding.CreateGamePacket(0, LoginRespMessage(
newToken, LoginError.unk1, StationError.AccountActive,
StationSubscriptionStatus.Active, 685276011, username, 10001
)))
}
def loginAccountFailureResponse(username: String, newToken: String) = {
log.info(s"Account $username inactive")
sendResponse(PacketCoding.CreateGamePacket(0, LoginRespMessage(
newToken, LoginError.BadUsernameOrPassword, StationError.AccountClosed,
StationSubscriptionStatus.Active, 685276011, username, 10001
)))
}
def generateToken() = {
val r = new scala.util.Random
val sb = new StringBuilder
for (_ <- 1 to 31) {
sb.append(r.nextPrintableChar)
}
sb.toString
}
def updateServerList() = {
val msg = VNLWorldStatusMessage("Welcome to PlanetSide! ",
Vector(
WorldInformation(
serverName, WorldStatus.Up,
WorldConfig.Get[ServerType.Value]("worldserver.ServerType"), Vector(WorldConnectionInfo(serverAddress)), PlanetSideEmpire.VS
)
)
)
sendResponse(PacketCoding.CreateGamePacket(0, msg))
}
def failWithError(error : String) = {
log.error(error)
//sendResponse(PacketCoding.CreateControlPacket(ConnectionClose()))
}
def sendResponse(cont : Any) = {
log.trace("LOGIN SEND: " + cont)
MDC("sessionId") = sessionId.toString
rightRef !> cont
}
def sendRawResponse(pkt : ByteVector) = {
log.trace("LOGIN SEND RAW: " + pkt)
MDC("sessionId") = sessionId.toString
rightRef !> RawPacket(pkt)
}
}