/* This file is part of the OdinMS Maple Story Server Copyright (C) 2008 Patrick Huy Matthias Butz Jan Christian Meyer This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation version 3 as published by the Free Software Foundation. You may not use, modify or distribute this program under any other version of the GNU Affero General Public License. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ package client; import config.YamlConfig; import database.account.Account; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInboundHandlerAdapter; import io.netty.handler.timeout.IdleStateEvent; import net.PacketHandler; import net.PacketProcessor; import net.netty.DisconnectException; import net.netty.GameViolationException; import net.netty.InvalidPacketHeaderException; import net.packet.InPacket; import net.packet.Packet; import net.packet.logging.LoggingUtil; import net.packet.logging.MonitoredChrLogger; import net.server.Server; import net.server.channel.Channel; import net.server.coordinator.login.LoginBypassCoordinator; import net.server.coordinator.session.Hwid; import net.server.coordinator.session.SessionCoordinator; import net.server.world.Party; import net.server.world.World; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import scripting.AbstractPlayerInteraction; import scripting.event.EventManager; import scripting.npc.NPCConversationManager; import scripting.npc.NPCScriptManager; import scripting.quest.QuestActionManager; import scripting.quest.QuestScriptManager; import server.TimerManager; import server.life.Monster; import tools.DatabaseConnection; import tools.PacketCreator; import javax.script.ScriptEngine; import java.net.InetSocketAddress; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Timestamp; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.Arrays; import java.util.Calendar; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.concurrent.Semaphore; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import static java.util.concurrent.TimeUnit.SECONDS; public class Client extends ChannelInboundHandlerAdapter { private static final Logger log = LoggerFactory.getLogger(Client.class); private static final int MAX_FAILED_LOGIN_ATTEMPTS = 5; private static final int MAX_CHR_SLOTS = 15; private final Type type; private final long sessionId; private final PacketProcessor packetProcessor; private Hwid hwid; private String remoteAddress; private io.netty.channel.Channel ioChannel; private Account account; private Character player; private int channel = 1; private int accId = -4; private boolean loggedIn = false; private boolean inServerTransition = false; private Calendar birthday = null; // TODO: convert to LocalDate private String accountName = null; private int world; private volatile long lastPong; private int gmlevel; // TODO: remove? There's a gmlevel in Character too. private Set macs = new HashSet<>(); private Map engines = new HashMap<>(); private byte characterSlots = 3; private byte failedLoginAttempts = 0; private String pin = ""; private int pinattempt = 0; private String pic = ""; private int picattempt = 0; private byte csattempt = 0; private byte gender = -1; private boolean disconnecting = false; private final Semaphore actionsSemaphore = new Semaphore(7); private final Lock lock = new ReentrantLock(true); private final Lock announcerLock = new ReentrantLock(true); // thanks Masterrulax & try2hack for pointing out a bottleneck issue with shared locks, shavit for noticing an opportunity for improvement private Calendar tempBanCalendar; private long lastNpcClick; private long lastPacket = System.currentTimeMillis(); public enum Type { LOGIN, CHANNEL } public Client(Type type, long sessionId, String remoteAddress, PacketProcessor packetProcessor, int world, int channel) { this.type = type; this.sessionId = sessionId; this.remoteAddress = remoteAddress; this.packetProcessor = packetProcessor; this.world = world; this.channel = channel; } public static Client createLoginClient(long sessionId, String remoteAddress, PacketProcessor packetProcessor, int world, int channel) { return new Client(Type.LOGIN, sessionId, remoteAddress, packetProcessor, world, channel); } public static Client createChannelClient(long sessionId, String remoteAddress, PacketProcessor packetProcessor, int world, int channel) { return new Client(Type.CHANNEL, sessionId, remoteAddress, packetProcessor, world, channel); } public static Client createMock() { return new Client(null, -1, null, null, -123, -123); } @Override public void channelActive(ChannelHandlerContext ctx) { final io.netty.channel.Channel channel = ctx.channel(); if (!Server.getInstance().isOnline()) { channel.close(); return; } this.remoteAddress = getRemoteAddress(channel); this.ioChannel = channel; } private static String getRemoteAddress(io.netty.channel.Channel channel) { String remoteAddress = "null"; try { remoteAddress = ((InetSocketAddress) channel.remoteAddress()).getAddress().getHostAddress(); } catch (NullPointerException npe) { log.warn("Unable to get remote address for client", npe); } return remoteAddress; } @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { if (!(msg instanceof InPacket packet)) { log.warn("Received invalid message: {}", msg); return; } short opcode = packet.readShort(); final PacketHandler handler = packetProcessor.getHandler(opcode); if (YamlConfig.config.server.USE_DEBUG_SHOW_RCVD_PACKET && !LoggingUtil.isIgnoredRecvPacket(opcode)) { log.debug("Received packet id {}", opcode); } if (handler != null && handler.validateState(this)) { try { MonitoredChrLogger.logPacketIfMonitored(this, opcode, packet.getBytes()); handler.handlePacket(packet, this); } catch (GameViolationException gve) { log.warn("Game violation (disconnecting): {}", gve.getMessage()); throw new DisconnectException(this, true); } catch (final Throwable t) { final String chrInfo = player != null ? player.getName() + " on map " + player.getMapId() : "?"; log.warn("Error in packet handler {}. Chr {}, account {}. Packet: {}", handler.getClass().getSimpleName(), chrInfo, getAccountName(), packet, t); //client.sendPacket(PacketCreator.enableActions());//bugs sometimes } } updateLastPacket(); } @Override public void userEventTriggered(ChannelHandlerContext ctx, Object event) { if (event instanceof IdleStateEvent idleEvent) { checkIfIdle(idleEvent); } } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { if (player != null) { log.warn("Exception caught by {}", player, cause); } if (cause instanceof InvalidPacketHeaderException) { SessionCoordinator.getInstance().closeSession(this, true); } else { ctx.fireExceptionCaught(cause); } } @Override public void channelInactive(ChannelHandlerContext ctx) { switch (type) { case LOGIN -> SessionCoordinator.getInstance().closeLoginSession(this); case CHANNEL -> SessionCoordinator.getInstance().closeSession(this, false); } try { // client freeze issues on session transition states found thanks to yolinlin, Omo Oppa, Nozphex if (!inServerTransition) { ctx.fireExceptionCaught(new DisconnectException(this, false)); } } catch (Throwable t) { log.warn("Account stuck", t); } finally { closeSession(); } } public void updateLastPacket() { lastPacket = System.currentTimeMillis(); } public long getLastPacket() { return lastPacket; } public void closeSession() { ioChannel.close(); } public void disconnectSession() { ioChannel.disconnect(); } public Hwid getHwid() { return hwid; } public void setHwid(Hwid hwid) { this.hwid = hwid; } public String getRemoteAddress() { return remoteAddress; } public EventManager getEventManager(String event) { return getChannelServer().getEventSM().getEventManager(event); } public Account getAccount() { return account; } public void setAccount(Account account) { Objects.requireNonNull(account); this.account = account; this.accId = account.id(); this.accountName = account.name(); this.characterSlots = account.chrSlots(); this.pin = account.pin(); this.pic = account.pic(); this.gender = Objects.requireNonNullElse(account.gender(), Gender.NOT_SET); Calendar calendar = Calendar.getInstance(); LocalDate birthdate = account.birthdate(); calendar.set(birthdate.getYear(), birthdate.getMonthValue() - 1, birthdate.getDayOfMonth()); this.birthday = calendar; loggedIn = account.loginState() == LoginState.LOGGED_IN; inServerTransition = account.loginState() == LoginState.SERVER_TRANSITION; } public Character getPlayer() { return player; } public void setPlayer(Character player) { this.player = player; } public AbstractPlayerInteraction getAbstractPlayerInteraction() { return new AbstractPlayerInteraction(this); } public boolean isLoggedIn() { return loggedIn; } public boolean isInTransition() { return inServerTransition; } // TODO: load ipbans on server start and query it on demand. This query should not be run on every login! @Deprecated public boolean hasBannedIP() { boolean ret = false; try (Connection con = DatabaseConnection.getConnection(); PreparedStatement ps = con.prepareStatement("SELECT COUNT(*) FROM ipbans WHERE ? LIKE CONCAT(ip, '%')")) { ps.setString(1, remoteAddress); try (ResultSet rs = ps.executeQuery()) { rs.next(); if (rs.getInt(1) > 0) { ret = true; } } } catch (SQLException e) { e.printStackTrace(); } return ret; } public boolean hasBannedHWID() { if (hwid == null) { return false; } boolean ret = false; try (Connection con = DatabaseConnection.getConnection(); PreparedStatement ps = con.prepareStatement("SELECT COUNT(*) FROM hwidbans WHERE hwid LIKE ?")) { ps.setString(1, hwid.hwid()); try (ResultSet rs = ps.executeQuery()) { if (rs != null && rs.next()) { if (rs.getInt(1) > 0) { ret = true; } } } } catch (SQLException e) { e.printStackTrace(); } return ret; } // TODO: load macbans on server start and query it on demand. This query should not be run on every login! @Deprecated public boolean hasBannedMac() { if (macs.isEmpty()) { return false; } boolean ret = false; int i; StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM macbans WHERE mac IN ("); for (i = 0; i < macs.size(); i++) { sql.append("?"); if (i != macs.size() - 1) { sql.append(", "); } } sql.append(")"); try (Connection con = DatabaseConnection.getConnection(); PreparedStatement ps = con.prepareStatement(sql.toString())) { i = 0; for (String mac : macs) { ps.setString(++i, mac); } try (ResultSet rs = ps.executeQuery()) { rs.next(); if (rs.getInt(1) > 0) { ret = true; } } } catch (Exception e) { e.printStackTrace(); } return ret; } // TODO: Recode to close statements... // Only used from ban command. private void loadMacsIfNescessary() throws SQLException { if (macs.isEmpty()) { try (Connection con = DatabaseConnection.getConnection(); PreparedStatement ps = con.prepareStatement("SELECT macs FROM accounts WHERE id = ?")) { ps.setInt(1, accId); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { for (String mac : rs.getString("macs").split(", ")) { if (!mac.equals("")) { macs.add(mac); } } } } } } } public void banMacs() { try { loadMacsIfNescessary(); List filtered = new LinkedList<>(); try (Connection con = DatabaseConnection.getConnection()) { try (PreparedStatement ps = con.prepareStatement("SELECT filter FROM macfilters"); ResultSet rs = ps.executeQuery()) { while (rs.next()) { filtered.add(rs.getString("filter")); } } try (PreparedStatement ps = con.prepareStatement("INSERT INTO macbans (mac, aid) VALUES (?, ?)")) { for (String mac : macs) { boolean matched = false; for (String filter : filtered) { if (mac.matches(filter)) { matched = true; break; } } if (!matched) { ps.setString(1, mac); ps.setString(2, String.valueOf(getAccID())); ps.executeUpdate(); } } } } } catch (SQLException e) { e.printStackTrace(); } } public void setPin(String pin) { this.pin = pin; } public String getPin() { return pin; } public boolean checkPin(String other) { if (!(YamlConfig.config.server.ENABLE_PIN && !canBypassPin())) { return true; } if (++pinattempt >= MAX_FAILED_LOGIN_ATTEMPTS) { SessionCoordinator.getInstance().closeSession(this, false); } if (pin.equals(other)) { pinattempt = 0; LoginBypassCoordinator.getInstance().registerLoginBypassEntry(hwid, accId, false); return true; } return false; } public void setPic(String pic) { this.pic = pic; } public String getPic() { return pic; } public boolean checkPic(String other) { if (!(YamlConfig.config.server.ENABLE_PIC && !canBypassPic())) { return true; } if (++picattempt >= MAX_FAILED_LOGIN_ATTEMPTS) { SessionCoordinator.getInstance().closeSession(this, false); } if (pic.equals(other)) { // thanks ryantpayton (HeavenClient) for noticing null pics being checked here picattempt = 0; LoginBypassCoordinator.getInstance().registerLoginBypassEntry(hwid, accId, true); return true; } return false; } public boolean attemptLogin() { if (++failedLoginAttempts >= MAX_FAILED_LOGIN_ATTEMPTS) { SessionCoordinator.getInstance().closeSession(this, false); return false; } return true; } // TODO: check tempban directly on loaded account @Deprecated public Calendar getTempBanCalendarFromDB() { final Calendar lTempban = Calendar.getInstance(); try (Connection con = DatabaseConnection.getConnection(); PreparedStatement ps = con.prepareStatement("SELECT `tempban` FROM accounts WHERE id = ?")) { ps.setInt(1, getAccID()); final Timestamp tempban; try (ResultSet rs = ps.executeQuery()) { if (!rs.next()) { return null; } tempban = rs.getTimestamp("tempban"); if (tempban.toLocalDateTime().equals(DefaultDates.getTempban())) { return null; } } lTempban.setTimeInMillis(tempban.getTime()); tempBanCalendar = lTempban; return lTempban; } catch (SQLException e) { e.printStackTrace(); } return null;//why oh why!?! } public Calendar getTempBanCalendar() { return tempBanCalendar; } public void updateHwid(Hwid hwid) { this.hwid = hwid; try (Connection con = DatabaseConnection.getConnection(); PreparedStatement ps = con.prepareStatement("UPDATE accounts SET hwid = ? WHERE id = ?")) { ps.setString(1, hwid.hwid()); ps.setInt(2, accId); ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); } } public void updateMacs(String macData) { macs.addAll(Arrays.asList(macData.split(", "))); StringBuilder newMacData = new StringBuilder(); Iterator iter = macs.iterator(); while (iter.hasNext()) { String cur = iter.next(); newMacData.append(cur); if (iter.hasNext()) { newMacData.append(", "); } } try (Connection con = DatabaseConnection.getConnection(); PreparedStatement ps = con.prepareStatement("UPDATE accounts SET macs = ? WHERE id = ?")) { ps.setString(1, newMacData.toString()); ps.setInt(2, accId); ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); } } public void setAccID(int id) { this.accId = id; } public int getAccID() { return accId; } public void updateLoginState(int newState) { try (Connection con = DatabaseConnection.getConnection(); PreparedStatement ps = con.prepareStatement("UPDATE accounts SET loggedin = ?, lastlogin = ? WHERE id = ?")) { // using sql currenttime here could potentially break the login, thanks Arnah for pointing this out ps.setInt(1, newState); ps.setTimestamp(2, new java.sql.Timestamp(Server.getInstance().getCurrentTime())); ps.setInt(3, getAccID()); ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); } if (newState == LoginState.LOGGED_OUT) { loggedIn = false; inServerTransition = false; setAccID(0); } else if (newState == LoginState.SERVER_TRANSITION) { loggedIn = false; inServerTransition = true; } else { loggedIn = true; inServerTransition = false; } } public void setLoginState(int newState) { if (newState == LoginState.LOGGED_OUT) { loggedIn = false; inServerTransition = false; setAccID(0); } else if (newState == LoginState.SERVER_TRANSITION) { loggedIn = false; inServerTransition = true; } else if (newState == LoginState.LOGGED_IN) { loggedIn = true; inServerTransition = false; } else { throw new IllegalArgumentException("Invalid login state: " + newState); } } public byte getLoginState(Account account) { byte loginState = account.loginState(); if (loginState == LoginState.SERVER_TRANSITION && lastLoginOverThirtySecondsAgo(account)) { loginState = LoginState.LOGGED_OUT; updateLoginState(LoginState.LOGGED_OUT); } if (loginState == LoginState.LOGGED_IN) { loggedIn = true; } else if (loginState == LoginState.SERVER_TRANSITION) { try (Connection con = DatabaseConnection.getConnection(); PreparedStatement ps2 = con.prepareStatement("UPDATE accounts SET loggedin = 0 WHERE id = ?")) { ps2.setInt(1, getAccID()); ps2.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); } } return loginState; } private static boolean lastLoginOverThirtySecondsAgo(Account account) { if (account.lastLogin() == null) { return true; } return account.lastLogin().isBefore(LocalDateTime.now().minusSeconds(30)); } public boolean checkBirthDate(Calendar date) { return date.get(Calendar.YEAR) == birthday.get(Calendar.YEAR) && date.get(Calendar.MONTH) == birthday.get(Calendar.MONTH) && date.get(Calendar.DAY_OF_MONTH) == birthday.get(Calendar.DAY_OF_MONTH); } public synchronized boolean tryDisconnect() { if (disconnecting) { return false; } disconnecting = true; return true; } public void clear() { // player hard reference removal thanks to Steve (kaito1410) if (this.player != null) { this.player.empty(true); // clears schedules and stuff } Server.getInstance().unregisterLoginState(this); this.accountName = null; this.macs = null; this.hwid = null; this.birthday = null; this.engines = null; this.player = null; } public void clearEngines() { if (engines != null) { engines.clear(); } } public int getChannel() { return channel; } public Channel getChannelServer() { return Server.getInstance().getChannel(world, channel); } public World getWorldServer() { return Server.getInstance().getWorld(world); } public Channel getChannelServer(byte channel) { return Server.getInstance().getChannel(world, channel); } public boolean deleteCharacter(int cid, int senderAccId) { try { Character chr = Character.loadCharFromDB(cid, this, false); Integer partyid = chr.getWorldServer().getCharacterPartyid(cid); if (partyid != null) { this.setPlayer(chr); Party party = chr.getWorldServer().getParty(partyid); chr.setParty(party); chr.getMPC(); chr.leaveParty(); // thanks Vcoc for pointing out deleted characters would still stay in a party this.setPlayer(null); } return Character.deleteCharFromDB(chr, senderAccId); } catch (SQLException ex) { ex.printStackTrace(); return false; } } public String getAccountName() { return accountName; } public void setAccountName(String a) { this.accountName = a; } public void setChannel(int channel) { this.channel = channel; } public int getWorld() { return world; } public void setWorld(int world) { this.world = world; } public void pongReceived() { lastPong = System.currentTimeMillis(); } public void checkIfIdle(final IdleStateEvent event) { final long pingedAt = System.currentTimeMillis(); sendPacket(PacketCreator.getPing()); TimerManager.getInstance().schedule(() -> { try { if (lastPong < pingedAt) { if (ioChannel.isActive()) { log.info("Disconnected {} due to idling. Reason: {}", remoteAddress, event.state()); disconnectSession(); } } } catch (NullPointerException e) { e.printStackTrace(); } }, SECONDS.toMillis(15)); } public Set getMacs() { return Collections.unmodifiableSet(macs); } public int getGMLevel() { return gmlevel; } public void setGMLevel(int level) { gmlevel = level; } public void setScriptEngine(String name, ScriptEngine e) { engines.put(name, e); } public ScriptEngine getScriptEngine(String name) { return engines.get(name); } public void removeScriptEngine(String name) { engines.remove(name); } public NPCConversationManager getCM() { return NPCScriptManager.getInstance().getCM(this); } public QuestActionManager getQM() { return QuestScriptManager.getInstance().getQM(this); } public void lockClient() { lock.lock(); } public void unlockClient() { lock.unlock(); } public boolean tryacquireClient() { if (actionsSemaphore.tryAcquire()) { lockClient(); return true; } else { return false; } } public void releaseClient() { unlockClient(); actionsSemaphore.release(); } public short getAvailableCharacterSlots() { return (short) Math.max(0, characterSlots - Server.getInstance().getAccountCharacterCount(accId)); } public short getAvailableCharacterWorldSlots() { return (short) Math.max(0, characterSlots - Server.getInstance().getAccountWorldCharacterCount(accId, world)); } public short getAvailableCharacterWorldSlots(int world) { return (short) Math.max(0, characterSlots - Server.getInstance().getAccountWorldCharacterCount(accId, world)); } public short getCharacterSlots() { return characterSlots; } public void setCharacterSlots(byte slots) { characterSlots = slots; } public boolean canGainCharacterSlot() { return characterSlots < MAX_CHR_SLOTS; } public boolean gainCharacterSlot() { if (canGainCharacterSlot()) { characterSlots++; return true; } return false; } public final byte getGReason() { try (Connection con = DatabaseConnection.getConnection(); PreparedStatement ps = con.prepareStatement("SELECT `greason` FROM `accounts` WHERE id = ?")) { ps.setInt(1, accId); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { return rs.getByte("greason"); } } } catch (SQLException e) { e.printStackTrace(); } return 0; } public byte getGender() { return gender; } public void setGender(byte gender) { if (gender != Gender.MALE && gender != Gender.FEMALE) { throw new IllegalArgumentException("Invalid gender: " + gender); } this.gender = gender; } private void announceDisableServerMessage() { if (!this.getWorldServer().registerDisabledServerMessage(player.getId())) { sendPacket(PacketCreator.serverMessage("")); } } public void announceServerMessage() { sendPacket(PacketCreator.serverMessage(this.getChannelServer().getServerMessage())); } public synchronized void announceBossHpBar(Monster mm, final int mobHash, Packet packet) { long timeNow = System.currentTimeMillis(); int targetHash = player.getTargetHpBarHash(); if (mobHash != targetHash) { if (timeNow - player.getTargetHpBarTime() >= SECONDS.toMillis(5)) { // is there a way to INTERRUPT this annoying thread running on the client that drops the boss bar after some time at every attack? announceDisableServerMessage(); sendPacket(packet); player.setTargetHpBarHash(mobHash); player.setTargetHpBarTime(timeNow); } } else { announceDisableServerMessage(); sendPacket(packet); player.setTargetHpBarTime(timeNow); } } public void sendPacket(Packet packet) { announcerLock.lock(); try { ioChannel.writeAndFlush(packet); } finally { announcerLock.unlock(); } } public void announceHint(String msg, int length) { sendPacket(PacketCreator.sendHint(msg, length, 10)); sendPacket(PacketCreator.enableActions()); } public long getSessionId() { return this.sessionId; } public boolean canClickNPC() { return lastNpcClick + 500 < Server.getInstance().getCurrentTime(); } public void setClickedNPC() { lastNpcClick = Server.getInstance().getCurrentTime(); } public void removeClickedNPC() { lastNpcClick = 0; } public void closePlayerScriptInteractions() { this.removeClickedNPC(); NPCScriptManager.getInstance().dispose(this); QuestScriptManager.getInstance().dispose(this); } public boolean attemptCsCoupon() { if (csattempt > 2) { resetCsCoupon(); return false; } csattempt++; return true; } public void resetCsCoupon() { csattempt = 0; } public void enableCSActions() { sendPacket(PacketCreator.enableCSUse(player)); } public boolean canBypassPin() { return LoginBypassCoordinator.getInstance().canLoginBypass(hwid, accId, false); } public boolean canBypassPic() { return LoginBypassCoordinator.getInstance().canLoginBypass(hwid, accId, true); } }