using System.Net; using System.Net.Sockets; using System.Text.Json; using System.Text.Json.Nodes; namespace CommonwealthOnline.Server; internal sealed class ServerOptions { public string Host { get; set; } = "0.0.0.0"; public int Port { get; set; } = 7777; public string ServerName { get; set; } = "Commonwealth Online Server"; public string ServerDescription { get; set; } = string.Empty; public int MaxPlayers { get; set; } = 16; public string LogVerbosity { get; set; } = "info"; public int AdminPort { get; set; } = 7779; public bool EnableGnsTransport { get; set; } public string? GnsBridgePath { get; set; } public string ConfigPath { get; set; } = Path.GetFullPath("commonwealth-server.json"); public string BaseDirectory => Path.GetDirectoryName(ConfigPath)!; public string BansPath => Path.Combine(BaseDirectory, "bans.json"); public string AdminTokenPath => Path.Combine(BaseDirectory, ".admin-token"); public static ServerOptions Load(string path) { var fullPath = Path.GetFullPath(path); if (!File.Exists(fullPath)) throw new FileNotFoundException("Config file not found", fullPath); var root = JsonNode.Parse(File.ReadAllBytes(fullPath)) as JsonObject ?? throw new InvalidDataException("Config file must contain a JSON object at root level."); var options = new ServerOptions { ConfigPath = fullPath }; options.Host = JsonHelpers.String(root["host"]) ?? options.Host; options.Port = ReadInt(root["port"], options.Port, "port"); options.ServerName = JsonHelpers.String(root["server_name"] ?? root["serverName"]) ?? options.ServerName; options.ServerDescription = JsonHelpers.String(root["server_description"] ?? root["serverDescription"]) ?? options.ServerDescription; options.MaxPlayers = ReadInt(root["max_players"] ?? root["maxPlayers"], options.MaxPlayers, "max_players"); options.LogVerbosity = (JsonHelpers.String(root["log_verbosity"] ?? root["logVerbosity"]) ?? options.LogVerbosity).Trim().ToLowerInvariant(); options.AdminPort = ReadInt(root["admin_port"] ?? root["adminPort"], options.AdminPort, "admin_port"); options.EnableGnsTransport = ReadBool(root["enable_gns_transport"] ?? root["enableGnsTransport"], false, "enable_gns_transport"); var bridge = JsonHelpers.String(root["gns_bridge_path"] ?? root["gnsBridgePath"]); options.GnsBridgePath = string.IsNullOrWhiteSpace(bridge) ? null : bridge.Trim(); return options; } public static ServerOptions CreateDefault(string path) { var options = new ServerOptions { ConfigPath = Path.GetFullPath(path) }; options.Save(); return options; } public void Save() { Directory.CreateDirectory(BaseDirectory); var root = new JsonObject { ["host"] = Host, ["port"] = Port, ["server_name"] = ServerName, ["server_description"] = ServerDescription, ["max_players"] = MaxPlayers, ["log_verbosity"] = LogVerbosity, ["admin_port"] = AdminPort, ["enable_gns_transport"] = EnableGnsTransport, ["gns_bridge_path"] = GnsBridgePath }; using var stream = File.Create(ConfigPath); JsonSerializer.Serialize(stream, root, new JsonSerializerOptions { WriteIndented = true }); stream.WriteByte((byte)'\n'); } public IReadOnlyList Validate() { var errors = new List(); if (string.IsNullOrWhiteSpace(Host)) errors.Add("host cannot be empty"); else if (!TryResolveIpv4(Host, out _)) errors.Add($"host '{Host}' is not a valid IPv4 address or resolvable hostname"); else if (EnableGnsTransport && (!IPAddress.TryParse(Host.Trim(), out var gnsAddress) || gnsAddress.AddressFamily != AddressFamily.InterNetwork)) errors.Add("enable_gns_transport requires host to be an explicit IPv4 bind address such as 0.0.0.0 or 127.0.0.1"); if (Port is < 1 or > 65535) errors.Add($"port must be 1-65535, got {Port}"); if (AdminPort is < 1 or > 65535) errors.Add($"admin_port must be 1-65535, got {AdminPort}"); else if (AdminPort == Port) errors.Add("admin_port must differ from the game port"); if (string.IsNullOrEmpty(ServerName)) errors.Add("server_name cannot be empty"); else if (ServerName.Length > 64) errors.Add($"server_name must be <= 64 characters, got {ServerName.Length}"); if (ServerDescription.Length > 256) errors.Add($"server_description must be <= 256 characters, got {ServerDescription.Length}"); if (MaxPlayers < 1) errors.Add($"max_players must be >= 1, got {MaxPlayers}"); else if (MaxPlayers > 256) errors.Add($"max_players must be <= 256, got {MaxPlayers}"); if (LogVerbosity is not ("debug" or "info" or "warning" or "error")) errors.Add($"log_verbosity must be debug/info/warning/error, got {LogVerbosity}"); return errors; } public static bool TryResolveIpv4(string host, out IPAddress address) { if (IPAddress.TryParse(host.Trim(), out var parsed) && parsed.AddressFamily == AddressFamily.InterNetwork) { address = parsed; return true; } try { address = Dns.GetHostAddresses(host.Trim()).First(x => x.AddressFamily == AddressFamily.InterNetwork); return true; } catch { address = IPAddress.None; return false; } } private static int ReadInt(JsonNode? node, int defaultValue, string field) { if (node is null) return defaultValue; if (node is JsonValue value && value.TryGetValue(out var number)) return number; if (node is JsonValue textValue && textValue.TryGetValue(out var text) && int.TryParse(text, out number)) return number; throw new InvalidDataException($"Invalid numeric config field: {field}"); } private static bool ReadBool(JsonNode? node, bool defaultValue, string field) { if (node is null) return defaultValue; if (node is JsonValue value && value.TryGetValue(out var boolean)) return boolean; if (node is JsonValue intValue && intValue.TryGetValue(out var number) && number is 0 or 1) return number == 1; if (node is JsonValue textValue && textValue.TryGetValue(out var text)) { switch (text.Trim().ToLowerInvariant()) { case "1": case "true": case "yes": case "on": return true; case "0": case "false": case "no": case "off": case "": return false; } } throw new InvalidDataException($"{field} must be a boolean or one of true/false, yes/no, on/off, 1/0"); } }