Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions TShockAPI/Commands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1742,12 +1742,8 @@ void BanDetails()

private static void Whitelist(CommandArgs args)
{
if (args.Parameters.Count == 1)
if (args.Parameters is [{ } ip] && TShock.Whitelist.AddToWhitelist(ip))
{
using (var tw = new StreamWriter(FileTools.WhitelistPath, true))
{
tw.WriteLine(args.Parameters[0]);
}
args.Player.SendSuccessMessage(GetString($"Added {args.Parameters[0]} to the whitelist."));
}
}
Expand Down
42 changes: 3 additions & 39 deletions TShockAPI/FileTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,7 @@ internal static string MotdPath
/// <summary>
/// Path to the file containing the whitelist.
/// </summary>
internal static string WhitelistPath
{
get { return Path.Combine(TShock.SavePath, "whitelist.txt"); }
}
internal static string WhitelistPath => Path.Combine(TShock.SavePath, "whitelist.txt");

/// <summary>
/// Path to the file containing the config.
Expand Down Expand Up @@ -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))
{
Expand Down Expand Up @@ -140,39 +137,6 @@ public static void SetupConfig()
}
}

/// <summary>
/// Tells if a user is on the whitelist
/// </summary>
/// <param name="ip">string ip of the user</param>
/// <returns>true/false</returns>
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;
}
}

/// <summary>
/// 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
Expand Down
9 changes: 8 additions & 1 deletion TShockAPI/TShock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,12 @@ public class TShock : TerrariaPlugin
public static ILog Log;
/// <summary>instance - Static reference to the TerrariaPlugin instance.</summary>
public static TerrariaPlugin instance;

/// <summary>
/// Whitelist - Static reference to the whitelist system, which allows whitelisting of IP addresses and networks.
/// </summary>
public static Whitelist Whitelist { get; set; }

/// <summary>
/// Static reference to a <see cref="CommandLineParser"/> used for simple command-line parsing
/// </summary>
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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;
Expand Down
270 changes: 270 additions & 0 deletions TShockAPI/Whitelist.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
#nullable enable

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading;
using TShockAPI.Configuration;

namespace TShockAPI;

/// <summary>
/// Provides the storage for a whitelist.
/// </summary>
public sealed class Whitelist
{
private readonly FileInfo _file;
private readonly Lock _fileLock = new();

private readonly HashSet<IPAddress> _whitelistAddresses = [];
private readonly HashSet<IPNetwork> _whitelistNetworks = [];

/// <summary>
/// Defines if the whitelist is enabled or not.
/// </summary>
/// <remarks>Shorthand to the current <see cref="TShockSettings.EnableWhitelist" /> setting.</remarks>
private bool Enabled => TShock.Config.Settings.EnableWhitelist;

internal const string DefaultWhitelistContent = /*lang=conf*/
"""
# Localhost
127.0.0.1
::1
# Uncomment to allow IPs within private ranges
# 10.0.0.0/8
# 172.16.0.0/12
# 192.168.0.0/16
# fe80::/10
# fd00::/8
""";

internal const char CommentPrefix = '#';

/// <summary>
/// Initializes a new instance of the <see cref="Whitelist"/> class.
/// Creates the whitelist file if it does not exist on disk.
/// </summary>
public Whitelist(string path)
{
_file = new(path);

if (!_file.Exists)
{
throw new FileNotFoundException("The whitelist file does not exist", _file.FullName);
}

ReadWhitelistFromFile();
}

/// <summary>
/// Tells if a user is on the whitelist
/// </summary>
/// <param name="host">string ip of the user</param>
/// <returns>true/false</returns>
public bool IsWhitelisted(string host)
{
if (!Enabled)
{
return true;
}

if (!IPAddress.TryParse(TShock.Utils.GetRealIP(host), out IPAddress? ip))
{
throw new ArgumentException($"The provided host '{host}' is not a valid IP address.", nameof(host));
}

// 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));
}

private void ReadWhitelistFromFile()
{
using StreamReader sr = _file.OpenText();

int i = 0;
while (!sr.EndOfStream)
{
ReadWhitelistLine(sr.ReadLine(), i);
i++;
}
}

private void ReadWhitelistLine(scoped ReadOnlySpan<char> content, int line)
{
// Ignore blank line or comment
if (content is [] or [CommentPrefix, ..])
{
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($"Invalid whitelist entry at line {line}: \"{content.ToString()}\", skipped");
}
}

/// <summary>
/// Adds an IP address or network to the whitelist.
/// </summary>
/// <param name="ip">The IP address or network to add.</param>
/// <returns>true if the address or network was added successfully; otherwise, false.</returns>
public bool AddToWhitelist(scoped ReadOnlySpan<char> ip)
{
if (IPNetwork.TryParse(ip, out IPNetwork range))
{
return AddToWhitelist(range);
}

if (IPAddress.TryParse(ip, out IPAddress? address))
{
return AddToWhitelist(address);
}

return false;
}

/// <summary>
/// Removes an IP address or network from the whitelist.
/// </summary>
/// <param name="ip">The IP address or network to remove.</param>
/// <returns>>true if the address or network was removed successfully; otherwise, false.</returns>
public bool RemoveFromWhitelist(scoped ReadOnlySpan<char> 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<char> content)
{
lock (_fileLock)
{

using StreamWriter sw = _file.AppendText();

// Case: File does not end with a newline, add one
bool needsNewLine;

using (FileStream fs = _file.OpenRead())
{
fs.Seek(-1, SeekOrigin.End);
needsNewLine = fs.Length > 0 && fs.ReadByte() != '\n';
}

if (needsNewLine)
{
sw.WriteLine();
}

sw.WriteLine(content);
return true;
}
}


private bool RemoveLine(scoped ReadOnlySpan<char> 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<char> 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;
}
}
}
Loading