diff --git a/src/network.zig b/src/network.zig index 7b6477bf4e..8fc4321a05 100644 --- a/src/network.zig +++ b/src/network.zig @@ -1737,7 +1737,7 @@ pub const Connection = struct { // MARK: Connection pub fn receive(self: *Connection, data: []const u8) void { self.tryReceive(data) catch |err| { - std.log.err("Got error while processing received network data: {s}", .{@errorName(err)}); + std.log.warn("Got error while processing received network data: {s}", .{@errorName(err)}); if (@errorReturnTrace()) |trace| { std.log.info("{f}", .{main.fmt.FormatErrorTrace{.stackTrace = trace.*}}); } diff --git a/src/network/protocols.zig b/src/network/protocols.zig index 50a7350942..b3fa085423 100644 --- a/src/network/protocols.zig +++ b/src/network/protocols.zig @@ -169,7 +169,12 @@ pub const handShake = struct { // MARK: handShake if (main.server.world.?.mode != .singleplayer) { const keys = zon.getChild("keys"); - try conn.user.?.identifyFromKeysAndName(name, keys); + try conn.user.?.identifyFromKeysAndName(name, keys, main.server.world.?.settings.whitelistEnabled.load(.monotonic)); + + if (!conn.user.?.allowedToJoin) { + std.log.info("Rejected connection from '{s}' ({s})", .{name, conn.user.?.newKeyString.?}); + return error.NotWhitelisted; + } var writer: utils.BinaryWriter = .init(main.stackAllocator); defer writer.deinit(); diff --git a/src/server/command.zig b/src/server/command.zig index 1acdc5472e..d2bcc70a04 100644 --- a/src/server/command.zig +++ b/src/server/command.zig @@ -169,6 +169,26 @@ pub const PlayerIndex = struct { } }; +pub const KeyString = struct { + key: []const u8, + + pub fn parse(_: NeverFailingAllocator, name: []const u8, arg: []const u8, errorMessage: *ListManaged(u8)) error{ParseError}!KeyString { + const colonIndex = std.mem.indexOfScalar(u8, arg, ':') orelse { + errorMessage.print("Expected a public key of the form \":\" for <{s}>, found \"{s}\"", .{name, arg}); + return error.ParseError; + }; + const keyType = std.meta.stringToEnum(main.network.authentication.KeyTypeEnum, arg[0..colonIndex]) orelse { + errorMessage.print("Unknown key type \"{s}\" for <{s}>", .{arg[0..colonIndex], name}); + return error.ParseError; + }; + _ = main.network.authentication.PublicKey.initFromBase64(arg[colonIndex + 1 ..], keyType) catch { + errorMessage.print("Invalid public key \"{s}\" for <{s}>", .{arg, name}); + return error.ParseError; + }; + return .{.key = arg}; + } +}; + pub const BiomeId = struct { biome: *const main.server.terrain.biomes.Biome, diff --git a/src/server/command/_list.zig b/src/server/command/_list.zig index 00b2dd1f4e..a4e0e44980 100644 --- a/src/server/command/_list.zig +++ b/src/server/command/_list.zig @@ -11,6 +11,7 @@ pub const spawn = @import("spawn.zig"); pub const tickspeed = @import("tickspeed.zig"); pub const time = @import("time.zig"); pub const tp = @import("tp.zig"); +pub const whitelist = @import("whitelist.zig"); pub const avatar = @import("entity/avatar.zig"); diff --git a/src/server/command/whitelist.zig b/src/server/command/whitelist.zig new file mode 100644 index 0000000000..12292c970d --- /dev/null +++ b/src/server/command/whitelist.zig @@ -0,0 +1,68 @@ +const std = @import("std"); + +const main = @import("main"); +const command = main.server.command; +const Source = command.Source; +const players = main.server.players; + +pub const description = "Manages the connection whitelist"; +pub const usage = + \\/whitelist : + \\/whitelist @ + \\/whitelist +; + +const Action = enum { add, block }; +const Toggle = enum { enable, disable }; + +pub const Args = union(enum) { + @"/whitelist ": struct { action: Action, key: command.KeyString }, + @"/whitelist ": struct { action: Action, playerIndex: command.PlayerIndex }, + @"/whitelist ": struct { toggle: Toggle }, +}; + +pub fn execute(args: Args, source: Source) void { + switch (args) { + .@"/whitelist " => |params| applyAction(source, params.action, params.key.key), + .@"/whitelist " => |params| { + const target = command.Target.fromPlayerIndex(params.playerIndex, source) catch return; + const key = target.user.newKeyString orelse { + source.sendMessage("#ff0000Player {s}§#ff0000 has no public key to whitelist", .{target.user.name}); + return; + }; + applyAction(source, params.action, key); + }, + .@"/whitelist " => |params| { + main.server.world.?.settings.whitelistEnabled.store(params.toggle == .enable, .monotonic); + main.server.world.?.saveWorldConfig() catch |err| { + std.log.err("Error while saving world config: {s}", .{@errorName(err)}); + }; + source.sendMessage("#00ff00Whitelist {s}", .{if (params.toggle == .enable) "enabled" else "disabled"}); + }, + } +} + +fn applyAction(source: Source, action: Action, key: []const u8) void { + switch (action) { + .add => switch (players.add(key)) { + .added => source.sendMessage("#00ff00Added {s}§#00ff00 to the whitelist", .{key}), + .alreadyAllowed => source.sendMessage("#ff0000{s}§#ff0000 is already on the whitelist", .{key}), + }, + .block => { + switch (players.block(key)) { + .blocked => source.sendMessage("#00ff00Blocked {s}§#00ff00 from connecting", .{key}), + .alreadyBlocked => source.sendMessage("#ff0000{s}§#ff0000 is already blocked", .{key}), + } + const userList = main.server.getUserList(main.stackAllocator); + defer main.stackAllocator.free(userList); + for (userList) |user| { + if (user.newKeyString) |userKey| { + if (std.mem.eql(u8, userKey, key)) { + user.conn.disconnect(); + break; + } + } + } + }, + } +} diff --git a/src/server/players.zig b/src/server/players.zig index a45051674c..7ed31a332f 100644 --- a/src/server/players.zig +++ b/src/server/players.zig @@ -69,11 +69,13 @@ pub fn getLocalPlayerIndex() usize { return localPlayerIndex; } -pub fn lookupIndex(key: []const u8) ?usize { +const LookupResult = struct { playerIndex: usize, blocked: bool }; + +pub fn lookupIndex(key: []const u8) ?LookupResult { mutex.lock(); defer mutex.unlock(); const entry = playerDatabase.get(key) orelse return null; - return entry.playerIndex; + return .{.playerIndex = entry.playerIndex, .blocked = entry.blocked}; } pub fn isEmpty() bool { @@ -164,6 +166,7 @@ fn saveBlocked(index: usize, value: bool) void { const AddResult = enum { added, alreadyAllowed }; pub fn add(key: []const u8) AddResult { + sync.threadContext.assertCorrectContext(.server); mutex.lock(); defer mutex.unlock(); const result = ensurePlayerRecord(key); @@ -176,6 +179,7 @@ pub fn add(key: []const u8) AddResult { const BlockResult = enum { blocked, alreadyBlocked }; pub fn block(key: []const u8) BlockResult { + sync.threadContext.assertCorrectContext(.server); mutex.lock(); defer mutex.unlock(); const result = ensurePlayerRecord(key); @@ -185,27 +189,19 @@ pub fn block(key: []const u8) BlockResult { return if (result.wasNew or !wasBlocked) .blocked else .alreadyBlocked; } -pub fn isAllowedToJoin(key: []const u8) bool { - sync.threadContext.assertCorrectContext(.server); - mutex.lock(); - defer mutex.unlock(); - const entry = playerDatabase.get(key) orelse return false; - return !entry.blocked; -} - test "addContainsRemove" { main.heap.allocators.createWorldArena(); defer main.heap.allocators.destroyWorldArena(); init("test", 0); - try std.testing.expectEqual(false, isAllowedToJoin("ed25519:abc")); + try std.testing.expectEqual(null, lookupIndex("ed25519:abc")); try std.testing.expectEqual(.added, add("ed25519:abc")); try std.testing.expectEqual(.alreadyAllowed, add("ed25519:abc")); - try std.testing.expectEqual(true, isAllowedToJoin("ed25519:abc")); + try std.testing.expectEqual(false, lookupIndex("ed25519:abc").?.blocked); try std.testing.expectEqual(.blocked, block("ed25519:abc")); try std.testing.expectEqual(.alreadyBlocked, block("ed25519:abc")); - try std.testing.expectEqual(false, isAllowedToJoin("ed25519:abc")); + try std.testing.expectEqual(true, lookupIndex("ed25519:abc").?.blocked); } test "addUnblocks" { @@ -215,12 +211,12 @@ test "addUnblocks" { init("test", 0); try std.testing.expectEqual(.blocked, block("ed25519:xyz")); - try std.testing.expectEqual(false, isAllowedToJoin("ed25519:xyz")); + try std.testing.expectEqual(true, lookupIndex("ed25519:xyz").?.blocked); try std.testing.expectEqual(.added, add("ed25519:xyz")); - try std.testing.expectEqual(true, isAllowedToJoin("ed25519:xyz")); + try std.testing.expectEqual(false, lookupIndex("ed25519:xyz").?.blocked); } -test "knownPlayerAllowedByDefaultButBlockable" { +test "lookupIndexDistinguishesKnownAndUnknownKeys" { main.heap.allocators.createWorldArena(); defer main.heap.allocators.destroyWorldArena(); @@ -228,12 +224,12 @@ test "knownPlayerAllowedByDefaultButBlockable" { playerDatabase.put(main.worldArena.allocator, main.worldArena.dupe(u8, "ed25519:known"), .{.playerIndex = 0, .blocked = false}) catch unreachable; - try std.testing.expectEqual(true, isAllowedToJoin("ed25519:known")); - try std.testing.expectEqual(false, isAllowedToJoin("ed25519:unknown")); + try std.testing.expectEqual(false, lookupIndex("ed25519:known").?.blocked); + try std.testing.expectEqual(null, lookupIndex("ed25519:unknown")); try std.testing.expectEqual(.blocked, block("ed25519:known")); - try std.testing.expectEqual(false, isAllowedToJoin("ed25519:known")); + try std.testing.expectEqual(true, lookupIndex("ed25519:known").?.blocked); try std.testing.expectEqual(.added, add("ed25519:known")); - try std.testing.expectEqual(true, isAllowedToJoin("ed25519:known")); + try std.testing.expectEqual(false, lookupIndex("ed25519:known").?.blocked); } diff --git a/src/server/server.zig b/src/server/server.zig index 4860d956cd..f2afea50dd 100644 --- a/src/server/server.zig +++ b/src/server/server.zig @@ -135,6 +135,7 @@ pub const User = struct { // MARK: User newKeyString: ?[]const u8 = null, key: network.authentication.PublicKey = undefined, legacyKey: ?network.authentication.PublicKey = null, + allowedToJoin: bool = false, inventoryClientToServerIdMap: std.AutoHashMap(InventoryId, InventoryId) = undefined, inventory: ?InventoryId = null, @@ -232,9 +233,10 @@ pub const User = struct { // MARK: User self.jobQueue.deinit(); } - pub fn identifyFromKeysAndName(self: *User, name: []const u8, keys: main.ZonElement) !void { + pub fn identifyFromKeysAndName(self: *User, name: []const u8, keys: main.ZonElement, whitelistEnabled: bool) !void { std.debug.assert(self.name.len == 0); self.name = main.globalAllocator.dupe(u8, name); + self.allowedToJoin = !whitelistEnabled; { const keyBase64 = keys.get([]const u8, @tagName(main.settings.launchConfig.preferredAuthenticationAlgorithm)) orelse return error.PublicKeyNotPresent; self.key = try .initFromBase64(keyBase64, main.settings.launchConfig.preferredAuthenticationAlgorithm); @@ -245,7 +247,9 @@ pub const User = struct { // MARK: User const keyBase64 = keys.get([]const u8, keyTypeName) orelse continue; const keyWithType = main.stackAllocator.print("{s}:{s}", .{keyTypeName, keyBase64}); defer main.stackAllocator.free(keyWithType); - self.playerIndex = main.server.players.lookupIndex(keyWithType) orelse continue; + const lookup = main.server.players.lookupIndex(keyWithType) orelse continue; + self.playerIndex = lookup.playerIndex; + self.allowedToJoin = !lookup.blocked; foundKey = true; const keyType = std.meta.stringToEnum(main.network.authentication.KeyTypeEnum, keyTypeName).?; if (keyType == self.key) break; @@ -256,10 +260,16 @@ pub const User = struct { // MARK: User if (main.server.players.isEmpty()) { // Claim the local player std.log.info("Here", .{}); self.playerIndex = main.server.players.getLocalPlayerIndex(); + self.allowedToJoin = true; } else { const nameEntry = main.stackAllocator.print("name:{s}", .{name}); defer main.stackAllocator.free(nameEntry); - self.playerIndex = main.server.players.lookupIndex(nameEntry) orelse main.server.players.allocateNewIndex(); + if (main.server.players.lookupIndex(nameEntry)) |lookup| { + self.playerIndex = lookup.playerIndex; + self.allowedToJoin = !lookup.blocked; + } else { + self.playerIndex = main.server.players.allocateNewIndex(); + } } } } diff --git a/src/server/world.zig b/src/server/world.zig index 2aba5ac0b0..ba3bf1c9de 100644 --- a/src/server/world.zig +++ b/src/server/world.zig @@ -35,6 +35,7 @@ pub const Settings = struct { defaultGamemode: Gamemode = .creative, allowCheats: bool = true, testingMode: bool = false, + whitelistEnabled: std.atomic.Value(bool) = .init(false), seed: u64 = undefined, pub const defaults: Settings = .{}; @@ -48,15 +49,17 @@ pub const Settings = struct { .defaultGamemode = std.meta.stringToEnum(main.game.Gamemode, zon.get([]const u8, "defaultGamemode") orelse @tagName(defaults.defaultGamemode)) orelse defaults.defaultGamemode, .allowCheats = zon.get(bool, "allowCheats") orelse defaults.allowCheats, .testingMode = zon.get(bool, "testingMode") orelse defaults.testingMode, + .whitelistEnabled = .init(zon.get(bool, "whitelistEnabled") orelse defaults.whitelistEnabled.load(.monotonic)), }; } - pub fn toZon(self: Settings, allocator: NeverFailingAllocator) ZonElement { + pub fn toZon(self: *const Settings, allocator: NeverFailingAllocator) ZonElement { const zon = main.ZonElement.initObject(allocator); zon.put("defaultGamemode", @tagName(self.defaultGamemode)); zon.put("allowCheats", self.allowCheats); zon.put("testingMode", self.testingMode); + zon.put("whitelistEnabled", self.whitelistEnabled.load(.monotonic)); zon.put("seed", self.seed); return zon; @@ -665,6 +668,7 @@ pub const ServerWorld = struct { // MARK: ServerWorld worldData.put("name", self.name); worldData.put("lastUsedTime", std.Io.Clock.Timestamp.now(main.io, .real).raw.toMilliseconds()); worldData.put("tickSpeed", self.tickSpeed.load(.monotonic)); + worldData.put("settings", self.settings.toZon(main.stackAllocator)); worldData.put("localPlayer", players.getLocalPlayerIndex()); try files.cubyzDir().writeZon(path, worldData);