Compare commits

..

37 Commits

Author SHA1 Message Date
github-actions[bot] c5137bf3dc 🆙 Bump version to 4.1.10 [skip ci] 2026-04-29 15:11:05 +00:00
DuckieTM 5150418796 Merge pull request #94 from duckietm/dev
Dev
2026-04-29 17:10:02 +02:00
duckietm 5c71b318fb 🆙 Latest compiled version 2026-04-29 17:09:43 +02:00
duckietm 1cac407c45 🆕 Effect selection in user dropdown 2026-04-29 13:20:53 +02:00
github-actions[bot] d85eecd624 🆙 Bump version to 4.1.9 [skip ci] 2026-04-28 11:52:58 +00:00
DuckieTM c50098a945 Merge pull request #93 from duckietm/dev
🆕 Added Staffchat to the Emu
2026-04-28 13:52:02 +02:00
duckietm 0224f3f416 🆕 Added Staffchat to the Emu
!!! Do not run the Staffchat plugin anymore !!!!

- execute the sql:

INSERT INTO `permission_definitions` (`permission_key`, `max_value`, `comment`)
VALUES ( 'acc_staff_chat', 1, 'Grants access to the in-game Staff Chat group buddy: receives broadcasts from other staff and can broadcast to anyone holding this permission.' )
ON DUPLICATE KEY UPDATE `max_value` = VALUES(`max_value`), `comment`   = VALUES(`comment`);
2026-04-28 13:51:04 +02:00
github-actions[bot] 03d37650a0 🆙 Bump version to 4.1.8 [skip ci] 2026-04-28 09:32:12 +00:00
DuckieTM f4e5449443 Merge pull request #92 from duckietm/dev
🆙 Added Ban to the API
2026-04-28 11:31:15 +02:00
duckietm 1ebc8314a8 🆙 Added Ban to the API 2026-04-28 11:30:54 +02:00
github-actions[bot] 85a60cf591 🆙 Bump version to 4.1.7 [skip ci] 2026-04-24 20:10:07 +00:00
DuckieTM 41d7420251 Merge pull request #91 from duckietm/dev
🆙 Added some btter logging and fix pre-existing leak in GameByteDecoder
2026-04-24 22:09:13 +02:00
DuckieTM 5dd602ebab 🆙 Added some btter logging and fix pre-existing leak in GameByteDecoder 2026-04-24 22:08:27 +02:00
github-actions[bot] 6d203c1267 🆙 Bump version to 4.1.6 [skip ci] 2026-04-24 14:35:01 +00:00
DuckieTM a8bcb27d27 Merge pull request #90 from duckietm/dev
🆙 CryptoV2 - please red the how_things_work on DC !!!
2026-04-24 16:33:57 +02:00
duckietm b18d65bd79 🆙 CryptoV2 - please red the how_things_work on DC !!! 2026-04-24 15:54:37 +02:00
github-actions[bot] 13958cb11e 🆙 Bump version to 4.1.5 [skip ci] 2026-04-24 09:19:58 +00:00
DuckieTM 7414bc2589 Merge pull request #89 from duckietm/dev
Dev
2026-04-24 11:19:14 +02:00
duckietm da2307f3b5 🆙 Updated Tokens to use JWT rotational tokens 2026-04-24 11:18:46 +02:00
duckietm 030b5ec174 🆕 Handshake on connect - ECDH key exchange (P-256 so it works in every browser's crypto.subtle) 2026-04-23 15:53:30 +02:00
github-actions[bot] ec54dc5c85 🆙 Bump version to 4.1.4 [skip ci] 2026-04-23 08:20:27 +00:00
DuckieTM 50acf6217e Merge pull request #88 from duckietm/dev
Dev
2026-04-23 10:19:32 +02:00
duckietm dd06f2b15c 🆙 Token login added 2026-04-23 10:19:06 +02:00
duckietm d5497e49ad 🆙 Update API and added Copy to Template room command 2026-04-22 16:03:40 +02:00
github-actions[bot] 1916c6c785 🆙 Bump version to 4.1.3 [skip ci] 2026-04-22 05:38:10 +00:00
DuckieTM 4c46c0cb00 Merge pull request #87 from duckietm/dev
Dev
2026-04-22 07:37:10 +02:00
DuckieTM fdcc33212f Merge branch 'main' into dev 2026-04-22 07:37:02 +02:00
duckietm bcee750ff8 🆙 Bump to version 4.1.2 2026-04-22 07:36:19 +02:00
duckietm 872dd11bd2 🆕 API installed
Api has been enabled over the websocket address :

/api/auth/login
/api/auth/register
/api/auth/forgot-password
/api/auth/logout
/api/auth/check-email
/api/health
2026-04-22 07:35:06 +02:00
github-actions[bot] da0523a794 🆙 Bump version to 4.1.5 [skip ci] 2026-04-20 19:57:00 +00:00
DuckieTM 1184ccdbae Merge pull request #86 from duckietm/dev
Dev
2026-04-20 21:56:08 +02:00
duckietm 1b08e083bf 🆙 Small update 2026-04-20 15:14:21 +02:00
duckietm 7347906786 🆕 Added UI login to the Emu 2026-04-20 14:27:19 +02:00
github-actions[bot] 3f47321583 🆙 Bump version to 4.1.4 [skip ci] 2026-04-19 09:01:05 +00:00
DuckieTM f6faf36709 Merge pull request #85 from duckietm/dev
Dev
2026-04-19 11:00:09 +02:00
DuckieTM e2c823253f 🆙 update SQL 2026-04-19 09:41:21 +02:00
DuckieTM 671ab04b50 🆙 Update SQL files 2026-04-19 09:39:51 +02:00
35 changed files with 2532 additions and 49 deletions
+6 -35
View File
@@ -1,37 +1,3 @@
-- =============================================================================
-- Consolidated Database Updates - All-in-One
-- =============================================================================
-- This file combines ALL individual update scripts from SQL/Database Updates/
-- into a single idempotent migration. Every statement is safe to re-run:
-- - ALTER TABLE ADD COLUMN IF NOT EXISTS (MariaDB 10.0+)
-- - ALTER TABLE CHANGE/MODIFY COLUMN IF EXISTS
-- - CREATE TABLE IF NOT EXISTS
-- - INSERT IGNORE / ON DUPLICATE KEY UPDATE for settings
-- - TRUNCATE + re-insert for reference data (breeding)
--
-- Run order: This file FIRST, then 001_optimize_gameserver.sql
--
-- Source files (in applied order):
-- 1. UpdateDatabase_Allow_diagonale.sql
-- 2. UpdateDatabase_BOT.sql
-- 3. UpdateDatabase_Banners.sql
-- 4. UpdateDatabase_DanceCMD.sql
-- 5. UpdateDatabase_Happiness.sql
-- 6. UpdateDatabase_Websocket.sql
-- 7. UpdateDatabase_unignorable.sql
-- 8. Default_Camera.sql
-- 9. 07012026_UpdateDatabase_to_4-0-1.sql
-- 10. 09012026_UpdateDatabase_to_4-0-2.sql
-- 11. 12012026_Battle Banzai.sql (same as #10, deduplicated)
-- 12. 12012026_Breeding Fixes.sql
-- 13. 12012026_ChatBubbles.sql
-- 14. 16032026_updateall_command.sql
-- 15. 17032026_allow_underpass.sql
-- 16. 19032026_hotel_timezone.sql
-- 17. 21022026_user_prefixes.sql
-- 18. 06042026_builders_club_catalog_offers.sql
-- =============================================================================
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
SET @OLD_SQL_MODE = @@SQL_MODE;
@@ -512,8 +478,13 @@ ALTER TABLE `users_settings`
ADD COLUMN IF NOT EXISTS `builders_club_bonus_furni` INT(11) NOT NULL DEFAULT 0 AFTER `hc_gifts_claimed`;
INSERT INTO `permission_definitions` (`permission_key`, `max_value`, `comment`)
VALUES ( 'acc_staff_chat', 1, 'Grants access to the in-game Staff Chat group buddy: receives broadcasts from other staff and can broadcast to anyone holding this permission.' )
ON DUPLICATE KEY UPDATE `max_value` = VALUES(`max_value`), `comment` = VALUES(`comment`);
-- =============================================================================
-- Done
-- Done.
-- =============================================================================
SET FOREIGN_KEY_CHECKS = 1;
SET SQL_MODE = @OLD_SQL_MODE;
@@ -80,7 +80,6 @@ END//
DELIMITER ;
CALL `_add_fk_if_missing`('rooms', 'fk_rooms_owner', 'owner_id', 'users', 'id', 'CASCADE');
CALL `_add_fk_if_missing`('items', 'fk_items_user', 'user_id', 'users', 'id', 'CASCADE');
CALL `_add_fk_if_missing`('catalog_items', 'fk_catitems_page', 'page_id', 'catalog_pages', 'id', 'CASCADE');
CALL `_add_fk_if_missing`('guilds', 'fk_guilds_user', 'user_id', 'users', 'id', 'CASCADE');
@@ -1 +0,0 @@
INSERT INTO emulator_settings (`key`, `value`) VALUES ('wired.tick.workers', '6');
@@ -0,0 +1,2 @@
INSERT INTO `emulator_settings` (`key`, `value`, `comment`) VALUES
('wired.tick.workers', '6', '');
+135
View File
@@ -0,0 +1,135 @@
ALTER TABLE emulator_settings
CHANGE COLUMN `comment` `comment` TEXT NULL DEFAULT '' ;
CREATE TABLE IF NOT EXISTS `password_resets` (
`user_id` INT NOT NULL PRIMARY KEY,
`token` VARCHAR(128) NOT NULL,
`expires_at` TIMESTAMP NOT NULL,
`created_ip` VARCHAR(64) NOT NULL DEFAULT '',
UNIQUE KEY `idx_token` (`token`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
INSERT INTO `emulator_settings` (`key`, `value`) VALUES
('login.turnstile.enabled', '0'),
('login.turnstile.sitekey', ''),
('login.turnstile.secretkey', ''),
('login.ratelimit.enabled', '1'),
('login.ratelimit.max_attempts','5'),
('login.ratelimit.window_sec', '60'),
('login.ratelimit.lockout_sec', '120'),
('login.register.enabled', '1'),
('register.max_per_ip', '5'),
('register.default.look', 'hr-100-7.hd-180-1.ch-210-66.lg-270-82.sh-290-80'),
('register.default.motto', 'I love Habbo!'),
('password.reset.url', 'http://localhost/reset-password'),
('smtp.provider', 'own'),
('smtp.host', 'localhost'),
('smtp.port', '587'),
('smtp.username', ''),
('smtp.password', ''),
('smtp.from_address', 'no-reply@example.com'),
('smtp.from_name', 'Habbo Hotel'),
('smtp.use_tls', '1'),
('smtp.use_ssl', '0')
ON DUPLICATE KEY UPDATE `value` = VALUES(`value`);
INSERT INTO emulator_settings (`key`, `value`, `comment`) VALUES
('new_user_credits', '0' , 'This is the default setting for habbo credits when creating an account for the NitroV3 Login'),
('new_user_duckets', '0' , 'This is the default setting for habbo duckets when creating an account for the NitroV3 Login'),
('new_user_diamonds', '0' , 'This is the default setting for habbo diamonds when creating an account for the NitroV3 Login')
ON DUPLICATE KEY UPDATE `value` = VALUES(`value`);
-- Grant to rank 7 only (adjust rank_7 if your rank id differs)
INSERT INTO `permission_definitions` (`permission_key`, `rank_7`, `comment`) VALUES
('cmd_setroom_template', '1', 'Use the setroom_template to copy the room into the template')
ON DUPLICATE KEY UPDATE `rank_7` = VALUES(`rank_7`);
INSERT INTO `emulator_texts` (`key`, `value`) VALUES
('commands.keys.cmd_setroom_template', 'setroom_template;set_room_template'),
('commands.succes.cmd_setroom_template.verify', 'Copy the current room "%roomname%" to room_templates? Type :setroom_template %generic.yes% to confirm.'),
('commands.succes.cmd_setroom_template', 'Room saved as template id %id% with %items% items (%skipped% skipped - item_id not in items_base).'),
('commands.error.cmd_setroom_template', 'Could not save room as template. Check the server log for details.'),
('commands.error.cmd_setroom_template.no_room', 'You must be inside a room to use this command.')
ON DUPLICATE KEY UPDATE `value` = VALUES(`value`);
CREATE TABLE IF NOT EXISTS `room_templates` (
`template_id` int(11) NOT NULL AUTO_INCREMENT,
`title` varchar(128) NOT NULL DEFAULT '',
`description` varchar(256) NOT NULL DEFAULT '',
`thumbnail` varchar(512) NOT NULL DEFAULT '',
`sort_order` int(11) NOT NULL DEFAULT 0,
`enabled` enum('0','1') NOT NULL DEFAULT '1',
`name` varchar(50) NOT NULL DEFAULT '',
`room_description` varchar(250) NOT NULL DEFAULT '',
`model` varchar(100) NOT NULL,
`password` varchar(50) NOT NULL DEFAULT '',
`state` enum('open','locked','password','invisible') NOT NULL DEFAULT 'open',
`users_max` int(11) NOT NULL DEFAULT 25,
`category` int(11) NOT NULL DEFAULT 0,
`paper_floor` varchar(50) NOT NULL DEFAULT '0.0',
`paper_wall` varchar(50) NOT NULL DEFAULT '0.0',
`paper_landscape` varchar(50) NOT NULL DEFAULT '0.0',
`thickness_wall` int(11) NOT NULL DEFAULT 0,
`thickness_floor` int(11) NOT NULL DEFAULT 0,
`moodlight_data` varchar(2048) NOT NULL DEFAULT '',
`override_model` enum('0','1') NOT NULL DEFAULT '0',
`trade_mode` int(2) NOT NULL DEFAULT 2,
`heightmap` mediumtext NOT NULL DEFAULT '',
`door_x` int(11) NOT NULL DEFAULT 0,
`door_y` int(11) NOT NULL DEFAULT 0,
`door_dir` int(4) NOT NULL DEFAULT 2,
PRIMARY KEY (`template_id`),
KEY `enabled_sort` (`enabled`, `sort_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
-- --------------------------------------------------------
-- Items belonging to a template. Clone target is `items`.
-- `template_id` replaces `room_id`; `user_id` is absent because items
-- are re-owned by the new user at clone time.
-- --------------------------------------------------------
CREATE TABLE IF NOT EXISTS `room_templates_items` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`template_id` int(11) NOT NULL,
`item_id` int(11) unsigned NOT NULL,
`wall_pos` varchar(20) NOT NULL DEFAULT '',
`x` int(11) NOT NULL DEFAULT 0,
`y` int(11) NOT NULL DEFAULT 0,
`z` double(10,6) NOT NULL DEFAULT 0.000000,
`rot` int(11) NOT NULL DEFAULT 0,
`extra_data` varchar(2096) NOT NULL DEFAULT '',
`wired_data` varchar(4096) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `template_id` (`template_id`),
CONSTRAINT `fk_rt_items_template`
FOREIGN KEY (`template_id`) REFERENCES `room_templates` (`template_id`) ON DELETE CASCADE,
CONSTRAINT `fk_rt_items_item_base`
FOREIGN KEY (`item_id`) REFERENCES `items_base` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
CREATE TABLE IF NOT EXISTS `users_remember_families` (
`family_id` char(36) NOT NULL,
`user_id` int(11) NOT NULL,
`current_version` int(11) NOT NULL DEFAULT 1,
`created_at` int(11) NOT NULL,
`expires_at` int(11) NOT NULL,
`revoked` tinyint(1) NOT NULL DEFAULT 0,
`last_ip` varchar(45) NOT NULL DEFAULT '',
PRIMARY KEY (`family_id`),
KEY `user_id` (`user_id`),
KEY `expires_at` (`expires_at`),
CONSTRAINT `fk_remember_family_user`
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
DROP TABLE IF EXISTS `users_remember_tokens`;
INSERT INTO `emulator_settings` (`key`, `value`) VALUES
('login.remember.duration.days', '30'),
('login.remember.rotate.interval.minutes', '15'),
('login.remember.jwt.secret', '')
ON DUPLICATE KEY UPDATE `value` = `value`;
+8
View File
@@ -0,0 +1,8 @@
INSERT INTO `emulator_settings` (`key`, `value`) VALUES
('crypto.ws.enabled', '0'),
('crypto.ws.signing.enabled', '0'),
('crypto.ws.signing.public_key', ''),
('crypto.ws.signing.private_key', '')
ON DUPLICATE KEY UPDATE `value` = `value`;
+18 -2
View File
@@ -6,7 +6,7 @@
<groupId>com.eu.habbo</groupId>
<artifactId>Habbo</artifactId>
<version>4.1.3</version>
<version>4.1.10</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -162,5 +162,21 @@
<artifactId>joda-time</artifactId>
<version>2.13.0</version>
</dependency>
<!-- jBCrypt — used by the built-in /api/auth/* HTTP login handler
to verify Laravel-style $2y$ BCrypt hashes from users.password -->
<dependency>
<groupId>org.mindrot</groupId>
<artifactId>jbcrypt</artifactId>
<version>0.4</version>
</dependency>
<!-- Jakarta Mail — used by the built-in forgot-password endpoint
when smtp.* keys are configured in emulator_settings -->
<dependency>
<groupId>org.eclipse.angus</groupId>
<artifactId>jakarta.mail</artifactId>
<version>2.0.3</version>
</dependency>
</dependencies>
</project>
</project>
@@ -190,7 +190,7 @@ public class CommandHandler {
addCommand(new ControlCommand());
addCommand(new CoordsCommand());
addCommand(new CreditsCommand());
addCommand(new DanceCommand());
addCommand(new DanceCommand());
addCommand(new DiagonalCommand());
addCommand(new DisconnectCommand());
addCommand(new EjectAllCommand());
@@ -230,7 +230,7 @@ public class CommandHandler {
addCommand(new MutePetsCommand());
addCommand(new PetInfoCommand());
addCommand(new PickallCommand());
addCommand(new PingCommand());
addCommand(new PingCommand());
addCommand(new PixelCommand());
addCommand(new PluginsCommand());
addCommand(new PointsCommand());
@@ -253,6 +253,7 @@ public class CommandHandler {
addCommand(new SayCommand());
addCommand(new SetMaxCommand());
addCommand(new SetPollCommand());
addCommand(new SetRoomTemplateCommand());
addCommand(new SetSpeedCommand());
addCommand(new ShoutAllCommand());
addCommand(new ShoutCommand());
@@ -0,0 +1,116 @@
package com.eu.habbo.habbohotel.commands;
import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.gameclients.GameClient;
import com.eu.habbo.habbohotel.rooms.Room;
import com.eu.habbo.habbohotel.rooms.RoomChatMessageBubbles;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.*;
public class SetRoomTemplateCommand extends Command {
private static final Logger LOGGER = LoggerFactory.getLogger(SetRoomTemplateCommand.class);
public SetRoomTemplateCommand() {
super("cmd_setroom_template", Emulator.getTexts().getValue("commands.keys.cmd_setroom_template").split(";"));
}
@Override
public boolean handle(GameClient gameClient, String[] params) throws Exception {
Room room = gameClient.getHabbo().getHabboInfo().getCurrentRoom();
if (room == null) {
gameClient.getHabbo().whisper(
Emulator.getTexts().getValue("commands.error.cmd_setroom_template.no_room"),
RoomChatMessageBubbles.ALERT);
return true;
}
String yes = Emulator.getTexts().getValue("generic.yes");
if (params.length < 2 || !params[1].equalsIgnoreCase(yes)) {
gameClient.getHabbo().alert(
Emulator.getTexts().getValue("commands.succes.cmd_setroom_template.verify")
.replace("%generic.yes%", yes)
.replace("%roomname%", room.getName()));
return true;
}
int newTemplateId = 0;
int itemsCopied = 0;
int itemsSkipped = 0;
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection()) {
try (PreparedStatement insTemplate = connection.prepareStatement(
"INSERT INTO room_templates (title, description, thumbnail, sort_order, enabled, " +
"name, room_description, model, password, state, users_max, category, " +
"paper_floor, paper_wall, paper_landscape, thickness_wall, thickness_floor, " +
"moodlight_data, override_model, trade_mode) " +
"(SELECT name, description, '', 0, '1', " +
"name, description, model, password, state, users_max, category, " +
"paper_floor, paper_wall, paper_landscape, thickness_wall, thickness_floor, " +
"moodlight_data, override_model, trade_mode " +
"FROM rooms WHERE id = ?)",
Statement.RETURN_GENERATED_KEYS)) {
insTemplate.setInt(1, room.getId());
insTemplate.executeUpdate();
try (ResultSet keys = insTemplate.getGeneratedKeys()) {
if (keys.next()) newTemplateId = keys.getInt(1);
}
}
if (newTemplateId <= 0) {
gameClient.getHabbo().whisper(
Emulator.getTexts().getValue("commands.error.cmd_setroom_template"),
RoomChatMessageBubbles.ALERT);
return true;
}
if (room.hasCustomLayout()) {
try (PreparedStatement updLayout = connection.prepareStatement(
"UPDATE room_templates t " +
"JOIN room_models_custom c ON c.id = ? " +
"SET t.heightmap = c.heightmap, t.door_x = c.door_x, " +
" t.door_y = c.door_y, t.door_dir = c.door_dir " +
"WHERE t.template_id = ?")) {
updLayout.setInt(1, room.getId());
updLayout.setInt(2, newTemplateId);
updLayout.executeUpdate();
}
}
try (PreparedStatement insItems = connection.prepareStatement(
"INSERT INTO room_templates_items (template_id, item_id, wall_pos, x, y, z, rot, extra_data, wired_data) " +
"SELECT ?, i.item_id, i.wall_pos, i.x, i.y, i.z, i.rot, i.extra_data, i.wired_data " +
"FROM items i JOIN items_base ib ON ib.id = i.item_id " +
"WHERE i.room_id = ?")) {
insItems.setInt(1, newTemplateId);
insItems.setInt(2, room.getId());
itemsCopied = insItems.executeUpdate();
}
try (PreparedStatement countTotal = connection.prepareStatement(
"SELECT COUNT(*) FROM items WHERE room_id = ?")) {
countTotal.setInt(1, room.getId());
try (ResultSet rs = countTotal.executeQuery()) {
if (rs.next()) itemsSkipped = Math.max(0, rs.getInt(1) - itemsCopied);
}
}
} catch (SQLException e) {
LOGGER.error("cmd_setroom_template failed for roomId=" + room.getId(), e);
gameClient.getHabbo().whisper(
Emulator.getTexts().getValue("commands.error.cmd_setroom_template"),
RoomChatMessageBubbles.ALERT);
return true;
}
gameClient.getHabbo().whisper(
Emulator.getTexts().getValue("commands.succes.cmd_setroom_template")
.replace("%id%", Integer.toString(newTemplateId))
.replace("%items%", Integer.toString(itemsCopied))
.replace("%skipped%", Integer.toString(itemsSkipped)),
RoomChatMessageBubbles.ALERT);
return true;
}
}
@@ -219,6 +219,10 @@ public class Messenger {
} catch (SQLException e) {
LOGGER.error("Caught SQL exception", e);
}
if (habbo.hasPermission(StaffChatBuddy.PERMISSION_KEY)) {
this.friends.putIfAbsent(StaffChatBuddy.BUDDY_ID, new StaffChatBuddy(habbo.getHabboInfo().getId()));
}
}
public MessengerBuddy loadFriend(Habbo habbo, int userId) {
@@ -0,0 +1,57 @@
package com.eu.habbo.habbohotel.messenger;
import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.commands.CommandHandler;
import com.eu.habbo.habbohotel.users.Habbo;
import com.eu.habbo.habbohotel.users.HabboGender;
import com.eu.habbo.messages.ServerMessage;
import com.eu.habbo.messages.outgoing.friends.FriendChatMessageComposer;
public class StaffChatBuddy extends MessengerBuddy {
public static final int BUDDY_ID = -1;
public static final String PERMISSION_KEY = "acc_staff_chat";
public static final String DISPLAY_NAME = "Staff Chat";
public static final String DEFAULT_LOOK = "ADM";
public StaffChatBuddy(int userOne) {
super(BUDDY_ID, DISPLAY_NAME, DEFAULT_LOOK, (short) 0, userOne);
this.setOnline(true);
}
@Override
public void onMessageReceived(Habbo from, String message) {
if (from == null || message == null || message.isEmpty()) return;
// Re-check permission so a staff member who was demoted mid-session
// can no longer broadcast to the staff channel.
if (!from.hasPermission(PERMISSION_KEY)) return;
if (message.charAt(0) == ':') {
CommandHandler.handleCommand(from.getClient(), message);
return;
}
Message chatMessage = new Message(from.getHabboInfo().getId(), BUDDY_ID, message);
Emulator.getGameServer().getGameClientManager().sendBroadcastResponse(
new FriendChatMessageComposer(chatMessage, BUDDY_ID, from.getHabboInfo().getId()).compose(),
PERMISSION_KEY,
from.getClient());
}
@Override
public void serialize(ServerMessage message) {
message.appendInt(this.getId());
message.appendString(this.getUsername());
message.appendInt(this.getGender().equals(HabboGender.M) ? 0 : 1);
message.appendBoolean(true); // online
message.appendBoolean(false); // not in room
message.appendString(this.getLook());
message.appendInt(0); // category
message.appendString(""); // motto
message.appendString(""); // last seen
message.appendString(""); // realname
message.appendBoolean(true); // offline messaging supported
message.appendBoolean(false);
message.appendBoolean(false);
message.appendShort(0); // relation
}
}
@@ -1,14 +1,30 @@
package com.eu.habbo.messages.incoming.users;
import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.rooms.Room;
import com.eu.habbo.habbohotel.users.Habbo;
import com.eu.habbo.messages.incoming.MessageHandler;
public class ActivateEffectEvent extends MessageHandler {
@Override
public void handle() throws Exception {
int effectId = this.packet.readInt();
Habbo habbo = this.client.getHabbo();
if (habbo == null) return;
if (this.client.getHabbo().getInventory().getEffectsComponent().ownsEffect(effectId)) {
this.client.getHabbo().getInventory().getEffectsComponent().activateEffect(effectId);
if (habbo.getInventory().getEffectsComponent().ownsEffect(effectId)) {
habbo.getInventory().getEffectsComponent().activateEffect(effectId);
return;
}
int rankId = habbo.getHabboInfo().getRank().getId();
if (Emulator.getGameEnvironment().getPermissionsManager().isEffectBlocked(effectId, rankId)) {
return;
}
Room room = habbo.getHabboInfo().getCurrentRoom();
if (room == null || habbo.getHabboInfo().getRiding() != null) return;
room.giveEffect(habbo, effectId, -1);
}
}
@@ -96,6 +96,16 @@ public class GameServer extends Server {
LOGGER.error("Failed to start WebSocket server on {}:{}", wsHost, wsPort);
} else {
LOGGER.info("WebSocket server started on {}:{} (SSL: {})", wsHost, wsPort, wsInitializer.isSslEnabled());
if (com.eu.habbo.Emulator.getConfig().getBoolean("crypto.ws.signing.enabled", false)) {
try {
com.eu.habbo.networking.gameserver.crypto.CryptoSigningKeyManager.get();
LOGGER.info("[ws-crypto] signing public key ready: {}",
com.eu.habbo.networking.gameserver.crypto.CryptoSigningKeyManager.publicKeyBase64());
} catch (Exception e) {
LOGGER.error("[ws-crypto] failed to warm signing keypair", e);
}
}
}
}
@@ -10,4 +10,6 @@ public class GameServerAttributes {
public static final AttributeKey<HabboRC4> CRYPTO_CLIENT = AttributeKey.valueOf("CryptoClient");
public static final AttributeKey<HabboRC4> CRYPTO_SERVER = AttributeKey.valueOf("CryptoServer");
public static final AttributeKey<String> WS_IP = AttributeKey.valueOf("WebSocketIP");
public static final AttributeKey<byte[]> WS_AES_KEY = AttributeKey.valueOf("WsAesKey");
}
@@ -1,7 +1,10 @@
package com.eu.habbo.networking.gameserver;
import com.eu.habbo.Emulator;
import com.eu.habbo.messages.PacketManager;
import com.eu.habbo.networking.gameserver.auth.AuthHttpHandler;
import com.eu.habbo.networking.gameserver.codec.WebSocketCodec;
import com.eu.habbo.networking.gameserver.crypto.WsHandshakeHandler;
import com.eu.habbo.networking.gameserver.decoders.*;
import com.eu.habbo.networking.gameserver.encoders.GameServerMessageEncoder;
import com.eu.habbo.networking.gameserver.encoders.GameServerMessageLogger;
@@ -49,10 +52,14 @@ public class WebSocketChannelInitializer extends ChannelInitializer<SocketChanne
ch.pipeline().addLast("httpCodec", new HttpServerCodec());
ch.pipeline().addLast("httpAggregator", new HttpObjectAggregator(MAX_FRAME_SIZE));
ch.pipeline().addLast("wsHttpHandler", new WebSocketHttpHandler());
ch.pipeline().addLast("authHttpHandler", new AuthHttpHandler());
ch.pipeline().addLast("wsProtocolHandler", new WebSocketServerProtocolHandler(this.wsConfig));
ch.pipeline().addLast("wsCodec", new WebSocketCodec());
// Standard game decoders
if (Emulator.getConfig().getBoolean("crypto.ws.enabled", false)) {
ch.pipeline().addLast(WsHandshakeHandler.HANDLER_NAME, new WsHandshakeHandler());
}
ch.pipeline().addLast(new GamePolicyDecoder());
ch.pipeline().addLast(new GameByteFrameDecoder());
ch.pipeline().addLast(new GameByteDecoder());
@@ -64,8 +71,6 @@ public class WebSocketChannelInitializer extends ChannelInitializer<SocketChanne
ch.pipeline().addLast("idleEventHandler", new IdleTimeoutHandler(30, 60));
ch.pipeline().addLast(new GameMessageRateLimit());
ch.pipeline().addLast(new GameMessageHandler());
// Encoders
ch.pipeline().addLast("messageEncoder", new GameServerMessageEncoder());
if (PacketManager.DEBUG_SHOW_PACKETS) {
@@ -0,0 +1,102 @@
package com.eu.habbo.networking.gameserver.auth;
import com.eu.habbo.Emulator;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
public final class AuthRateLimiter {
private static final Map<String, AtomicReference<State>> STATE = new ConcurrentHashMap<>();
private static final Map<String, AtomicReference<ProbeState>> PROBE_STATE = new ConcurrentHashMap<>();
private AuthRateLimiter() {}
public static boolean isLocked(String ip) {
if (!isEnabled() || ip == null || ip.isEmpty()) return false;
AtomicReference<State> ref = STATE.get(ip);
if (ref == null) return false;
State current = ref.get();
return current != null && current.lockedUntilMillis > System.currentTimeMillis();
}
public static long secondsUntilUnlock(String ip) {
AtomicReference<State> ref = STATE.get(ip);
if (ref == null) return 0;
State current = ref.get();
if (current == null) return 0;
long remainingMs = current.lockedUntilMillis - System.currentTimeMillis();
return remainingMs > 0 ? (remainingMs / 1000L) + 1L : 0L;
}
public static void recordFailure(String ip) {
if (!isEnabled() || ip == null || ip.isEmpty()) return;
long now = System.currentTimeMillis();
long windowMs = configInt("login.ratelimit.window_sec", 60) * 1000L;
int maxAttempts = configInt("login.ratelimit.max_attempts", 5);
long lockoutMs = configInt("login.ratelimit.lockout_sec", 120) * 1000L;
STATE.computeIfAbsent(ip, k -> new AtomicReference<>(new State(0, 0L, 0L)))
.updateAndGet(prev -> {
if (prev == null || (now - prev.windowStartMillis) > windowMs) {
return new State(1, now, 0L);
}
int attempts = prev.attempts + 1;
long lockedUntil = attempts >= maxAttempts ? now + lockoutMs : 0L;
return new State(attempts, prev.windowStartMillis, lockedUntil);
});
}
public static void recordSuccess(String ip) {
if (ip == null || ip.isEmpty()) return;
STATE.remove(ip);
}
public static boolean tryProbe(String ip) {
if (!isEnabled() || ip == null || ip.isEmpty()) return true;
if (isLocked(ip)) return false;
long now = System.currentTimeMillis();
long windowMs = configInt("login.probe.window_sec", 60) * 1000L;
int maxAttempts = configInt("login.probe.max_attempts", 20);
ProbeState next = PROBE_STATE.computeIfAbsent(ip, k -> new AtomicReference<>(new ProbeState(0, now)))
.updateAndGet(prev -> {
if (prev == null || (now - prev.windowStartMillis) > windowMs) {
return new ProbeState(1, now);
}
return new ProbeState(prev.count + 1, prev.windowStartMillis);
});
return next.count <= maxAttempts;
}
public static long secondsUntilProbeReset(String ip) {
AtomicReference<ProbeState> ref = PROBE_STATE.get(ip);
if (ref == null) return 0;
ProbeState current = ref.get();
if (current == null) return 0;
long windowMs = configInt("login.probe.window_sec", 60) * 1000L;
long remainingMs = (current.windowStartMillis + windowMs) - System.currentTimeMillis();
return remainingMs > 0 ? (remainingMs / 1000L) + 1L : 0L;
}
private static boolean isEnabled() {
return Emulator.getConfig() != null
&& Emulator.getConfig().getBoolean("login.ratelimit.enabled", true);
}
private static int configInt(String key, int fallback) {
return Emulator.getConfig() != null ? Emulator.getConfig().getInt(key, fallback) : fallback;
}
private record State(int attempts, long windowStartMillis, long lockedUntilMillis) {}
private record ProbeState(int count, long windowStartMillis) {}
}
@@ -0,0 +1,91 @@
package com.eu.habbo.networking.gameserver.auth;
import com.eu.habbo.Emulator;
import java.util.Iterator;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public final class AvailabilityCache {
private static final Map<String, Entry> EMAIL_CACHE = new ConcurrentHashMap<>();
private static final Map<String, Entry> USERNAME_CACHE = new ConcurrentHashMap<>();
private AvailabilityCache() {}
public static Boolean lookupEmail(String email) {
return read(EMAIL_CACHE, key(email));
}
public static Boolean lookupUsername(String username) {
return read(USERNAME_CACHE, key(username));
}
public static void storeEmail(String email, boolean available) {
write(EMAIL_CACHE, key(email), available);
}
public static void storeUsername(String username, boolean available) {
write(USERNAME_CACHE, key(username), available);
}
public static void invalidateEmail(String email) {
EMAIL_CACHE.remove(key(email));
}
public static void invalidateUsername(String username) {
USERNAME_CACHE.remove(key(username));
}
private static String key(String value) {
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
}
private static Boolean read(Map<String, Entry> cache, String key) {
if (!isEnabled() || key.isEmpty()) return null;
Entry entry = cache.get(key);
if (entry == null) return null;
if (entry.expiresAt < System.currentTimeMillis()) {
cache.remove(key, entry);
return null;
}
return entry.available;
}
private static void write(Map<String, Entry> cache, String key, boolean available) {
if (!isEnabled() || key.isEmpty()) return;
int maxEntries = configInt("login.probe.cache_max_entries", 10_000);
if (cache.size() >= maxEntries) evict(cache, maxEntries);
long ttlMs = configInt("login.probe.cache_ttl_sec", 60) * 1000L;
cache.put(key, new Entry(available, System.currentTimeMillis() + ttlMs));
}
private static void evict(Map<String, Entry> cache, int maxEntries) {
long now = System.currentTimeMillis();
cache.values().removeIf(e -> e.expiresAt < now);
if (cache.size() < maxEntries) return;
int overflow = cache.size() - maxEntries + 1;
Iterator<String> it = cache.keySet().iterator();
while (overflow > 0 && it.hasNext()) {
it.next();
it.remove();
overflow--;
}
}
private static boolean isEnabled() {
return Emulator.getConfig() == null
|| Emulator.getConfig().getBoolean("login.probe.cache_enabled", true);
}
private static int configInt(String key, int fallback) {
return Emulator.getConfig() != null ? Emulator.getConfig().getInt(key, fallback) : fallback;
}
private record Entry(boolean available, long expiresAt) {}
}
@@ -0,0 +1,277 @@
package com.eu.habbo.networking.gameserver.auth;
import com.eu.habbo.Emulator;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Base64;
import java.util.UUID;
public final class RememberJwtService {
private static final Logger LOGGER = LoggerFactory.getLogger(RememberJwtService.class);
private static final SecureRandom RNG = new SecureRandom();
private static final Base64.Encoder URL_ENC = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder URL_DEC = Base64.getUrlDecoder();
private static volatile String cachedSecret = null;
private RememberJwtService() {}
public static final class RotationResult {
public final String jwt;
public final int userId;
public final String username;
public final long expiresAt;
RotationResult(String jwt, int userId, String username, long expiresAt) {
this.jwt = jwt;
this.userId = userId;
this.username = username;
this.expiresAt = expiresAt;
}
}
private static int familyTtlDays() {
return Math.max(1, Emulator.getConfig().getInt("login.remember.duration.days", 30));
}
private static long familyTtlSeconds() {
return familyTtlDays() * 86400L;
}
private static String secret() {
String s = cachedSecret;
if (s != null && !s.isEmpty()) return s;
synchronized (RememberJwtService.class) {
if (cachedSecret != null && !cachedSecret.isEmpty()) return cachedSecret;
String configured = Emulator.getConfig().getValue("login.remember.jwt.secret", "");
if (configured != null && !configured.isEmpty()) {
cachedSecret = configured;
return configured;
}
byte[] buf = new byte[48];
RNG.nextBytes(buf);
String generated = Base64.getEncoder().withoutPadding().encodeToString(buf);
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement stmt = conn.prepareStatement(
"INSERT INTO emulator_settings (`key`, `value`) VALUES ('login.remember.jwt.secret', ?) "
+ "ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)")) {
stmt.setString(1, generated);
stmt.executeUpdate();
} catch (SQLException e) {
LOGGER.error("Could not persist generated login.remember.jwt.secret; using in-memory only", e);
}
Emulator.getConfig().update("login.remember.jwt.secret", generated);
cachedSecret = generated;
LOGGER.info("[auth/remember] generated new JWT signing secret (persisted to emulator_settings)");
return generated;
}
}
public static RotationResult issueForNewFamily(Connection conn, int userId, String username, String ip) throws SQLException {
String familyId = UUID.randomUUID().toString();
long now = Emulator.getIntUnixTimestamp();
long expiresAt = now + familyTtlSeconds();
try (PreparedStatement ins = conn.prepareStatement(
"INSERT INTO users_remember_families (family_id, user_id, current_version, created_at, expires_at, revoked, last_ip) "
+ "VALUES (?, ?, 1, ?, ?, 0, ?)")) {
ins.setString(1, familyId);
ins.setInt(2, userId);
ins.setLong(3, now);
ins.setLong(4, expiresAt);
ins.setString(5, ip == null ? "" : ip);
ins.executeUpdate();
}
String jwt = buildJwt(userId, familyId, 1, now, expiresAt);
return new RotationResult(jwt, userId, username, expiresAt);
}
public static RotationResult rotate(Connection conn, String jwt, String ip) {
ParsedJwt parsed;
try {
parsed = verifyAndParse(jwt);
} catch (Exception e) {
LOGGER.debug("[auth/remember] invalid JWT: {}", e.getMessage());
return null;
}
long now = Emulator.getIntUnixTimestamp();
if (parsed.exp <= now) return null;
int familyVersion = 0;
boolean revoked = false;
long familyExpiresAt = 0;
try (PreparedStatement sel = conn.prepareStatement(
"SELECT current_version, revoked, expires_at FROM users_remember_families WHERE family_id = ? AND user_id = ? LIMIT 1")) {
sel.setString(1, parsed.familyId);
sel.setInt(2, parsed.userId);
try (ResultSet rs = sel.executeQuery()) {
if (!rs.next()) return null;
familyVersion = rs.getInt("current_version");
revoked = rs.getInt("revoked") != 0;
familyExpiresAt = rs.getLong("expires_at");
}
} catch (SQLException e) {
LOGGER.error("[auth/remember] family lookup failed", e);
return null;
}
if (revoked || familyExpiresAt <= now) return null;
if (parsed.version < familyVersion) {
LOGGER.warn("[auth/remember] replay detected: familyId={} presented v={} but current is v={}, revoking family",
parsed.familyId, parsed.version, familyVersion);
revokeFamilyById(conn, parsed.familyId);
return null;
}
if (parsed.version > familyVersion) {
LOGGER.warn("[auth/remember] future version: familyId={} presented v={} but current is v={}",
parsed.familyId, parsed.version, familyVersion);
return null;
}
int newVersion = familyVersion + 1;
long newExpiresAt = now + familyTtlSeconds();
try (PreparedStatement upd = conn.prepareStatement(
"UPDATE users_remember_families SET current_version = ?, expires_at = ?, last_ip = ? "
+ "WHERE family_id = ? AND current_version = ? AND revoked = 0")) {
upd.setInt(1, newVersion);
upd.setLong(2, newExpiresAt);
upd.setString(3, ip == null ? "" : ip);
upd.setString(4, parsed.familyId);
upd.setInt(5, familyVersion);
int rows = upd.executeUpdate();
if (rows == 0) return null;
} catch (SQLException e) {
LOGGER.error("[auth/remember] rotation update failed", e);
return null;
}
String username = null;
try (PreparedStatement usr = conn.prepareStatement("SELECT username FROM users WHERE id = ? LIMIT 1")) {
usr.setInt(1, parsed.userId);
try (ResultSet rs = usr.executeQuery()) {
if (rs.next()) username = rs.getString("username");
}
} catch (SQLException e) {
LOGGER.error("[auth/remember] username lookup failed", e);
}
if (username == null) return null;
String newJwt = buildJwt(parsed.userId, parsed.familyId, newVersion, now, newExpiresAt);
return new RotationResult(newJwt, parsed.userId, username, newExpiresAt);
}
public static void revokeFromToken(Connection conn, String jwt) {
try {
ParsedJwt p = verifyAndParse(jwt);
revokeFamilyById(conn, p.familyId);
} catch (Exception ignored) { }
}
private static void revokeFamilyById(Connection conn, String familyId) {
try (PreparedStatement upd = conn.prepareStatement(
"UPDATE users_remember_families SET revoked = 1 WHERE family_id = ?")) {
upd.setString(1, familyId);
upd.executeUpdate();
} catch (SQLException e) {
LOGGER.error("[auth/remember] revoke failed for familyId=" + familyId, e);
}
}
private static String buildJwt(int userId, String familyId, int version, long iat, long exp) {
JsonObject header = new JsonObject();
header.addProperty("alg", "HS256");
header.addProperty("typ", "JWT");
JsonObject payload = new JsonObject();
payload.addProperty("sub", userId);
payload.addProperty("fid", familyId);
payload.addProperty("v", version);
payload.addProperty("iat", iat);
payload.addProperty("exp", exp);
payload.addProperty("typ", "refresh");
String h = URL_ENC.encodeToString(header.toString().getBytes(StandardCharsets.UTF_8));
String p = URL_ENC.encodeToString(payload.toString().getBytes(StandardCharsets.UTF_8));
String signingInput = h + "." + p;
String sig = URL_ENC.encodeToString(hmacSha256(secret().getBytes(StandardCharsets.UTF_8),
signingInput.getBytes(StandardCharsets.UTF_8)));
return signingInput + "." + sig;
}
private static final class ParsedJwt {
final int userId;
final String familyId;
final int version;
final long exp;
ParsedJwt(int userId, String familyId, int version, long exp) {
this.userId = userId;
this.familyId = familyId;
this.version = version;
this.exp = exp;
}
}
private static ParsedJwt verifyAndParse(String jwt) throws Exception {
if (jwt == null || jwt.isEmpty()) throw new IllegalArgumentException("empty");
String[] parts = jwt.split("\\.");
if (parts.length != 3) throw new IllegalArgumentException("not 3 segments");
String signingInput = parts[0] + "." + parts[1];
byte[] expected = hmacSha256(secret().getBytes(StandardCharsets.UTF_8), signingInput.getBytes(StandardCharsets.UTF_8));
byte[] provided = URL_DEC.decode(parts[2]);
if (!constantTimeEquals(expected, provided)) throw new SecurityException("bad signature");
byte[] payloadBytes = URL_DEC.decode(parts[1]);
JsonObject payload = JsonParser.parseString(new String(payloadBytes, StandardCharsets.UTF_8)).getAsJsonObject();
if (!payload.has("typ") || !"refresh".equals(payload.get("typ").getAsString())) throw new IllegalArgumentException("wrong typ");
int userId = payload.get("sub").getAsInt();
String fid = payload.get("fid").getAsString();
int version = payload.get("v").getAsInt();
long exp = payload.get("exp").getAsLong();
return new ParsedJwt(userId, fid, version, exp);
}
private static byte[] hmacSha256(byte[] key, byte[] data) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
return mac.doFinal(data);
} catch (Exception e) {
throw new IllegalStateException("HmacSHA256 unavailable", e);
}
}
private static boolean constantTimeEquals(byte[] a, byte[] b) {
if (a == null || b == null || a.length != b.length) return false;
int r = 0;
for (int i = 0; i < a.length; i++) r |= a[i] ^ b[i];
return r == 0;
}
}
@@ -0,0 +1,101 @@
package com.eu.habbo.networking.gameserver.auth;
import com.eu.habbo.Emulator;
import jakarta.mail.Authenticator;
import jakarta.mail.Message;
import jakarta.mail.PasswordAuthentication;
import jakarta.mail.Session;
import jakarta.mail.Transport;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Properties;
public final class SmtpMailService {
private static final Logger LOGGER = LoggerFactory.getLogger(SmtpMailService.class);
private SmtpMailService() {}
public static boolean send(String toAddress, String subject, String body) {
try {
String provider = Emulator.getConfig().getValue("smtp.provider", "own").toLowerCase();
String username = Emulator.getConfig().getValue("smtp.username", "");
String password = Emulator.getConfig().getValue("smtp.password", "");
String fromAddr = Emulator.getConfig().getValue("smtp.from_address", username);
String fromName = Emulator.getConfig().getValue("smtp.from_name", "Habbo Hotel");
if (toAddress == null || toAddress.isEmpty() || fromAddr == null || fromAddr.isEmpty()) {
LOGGER.warn("SMTP send aborted — missing to/from address (to={}, from={})", toAddress, fromAddr);
return false;
}
String host;
int port;
boolean useSsl;
boolean useTls;
switch (provider) {
case "gmail" -> {
host = "smtp.gmail.com";
port = 465;
useSsl = true;
useTls = false;
}
case "sendgrid" -> {
host = "smtp.sendgrid.net";
port = 587;
useSsl = false;
useTls = true;
}
case "mailgun" -> {
host = "smtp.mailgun.org";
port = 587;
useSsl = false;
useTls = true;
}
default -> {
host = Emulator.getConfig().getValue("smtp.host", "localhost");
port = Emulator.getConfig().getInt("smtp.port", 587);
useSsl = Emulator.getConfig().getBoolean("smtp.use_ssl", false);
useTls = Emulator.getConfig().getBoolean("smtp.use_tls", true);
}
}
Properties props = new Properties();
props.put("mail.smtp.host", host);
props.put("mail.smtp.port", String.valueOf(port));
props.put("mail.smtp.auth", String.valueOf(!username.isEmpty()));
if (useTls) props.put("mail.smtp.starttls.enable", "true");
if (useSsl) {
props.put("mail.smtp.ssl.enable", "true");
props.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");
props.put("mail.smtp.socketFactory.port", String.valueOf(port));
}
props.put("mail.smtp.connectiontimeout", "10000");
props.put("mail.smtp.timeout", "10000");
Session session = username.isEmpty()
? Session.getInstance(props)
: Session.getInstance(props, new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(username, password);
}
});
MimeMessage message = new MimeMessage(session);
message.setFrom(new InternetAddress(fromAddr, fromName, "UTF-8"));
message.addRecipient(Message.RecipientType.TO, new InternetAddress(toAddress));
message.setSubject(subject, "UTF-8");
message.setText(body, "UTF-8");
Transport.send(message);
return true;
} catch (Exception e) {
LOGGER.error("Failed to send SMTP mail to " + toAddress, e);
return false;
}
}
}
@@ -0,0 +1,76 @@
package com.eu.habbo.networking.gameserver.auth;
import com.eu.habbo.Emulator;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
public final class TurnstileVerifier {
private static final Logger LOGGER = LoggerFactory.getLogger(TurnstileVerifier.class);
private static final String VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
private static final HttpClient CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
private TurnstileVerifier() {}
public static boolean isEnabled() {
return Emulator.getConfig() != null
&& Emulator.getConfig().getBoolean("login.turnstile.enabled", false);
}
public static boolean verify(String token, String remoteIp) {
if (!isEnabled()) return true;
if (token == null || token.isEmpty()) return false;
String secret = Emulator.getConfig().getValue("login.turnstile.secretkey", "");
if (secret.isEmpty()) {
LOGGER.warn("login.turnstile.enabled=1 but login.turnstile.secretkey is empty — refusing the request");
return false;
}
StringBuilder form = new StringBuilder();
form.append("secret=").append(URLEncoder.encode(secret, StandardCharsets.UTF_8));
form.append("&response=").append(URLEncoder.encode(token, StandardCharsets.UTF_8));
if (remoteIp != null && !remoteIp.isEmpty()) {
form.append("&remoteip=").append(URLEncoder.encode(remoteIp, StandardCharsets.UTF_8));
}
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(VERIFY_URL))
.timeout(Duration.ofSeconds(8))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(form.toString(), StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
LOGGER.warn("Turnstile siteverify returned HTTP {} for ip={}", response.statusCode(), remoteIp);
return false;
}
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
boolean success = json.has("success") && json.get("success").getAsBoolean();
if (!success) {
LOGGER.info("Turnstile token rejected for ip={} body={}", remoteIp, response.body());
}
return success;
} catch (Exception e) {
LOGGER.error("Turnstile verification failed for ip=" + remoteIp, e);
return false;
}
}
}
@@ -0,0 +1,90 @@
package com.eu.habbo.networking.gameserver.crypto;
import com.eu.habbo.Emulator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.util.Base64;
public final class CryptoSigningKeyManager {
private static final Logger LOGGER = LoggerFactory.getLogger(CryptoSigningKeyManager.class);
private static final String KEY_PUBLIC = "crypto.ws.signing.public_key";
private static final String KEY_PRIVATE = "crypto.ws.signing.private_key";
private static volatile KeyPair cached;
private static volatile String cachedPublicB64;
private CryptoSigningKeyManager() {}
public static KeyPair get() {
KeyPair kp = cached;
if (kp != null) return kp;
synchronized (CryptoSigningKeyManager.class) {
if (cached != null) return cached;
String pubB64 = Emulator.getConfig().getValue(KEY_PUBLIC, "");
String privB64 = Emulator.getConfig().getValue(KEY_PRIVATE, "");
if (pubB64 != null && !pubB64.isEmpty() && privB64 != null && !privB64.isEmpty()) {
try {
byte[] pubDer = Base64.getDecoder().decode(pubB64);
byte[] privDer = Base64.getDecoder().decode(privB64);
KeyFactory kf = KeyFactory.getInstance("EC");
PublicKey pub = kf.generatePublic(new X509EncodedKeySpec(pubDer));
PrivateKey priv = kf.generatePrivate(new PKCS8EncodedKeySpec(privDer));
cached = new KeyPair(pub, priv);
cachedPublicB64 = pubB64;
return cached;
} catch (Exception e) {
LOGGER.error("[ws-crypto] persisted signing key is corrupt, generating a new pair", e);
}
}
try {
KeyPair generated = WsSessionCrypto.generateSigningKeyPair();
byte[] pubDer = WsSessionCrypto.encodePublicKeySpki(generated.getPublic());
byte[] privDer = WsSessionCrypto.encodePrivateKeyPkcs8(generated.getPrivate());
String newPubB64 = Base64.getEncoder().withoutPadding().encodeToString(pubDer);
String newPrivB64 = Base64.getEncoder().withoutPadding().encodeToString(privDer);
persist(KEY_PUBLIC, newPubB64);
persist(KEY_PRIVATE, newPrivB64);
Emulator.getConfig().update(KEY_PUBLIC, newPubB64);
Emulator.getConfig().update(KEY_PRIVATE, newPrivB64);
cached = generated;
cachedPublicB64 = newPubB64;
LOGGER.info("[ws-crypto] generated a new ECDSA P-256 signing keypair (persisted to emulator_settings)");
return cached;
} catch (Exception e) {
throw new IllegalStateException("Cannot generate signing keypair", e);
}
}
}
public static String publicKeyBase64() {
if (cachedPublicB64 == null) get();
return cachedPublicB64;
}
private static void persist(String key, String value) {
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement stmt = conn.prepareStatement(
"INSERT INTO emulator_settings (`key`, `value`) VALUES (?, ?) "
+ "ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)")) {
stmt.setString(1, key);
stmt.setString(2, value);
stmt.executeUpdate();
} catch (Exception e) {
LOGGER.error("[ws-crypto] failed to persist " + key + " to emulator_settings (key stays in-memory only)", e);
}
}
}
@@ -0,0 +1,46 @@
package com.eu.habbo.networking.gameserver.crypto;
import com.eu.habbo.networking.gameserver.GameServerAttributes;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToMessageDecoder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
public class WsAesDecoder extends MessageToMessageDecoder<ByteBuf> {
private static final Logger LOGGER = LoggerFactory.getLogger(WsAesDecoder.class);
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
byte[] key = ctx.channel().attr(GameServerAttributes.WS_AES_KEY).get();
if (key == null) {
LOGGER.warn("[ws-crypto] inbound frame with no session key, closing");
ctx.close();
return;
}
int readable = in.readableBytes();
if (readable < WsSessionCrypto.NONCE_LEN + 16) {
LOGGER.warn("[ws-crypto] inbound frame too short ({} bytes)", readable);
ctx.close();
return;
}
byte[] nonce = new byte[WsSessionCrypto.NONCE_LEN];
in.readBytes(nonce);
byte[] ct = new byte[in.readableBytes()];
in.readBytes(ct);
try {
byte[] plain = WsSessionCrypto.aesGcmDecrypt(key, nonce, ct);
out.add(Unpooled.wrappedBuffer(plain));
} catch (Exception e) {
LOGGER.warn("[ws-crypto] AES-GCM decrypt failed ({}), closing channel", e.getClass().getSimpleName());
ctx.close();
}
}
}
@@ -0,0 +1,35 @@
package com.eu.habbo.networking.gameserver.crypto;
import com.eu.habbo.networking.gameserver.GameServerAttributes;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToMessageEncoder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
public class WsAesEncoder extends MessageToMessageEncoder<ByteBuf> {
private static final Logger LOGGER = LoggerFactory.getLogger(WsAesEncoder.class);
@Override
protected void encode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
byte[] key = ctx.channel().attr(GameServerAttributes.WS_AES_KEY).get();
if (key == null) {
LOGGER.warn("[ws-crypto] outbound frame with no session key, dropping");
return;
}
byte[] plain = new byte[in.readableBytes()];
in.readBytes(plain);
byte[] nonce = WsSessionCrypto.randomNonce();
byte[] ct = WsSessionCrypto.aesGcmEncrypt(key, nonce, plain);
ByteBuf framed = ctx.alloc().buffer(nonce.length + ct.length);
framed.writeBytes(nonce);
framed.writeBytes(ct);
out.add(framed);
}
}
@@ -0,0 +1,152 @@
package com.eu.habbo.networking.gameserver.crypto;
import com.eu.habbo.Emulator;
import com.eu.habbo.networking.gameserver.GameServerAttributes;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.channel.ChannelPipeline;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.security.PublicKey;
public class WsHandshakeHandler extends ChannelInboundHandlerAdapter {
private static final Logger LOGGER = LoggerFactory.getLogger(WsHandshakeHandler.class);
public static final String HANDLER_NAME = "wsCryptoHandshake";
private static final boolean SIGN_ENABLED = Emulator.getConfig().getBoolean("crypto.ws.signing.enabled", false);
private KeyPair serverKeyPair;
private boolean helloSent = false;
private boolean handshakeComplete = false;
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof WebSocketServerProtocolHandler.HandshakeComplete) {
sendServerHello(ctx);
}
super.userEventTriggered(ctx, evt);
}
private void sendServerHello(ChannelHandlerContext ctx) {
if (helloSent) return;
try {
this.serverKeyPair = WsSessionCrypto.generateEphemeralKeyPair();
byte[] spki = WsSessionCrypto.encodePublicKeySpki(serverKeyPair.getPublic());
byte[] sigIeee = null;
if (SIGN_ENABLED) {
KeyPair signingKp = CryptoSigningKeyManager.get();
byte[] sigDer = WsSessionCrypto.signEcdsaSha256(signingKp.getPrivate(), spki);
sigIeee = WsSessionCrypto.derToIeee1363(sigDer);
}
int frameLen = 4 + 1 + 2 + spki.length + (sigIeee != null ? 2 + sigIeee.length : 0);
ByteBuf buf = ctx.alloc().buffer(frameLen);
buf.writeInt(WsSessionCrypto.HANDSHAKE_MAGIC);
buf.writeByte(WsSessionCrypto.TYPE_SERVER_HELLO);
buf.writeShort(spki.length);
buf.writeBytes(spki);
if (sigIeee != null) {
buf.writeShort(sigIeee.length);
buf.writeBytes(sigIeee);
}
ctx.writeAndFlush(buf);
helloSent = true;
} catch (Exception e) {
LOGGER.error("[ws-crypto] failed to send server_hello", e);
ctx.close();
}
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
if (handshakeComplete) {
ctx.fireChannelRead(msg);
return;
}
if (!(msg instanceof ByteBuf)) {
ctx.fireChannelRead(msg);
return;
}
ByteBuf in = (ByteBuf) msg;
try {
if (in.readableBytes() < 7) {
LOGGER.warn("[ws-crypto] handshake frame too short ({} bytes) from {}", in.readableBytes(), clientAddress(ctx));
ctx.close();
return;
}
int magic = in.readInt();
if (magic != WsSessionCrypto.HANDSHAKE_MAGIC) {
LOGGER.warn("[ws-crypto] handshake magic mismatch: 0x{} from {}", Integer.toHexString(magic), clientAddress(ctx));
ctx.close();
return;
}
byte type = in.readByte();
if (type != WsSessionCrypto.TYPE_CLIENT_HELLO) {
LOGGER.warn("[ws-crypto] expected client_hello, got type=0x{} from {}", Integer.toHexString(type & 0xff), clientAddress(ctx));
ctx.close();
return;
}
int keyLen = in.readUnsignedShort();
if (keyLen <= 0 || keyLen > in.readableBytes() || keyLen > 2048) {
LOGGER.warn("[ws-crypto] invalid client key length {} from {}", keyLen, clientAddress(ctx));
ctx.close();
return;
}
byte[] clientSpki = new byte[keyLen];
in.readBytes(clientSpki);
PublicKey clientPub = WsSessionCrypto.decodePublicKeySpki(clientSpki);
PrivateKey ourPriv = serverKeyPair.getPrivate();
byte[] shared = WsSessionCrypto.deriveSharedSecret(ourPriv, clientPub);
byte[] aesKey = WsSessionCrypto.deriveAesKey(shared);
ctx.channel().attr(GameServerAttributes.WS_AES_KEY).set(aesKey);
ChannelPipeline p = ctx.pipeline();
p.addAfter(HANDLER_NAME, "wsAesDecoder", new WsAesDecoder());
p.addAfter(HANDLER_NAME, "wsAesEncoder", new WsAesEncoder());
handshakeComplete = true;
p.remove(this);
LOGGER.debug("[ws-crypto] handshake complete for {}", clientAddress(ctx));
} catch (Exception e) {
LOGGER.warn("[ws-crypto] handshake failed from {} : {}", clientAddress(ctx), friendlyReason(e));
ctx.close();
} finally {
in.release();
}
}
private static String clientAddress(ChannelHandlerContext ctx) {
String wsIp = ctx.channel().attr(GameServerAttributes.WS_IP).get();
if (wsIp != null && !wsIp.isEmpty()) return wsIp;
return String.valueOf(ctx.channel().remoteAddress());
}
private static String friendlyReason(Throwable t) {
if (t == null) return "unknown";
String name = t.getClass().getSimpleName();
String msg = t.getMessage();
return (msg == null || msg.isEmpty()) ? name : name + ": " + msg;
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
if (cause instanceof java.io.IOException) {
LOGGER.debug("[ws-crypto] client disconnected during handshake ({}): {}",
clientAddress(ctx), friendlyReason(cause));
} else {
LOGGER.error("[ws-crypto] handshake handler error from " + clientAddress(ctx), cause);
}
ctx.close();
}
}
@@ -0,0 +1,163 @@
package com.eu.habbo.networking.gameserver.crypto;
import javax.crypto.Cipher;
import javax.crypto.KeyAgreement;
import javax.crypto.Mac;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Arrays;
public final class WsSessionCrypto {
public static final int HANDSHAKE_MAGIC = 0xC0DEC0DE;
public static final byte TYPE_SERVER_HELLO = 0x01;
public static final byte TYPE_CLIENT_HELLO = 0x02;
public static final String HKDF_INFO = "nitro-ws-v1";
public static final int AES_KEY_LEN = 32;
public static final int NONCE_LEN = 12;
public static final int GCM_TAG_BITS = 128;
private static final SecureRandom RNG = new SecureRandom();
private WsSessionCrypto() {}
public static KeyPair generateEphemeralKeyPair() throws GeneralSecurityException {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
kpg.initialize(new java.security.spec.ECGenParameterSpec("secp256r1"), RNG);
return kpg.generateKeyPair();
}
public static byte[] encodePublicKeySpki(PublicKey publicKey) {
return publicKey.getEncoded();
}
public static PublicKey decodePublicKeySpki(byte[] spki) throws GeneralSecurityException {
KeyFactory kf = KeyFactory.getInstance("EC");
return kf.generatePublic(new X509EncodedKeySpec(spki));
}
public static byte[] deriveSharedSecret(PrivateKey ourPrivate, PublicKey theirPublic) throws GeneralSecurityException {
KeyAgreement ka = KeyAgreement.getInstance("ECDH");
ka.init(ourPrivate);
ka.doPhase(theirPublic, true);
return ka.generateSecret();
}
public static byte[] hkdfSha256(byte[] ikm, byte[] salt, byte[] info, int outLen) throws GeneralSecurityException {
if (salt == null || salt.length == 0) salt = new byte[32];
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(salt, "HmacSHA256"));
byte[] prk = mac.doFinal(ikm);
int hashLen = 32;
int n = (outLen + hashLen - 1) / hashLen;
if (n > 255) throw new GeneralSecurityException("HKDF output too long");
ByteArrayOutputStream okm = new ByteArrayOutputStream();
byte[] t = new byte[0];
for (int i = 1; i <= n; i++) {
mac.init(new SecretKeySpec(prk, "HmacSHA256"));
mac.update(t);
if (info != null) mac.update(info);
mac.update((byte) i);
t = mac.doFinal();
okm.write(t, 0, t.length);
}
byte[] result = okm.toByteArray();
return (result.length == outLen) ? result : Arrays.copyOf(result, outLen);
}
public static byte[] deriveAesKey(byte[] sharedSecret) throws GeneralSecurityException {
return hkdfSha256(sharedSecret, null, HKDF_INFO.getBytes(StandardCharsets.UTF_8), AES_KEY_LEN);
}
public static byte[] aesGcmEncrypt(byte[] key, byte[] nonce, byte[] plaintext) throws GeneralSecurityException {
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(GCM_TAG_BITS, nonce));
return c.doFinal(plaintext);
}
public static byte[] aesGcmDecrypt(byte[] key, byte[] nonce, byte[] ciphertextWithTag) throws GeneralSecurityException {
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(GCM_TAG_BITS, nonce));
return c.doFinal(ciphertextWithTag);
}
public static byte[] randomNonce() {
byte[] n = new byte[NONCE_LEN];
RNG.nextBytes(n);
return n;
}
public static KeyPair generateSigningKeyPair() throws GeneralSecurityException {
return generateEphemeralKeyPair();
}
public static PrivateKey decodePrivateKeyPkcs8(byte[] pkcs8) throws GeneralSecurityException {
KeyFactory kf = KeyFactory.getInstance("EC");
return kf.generatePrivate(new PKCS8EncodedKeySpec(pkcs8));
}
public static byte[] encodePrivateKeyPkcs8(PrivateKey privateKey) {
return privateKey.getEncoded();
}
public static byte[] signEcdsaSha256(PrivateKey signingKey, byte[] message) throws GeneralSecurityException {
Signature sig = Signature.getInstance("SHA256withECDSA");
sig.initSign(signingKey);
sig.update(message);
return sig.sign();
}
public static byte[] derToIeee1363(byte[] der) throws GeneralSecurityException {
if (der == null || der.length < 8 || der[0] != 0x30) {
throw new GeneralSecurityException("Malformed DER signature");
}
int seqLen;
int idx;
if ((der[1] & 0x80) == 0) {
seqLen = der[1] & 0xff;
idx = 2;
} else {
int lenBytes = der[1] & 0x7f;
if (lenBytes > 2) throw new GeneralSecurityException("DER length too big");
seqLen = 0;
for (int i = 0; i < lenBytes; i++) seqLen = (seqLen << 8) | (der[2 + i] & 0xff);
idx = 2 + lenBytes;
}
if (idx + seqLen > der.length) throw new GeneralSecurityException("DER truncated");
if (der[idx] != 0x02) throw new GeneralSecurityException("Expected INTEGER r");
int rLen = der[idx + 1] & 0xff;
int rStart = idx + 2;
int sHeader = rStart + rLen;
if (der[sHeader] != 0x02) throw new GeneralSecurityException("Expected INTEGER s");
int sLen = der[sHeader + 1] & 0xff;
int sStart = sHeader + 2;
byte[] r = stripLeadingZero(Arrays.copyOfRange(der, rStart, rStart + rLen));
byte[] s = stripLeadingZero(Arrays.copyOfRange(der, sStart, sStart + sLen));
byte[] out = new byte[64];
System.arraycopy(r, 0, out, 32 - r.length, r.length);
System.arraycopy(s, 0, out, 64 - s.length, s.length);
return out;
}
private static byte[] stripLeadingZero(byte[] v) {
int i = 0;
while (i < v.length - 1 && v[i] == 0x00) i++;
return Arrays.copyOfRange(v, i, v.length);
}
}
@@ -2,7 +2,6 @@ package com.eu.habbo.networking.gameserver.decoders;
import com.eu.habbo.messages.ClientMessage;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ByteToMessageDecoder;
@@ -12,8 +11,7 @@ public class GameByteDecoder extends ByteToMessageDecoder {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
short header = in.readShort();
ByteBuf body = Unpooled.copiedBuffer(in.readBytes(in.readableBytes()));
ByteBuf body = in.readBytes(in.readableBytes());
out.add(new ClientMessage(header, body));
}
}
@@ -8,6 +8,9 @@ db.params=
db.pool.minsize=25
db.pool.maxsize=100
# Encrypt your traffic
crypto.ws.enabled=0
#Game Configuration.
#Host IP. Most likely just 0.0.0.0 Use 127.0.0.1 if you want to play on LAN.
game.host=0.0.0.0