diff --git a/README.md b/README.md index 3d8ce8741..362d4af75 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,7 @@ Navigate to `build/sharedconfig.ini` and fill in the following fields: * `chatconfig.ini` contains a port option. * `masterconfig.ini` contains options related to permissions you want to run your servers with. * `sharedconfig.ini` contains several options that are shared across all servers + * `bind_ip` sets the local IPv4 address the servers listen on (empty, the default, listens on all interfaces). Players are still sent `external_ip`, so the two can differ behind NAT or a proxy. * `worldconfig.ini` contains several options to turn on Quality of Life improvements should you want them. If you would like the most vanilla experience possible, you will need to turn some of these settings off. ## Verify your setup diff --git a/dCommon/GeneralUtils.cpp b/dCommon/GeneralUtils.cpp index 96bf9e017..3185c4430 100644 --- a/dCommon/GeneralUtils.cpp +++ b/dCommon/GeneralUtils.cpp @@ -271,6 +271,26 @@ std::vector GeneralUtils::SplitString(const std::u16string_view return vector; } +std::optional GeneralUtils::ParseBindAddress(const std::string_view value) { + const auto first = value.find_first_not_of(" \t\r\n"); + if (first == std::string_view::npos) return std::string{}; + const auto trimmed = value.substr(first, value.find_last_not_of(" \t\r\n") - first + 1); + + if (trimmed == "*" || trimmed == "0.0.0.0") return std::string{}; + if (CaseInsensitiveStringCompare(trimmed, "localhost")) return std::string{ "127.0.0.1" }; + + // Dotted quad only: four decimal octets of 0-255, no signs, no empty parts, no leading zeros that read as octal + const auto octets = SplitString(trimmed, '.'); + if (octets.size() != 4) return std::nullopt; + for (const auto& octet : octets) { + if (octet.empty() || octet.size() > 3) return std::nullopt; + if (octet.find_first_not_of("0123456789") != std::string::npos) return std::nullopt; + if (octet.size() > 1 && octet[0] == '0') return std::nullopt; + if (std::stoi(octet) > 255) return std::nullopt; + } + return std::string{ trimmed }; +} + std::vector GeneralUtils::SplitString(const std::string_view str, const char delimiter) { std::vector vector = std::vector(); std::string current = ""; diff --git a/dCommon/GeneralUtils.h b/dCommon/GeneralUtils.h index e08cada13..ba5fc913b 100644 --- a/dCommon/GeneralUtils.h +++ b/dCommon/GeneralUtils.h @@ -153,6 +153,12 @@ namespace GeneralUtils { std::vector GetSqlFileNamesFromFolder(const std::string_view folder); + /** + * Reads a bind address setting (bind_ip). Returns the IPv4 address to bind to, an empty string for all + * interfaces (empty, "*" or "0.0.0.0"), "127.0.0.1" for "localhost", or nullopt when the value isn't an IPv4 address. + */ + [[nodiscard]] std::optional ParseBindAddress(const std::string_view value); + /** * Transparent string hasher - used to allow string_view key lookups for maps storing std::string keys * https://www.reddit.com/r/cpp_questions/comments/12xw3sn/find_stdstring_view_in_unordered_map_with/jhki225/ diff --git a/dDashboardServer/routes/SettingsCatalog.cpp b/dDashboardServer/routes/SettingsCatalog.cpp index 41b62a62b..813fea104 100644 --- a/dDashboardServer/routes/SettingsCatalog.cpp +++ b/dDashboardServer/routes/SettingsCatalog.cpp @@ -113,6 +113,7 @@ namespace { c.AddSection("Network", "Addresses and ports. Changing a port needs a restart of every server."); c.Add(Format(Text(SHARED, "external_ip", "Public address", "The address players connect to. localhost for a server only you play on.", "localhost", true), eFormat::HOST)); + c.Add(Format(Text(SHARED, "bind_ip", "Listen address", "The local IPv4 address the servers listen on. Empty for all interfaces; players are still sent the public address.", "", true), eFormat::HOST)); c.Add(Format(Text(MASTER, "master_ip", "Master address", "The address the other servers use to reach master.", "localhost", true), eFormat::HOST)); c.Add(Port(MASTER, "master_server_port", "Master port", "", "2000")); c.Add(Port(AUTH, "auth_server_port", "Auth port", "The retail client always connects to 1001.", "1001")); diff --git a/dNet/dServer.cpp b/dNet/dServer.cpp index ad81bf710..897885a67 100644 --- a/dNet/dServer.cpp +++ b/dNet/dServer.cpp @@ -16,6 +16,7 @@ #include "MasterPackets.h" #include "ZoneInstanceManager.h" #include "StringifiedEnum.h" +#include "GeneralUtils.h" //! Replica Constructor class class ReplicaConstructor : public ReceiveConstructionInterface { @@ -81,6 +82,7 @@ dServer::dServer( mLogger->SetLogToConsole(true); if (mIsOkay) { + LOG("Bound to %s (bind_ip)", mBindAddress.empty() ? "all interfaces" : mBindAddress.c_str()); if (zoneID == 0) LOG("%s Server is listening on %s:%i with encryption: %i", StringifiedEnum::ToString(serverType).data(), ip.c_str(), port, int(useEncryption)); else @@ -231,7 +233,16 @@ bool dServer::IsConnected(const SystemAddress& sysAddr) { } bool dServer::Startup() { - mSocketDescriptor = SocketDescriptor(uint16_t(mPort), 0); + // bind_ip picks the local interface the sockets listen on; players are still sent external_ip + const auto bindIP = mConfig->GetValue("bind_ip"); + const auto bindAddress = GeneralUtils::ParseBindAddress(bindIP); + if (!bindAddress) { + LOG("bind_ip \"%s\" is not an IPv4 address (leave it empty to listen on all interfaces)", bindIP.c_str()); + return false; + } + mBindAddress = *bindAddress; + + mSocketDescriptor = SocketDescriptor(uint16_t(mPort), mBindAddress.c_str()); mPeer = RakNetworkFactory::GetRakPeerInterface(); if (!mPeer) return false; @@ -283,7 +294,7 @@ void dServer::Shutdown() { } void dServer::SetupForMasterConnection() { - mMasterSocketDescriptor = SocketDescriptor(uint16_t(mPort + 1), 0); + mMasterSocketDescriptor = SocketDescriptor(uint16_t(mPort + 1), mBindAddress.c_str()); mMasterPeer = RakNetworkFactory::GetRakPeerInterface(); bool ret = mMasterPeer->Startup(1, 30, &mMasterSocketDescriptor, 1); if (!ret) LOG("Failed MasterPeer Startup!"); diff --git a/dNet/dServer.h b/dNet/dServer.h index ad06ac203..d280ae8fa 100644 --- a/dNet/dServer.h +++ b/dNet/dServer.h @@ -98,6 +98,8 @@ protected: Game::signal_t* mShouldShutdown = nullptr; SocketDescriptor mSocketDescriptor; std::string mIP; + // Local address the sockets are bound to (bind_ip), empty for all interfaces + std::string mBindAddress; int mPort; int mMaxConnections; unsigned int mZoneID; diff --git a/docs/Dashboard.md b/docs/Dashboard.md index e81cab933..a230d0e41 100644 --- a/docs/Dashboard.md +++ b/docs/Dashboard.md @@ -3,7 +3,8 @@ DarkflameServer ships with a web dashboard for managing accounts, moderating, and watching the server. It replaces the separate NexusDashboard. Master starts it when `enable_dashboard=1` is set in `masterconfig.ini`, and it listens on the `port` in `dashboardconfig.ini` (2006 by default). It also talks to master over UDP on `net_port` and the port after -it (2010 and 2011 by default); keep those clear of the other servers' ports. +it (2010 and 2011 by default); keep those clear of the other servers' ports. Those UDP ports listen on `bind_ip` from +`sharedconfig.ini` like every other server; the web page itself listens on `listen_ip`. This page is for server operators. Everything below the first section is optional. diff --git a/resources/sharedconfig.ini b/resources/sharedconfig.ini index 333f22494..8504ee26c 100644 --- a/resources/sharedconfig.ini +++ b/resources/sharedconfig.ini @@ -13,6 +13,14 @@ log_debug_statements=0 # The public facing IP address. Can be 'localhost' for locally hosted servers external_ip=localhost +# The local IPv4 address every server (auth, chat, master, world and the dashboard's +# connection to master) listens on, for machines with more than one network interface. +# Empty (the default) or 0.0.0.0 listens on all interfaces; localhost means 127.0.0.1. +# This only changes where the servers listen: players are still sent external_ip, so +# use it when the public address isn't one this machine can bind to (NAT, a proxy). +# The servers refuse to start if this isn't an IPv4 address. +bind_ip= + # 0 or 1, should not compile chat hash map to file dont_generate_dcf=0 diff --git a/tests/dCommonTests/BindAddressTests.cpp b/tests/dCommonTests/BindAddressTests.cpp new file mode 100644 index 000000000..8f9ab8e69 --- /dev/null +++ b/tests/dCommonTests/BindAddressTests.cpp @@ -0,0 +1,25 @@ +#include + +#include "GeneralUtils.h" + +TEST(BindAddressTests, EmptyMeansAllInterfaces) { + EXPECT_EQ(GeneralUtils::ParseBindAddress(""), std::string{}); + EXPECT_EQ(GeneralUtils::ParseBindAddress(" "), std::string{}); + EXPECT_EQ(GeneralUtils::ParseBindAddress("0.0.0.0"), std::string{}); + EXPECT_EQ(GeneralUtils::ParseBindAddress("*"), std::string{}); +} + +TEST(BindAddressTests, AcceptsIPv4) { + EXPECT_EQ(GeneralUtils::ParseBindAddress("127.0.0.1"), "127.0.0.1"); + EXPECT_EQ(GeneralUtils::ParseBindAddress(" 192.168.1.20\r"), "192.168.1.20"); + EXPECT_EQ(GeneralUtils::ParseBindAddress("255.255.255.255"), "255.255.255.255"); + EXPECT_EQ(GeneralUtils::ParseBindAddress("localhost"), "127.0.0.1"); + EXPECT_EQ(GeneralUtils::ParseBindAddress("LocalHost"), "127.0.0.1"); +} + +TEST(BindAddressTests, RejectsEverythingElse) { + for (const auto* bad : { "256.0.0.1", "1.2.3", "1.2.3.4.5", "1..2.3", "a.b.c.d", "010.0.0.1", "-1.0.0.1", + "1.2.3.4:2000", "example.com", "::1", "1.2.3.4 5", "+1.2.3.4" }) { + EXPECT_FALSE(GeneralUtils::ParseBindAddress(bad).has_value()) << bad; + } +} diff --git a/tests/dCommonTests/CMakeLists.txt b/tests/dCommonTests/CMakeLists.txt index 98e9bdc07..c013a5436 100644 --- a/tests/dCommonTests/CMakeLists.txt +++ b/tests/dCommonTests/CMakeLists.txt @@ -28,6 +28,7 @@ set(DCOMMONTEST_SOURCES "HotPropertySlotsTests.cpp" "PropertyRentRulesTests.cpp" "PropertyReputationRulesTests.cpp" + "BindAddressTests.cpp" ) add_subdirectory(dEnumsTests)