From db87c14a75a2cf19169e4d0b8775feb6ed615de3 Mon Sep 17 00:00:00 2001 From: Eveldee Date: Wed, 17 Jul 2019 21:01:14 +0200 Subject: [PATCH] Initial commit. --- .gitignore | 3 +- DearFTP/Configurations/Configuration.cs | 71 +++++ DearFTP/Configurations/ServerConfiguration.cs | 18 ++ DearFTP/Configurations/Share.cs | 28 ++ DearFTP/Configurations/User.cs | 25 ++ DearFTP/Connection/Commands/ClntCommand.cs | 19 ++ .../Connection/Commands/CommandsDispatcher.cs | 55 ++++ DearFTP/Connection/Commands/CwdCommand.cs | 27 ++ DearFTP/Connection/Commands/DeleteCommand.cs | 70 +++++ .../Connection/Commands/FeaturesCommand.cs | 31 ++ .../Commands/FileModificationTimeCommand.cs | 70 +++++ DearFTP/Connection/Commands/HelpCommand.cs | 72 +++++ DearFTP/Connection/Commands/ICommand.cs | 13 + DearFTP/Connection/Commands/ListCommand.cs | 141 +++++++++ .../Connection/Commands/ListMachineCommand.cs | 144 +++++++++ .../Commands/MakeDirectoryCommand.cs | 56 ++++ .../Commands/ParentDirectoryCommand.cs | 27 ++ DearFTP/Connection/Commands/PassiveCommand.cs | 30 ++ DearFTP/Connection/Commands/PwdCommand.cs | 20 ++ DearFTP/Connection/Commands/QuitCommand.cs | 20 ++ DearFTP/Connection/Commands/RenameCommand.cs | 80 +++++ .../Connection/Commands/RetrieveCommand.cs | 71 +++++ DearFTP/Connection/Commands/SiteCommand.cs | 19 ++ DearFTP/Connection/Commands/SizeCommand.cs | 37 +++ DearFTP/Connection/Commands/StoreCommand.cs | 79 +++++ DearFTP/Connection/Commands/SystemCommand.cs | 19 ++ DearFTP/Connection/Commands/TypeCommand.cs | 20 ++ DearFTP/Connection/Commands/UserCommand.cs | 60 ++++ DearFTP/Connection/DataConnection.cs | 59 ++++ DearFTP/Connection/FtpStream.cs | 80 +++++ DearFTP/Connection/ResponseCode.cs | 216 ++++++++++++++ DearFTP/Connection/Session.cs | 91 ++++++ DearFTP/FtpServer.cs | 91 ++++++ DearFTP/Logger.cs | 56 ++++ DearFTP/Program.cs | 50 +++- DearFTP/Utils/NavigablePath.cs | 275 ++++++++++++++++++ DearFTP/Utils/PasswordHash.cs | 21 ++ 37 files changed, 2261 insertions(+), 3 deletions(-) create mode 100644 DearFTP/Configurations/Configuration.cs create mode 100644 DearFTP/Configurations/ServerConfiguration.cs create mode 100644 DearFTP/Configurations/Share.cs create mode 100644 DearFTP/Configurations/User.cs create mode 100644 DearFTP/Connection/Commands/ClntCommand.cs create mode 100644 DearFTP/Connection/Commands/CommandsDispatcher.cs create mode 100644 DearFTP/Connection/Commands/CwdCommand.cs create mode 100644 DearFTP/Connection/Commands/DeleteCommand.cs create mode 100644 DearFTP/Connection/Commands/FeaturesCommand.cs create mode 100644 DearFTP/Connection/Commands/FileModificationTimeCommand.cs create mode 100644 DearFTP/Connection/Commands/HelpCommand.cs create mode 100644 DearFTP/Connection/Commands/ICommand.cs create mode 100644 DearFTP/Connection/Commands/ListCommand.cs create mode 100644 DearFTP/Connection/Commands/ListMachineCommand.cs create mode 100644 DearFTP/Connection/Commands/MakeDirectoryCommand.cs create mode 100644 DearFTP/Connection/Commands/ParentDirectoryCommand.cs create mode 100644 DearFTP/Connection/Commands/PassiveCommand.cs create mode 100644 DearFTP/Connection/Commands/PwdCommand.cs create mode 100644 DearFTP/Connection/Commands/QuitCommand.cs create mode 100644 DearFTP/Connection/Commands/RenameCommand.cs create mode 100644 DearFTP/Connection/Commands/RetrieveCommand.cs create mode 100644 DearFTP/Connection/Commands/SiteCommand.cs create mode 100644 DearFTP/Connection/Commands/SizeCommand.cs create mode 100644 DearFTP/Connection/Commands/StoreCommand.cs create mode 100644 DearFTP/Connection/Commands/SystemCommand.cs create mode 100644 DearFTP/Connection/Commands/TypeCommand.cs create mode 100644 DearFTP/Connection/Commands/UserCommand.cs create mode 100644 DearFTP/Connection/DataConnection.cs create mode 100644 DearFTP/Connection/FtpStream.cs create mode 100644 DearFTP/Connection/ResponseCode.cs create mode 100644 DearFTP/Connection/Session.cs create mode 100644 DearFTP/FtpServer.cs create mode 100644 DearFTP/Logger.cs create mode 100644 DearFTP/Utils/NavigablePath.cs create mode 100644 DearFTP/Utils/PasswordHash.cs diff --git a/.gitignore b/.gitignore index 4ce6fdd..31aeb6d 100644 --- a/.gitignore +++ b/.gitignore @@ -337,4 +337,5 @@ ASALocalRun/ .localhistory/ # BeatPulse healthcheck temp database -healthchecksdb \ No newline at end of file +healthchecksdb +/.vscode diff --git a/DearFTP/Configurations/Configuration.cs b/DearFTP/Configurations/Configuration.cs new file mode 100644 index 0000000..f74fa1f --- /dev/null +++ b/DearFTP/Configurations/Configuration.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using YamlDotNet.Serialization; + +namespace DearFTP.Configurations +{ + class Configuration + { + public const string ConfigurationPath = "config.yml"; + + public static Configuration Load() + { + Configuration configuration; + + if (File.Exists(ConfigurationPath)) + { + var deserializer = new DeserializerBuilder().Build(); + + configuration = deserializer.Deserialize(File.ReadAllText(ConfigurationPath)); + } + else + { + configuration = new Configuration(); + configuration.Save(); + } + + return configuration; + } + + public ServerConfiguration Server { get; set; } = new ServerConfiguration(); + public Share[] Shares { get; set; } = Array.Empty(); + public User[] Users { get; set; } = Array.Empty(); + + public Configuration() + { + + } + + public bool Check() + { + foreach (var share in Shares) + { + share.Name = Path.GetFileName(share.Path); + + if (!Directory.Exists(share.Path)) + { + Console.WriteLine($"Path does not exist for '{share.Name}' share!"); + return false; + } + } + + if (!string.IsNullOrWhiteSpace(Server.Guest) && Users.Count(x => x.Name == Server.Guest) != 1) + { + Console.WriteLine($"Guest user does not exist: {Server.Guest}"); + return false; + } + + return true; + } + + public void Save() + { + var serializer = new SerializerBuilder().EmitDefaults().Build(); + + File.WriteAllText(ConfigurationPath, serializer.Serialize(this)); + } + } +} diff --git a/DearFTP/Configurations/ServerConfiguration.cs b/DearFTP/Configurations/ServerConfiguration.cs new file mode 100644 index 0000000..7914c8b --- /dev/null +++ b/DearFTP/Configurations/ServerConfiguration.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace DearFTP.Configurations +{ + class ServerConfiguration + { + public ushort Port { get; set; } = 21; + public string MOTD { get; set; } = "DearFTP v0.1"; + public string LoginMessage { get; set; } = "Logged in as %user%"; + + public bool LogFileEnabled { get; set; } = false; + public string LogFilePath { get; set; } = "log.txt"; + + public string Guest { get; set; } = ""; + } +} diff --git a/DearFTP/Configurations/Share.cs b/DearFTP/Configurations/Share.cs new file mode 100644 index 0000000..338f8a0 --- /dev/null +++ b/DearFTP/Configurations/Share.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Text; +using YamlDotNet.Serialization; + +namespace DearFTP.Configurations +{ + class Share + { + [YamlIgnore] + public string Name { get; set; } + + public string Path { get; set; } + public string Group { get; set; } + + public Share() + { + + } + + public Share(string name, string path, string group) + { + Name = name; + Path = path; + Group = group; + } + } +} diff --git a/DearFTP/Configurations/User.cs b/DearFTP/Configurations/User.cs new file mode 100644 index 0000000..140ed54 --- /dev/null +++ b/DearFTP/Configurations/User.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace DearFTP.Configurations +{ + class User + { + public string Name { get; set; } + public string Password { get; set; } + public string[] Groups { get; set; } + + public User() + { + + } + + public User(string name, string password, string[] groups) + { + Name = name; + Password = password; + Groups = groups; + } + } +} diff --git a/DearFTP/Connection/Commands/ClntCommand.cs b/DearFTP/Connection/Commands/ClntCommand.cs new file mode 100644 index 0000000..80fde87 --- /dev/null +++ b/DearFTP/Connection/Commands/ClntCommand.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace DearFTP.Connection.Commands +{ + class ClntCommand : ICommand + { + public string[] Aliases { get; } = new string[] + { + "CLNT" + }; + + public void Execute(Session session, FtpStream stream, string alias, string argument) + { + stream.Send(ResponseCode.OK, "OK"); + } + } +} diff --git a/DearFTP/Connection/Commands/CommandsDispatcher.cs b/DearFTP/Connection/Commands/CommandsDispatcher.cs new file mode 100644 index 0000000..85b085e --- /dev/null +++ b/DearFTP/Connection/Commands/CommandsDispatcher.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DearFTP.Connection.Commands +{ + class CommandsDispatcher + { + public ICommand[] Commands { get; } = new ICommand[] + { + new ClntCommand(), + new CwdCommand(), + new DeleteCommand(), + new HelpCommand(), + new FeaturesCommand(), + new FileModificationTimeCommand(), + new ListCommand(), + new ListMachineCommand(), + new MakeDirectoryCommand(), + new ParentDirectoryCommand(), + new PassiveCommand(), + new PwdCommand(), + new QuitCommand(), + new RenameCommand(), + new RetrieveCommand(), + new SiteCommand(), + new SizeCommand(), + new StoreCommand(), + new SystemCommand(), + new TypeCommand(), + new UserCommand() + }; + + public void Dispatch(Session session, string command, string argument) + { + if (command == "END") + { + session.Stop(); + return; + } + + var commandExecutor = Commands.FirstOrDefault(x => x.Aliases.Contains(command, StringComparer.OrdinalIgnoreCase)); + + if (commandExecutor == null) + { + session.FtpStream.Send(ResponseCode.NotImplemented, $"Command '{command}' not implemented or invalid"); + + return; + } + + commandExecutor.Execute(session, session.FtpStream, command, argument); + } + } +} diff --git a/DearFTP/Connection/Commands/CwdCommand.cs b/DearFTP/Connection/Commands/CwdCommand.cs new file mode 100644 index 0000000..371745d --- /dev/null +++ b/DearFTP/Connection/Commands/CwdCommand.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace DearFTP.Connection.Commands +{ + class CwdCommand : ICommand + { + public string[] Aliases { get; } = new string[] + { + "CWD", + "XCWD" + }; + + public void Execute(Session session, FtpStream stream, string alias, string argument) + { + if (session.NavigablePath.NavigateTo(argument)) + { + stream.Send(ResponseCode.FileActionOK, "Directory successfully changed."); + } + else + { + stream.Send(ResponseCode.FileUnavailable, "Failed to change directory."); + } + } + } +} diff --git a/DearFTP/Connection/Commands/DeleteCommand.cs b/DearFTP/Connection/Commands/DeleteCommand.cs new file mode 100644 index 0000000..ea38096 --- /dev/null +++ b/DearFTP/Connection/Commands/DeleteCommand.cs @@ -0,0 +1,70 @@ +using DearFTP.Configurations; +using DearFTP.Utils; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +namespace DearFTP.Connection.Commands +{ + class DeleteCommand : ICommand + { + public string[] Aliases { get; } = new string[] + { + "DELE", + "RMD", + "XRMD" + }; + + public void Execute(Session session, FtpStream stream, string alias, string argument) + { + (NavigablePath navigablePath, string path, bool isDirectory) = session.NavigablePath.GetSystemFilePath(argument); + + if (navigablePath == null) + { + stream.Send(ResponseCode.FileUnavailable, "File does not exist."); + return; + } + + if (navigablePath.CurrentDirectory == "/") + { + stream.Send(ResponseCode.FileUnavailable, "Operation not allowed: can't remove a share."); + return; + } + + if (!session.WritablesShares.Contains(navigablePath.CurrentShare)) + { + stream.Send(ResponseCode.FileUnavailable, "You don't have write access to this file."); + return; + } + + if (isDirectory) + { + if (alias.ToUpper() == "RMD") + { + Directory.Delete(path, true); + + stream.Send(ResponseCode.FileActionOK, "Directory successfully deleted."); + } + else + { + stream.Send(ResponseCode.ArgumentsError, "Path is a directory and not a file."); + } + } + else + { + if (alias.ToUpper() == "DELE") + { + File.Delete(path); + + stream.Send(ResponseCode.FileActionOK, "File successfully deleted"); + } + else + { + stream.Send(ResponseCode.ArgumentsError, "Path is a file and not a directory."); + } + } + } + } +} diff --git a/DearFTP/Connection/Commands/FeaturesCommand.cs b/DearFTP/Connection/Commands/FeaturesCommand.cs new file mode 100644 index 0000000..82c6fd6 --- /dev/null +++ b/DearFTP/Connection/Commands/FeaturesCommand.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace DearFTP.Connection.Commands +{ + class FeaturesCommand : ICommand + { + public string[] Aliases { get; } = new string[] + { + "FEAT", + "FEATURES" + }; + + public void Execute(Session session, FtpStream stream, string alias, string argument) + { + stream.Send + ( + ResponseCode.SystemStatusOrHelpReply, + "Features:", + "MDTM", + "MLST", + "PASV", + "REST STREAM", + "SIZE", + "TVFS", + "UTF8" + ); + } + } +} diff --git a/DearFTP/Connection/Commands/FileModificationTimeCommand.cs b/DearFTP/Connection/Commands/FileModificationTimeCommand.cs new file mode 100644 index 0000000..1a7f791 --- /dev/null +++ b/DearFTP/Connection/Commands/FileModificationTimeCommand.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; + +namespace DearFTP.Connection.Commands +{ + class FileModificationTimeCommand : ICommand + { + public const string DateFormat = "yyyyMMddhhmmss"; + + public string[] Aliases { get; } = new string[] + { + "MDTM" + }; + + public void Execute(Session session, FtpStream stream, string alias, string argument) + { + if (string.IsNullOrWhiteSpace(argument)) + { + stream.Send(ResponseCode.ArgumentsError, "Path can't be empty."); + return; + } + + var split = argument.Split(' '); + if (split.Length > 1 && DateTime.TryParseExact(split[0], DateFormat, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var date)) + { + ModifyTime(session, stream, date, string.Join(' ', split.Skip(1))); + } + else + { + SendTime(session, stream, argument); + } + } + + private static void SendTime(Session session, FtpStream stream, string argument) + { + var (_, realPath, isDirectory) = session.NavigablePath.GetSystemFilePath(argument); + + if (realPath == null) + { + stream.Send(ResponseCode.FileUnavailable, "Requested file does not exist."); + return; + } + + FileSystemInfo info = isDirectory ? new DirectoryInfo(realPath) : (FileSystemInfo)new FileInfo(realPath); + + stream.Send(ResponseCode.FileStatus, info.LastWriteTimeUtc.ToString(DateFormat)); + } + + private void ModifyTime(Session session, FtpStream stream, DateTime date, string path) + { + var file = session.NavigablePath.GetSystemFilePath(path); + + if (file.realPath == null) + { + stream.Send(ResponseCode.FileUnavailable, "Requested file does not exist."); + return; + } + + FileSystemInfo info = file.isDirectory ? new DirectoryInfo(file.realPath) : (FileSystemInfo)new FileInfo(file.realPath); + + info.LastWriteTimeUtc = date; + + stream.Send(ResponseCode.OK, "Modification date successfully changed."); + } + } +} diff --git a/DearFTP/Connection/Commands/HelpCommand.cs b/DearFTP/Connection/Commands/HelpCommand.cs new file mode 100644 index 0000000..7549f93 --- /dev/null +++ b/DearFTP/Connection/Commands/HelpCommand.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace DearFTP.Connection.Commands +{ + class HelpCommand : ICommand + { + public string[] Aliases { get; } = new string[] + { + "HELP" + }; + + public void Execute(Session session, FtpStream stream, string alias, string argument) + { + if (argument.ToUpper() == "SITE") + { + stream.Send + ( + ResponseCode.HelpMessage, + "Commands:", + "ABOR", + "ALLO", + "APPE", + "CDUP", + "CWD", + "DELE", + "FEAT", + "HELP", + "LIST", + "MDTM", + "MKD", + "MLST", + "MLSD", + "MODE", + "NLST", + "NOOP", + "OPTS", + "PASS", + "PASV", + "PORT", + "PWD", + "QUIT", + "REIN", + "REST", + "RETR", + "RMD", + "RNFR", + "RNTO", + "SITE", + "SIZE", + "STAT", + "STOR", + "STOU", + "STRU", + "SYST", + "TYPE", + "USER", + "XCUP", + "XCWD", + "XMKD", + "XPWD", + "XRMD" + ); + } + else + { + stream.Send(ResponseCode.ArgumentNotImplemented, "Wrong argument"); + } + } + } +} diff --git a/DearFTP/Connection/Commands/ICommand.cs b/DearFTP/Connection/Commands/ICommand.cs new file mode 100644 index 0000000..81116cb --- /dev/null +++ b/DearFTP/Connection/Commands/ICommand.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace DearFTP.Connection.Commands +{ + interface ICommand + { + string[] Aliases { get; } + + void Execute(Session session, FtpStream stream, string alias, string argument); + } +} diff --git a/DearFTP/Connection/Commands/ListCommand.cs b/DearFTP/Connection/Commands/ListCommand.cs new file mode 100644 index 0000000..a75bc48 --- /dev/null +++ b/DearFTP/Connection/Commands/ListCommand.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; + +namespace DearFTP.Connection.Commands +{ + class ListCommand : ICommand + { + public const int BufferSize = 4096; + + public string[] Aliases { get; } = new string[] + { + "LIST", + "NLST" + }; + + public string[] SizeModifiers { get; } = new string[] { "", "K", "M", "G", "T", "P" }; + + public void Execute(Session session, FtpStream stream, string alias, string argument) + { + var dataConnection = session.DataConnection; + + if (!dataConnection.IsAvailable) + { + stream.Send(ResponseCode.DataConnectionOpenError, "Passive mode not activated."); + return; + } + + stream.Send(ResponseCode.FileStatusOK, "Listing coming."); + + string path = null; + bool humanReadable = false; + + foreach (string arg in argument.Split(' ')) + { + if (arg.StartsWith('-') && arg.ToLower().Contains('h')) + { + humanReadable = true; + continue; + } + + if (arg.StartsWith('-')) + { + continue; + } + + path = arg; + } + + SendList(session, path ?? ".", humanReadable); + + dataConnection.Close(); + + stream.Send(ResponseCode.CloseDataConnection, "Listing complete."); + } + + private void SendList(Session session, string path, bool humanReadable) + { + var files = new Stack(GenerateFilesInfo(session, path, humanReadable).Reverse()); + var stream = new FtpStream(session.DataConnection.Stream); + + var builder = new StringBuilder(BufferSize); + + while (files.Count > 0) + { + string file = files.Pop(); + + if (builder.Length + file.Length < BufferSize) + { + builder.Append(file); + } + else + { + files.Push(file); + + stream.Send(builder.ToString(), false); + builder = new StringBuilder(BufferSize); + } + } + if (builder.Length > 0) + { + stream.Send(builder.ToString(), false); + } + } + + private IEnumerable GenerateFilesInfo(Session session, string path, bool humanReadable) + { + bool writeable = session.WritablesShares.Contains(session.NavigablePath.CurrentShare); + + yield return GenerateFileInfo(true, writeable, "DearFTP", "DearFTP", 0L, DateTime.Now, ".", humanReadable); + yield return GenerateFileInfo(true, writeable, "DearFTP", "DearFTP", 0L, DateTime.Now, "..", humanReadable); + + foreach (string directory in session.NavigablePath.GetDirectories(path)) + { + var directoryInfo = new DirectoryInfo(directory); + + yield return GenerateFileInfo(true, writeable, "DearFTP", "DearFTP", 0L, directoryInfo.LastWriteTime, directoryInfo.Name, humanReadable); + } + foreach (string file in session.NavigablePath.GetFiles(path)) + { + var fileInfo = new FileInfo(file); + + yield return GenerateFileInfo(false, writeable, "DearFTP", "DearFTP", fileInfo.Length, fileInfo.LastWriteTime, fileInfo.Name, humanReadable); + } + } + + private string GenerateFileInfo(bool isDirectory, bool isWritable, string owner, string group, long size, DateTime lastModification, string name, bool humanReadableSize) + { + string permissions = isDirectory ? (isWritable ? "drwxr-xr-x" : "dr-xr-xr-x") : (isWritable ? "-rw-r--r--" : "-r--r--r--"); + string date = (DateTime.Now - lastModification).TotalDays > 365 ? lastModification.ToString("MMM dd yyyy", CultureInfo.InvariantCulture) : lastModification.ToString("MMM dd hh:mm", CultureInfo.InvariantCulture); + + return $"{permissions} 1 {owner} {group}{(humanReadableSize ? GenerateSize(size) : size.ToString()),13} {date,-13}{name}\r\n"; + } + + private string GenerateSize(long size) + { + double newSize = size; + + foreach (string modifier in SizeModifiers) + { + if (newSize < 1000) + { + if (newSize < 10) + { + return $"{newSize:0.#}{modifier}"; + } + return $"{(int)newSize}{modifier}"; + } + else + { + newSize /= 1000; + } + } + + throw new ArgumentOutOfRangeException("size", "Can't format 'size', it is too big."); + } + } +} diff --git a/DearFTP/Connection/Commands/ListMachineCommand.cs b/DearFTP/Connection/Commands/ListMachineCommand.cs new file mode 100644 index 0000000..0ac4f8f --- /dev/null +++ b/DearFTP/Connection/Commands/ListMachineCommand.cs @@ -0,0 +1,144 @@ +using DearFTP.Utils; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +namespace DearFTP.Connection.Commands +{ + class ListMachineCommand : ICommand + { + public const string DateFormat = "yyyyMMddhhmmss"; + + public string[] Aliases { get; } = new string[] + { + "MLST", + "MLSD" + }; + + public void Execute(Session session, FtpStream stream, string alias, string argument) + { + switch (alias.ToUpper()) + { + case "MLST": + ListSingleObject(session, stream, argument); + break; + case "MLSD": + ListMultipleObject(session, stream, argument); + break; + } + } + + private void ListSingleObject(Session session, FtpStream stream, string argument) + { + var (navigablePath, realPath, isDirectory) = session.NavigablePath.GetSystemFilePath(argument); + + if (realPath == null) + { + stream.Send(ResponseCode.FileUnavailable, "Requested file does not exist."); + return; + } + + FileSystemInfo info = isDirectory ? new DirectoryInfo(realPath) : (FileSystemInfo)new FileInfo(realPath); + + bool writeable = session.WritablesShares.Contains(navigablePath.CurrentShare); + + string line = GenerateInfo(info, writeable); + + stream.Send(ResponseCode.FileActionOK, "Listing.", line); + } + + private void ListMultipleObject(Session session, FtpStream stream, string argument) + { + var dataConnection = session.DataConnection; + + if (!dataConnection.IsAvailable) + { + stream.Send(ResponseCode.DataConnectionOpenError, "Passive mode not activated."); + return; + } + + var navigablePath = new NavigablePath(session.NavigablePath); + + if (!navigablePath.NavigateTo(argument)) + { + stream.Send(ResponseCode.FileUnavailable, "Invalid path."); + } + + bool writeable = session.WritablesShares.Contains(navigablePath.CurrentShare); + + var infos = GenerateFilesInfo(navigablePath, writeable); + + stream.Send(ResponseCode.FileStatusOK, "Listing coming."); + + var dataStream = new FtpStream(dataConnection.Stream); + + foreach (var info in infos) + { + dataStream.Send(info); + } + + dataConnection.Close(); + + stream.Send(ResponseCode.CloseDataConnection, "Listing complete."); + } + + private string GenerateInfo(FileSystemInfo fileSystemInfo, bool writeable, string specialType = null) + { + string date = fileSystemInfo.LastWriteTimeUtc.ToString(DateFormat); + + if (fileSystemInfo is DirectoryInfo directoryInfo) + { + string type = specialType ?? "dir"; + string permissions = $"el{(writeable ? "dfcmp" : "")}"; + + return $"Type={type};Modify={date};Perm={permissions}; {directoryInfo.Name}"; + } + else if (fileSystemInfo is FileInfo fileInfo) + { + string type = specialType ?? "file"; + string permissions = $"r{(writeable ? "dfaw" : "")}"; + + return $"Type={type};Modify={date};Perm={permissions};Size={fileInfo.Length}; {fileInfo.Name}"; + } + + return ""; + } + + private IEnumerable GenerateFilesInfo(NavigablePath navigablePath, bool writeable) + { + string virtualDirectory = navigablePath.CurrentDirectory; + + if (virtualDirectory != "/") + { + string currentDirectory = navigablePath.GetDirectoryPath(""); + + yield return GenerateInfo(new DirectoryInfo(currentDirectory), writeable, "cdir"); + + if (virtualDirectory.Count(x => x == '/') > 1) + { + yield return GenerateInfo(new DirectoryInfo(currentDirectory).Parent, writeable, "pdir"); + } + } + else + { + virtualDirectory = ""; + } + + foreach (var directory in navigablePath.GetDirectories()) + { + var directoryInfo = new DirectoryInfo(directory); + + yield return GenerateInfo(directoryInfo, writeable); + } + + foreach (var file in navigablePath.GetFiles()) + { + var fileInfo = new FileInfo(file); + + yield return GenerateInfo(fileInfo, writeable); + } + } + } +} diff --git a/DearFTP/Connection/Commands/MakeDirectoryCommand.cs b/DearFTP/Connection/Commands/MakeDirectoryCommand.cs new file mode 100644 index 0000000..59877a4 --- /dev/null +++ b/DearFTP/Connection/Commands/MakeDirectoryCommand.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +namespace DearFTP.Connection.Commands +{ + class MakeDirectoryCommand : ICommand + { + public string[] Aliases { get; } = new string[] + { + "MKD", + "XMKD" + }; + + public void Execute(Session session, FtpStream stream, string alias, string argument) + { + if (session.NavigablePath.GetSystemFilePath(argument).realPath != null) + { + stream.Send(ResponseCode.FileUnavailable, "Directory already exists."); + return; + } + + var newDirectory = session.NavigablePath.GetNewSystemFilePath(argument); + + if (newDirectory.navigablePath.CurrentDirectory == "/") + { + stream.Send(ResponseCode.FileUnavailable, "Can't modify the root."); + return; + } + + if (newDirectory.realPath == null) + { + stream.Send(ResponseCode.FileUnavailable, "Invalid destination path."); + return; + } + + if (File.Exists(newDirectory.realPath) || Directory.Exists(newDirectory.realPath)) + { + stream.Send(ResponseCode.FileUnavailable, "Destination path already exists."); + return; + } + + if (!session.WritablesShares.Contains(newDirectory.navigablePath.CurrentShare)) + { + stream.Send(ResponseCode.FileUnavailable, "You don't have write access to this file."); + return; + } + + Directory.CreateDirectory(newDirectory.realPath); + + stream.Send(ResponseCode.FileActionOK, "Directory successfully created."); + } + } +} diff --git a/DearFTP/Connection/Commands/ParentDirectoryCommand.cs b/DearFTP/Connection/Commands/ParentDirectoryCommand.cs new file mode 100644 index 0000000..0f78f0d --- /dev/null +++ b/DearFTP/Connection/Commands/ParentDirectoryCommand.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace DearFTP.Connection.Commands +{ + class ParentDirectoryCommand : ICommand + { + public string[] Aliases { get; } = new string[] + { + "CDUP", + "XCUP" + }; + + public void Execute(Session session, FtpStream stream, string alias, string argument) + { + if (session.NavigablePath.NavigateTo("..")) + { + stream.Send(ResponseCode.FileActionOK, "Directory successfully changed."); + } + else + { + stream.Send(ResponseCode.FileUnavailable, "Failed to change directory."); + } + } + } +} diff --git a/DearFTP/Connection/Commands/PassiveCommand.cs b/DearFTP/Connection/Commands/PassiveCommand.cs new file mode 100644 index 0000000..ad8fcf4 --- /dev/null +++ b/DearFTP/Connection/Commands/PassiveCommand.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace DearFTP.Connection.Commands +{ + class PassiveCommand : ICommand + { + public string[] Aliases { get; } = new string[] + { + "PASV" + }; + + public void Execute(Session session, FtpStream stream, string alias, string argument) + { + session.DataConnection.Create(); + + int port = session.DataConnection.Port; + var portBytes = BitConverter.GetBytes((ushort)port).Reverse().Select(x => x.ToString()); + string remote = string.Join(',', session.IP.Split('.').Concat(portBytes)); + + stream.Send(ResponseCode.PassiveMode, $"Entering Passive Mode ({remote})"); + + session.DataConnection.AcceptClient(); + } + } +} diff --git a/DearFTP/Connection/Commands/PwdCommand.cs b/DearFTP/Connection/Commands/PwdCommand.cs new file mode 100644 index 0000000..3c093e6 --- /dev/null +++ b/DearFTP/Connection/Commands/PwdCommand.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace DearFTP.Connection.Commands +{ + class PwdCommand : ICommand + { + public string[] Aliases { get; } = new string[] + { + "PWD", + "XPWD" + }; + + public void Execute(Session session, FtpStream stream, string alias, string argument) + { + stream.Send(ResponseCode.PathNameCreated, $"\"{session.CurrentWorkingDirectory}\" is the current working directory"); + } + } +} diff --git a/DearFTP/Connection/Commands/QuitCommand.cs b/DearFTP/Connection/Commands/QuitCommand.cs new file mode 100644 index 0000000..aca6c4a --- /dev/null +++ b/DearFTP/Connection/Commands/QuitCommand.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace DearFTP.Connection.Commands +{ + class QuitCommand : ICommand + { + public string[] Aliases { get; } = new string[] + { + "QUIT", + "EXIT" + }; + + public void Execute(Session session, FtpStream stream, string alias, string argument) + { + stream.Send(ResponseCode.ServiceClosingConnection, "Goodbye."); + } + } +} diff --git a/DearFTP/Connection/Commands/RenameCommand.cs b/DearFTP/Connection/Commands/RenameCommand.cs new file mode 100644 index 0000000..e89430e --- /dev/null +++ b/DearFTP/Connection/Commands/RenameCommand.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +namespace DearFTP.Connection.Commands +{ + class RenameCommand : ICommand + { + public string[] Aliases { get; } = new string[] + { + "RNFR" + }; + + public void Execute(Session session, FtpStream stream, string alias, string argument) + { + var sourceFile = session.NavigablePath.GetSystemFilePath(argument); + + if (sourceFile.navigablePath == null) + { + stream.Send(ResponseCode.FileUnavailable, "File does not exist."); + return; + } + + if (sourceFile.navigablePath.CurrentDirectory == "/") + { + stream.Send(ResponseCode.FileUnavailable, "Operation not allowed: can't remove a share."); + return; + } + + if (!session.WritablesShares.Contains(sourceFile.navigablePath.CurrentShare)) + { + stream.Send(ResponseCode.FileUnavailable, "You don't have write access to this file."); + return; + } + + stream.Send(ResponseCode.PendingFurtherInformation, "Waiting destination."); + + (string command, string destination) = stream.Receive(); + + if (command.ToUpper() != "RNTO") + { + stream.Send(ResponseCode.BadSequence, "Expected a RNTO command."); + return; + } + + var destinationPath = session.NavigablePath.GetNewSystemFilePath(destination); + + if (destinationPath.realPath == null) + { + stream.Send(ResponseCode.FileUnavailable, "Invalid destination path."); + return; + } + + if (File.Exists(destinationPath.realPath) || Directory.Exists(destinationPath.realPath)) + { + stream.Send(ResponseCode.FileUnavailable, "Destination path already exists."); + return; + } + + if (!session.WritablesShares.Contains(destinationPath.navigablePath.CurrentShare)) + { + stream.Send(ResponseCode.FileUnavailable, "You don't have write access to this file."); + return; + } + + if (sourceFile.isDirectory) + { + Directory.Move(sourceFile.realPath, destinationPath.realPath); + } + else + { + File.Move(sourceFile.realPath, destinationPath.realPath); + } + + stream.Send(ResponseCode.FileActionOK, "File successfully renamed."); + } + } +} diff --git a/DearFTP/Connection/Commands/RetrieveCommand.cs b/DearFTP/Connection/Commands/RetrieveCommand.cs new file mode 100644 index 0000000..6045d6d --- /dev/null +++ b/DearFTP/Connection/Commands/RetrieveCommand.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace DearFTP.Connection.Commands +{ + class RetrieveCommand : ICommand + { + public const int BufferSize = 4096; + + public string[] Aliases { get; } = new string[] + { + "RETR" + }; + + public void Execute(Session session, FtpStream stream, string alias, string argument) + { + var dataConnection = session.DataConnection; + + if (!dataConnection.IsAvailable) + { + stream.Send(ResponseCode.DataConnectionOpenError, "Passive mode not activated."); + return; + } + + var (_, realPath, isDirectory) = session.NavigablePath.GetSystemFilePath(argument); + + if (realPath == null) + { + stream.Send(ResponseCode.FileUnavailable, "Requested file does not exist."); + return; + } + + if (isDirectory) + { + stream.Send(ResponseCode.FileUnavailable, "Requested file is a directory."); + return; + } + + stream.Send(ResponseCode.FileStatusOK, "File coming."); + + SendFile(dataConnection.Stream, realPath); + + dataConnection.Close(); + + stream.Send(ResponseCode.CloseDataConnection, "File sent."); + } + + private void SendFile(Stream stream, string path) + { + using (var file = File.OpenRead(path)) + { + Span buffer = stackalloc byte[BufferSize]; + long bytesToWrite = Math.Min(file.Length - file.Position, BufferSize); + + while (file.Read(buffer) > 0) + { + if (bytesToWrite < BufferSize) + { + stream.Write(buffer.Slice(0, (int)bytesToWrite)); + break; + } + stream.Write(buffer); + + bytesToWrite = Math.Min(file.Length - file.Position, BufferSize); + } + } + } + } +} diff --git a/DearFTP/Connection/Commands/SiteCommand.cs b/DearFTP/Connection/Commands/SiteCommand.cs new file mode 100644 index 0000000..72d737f --- /dev/null +++ b/DearFTP/Connection/Commands/SiteCommand.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace DearFTP.Connection.Commands +{ + class SiteCommand : ICommand + { + public string[] Aliases { get; } = new string[] + { + "SITE" + }; + + public void Execute(Session session, FtpStream stream, string alias, string argument) + { + stream.Send(ResponseCode.NotImplemented, "SITE command not implemented."); + } + } +} diff --git a/DearFTP/Connection/Commands/SizeCommand.cs b/DearFTP/Connection/Commands/SizeCommand.cs new file mode 100644 index 0000000..6a5c19a --- /dev/null +++ b/DearFTP/Connection/Commands/SizeCommand.cs @@ -0,0 +1,37 @@ +using DearFTP.Utils; +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace DearFTP.Connection.Commands +{ + class SizeCommand : ICommand + { + public string[] Aliases { get; } = new string[] + { + "SIZE" + }; + + public void Execute(Session session, FtpStream stream, string alias, string argument) + { + if (string.IsNullOrWhiteSpace(argument)) + { + stream.Send(ResponseCode.ArgumentsError, "Path can't be empty."); + return; + } + + var file = session.NavigablePath.GetSystemFilePath(argument); + + if (file.realPath == null) + { + stream.Send(ResponseCode.FileUnavailable, "Requested file does exist."); + return; + } + + long size = file.isDirectory ? 4096L : new FileInfo(file.realPath).Length; + + stream.Send(ResponseCode.FileStatus, size.ToString()); + } + } +} diff --git a/DearFTP/Connection/Commands/StoreCommand.cs b/DearFTP/Connection/Commands/StoreCommand.cs new file mode 100644 index 0000000..772be37 --- /dev/null +++ b/DearFTP/Connection/Commands/StoreCommand.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Sockets; +using System.Text; + +namespace DearFTP.Connection.Commands +{ + class StoreCommand : ICommand + { + public const int BufferSize = 4096; + + public string[] Aliases { get; } = new string[] + { + "STOR" + }; + + public void Execute(Session session, FtpStream stream, string alias, string argument) + { + var dataConnection = session.DataConnection; + + if (!dataConnection.IsAvailable) + { + stream.Send(ResponseCode.DataConnectionOpenError, "Passive mode not activated."); + return; + } + + var (navigablePath, realPath) = session.NavigablePath.GetNewSystemFilePath(argument); + + if (realPath == null) + { + stream.Send(ResponseCode.FileUnavailable, "Invalid destination path."); + return; + } + + if (Directory.Exists(realPath)) + { + stream.Send(ResponseCode.FileUnavailable, "A directory with this name already exists."); + return; + } + + if (!session.WritablesShares.Contains(navigablePath.CurrentShare)) + { + stream.Send(ResponseCode.FileUnavailable, "You don't have write access to this file."); + return; + } + + stream.Send(ResponseCode.FileStatusOK, "Waiting file."); + + ReceiveFile(dataConnection.Stream, realPath); + + dataConnection.Close(); + + stream.Send(ResponseCode.CloseDataConnection, "File received."); + } + + private void ReceiveFile(NetworkStream stream, string path) + { + using (var file = File.Open(path, FileMode.Create)) + { + Span buffer = stackalloc byte[BufferSize]; + int readBytes; + + while ((readBytes = stream.Read(buffer)) > 0) + { + if (readBytes < BufferSize) + { + file.Write(buffer.Slice(0, readBytes)); + } + else + { + file.Write(buffer); + } + } + } + } + } +} diff --git a/DearFTP/Connection/Commands/SystemCommand.cs b/DearFTP/Connection/Commands/SystemCommand.cs new file mode 100644 index 0000000..5eb17e5 --- /dev/null +++ b/DearFTP/Connection/Commands/SystemCommand.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace DearFTP.Connection.Commands +{ + class SystemCommand : ICommand + { + public string[] Aliases { get; } = new string[] + { + "SYST" + }; + + public void Execute(Session session, FtpStream stream, string alias, string argument) + { + stream.Send(ResponseCode.NameSystemType, "UNIX Type: L8"); + } + } +} diff --git a/DearFTP/Connection/Commands/TypeCommand.cs b/DearFTP/Connection/Commands/TypeCommand.cs new file mode 100644 index 0000000..8483353 --- /dev/null +++ b/DearFTP/Connection/Commands/TypeCommand.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace DearFTP.Connection.Commands +{ + class TypeCommand : ICommand + { + public string[] Aliases { get; } = new string[] + { + "TYPE" + }; + + public void Execute(Session session, FtpStream stream, string alias, string argument) + { + // Is there something to do here? + stream.Send(ResponseCode.OK, "Type changed."); + } + } +} diff --git a/DearFTP/Connection/Commands/UserCommand.cs b/DearFTP/Connection/Commands/UserCommand.cs new file mode 100644 index 0000000..f43169d --- /dev/null +++ b/DearFTP/Connection/Commands/UserCommand.cs @@ -0,0 +1,60 @@ +using DearFTP.Utils; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DearFTP.Connection.Commands +{ + class UserCommand : ICommand + { + public string[] Aliases { get; } = new string[] + { + "USER" + }; + + public void Execute(Session session, FtpStream stream, string alias, string argument) + { + var configuration = FtpServer.Instance.Configuration; + var user = configuration.Users.FirstOrDefault(x => x.Name == argument); + + if (user == null) + { + string guest = configuration.Server.Guest; + + if (string.IsNullOrWhiteSpace(guest)) + { + stream.Send(ResponseCode.InvalidCreditentials, "Invalid username."); + session.Stop(); + + return; + } + + user = configuration.Users.First(x => x.Name == guest); + } + if (!string.IsNullOrWhiteSpace(user.Password)) + { + stream.Send(ResponseCode.NeedPassword, "Please specify the password."); + + (_, string pass) = stream.Receive(); + string hash = PasswordHash.GetHash(pass); + + if (user.Password != hash) + { + stream.Send(ResponseCode.InvalidCreditentials, "Invalid password."); + session.Stop(); + + return; + } + } + + session.User = user; + session.WritablesShares = configuration.Shares.Where(s => user.Groups.Contains($"+{s.Group}")).ToArray(); + session.Shares = session.WritablesShares.Concat(configuration.Shares.Where(s => user.Groups.Contains(s.Group))).ToArray(); + session.NavigablePath = new NavigablePath(session.Shares); + + session.Logger.Log($"Logged in as {user.Name}."); + stream.Send(ResponseCode.LoggedIn, configuration.Server.LoginMessage.Replace("%user%", user.Name)); + } + } +} diff --git a/DearFTP/Connection/DataConnection.cs b/DearFTP/Connection/DataConnection.cs new file mode 100644 index 0000000..94d9fdb --- /dev/null +++ b/DearFTP/Connection/DataConnection.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace DearFTP.Connection +{ + class DataConnection + { + public TcpListener Listener { get; private set; } + public TcpClient Client { get; private set; } + public NetworkStream Stream { get; private set; } + public int Port => ((IPEndPoint)Listener.LocalEndpoint).Port; + public bool IsAvailable { get; private set; } + + public DataConnection() + { + IsAvailable = false; + } + + public void Create() + { + // Clean old connections + if (Client?.Connected == true) + { + Close(); + } + + Listener = new TcpListener(IPAddress.Any, 0); + Listener.Start(); + + } + + public void AcceptClient() + { + Client = Listener.AcceptTcpClient(); + Stream = Client.GetStream(); + + IsAvailable = true; + } + public async void AcceptClientAsync() + { + Client = await Listener.AcceptTcpClientAsync(); + Stream = Client.GetStream(); + + IsAvailable = true; + } + + public void Close() + { + IsAvailable = false; + + Stream.Close(); + Client.Close(); + Listener.Stop(); + } + } +} diff --git a/DearFTP/Connection/FtpStream.cs b/DearFTP/Connection/FtpStream.cs new file mode 100644 index 0000000..b5e0653 --- /dev/null +++ b/DearFTP/Connection/FtpStream.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Sockets; +using System.Text; + +namespace DearFTP.Connection +{ + class FtpStream + { + public const int BUFFER_SIZE = 4096; + + public NetworkStream NetworkStream { get; } + + public FtpStream(NetworkStream networkStream) + { + NetworkStream = networkStream; + } + + public (string command, string argument) Receive() + { + var buffer = new byte[BUFFER_SIZE]; + + int readBytes = NetworkStream.Read(buffer, 0, buffer.Length); + + if (readBytes == 0) + { + return ("END", ""); + } + + string packet = Encoding.UTF8.GetString(buffer, 0, readBytes); + + if (!packet.EndsWith("\r\n")) + { + throw new Exception("Ftp packet is in wrong format"); + } + + packet = packet.Remove(packet.Length - 2); + + string[] split = packet.Split(' '); + + string command = split[0]; + string argument = string.Join(' ', split.Skip(1)); + + return (command, argument); + } + + public void Send(string message, bool end = true) + { + var bytes = Encoding.UTF8.GetBytes($"{message}{(end ? "\r\n" : "")}"); + + NetworkStream.Write(bytes, 0, bytes.Length); + } + + public void Send(ResponseCode code, string argument) + { + var bytes = Encoding.UTF8.GetBytes($"{(uint)code} {argument}\r\n"); + + NetworkStream.Write(bytes, 0, bytes.Length); + } + + public void Send(ResponseCode code, string message, params string[] arguments) + { + var builder = new StringBuilder(); + + builder.Append($"{(uint)code}-{message}\r\n"); + + foreach (string argument in arguments) + { + builder.Append($" {argument}\r\n"); + } + + builder.Append($"{(uint)code} End\r\n"); + + var bytes = Encoding.UTF8.GetBytes(builder.ToString()); + + NetworkStream.Write(bytes, 0, bytes.Length); + } + } +} diff --git a/DearFTP/Connection/ResponseCode.cs b/DearFTP/Connection/ResponseCode.cs new file mode 100644 index 0000000..77393c7 --- /dev/null +++ b/DearFTP/Connection/ResponseCode.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace DearFTP.Connection +{ + public enum ResponseCode : uint + { + // Source: https://www.smartfile.com/blog/big-list-ftp-server-response-codes/ + + // 1xx The request has started, expect another reply before proceeding with a new command. + /// + /// Restart marker replay. In this case, the text is exact and not left to the particular implementation; + /// it must read: MARK yyyy = mmmm where yyyy is User-process data stream marker, and mmmm server’s equivalent marker (note the spaces between markers and “=”). + /// + RestartMarkerReplay = 110, + /// + /// Service ready in xx minutes. + /// + ServiceReadyIn = 120, + /// + /// Data connection is already open and the transfer is starting. + /// + DataConnectionAlreadyOpen = 125, + /// + /// File status is okay and about to open data connection. + /// + FileStatusOK = 150, + + // 2xx The request was successfully completed. + /// + /// A server issues a 200 response code if a command is accepted and successfully processed. + /// + OK = 200, + /// + /// Command was not implemented, superfluous at this site. + /// + CommandNotImplemented = 202, + /// + /// System status, or system help reply. + /// + SystemStatusOrHelpReply = 211, + /// + /// Directory status. + /// + DirectoryStatus = 212, + /// + /// File status. + /// + FileStatus = 213, + /// + /// Help message. On how to use the server or the meaning of a particular non-standard command. + /// + HelpMessage = 214, + /// + /// NAME system type. Where NAME is an official system name from the registry kept by IANA. + /// + NameSystemType = 215, + /// + /// Service is ready for new user. + /// + ServiceReady = 220, + /// + /// Service closing control connection. + /// + ServiceClosingConnection = 221, + /// + /// Data connection is open and no transfer is in progress. + /// + DataConnectionOpenNoTransfer = 225, + /// + /// Closing the data connection. Requested file action successful (for example, file transfer or file abort). + /// + CloseDataConnection = 226, + /// + /// Entering Passive Mode (h1, h2, h3, h4, p1, p2). + /// + PassiveMode = 227, + /// + /// Entering Long Passive Mode (long address, port). + /// + LongPassiveMode = 228, + /// + /// Entering Extended Passive Mode (|||port|). + /// + ExtendedPassiveMode = 229, + /// + /// User has logged in, proceed. Logged out if appropriate. + /// + LoggedIn = 230, + /// + /// User has logged out and the service is terminated. + /// + LoggedOut = 231, + /// + /// Logout command noted, will complete when the transfer done. + /// + LogoutCommandOnEnd = 232, + /// + /// Specifies that the server accepts the authentication mechanism specified by the client, and the exchange of security data is complete. A higher level nonstandard code created by Microsoft. + /// + AcceptAuthenticationMechanism = 234, + /// + /// Requested file action okay and completed. + /// + FileActionOK = 250, + /// + /// “PATHNAME” created. + /// + PathNameCreated = 257, + + // 3xx The command was accepted, but the request is on hold, pending receipt of further information. + /// + /// User name okay, need password. + /// + NeedPassword = 331, + /// + /// Need account for login. + /// + NeedAccount = 332, + /// + /// Requested file action pending further information + /// + PendingFurtherInformation = 350, + + // 4xx The command wasn’t accepted and the requested action didn’t occur, but the error condition is temporary and the action may be requested again. + /// + /// Service not available, closing control connection. This may be a reply to any command if the service knows it must shut down. + /// + ServiceNotAvailable = 421, + /// + /// Can’t open data connection. + /// + DataConnectionOpenError = 425, + /// + /// Connection closed; transfer aborted. + /// + ConnectionClosed = 426, + /// + /// Invalid username or password. + /// + InvalidCreditentials = 430, + /// + /// Requested host unavailable. + /// + HostUnavailable = 434, + /// + /// Requested file action not taken. + /// + FileActionNotTaken = 450, + /// + /// Requested action aborted. Local error in processing. + /// + ActionAborted = 451, + /// + /// Requested action not taken. Insufficient storage space in system.File unavailable (e.g., file busy). + /// + InsufficientStorage = 452, + + // 5xx Syntax error, command unrecognized and the request did not take place. This may include errors such as command line too long. + /// + /// Syntax error in parameters or arguments. + /// + ArgumentsError = 501, + /// + /// Command not implemented. + /// + NotImplemented = 502, + /// + /// Bad sequence of commands. + /// + BadSequence = 503, + /// + /// Command not implemented for that parameter. + /// + ArgumentNotImplemented = 504, + /// + /// Not logged in. + /// + NotLoggedIn = 530, + /// + /// Need account for storing files. + /// + NeedAccountToStore = 532, + /// + /// Request not taken. File unavailable (e.g., file not found, no access). + /// + FileUnavailable = 550, + /// + /// Request aborted. Page type unknown. + /// + PageTypeUnknown = 551, + /// + /// Requested file action aborted. Exceeded storage allocation (for current directory or dataset). + /// + ExceededStorageAllocation = 552, + /// + /// Requested action not taken. File name not allowed. + /// + FileNameNotAllowed = 553, + + // 6xx Replies regarding confidentiality and integrity + /// + /// Integrity protected reply. + /// + IntegrityProtectedReply = 631, + /// + /// Confidentiality and integrity protected reply. + /// + ConfidentialityAndIntegrityProtectedReply = 632, + /// + /// Confidentiality protected reply. + /// + ConfidentialityProtectedReply = 633 + } +} diff --git a/DearFTP/Connection/Session.cs b/DearFTP/Connection/Session.cs new file mode 100644 index 0000000..682cbc0 --- /dev/null +++ b/DearFTP/Connection/Session.cs @@ -0,0 +1,91 @@ +using DearFTP.Configurations; +using DearFTP.Connection.Commands; +using DearFTP.Utils; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace DearFTP.Connection +{ + class Session : IDisposable + { + public Configuration Configuration { get; } + public CommandsDispatcher CommandsDispatcher { get; } + public Logger Logger { get; } + public User User { get; set; } + public Share[] Shares { get; set; } + public Share[] WritablesShares { get; set; } + public FtpStream FtpStream { get; } + public NavigablePath NavigablePath { get; set; } + public string CurrentWorkingDirectory => NavigablePath.CurrentDirectory; + public string IP => ((IPEndPoint)_client.Client.LocalEndPoint).Address.ToString(); + public DataConnection DataConnection { get; set; } + + private TcpClient _client; + private NetworkStream _networkStream; + private bool _isRunning = true; + + public Session(TcpClient client) + { + _client = client; + _networkStream = client.GetStream(); + FtpStream = new FtpStream(_networkStream); + + Configuration = FtpServer.Instance.Configuration; + CommandsDispatcher = FtpServer.Instance.CommandsDispatcher; + Logger = FtpServer.Instance.Logger; + DataConnection = new DataConnection(); + } + + public void Start() + { + FtpStream.Send(ResponseCode.ServiceReady, Configuration.Server.MOTD); + + while (_isRunning) + { + (string command, string argument) = FtpStream.Receive(); + Logger.Log($"[{_client.Client.RemoteEndPoint}]: {command} {argument}"); + + CommandsDispatcher.Dispatch(this, command, argument); + } + } + + public void Stop() + { + _isRunning = false; + + Dispose(); + } + + public string GetRealPath(string path) + { + string completePath = Path.Combine(CurrentWorkingDirectory, path); + + string[] split = CurrentWorkingDirectory.Substring(1).Split('/'); + + if (split.Length == 0) + { + return null; + } + + var share = Shares.FirstOrDefault(x => x.Name == split[0]); + + if (share == null) + { + return null; + } + + return Path.Combine(split.Skip(1).Prepend(share.Path).ToArray()); + } + + public void Dispose() + { + _networkStream.Close(); + _client.Close(); + } + } +} diff --git a/DearFTP/FtpServer.cs b/DearFTP/FtpServer.cs new file mode 100644 index 0000000..af976d6 --- /dev/null +++ b/DearFTP/FtpServer.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading.Tasks; +using DearFTP.Configurations; +using DearFTP.Connection; +using DearFTP.Connection.Commands; + +namespace DearFTP +{ + class FtpServer + { + public static FtpServer Instance { get; private set; } + + public Configuration Configuration { get; private set; } + public Logger Logger { get; private set; } + public CommandsDispatcher CommandsDispatcher { get; } + + private bool _isRunning = true; + private TcpListener _listener; + + public FtpServer() + { + Instance = this; + CommandsDispatcher = new CommandsDispatcher(); + } + + public void Start() + { + Console.WriteLine("Starting DearFTP"); + + LoadConfig(); + + Logger = new Logger(Configuration); + + Logger.Log("DearFTP started"); + Logger.Log(); + + TcpLoop(); + } + + public void Stop() + { + _isRunning = false; + _listener.Stop(); + } + + private void TcpLoop() + { + _listener = new TcpListener(IPAddress.Any, Configuration.Server.Port); + _listener.Start(5); + + Logger.Log($"Listening on: {_listener.LocalEndpoint}"); + + while (_isRunning) + { + var client = _listener.AcceptTcpClient(); + + Task.Run(() => CreateSession(client)); + } + } + + private void CreateSession(TcpClient client) + { + Logger.Log($"Client connected: {client.Client.RemoteEndPoint}"); + + using (var session = new Session(client)) + { + session.Start(); + } + } + + private void LoadConfig() + { + Console.WriteLine("Loading config..."); + + Configuration = Configuration.Load(); + + if (!Configuration.Check()) + { + Console.WriteLine("Error while loading configuration, exiting..."); + Environment.Exit(1); + } + + Console.WriteLine("Config loaded"); + } + } +} diff --git a/DearFTP/Logger.cs b/DearFTP/Logger.cs new file mode 100644 index 0000000..fb11531 --- /dev/null +++ b/DearFTP/Logger.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using DearFTP.Configurations; + +namespace DearFTP +{ + class Logger + { + + public bool WriteToFile { get; private set; } + public string FilePath { get; private set; } + + public Logger(Configuration configuration) + { + WriteToFile = configuration.Server.LogFileEnabled; + FilePath = configuration.Server.LogFilePath; + } + + public void Log(string message) + { + if (WriteToFile) + { + LogToFile(message); + } + + Console.WriteLine(message); + } + + public void Log() + { + if (WriteToFile) + { + LogToFile(""); + } + + Console.WriteLine(); + } + + public void LogError(string error, string description) + { + if (WriteToFile) + { + LogToFile($"[Error] {error}: {description}"); + } + + Console.Error.WriteLine($"[Error] {error}: {description}"); + } + + private void LogToFile(string content) + { + File.AppendAllText(FilePath, $"{content}\n"); + } + } +} diff --git a/DearFTP/Program.cs b/DearFTP/Program.cs index ce3f08c..bf7977f 100644 --- a/DearFTP/Program.cs +++ b/DearFTP/Program.cs @@ -1,4 +1,10 @@ -using System; +using DearFTP.Utils; +using System; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; namespace DearFTP { @@ -6,7 +12,47 @@ namespace DearFTP { static void Main(string[] args) { - Console.WriteLine("Hello World!"); + if (args.Length != 0) + { + ProceedArguments(args); + } + + // Make sure to use unique format + CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; + + Console.WriteLine("Welcome to DearFTP !"); + Console.WriteLine(); + + var server = new FtpServer(); + + Task.Run(() => server.Start()); + + while (Console.ReadLine() != "stop") { } + + server.Stop(); + + Environment.Exit(0); + } + + private static void ProceedArguments(string[] args) + { + string command = args[0].ToLower().Replace("-", ""); + string[] arguments = args.Skip(1).ToArray(); + + if (command == "hash" && arguments.Length > 0) + { + foreach (string password in arguments) + { + Console.WriteLine(PasswordHash.GetHash(password)); + } + } + else + { + Console.WriteLine("Invalid usage."); + Environment.Exit(1); + } + + Environment.Exit(0); } } } diff --git a/DearFTP/Utils/NavigablePath.cs b/DearFTP/Utils/NavigablePath.cs new file mode 100644 index 0000000..790fa04 --- /dev/null +++ b/DearFTP/Utils/NavigablePath.cs @@ -0,0 +1,275 @@ +using DearFTP.Configurations; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +namespace DearFTP.Utils +{ + class NavigablePath + { + public Share[] Shares { get; } + public Share CurrentShare { get; set; } + public string CurrentDirectory { get; private set; } + + private Stack _path; + + public NavigablePath(Share[] shares) + { + Shares = shares; + CurrentDirectory = "/"; + + _path = new Stack(); + } + public NavigablePath(NavigablePath navigablePath) + { + Shares = navigablePath.Shares; + CurrentShare = navigablePath.CurrentShare; + CurrentDirectory = navigablePath.CurrentDirectory; + + _path = new Stack(navigablePath._path); + } + + public string GetFilePath(string fileName) + { + string path = Path.Combine(GetRealPath(), fileName); + + if (File.Exists(path)) + { + return path; + } + else + { + return null; + } + } + public string GetFilePath(string path, string fileName) + { + var tempNavigablePath = new NavigablePath(this); + + if (tempNavigablePath.NavigateTo(path)) + { + return tempNavigablePath.GetFilePath(fileName); + } + else + { + return null; + } + } + + public string GetDirectoryPath(string directoryName) + { + string path = Path.Combine(GetRealPath(), directoryName); + + if (Directory.Exists(path)) + { + return path; + } + else + { + return null; + } + } + public string GetDirectoryPath(string path, string directoryName) + { + var tempNavigablePath = new NavigablePath(this); + + if (tempNavigablePath.NavigateTo(path)) + { + return tempNavigablePath.GetDirectoryPath(directoryName); + } + else + { + return null; + } + } + + public (NavigablePath navigablePath, string realPath, bool isDirectory) GetSystemFilePath(string path) + { + var tempNavigablePath = new NavigablePath(this); + + var split = path.Split('/'); + + if (split.Length > 1) + { + string workingPath = string.Join('/', split.SkipLast(1)); + + tempNavigablePath.NavigateTo(workingPath); + } + + string name = split.Last(); + + if (tempNavigablePath._path.Count < 1) + { + return (tempNavigablePath, null, false); + } + + if (tempNavigablePath.GetDirectoryPath(name) is string directoryPath) + { + return (tempNavigablePath, directoryPath, true); + } + else if (tempNavigablePath.GetFilePath(name) is string filePath) + { + return (tempNavigablePath, filePath, false); + } + + return (null, null, false); + } + + public (NavigablePath navigablePath, string realPath) GetNewSystemFilePath(string path) + { + var tempNavigablePath = new NavigablePath(this); + + var split = path.Split('/'); + + if (split.Length > 1) + { + string workingPath = string.Join('/', split.SkipLast(1)); + + if (!tempNavigablePath.NavigateTo(workingPath)) + { + return (null, null); + } + } + + if (tempNavigablePath._path.Count < 1) + { + return (tempNavigablePath, null); + } + + return (tempNavigablePath, Path.Combine(GetRealPath(), split.Last())); + } + + public IEnumerable GetDirectories() + { + if (_path.Count < 1) + { + return Shares.Select(x => x.Path); + } + + string path = GetRealPath(); + + return Directory.EnumerateDirectories(path); + } + public IEnumerable GetDirectories(string path) + { + var tempNavigablePath = new NavigablePath(this); + + if (tempNavigablePath.NavigateTo(path)) + { + return tempNavigablePath.GetDirectories(); + } + else + { + return Enumerable.Empty(); + } + } + + public IEnumerable GetFiles() + { + if (_path.Count < 1) + { + return Enumerable.Empty(); + } + + string path = GetRealPath(); + + return Directory.EnumerateFiles(path); + } + public IEnumerable GetFiles(string path) + { + var tempNavigablePath = new NavigablePath(this); + + if (tempNavigablePath.NavigateTo(path)) + { + return tempNavigablePath.GetFiles(); + } + else + { + return Enumerable.Empty(); + } + } + + public bool NavigateTo(string path) + { + var oldPath = new Stack(_path); + var queue = new Queue(path.Split('/')); + + // If absolute path + if (path.StartsWith('/')) + { + queue.Dequeue(); + _path.Clear(); + } + + if (NavigateTo(queue)) + { + CurrentDirectory = $"/{string.Join('/', _path)}"; + + return true; + } + else + { + _path = oldPath; + + return false; + } + } + private bool NavigateTo(Queue paths) + { + if (paths.TryDequeue(out string directory)) + { + // Go to parent + if (directory == "..") + { + _path.TryPop(out _); + + // Share changed + if (_path.Count == 0) + { + CurrentShare = null; + } + + return NavigateTo(paths); + } + + // Stay in same folder + if (directory == "." || directory == "") + { + return NavigateTo(paths); + } + + // Go to child directory + if (GetDirectories().Select(x => Path.GetFileName(x)).Contains(directory)) + { + // Share changed + if (_path.Count == 0) + { + CurrentShare = Shares.First(x => x.Name == directory); + } + + _path.Push(directory); + + return NavigateTo(paths); + } + else + { + return false; + } + } + + return true; + } + + private string GetRealPath() + { + if (_path.Count < 1) + { + return null; + } + + return Path.Combine(_path.Skip(1).Prepend(CurrentShare.Path).ToArray()); + } + } +} diff --git a/DearFTP/Utils/PasswordHash.cs b/DearFTP/Utils/PasswordHash.cs new file mode 100644 index 0000000..8c224d9 --- /dev/null +++ b/DearFTP/Utils/PasswordHash.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; + +namespace DearFTP.Utils +{ + public static class PasswordHash + { + private static readonly HashAlgorithm _hasher = SHA512.Create(); + private const string _salt = "DearFTP"; + + public static string GetHash(string password) + { + var bytes = Encoding.UTF8.GetBytes($"{_salt}{password}"); + + return string.Join("", _hasher.ComputeHash(bytes).Select(x => x.ToString("X2"))).ToLower(); + } + } +}