diff --git a/.gitignore b/.gitignore index 5ac4b6aad..b86d68654 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,5 @@ packages/* # Private key files # scripts/ssh_private_key + +.idea/ diff --git a/TShockAPI/Commands.cs b/TShockAPI/Commands.cs index 161834318..5d94fbe36 100644 --- a/TShockAPI/Commands.cs +++ b/TShockAPI/Commands.cs @@ -1742,13 +1742,28 @@ void BanDetails() private static void Whitelist(CommandArgs args) { - if (args.Parameters.Count == 1) + if (args.Parameters is [{ } ip]) { - using (var tw = new StreamWriter(FileTools.WhitelistPath, true)) + // Warn if IP addr/net is v6 + if (ip.Contains(':')) + { + args.Player.SendWarningMessage(GetString( + "IPv6 addresses are not supported as of yet by TShock. This rule will have no effect for now. Adding anyways." + )); + } + + if (TShock.Whitelist.AddToWhitelist(ip)) + { + args.Player.SendSuccessMessage(GetString($"Added {ip} to the whitelist.")); + } + else { - tw.WriteLine(args.Parameters[0]); + args.Player.SendErrorMessage(GetString($"Failed to add {ip} to the whitelist. Perhaps it is already whitelisted?")); } - args.Player.SendSuccessMessage(GetString($"Added {args.Parameters[0]} to the whitelist.")); + } + else + { + args.Player.SendErrorMessage(GetString($"Invalid Whitelist syntax. Usage: {Specifier}whitelist ")); } } diff --git a/TShockAPI/FileTools.cs b/TShockAPI/FileTools.cs index a3d6c1c20..5fe6ea1e3 100644 --- a/TShockAPI/FileTools.cs +++ b/TShockAPI/FileTools.cs @@ -49,10 +49,7 @@ internal static string MotdPath /// /// Path to the file containing the whitelist. /// - internal static string WhitelistPath - { - get { return Path.Combine(TShock.SavePath, "whitelist.txt"); } - } + internal static string WhitelistPath => Path.Combine(TShock.SavePath, "whitelist.txt"); /// /// Path to the file containing the config. @@ -104,8 +101,8 @@ public static void SetupConfig() CreateIfNot(RulesPath, "Respect the admins!\nDon't use TNT!"); CreateIfNot(MotdPath, MotdFormat); - - CreateIfNot(WhitelistPath); + + CreateIfNot(WhitelistPath, Whitelist.DefaultWhitelistContent); bool writeConfig = true; // Default to true if the file doesn't exist if (File.Exists(ConfigPath)) { @@ -140,39 +137,6 @@ public static void SetupConfig() } } - /// - /// Tells if a user is on the whitelist - /// - /// string ip of the user - /// true/false - public static bool OnWhitelist(string ip) - { - if (!TShock.Config.Settings.EnableWhitelist) - { - return true; - } - CreateIfNot(WhitelistPath, "127.0.0.1"); - using (var tr = new StreamReader(WhitelistPath)) - { - string whitelist = tr.ReadToEnd(); - ip = TShock.Utils.GetRealIP(ip); - bool contains = whitelist.Contains(ip); - if (!contains) - { - foreach (var line in whitelist.Split(Environment.NewLine.ToCharArray())) - { - if (string.IsNullOrWhiteSpace(line)) - continue; - contains = TShock.Utils.GetIPv4AddressFromHostname(line).Equals(ip); - if (contains) - return true; - } - return false; - } - return true; - } - } - /// /// Looks for a 'Settings' token in the json object. If one is not found, returns a new json object with all tokens of the previous object added /// as children to a root 'Settings' token diff --git a/TShockAPI/TShock.cs b/TShockAPI/TShock.cs index 99153deb8..e683835e7 100644 --- a/TShockAPI/TShock.cs +++ b/TShockAPI/TShock.cs @@ -128,6 +128,12 @@ public class TShock : TerrariaPlugin public static ILog Log; /// instance - Static reference to the TerrariaPlugin instance. public static TerrariaPlugin instance; + + /// + /// Whitelist - Static reference to the whitelist system, which allows whitelisting of IP addresses and networks. + /// + public static Whitelist Whitelist { get; set; } + /// /// Static reference to a used for simple command-line parsing /// @@ -354,6 +360,7 @@ public override void Initialize() Bouncer = new Bouncer(); RegionSystem = new RegionHandler(Regions); ItemBans = new ItemBans(this, DB); + Whitelist = new(FileTools.WhitelistPath); var geoippath = "GeoIP.dat"; if (Config.Settings.EnableGeoIP && File.Exists(geoippath)) @@ -1317,7 +1324,7 @@ private void OnConnect(ConnectEventArgs args) return; } - if (!FileTools.OnWhitelist(player.IP)) + if (!Whitelist.IsWhitelisted(player.IP)) { player.Kick(Config.Settings.WhitelistKickReason, true, true, null, false); args.Handled = true; diff --git a/TShockAPI/Utils.cs b/TShockAPI/Utils.cs index 7a3cad6ce..db221b447 100644 --- a/TShockAPI/Utils.cs +++ b/TShockAPI/Utils.cs @@ -1,4 +1,4 @@ -/* +/* TShock, a server mod for Terraria Copyright (C) 2011-2019 Pryaxis & TShock Contributors @@ -608,6 +608,7 @@ public void Reload() TShock.ProjectileBans.UpdateBans(); TShock.TileBans.UpdateBans(); TShock.Bans.UpdateBans(); + TShock.Whitelist.ReloadFromFile(); } /// diff --git a/TShockAPI/Whitelist.cs b/TShockAPI/Whitelist.cs new file mode 100644 index 000000000..a53d5d69d --- /dev/null +++ b/TShockAPI/Whitelist.cs @@ -0,0 +1,292 @@ +/* +TShock, a server mod for Terraria +Copyright (C) 2011-2025 Pryaxis & TShock Contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using TShockAPI.Configuration; + +namespace TShockAPI; + +/// +/// Provides the storage for a whitelist. +/// +public sealed class Whitelist +{ + private readonly FileInfo _file; + private readonly Lock _fileLock = new(); + + private readonly HashSet _whitelistAddresses = []; + private readonly HashSet _whitelistNetworks = []; + + /// + /// Defines if the whitelist is enabled or not. + /// + /// Shorthand to the current setting. + private bool Enabled => TShock.Config.Settings.EnableWhitelist; + + internal const string DefaultWhitelistContent = /*lang=conf*/ + """ + # Localhost + 127.0.0.1 + + # Uncomment to allow IPs within private ranges + # 10.0.0.0/8 + # 172.16.0.0/12 + # 192.168.0.0/16 + """; + + internal const char CommentPrefix = '#'; + + /// + /// Initializes a new instance of the class. + /// Creates the whitelist file if it does not exist on disk. + /// + public Whitelist(string path) + { + _file = new(path); + + if (!_file.Exists) + { + throw new FileNotFoundException("The whitelist file does not exist", _file.FullName); + } + + ReadWhitelistFromFile(); + } + + /// + /// Tells if a user is on the whitelist + /// + /// string ip of the user + /// true/false + public bool IsWhitelisted(string host) + { + if (!Enabled) + { + return true; + } + + if (!IPAddress.TryParse(host, out IPAddress? ip)) + { + throw new ArgumentException($"The provided host '{host}' is not a valid IP address.", nameof(host)); + } + + // HACK: Terraria doesn't support IPv6 yet, so we can't check for it. + // Remove once TShock supports IPv6. + if (ip.AddressFamily is AddressFamily.InterNetworkV6) + { + TShock.Log.Warn(GetString($"IPv6 address '{ip}' is not supported by Terraria. Skipping check.")); + TShock.Log.Warn(GetString("If you somehow managed to get this message, please report it to the TShock team :")); + TShock.Log.Warn(GetString("https://github.com/Pryaxis/TShock/issues")); + + return false; + } + + // First check if the IP address is directly whitelisted + return _whitelistAddresses.Contains(ip) + // Otherwise is it contained within a whitelisted network? + || _whitelistNetworks.Any(n => n.Contains(ip)); + } + + /// + /// Reloads the whitelist from the file. + /// + public void ReloadFromFile() + { + lock (_fileLock) + { + _whitelistAddresses.Clear(); + _whitelistNetworks.Clear(); + ReadWhitelistFromFile(); + } + } + + private void ReadWhitelistFromFile() + { + using StreamReader sr = _file.OpenText(); + + int i = 0; + while (!sr.EndOfStream) + { + ReadWhitelistLine(sr.ReadLine(), i); + i++; + } + } + + private void ReadWhitelistLine(scoped ReadOnlySpan content, int line) + { + // Ignore blank line or comment + if (content is [] or [CommentPrefix, ..] || content.IsWhiteSpace()) + { + return; + } + + // Try parse first IP range, which uses CIDR sep as discriminator. + if (IPNetwork.TryParse(content, out IPNetwork range)) + { + _whitelistNetworks.Add(range); + } + else if (IPAddress.TryParse(content, out IPAddress? ip)) + { + _whitelistAddresses.Add(ip); + } + else + { + // If we reach here, the line is not a valid IP address or network. + // We could throw this, but for now we just log and ignore it. + TShock.Log.Warn(GetString($"Invalid whitelist entry at line {line}: \"{content.ToString()}\", skipped")); + } + } + + /// + /// Adds an IP address or network to the whitelist. + /// + /// The IP address or network to add. + /// true if the address or network was added successfully; otherwise, false. + public bool AddToWhitelist(scoped ReadOnlySpan ip) + { + if (IPNetwork.TryParse(ip, out IPNetwork range)) + { + return AddToWhitelist(range); + } + + if (IPAddress.TryParse(ip, out IPAddress? address)) + { + return AddToWhitelist(address); + } + + return false; + } + + /// + /// Removes an IP address or network from the whitelist. + /// + /// The IP address or network to remove. + /// >true if the address or network was removed successfully; otherwise, false. + public bool RemoveFromWhitelist(scoped ReadOnlySpan ip) + { + if (IPNetwork.TryParse(ip, out IPNetwork range)) + { + return RemoveFromWhitelist(range); + } + + if (IPAddress.TryParse(ip, out IPAddress? address)) + { + return RemoveFromWhitelist(address); + } + + return false; + } + + + private bool AddToWhitelist(IPAddress ip) + => _whitelistAddresses.Add(ip) + & AddLine(ip.ToString()); + + private bool AddToWhitelist(IPNetwork network) + => _whitelistNetworks.Add(network) + & AddLine(network.ToString()); + + private bool RemoveFromWhitelist(IPAddress ip) + => _whitelistAddresses.Remove(ip) + & RemoveLine(ip.ToString()); + + private bool RemoveFromWhitelist(IPNetwork network) + => _whitelistNetworks.Remove(network) + & RemoveLine(network.ToString()); + + private bool AddLine(scoped ReadOnlySpan content) + { + lock (_fileLock) + { + // Case: File does not end with a newline, add one + bool needsNewLine; + + using (FileStream fs = _file.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + fs.Seek(-1, SeekOrigin.End); + needsNewLine = fs.Length > 0 && fs.ReadByte() is not '\n'; + } + + using StreamWriter sw = _file.AppendText(); + + if (needsNewLine) + { + sw.WriteLine(); + } + + sw.WriteLine(content); + return true; + } + } + + + private bool RemoveLine(scoped ReadOnlySpan content) + { + if (content is []) + { + throw new ArgumentException("Content cannot be empty.", nameof(content)); + } + + lock (_fileLock) + { + string tempFile = Path.GetTempFileName(); + + using StreamReader sr = _file.OpenText(); + using StreamWriter sw = new(tempFile); + + bool removed = false; + + while (!sr.EndOfStream) + { + scoped ReadOnlySpan line = sr.ReadLine(); + + // If the line does not match the content, write it to the temp file + if (line != content) + { + sw.WriteLine(line); + } + else + { + removed = true; + } + } + + // If we removed a line, we need to overwrite the original file + if (removed) + { + sw.Flush(); + + _file.Delete(); + File.Move(tempFile, _file.FullName); + } + else + { + File.Delete(tempFile); + } + + return removed; + } + } +}