diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ecdad1f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: nuget + directory: / + schedule: + interval: weekly + groups: + test-dependencies: + patterns: ["*"] + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..890dcf0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Restore + run: dotnet restore EasyFileTransfer.sln + + - name: Build + run: dotnet build EasyFileTransfer.sln --configuration Release --no-restore + + - name: Test + run: dotnet test EasyFileTransfer.Tests/EasyFileTransfer.Tests.csproj --configuration Release --no-build + + - name: Check for vulnerable packages + shell: bash + run: | + dotnet list EasyFileTransfer.sln package --vulnerable --include-transitive 2>&1 | tee vulnerable.txt + ! grep -q -i "has the following vulnerable packages" vulnerable.txt + + - name: Pack + if: matrix.os == 'ubuntu-latest' + run: dotnet pack EasyFileTransfer/EasyFileTransfer.csproj --configuration Release --no-build --output artifacts + + - uses: actions/upload-artifact@v4 + if: matrix.os == 'ubuntu-latest' + with: + name: nuget + path: artifacts/* diff --git a/EasyFileTransfer.Tests/EasyFileTransfer.Tests.csproj b/EasyFileTransfer.Tests/EasyFileTransfer.Tests.csproj new file mode 100644 index 0000000..7f87f37 --- /dev/null +++ b/EasyFileTransfer.Tests/EasyFileTransfer.Tests.csproj @@ -0,0 +1,26 @@ + + + + net8.0;net10.0 + enable + enable + false + true + true + + + + + + + + + + + + + + + + + diff --git a/EasyFileTransfer.Tests/FileNameValidatorTests.cs b/EasyFileTransfer.Tests/FileNameValidatorTests.cs new file mode 100644 index 0000000..59d495d --- /dev/null +++ b/EasyFileTransfer.Tests/FileNameValidatorTests.cs @@ -0,0 +1,51 @@ +using EasyFileTransfer.Internal; + +namespace EasyFileTransfer.Tests; + +public class FileNameValidatorTests +{ + [Theory] + [InlineData("report.pdf")] + [InlineData("archive.tar.gz")] + [InlineData("no-extension")] + [InlineData(".hidden")] + [InlineData("name with spaces.txt")] + [InlineData("\u0641\u0627\u06CC\u0644.txt")] + [InlineData("console.log")] + [InlineData("CONNECT.txt")] + public void Accepts_ordinary_names(string name) => Assert.True(FileNameValidator.IsValid(name)); + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(".")] + [InlineData("..")] + [InlineData("../evil.txt")] + [InlineData("..\\evil.txt")] + [InlineData("sub/file.txt")] + [InlineData("sub\\file.txt")] + [InlineData("/etc/passwd")] + [InlineData("C:\\Windows\\win.ini")] + [InlineData("C:evil.txt")] + [InlineData("file.txt:stream")] + [InlineData("\\\\server\\share\\x")] + [InlineData("trailing-dot.")] + [InlineData("trailing-space ")] + [InlineData("nul\0byte")] + [InlineData("new\nline")] + [InlineData("wild*card")] + [InlineData("CON")] + [InlineData("con.txt")] + [InlineData("NUL.tar.gz")] + [InlineData("COM1")] + [InlineData("lpt9.log")] + [InlineData("COM\u00B9")] + public void Rejects_dangerous_names(string name) => Assert.False(FileNameValidator.IsValid(name)); + + [Fact] + public void Rejects_names_longer_than_255_characters() + { + Assert.True(FileNameValidator.IsValid(new string('a', 255))); + Assert.False(FileNameValidator.IsValid(new string('a', 256))); + } +} diff --git a/EasyFileTransfer.Tests/RawClient.cs b/EasyFileTransfer.Tests/RawClient.cs new file mode 100644 index 0000000..34cadd8 --- /dev/null +++ b/EasyFileTransfer.Tests/RawClient.cs @@ -0,0 +1,67 @@ +using System.Net.Sockets; +using System.Security.Cryptography; +using EasyFileTransfer.Internal; + +namespace EasyFileTransfer.Tests; + +/// Speaks the wire protocol by hand so tests can send what a real client never would. +internal sealed class RawClient : IDisposable +{ + private readonly TcpClient _tcp; + + public RawClient(int port) + { + _tcp = new TcpClient(); + _tcp.Connect("127.0.0.1", port); + Stream = _tcp.GetStream(); + Stream.ReadTimeout = 15000; + } + + public NetworkStream Stream { get; } + + public byte[] ReadHello() + { + var hello = new byte[Protocol.HelloLength]; + Stream.ReadExactly(hello); + return hello; + } + + public static byte[] Nonce(byte[] hello) => hello.AsSpan(Protocol.MagicLength + 3, Protocol.NonceLength).ToArray(); + + public void SendRequest(byte[] nameBytes, long length, byte[]? proof = null, byte version = Protocol.Version) + { + var request = new byte[Protocol.RequestHeaderLength + nameBytes.Length + 8]; + Protocol.WriteMagic(request, 0); + request[Protocol.MagicLength] = version; + (proof ?? new byte[Protocol.ProofLength]).CopyTo(request, Protocol.MagicLength + 1); + Protocol.WriteUInt16(request, Protocol.RequestHeaderLength - 2, nameBytes.Length); + nameBytes.CopyTo(request, Protocol.RequestHeaderLength); + Protocol.WriteInt64(request, Protocol.RequestHeaderLength + nameBytes.Length, length); + Stream.Write(request); + } + + public void SendRequest(string name, long length, byte[]? proof = null) => + SendRequest(Protocol.StrictUtf8.GetBytes(name), length, proof); + + public void SendContent(byte[] content, byte[]? hash = null) + { + Stream.Write(content); + Stream.Write(hash ?? SHA256.HashData(content)); + } + + /// Reads one status byte, or returns null if the server closed the connection. + public EftStatus? ReadStatus() + { + try + { + int b = Stream.ReadByte(); + return b < 0 ? null : (EftStatus)b; + } + catch (IOException) + { + return null; + } + } + + public void Dispose() => _tcp.Dispose(); +} diff --git a/EasyFileTransfer.Tests/SecurityTests.cs b/EasyFileTransfer.Tests/SecurityTests.cs new file mode 100644 index 0000000..160a372 --- /dev/null +++ b/EasyFileTransfer.Tests/SecurityTests.cs @@ -0,0 +1,307 @@ +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using EasyFileTransfer.Internal; + +namespace EasyFileTransfer.Tests; + +public class SecurityTests +{ + [Theory] + [InlineData("../escaped.txt")] + [InlineData("..\\escaped.txt")] + [InlineData("../../escaped.txt")] + [InlineData("/tmp/escaped.txt")] + [InlineData("C:\\escaped.txt")] + [InlineData("sub/escaped.txt")] + [InlineData("..")] + [InlineData("CON")] + [InlineData("a.txt:ads")] + public void Path_traversal_is_rejected(string maliciousName) + { + using var server = new TestServer(); + using var raw = new RawClient(server.Port); + raw.ReadHello(); + raw.SendRequest(maliciousName, 4); + + Assert.Equal(EftStatus.InvalidFileName, raw.ReadStatus()); + Assert.Equal(EftStatus.InvalidFileName, Assert.IsType(server.NextFailure().Exception).Status); + Assert.Empty(server.AllFilesUnderRoot()); + } + + [Fact] + public void Invalid_utf8_file_name_is_rejected() + { + using var server = new TestServer(); + using var raw = new RawClient(server.Port); + raw.ReadHello(); + raw.SendRequest(new byte[] { 0x61, 0xC0, 0xAF, 0x62 }, 4); + + Assert.Equal(EftStatus.InvalidFileName, raw.ReadStatus()); + } + + [Theory] + [InlineData(0)] + [InlineData(Protocol.MaxFileNameBytes + 1)] + public void Out_of_range_name_length_is_rejected_before_allocating(int nameLength) + { + using var server = new TestServer(); + using var raw = new RawClient(server.Port); + raw.ReadHello(); + var header = new byte[Protocol.RequestHeaderLength]; + Protocol.WriteMagic(header, 0); + header[Protocol.MagicLength] = Protocol.Version; + Protocol.WriteUInt16(header, Protocol.RequestHeaderLength - 2, nameLength); + raw.Stream.Write(header); + + Assert.Equal(EftStatus.InvalidFileName, raw.ReadStatus()); + } + + [Fact] + public void Negative_file_length_is_rejected() + { + using var server = new TestServer(); + using var raw = new RawClient(server.Port); + raw.ReadHello(); + raw.SendRequest("x.bin", -1); + + Assert.Equal(EftStatus.ProtocolError, raw.ReadStatus()); + } + + [Fact] + public void Unsupported_protocol_version_is_rejected() + { + using var server = new TestServer(); + using var raw = new RawClient(server.Port); + raw.ReadHello(); + raw.SendRequest(Encoding.UTF8.GetBytes("x.bin"), 1, version: 99); + + Assert.Equal(EftStatus.UnsupportedVersion, raw.ReadStatus()); + } + + [Fact] + public async Task Garbage_input_does_not_crash_the_server() + { + using var server = new TestServer(); + + // The 0.1.x client's first packet, then random noise, then an abrupt disconnect. + foreach (byte[] junk in new[] { "\u0002125" + "8\u0004file.txt", "EFT", "\u0002999abc" }.Select(Encoding.UTF8.GetBytes).Append(RandomNumberGenerator.GetBytes(4096))) + { + using var raw = new RawClient(server.Port); + raw.ReadHello(); + raw.Stream.Write(junk); + raw.Stream.Write(new byte[Protocol.RequestHeaderLength]); + raw.ReadStatus(); + } + + await server.CreateClient().SendAsync(new MemoryStream("still alive"u8.ToArray()), "ok.txt"); + Assert.Equal(new[] { "ok.txt" }, server.SavedFiles()); + } + + [Fact] + public void Wrong_hash_is_rejected_and_nothing_is_saved() + { + using var server = new TestServer(); + using (var raw = new RawClient(server.Port)) + { + raw.ReadHello(); + raw.SendRequest("tampered.bin", 3); + Assert.Equal(EftStatus.Success, raw.ReadStatus()); + raw.SendContent(new byte[] { 1, 2, 3 }, hash: new byte[32]); + Assert.Equal(EftStatus.IntegrityCheckFailed, raw.ReadStatus()); + } + + server.NextFailure(); + Assert.Empty(Directory.EnumerateFileSystemEntries(server.SaveDirectory)); + } + + [Fact] + public void Truncated_upload_leaves_no_partial_file() + { + using var server = new TestServer(); + using (var raw = new RawClient(server.Port)) + { + raw.ReadHello(); + raw.SendRequest("partial.bin", 1_000_000); + Assert.Equal(EftStatus.Success, raw.ReadStatus()); + raw.Stream.Write(new byte[1000]); + } + + Assert.IsType(server.NextFailure().Exception); + Assert.Empty(Directory.EnumerateFileSystemEntries(server.SaveDirectory)); + } + + [Fact] + public async Task Unwritable_save_directory_reports_server_error() + { + if (OperatingSystem.IsWindows() || Environment.UserName == "root") + { + return; // Permission bits are not enforced here. + } + + using var server = new TestServer(); + File.SetUnixFileMode(server.SaveDirectory, UnixFileMode.UserRead | UnixFileMode.UserExecute); + try + { + var ex = await Assert.ThrowsAsync(() => server.CreateClient().SendAsync(new MemoryStream(new byte[1]), "x.bin")); + Assert.Equal(EftStatus.ServerError, ex.Status); + } + finally + { + File.SetUnixFileMode(server.SaveDirectory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + } + + [Fact] + public async Task Max_file_size_is_enforced() + { + using var server = new TestServer(o => o.MaxFileSize = 100); + var client = server.CreateClient(); + + await client.SendAsync(new MemoryStream(new byte[100]), "fits.bin"); + var ex = await Assert.ThrowsAsync(() => client.SendAsync(new MemoryStream(new byte[101]), "too-big.bin")); + + Assert.Equal(EftStatus.FileTooLarge, ex.Status); + Assert.Equal(new[] { "fits.bin" }, server.SavedFiles()); + } + + [Fact] + public void Absurd_file_size_is_rejected_for_lack_of_space() + { + using var server = new TestServer(); + using var raw = new RawClient(server.Port); + raw.ReadHello(); + raw.SendRequest("huge.bin", long.MaxValue); + + Assert.Equal(EftStatus.InsufficientStorage, raw.ReadStatus()); + } + + [Fact] + public async Task Idle_connections_are_dropped() + { + using var server = new TestServer(o => o.IdleTimeout = TimeSpan.FromMilliseconds(300)); + using var raw = new RawClient(server.Port); + raw.ReadHello(); + + Assert.IsType(server.NextFailure().Exception); + Assert.Null(raw.ReadStatus()); + await server.CreateClient().SendAsync(new MemoryStream(new byte[1]), "after.bin"); + } + + [Fact] + public async Task Connection_limit_is_enforced() + { + using var server = new TestServer(o => o.MaxConcurrentConnections = 1); + using var hog = new RawClient(server.Port); + Assert.Equal(EftStatus.Success, (EftStatus)hog.ReadHello()[Protocol.MagicLength + 1]); + + var ex = await Assert.ThrowsAsync(() => server.CreateClient().SendAsync(new MemoryStream(new byte[1]), "x.bin")); + Assert.Equal(EftStatus.ServerBusy, ex.Status); + + hog.Dispose(); + server.NextFailure(); // busy rejection + server.NextFailure(); // hog disconnected + await server.CreateClient().SendAsync(new MemoryStream(new byte[1]), "x.bin"); + } + + [Fact] + public async Task Access_token_is_required_when_configured() + { + using var server = new TestServer(o => o.AccessToken = "correct horse battery staple"); + var content = new MemoryStream(new byte[10]); + + var missing = await Assert.ThrowsAsync(() => server.CreateClient().SendAsync(content, "a.bin")); + Assert.Equal(EftStatus.AuthenticationFailed, missing.Status); + + content.Position = 0; + var wrong = await Assert.ThrowsAsync(() => + server.CreateClient(new EftClientOptions { AccessToken = "wrong" }).SendAsync(content, "a.bin")); + Assert.Equal(EftStatus.AuthenticationFailed, wrong.Status); + + content.Position = 0; + await server.CreateClient(new EftClientOptions { AccessToken = "correct horse battery staple" }).SendAsync(content, "a.bin"); + Assert.Equal(new[] { "a.bin" }, server.SavedFiles()); + } + + [Fact] + public void Proof_is_bound_to_the_servers_nonce() + { + using var server = new TestServer(o => o.AccessToken = "secret"); + byte[] replayedProof; + using (var first = new RawClient(server.Port)) + { + replayedProof = Protocol.ComputeProof("secret", RawClient.Nonce(first.ReadHello())); + } + + using var second = new RawClient(server.Port); + second.ReadHello(); + second.SendRequest("replay.bin", 1, replayedProof); + + Assert.Equal(EftStatus.AuthenticationFailed, second.ReadStatus()); + } + + [Fact] + public async Task Tls_transfer_works_with_a_pinned_certificate() + { + using var certificate = CreateSelfSignedCertificate(); + using var server = new TestServer(o => o.ServerCertificate = certificate); + var client = server.CreateClient(new EftClientOptions + { + UseTls = true, + TlsTargetHost = "localhost", + ServerCertificateValidationCallback = (_, cert, _, _) => + cert != null && cert.GetCertHashString() == certificate.Thumbprint, + }); + + await client.SendAsync(new MemoryStream("encrypted"u8.ToArray()), "secret.txt"); + + Assert.Equal("encrypted", await File.ReadAllTextAsync(Path.Combine(server.SaveDirectory, "secret.txt"))); + } + + [Fact] + public async Task Tls_client_rejects_an_untrusted_certificate_by_default() + { + using var certificate = CreateSelfSignedCertificate(); + using var server = new TestServer(o => o.ServerCertificate = certificate); + var client = server.CreateClient(new EftClientOptions { UseTls = true, TlsTargetHost = "localhost" }); + + await Assert.ThrowsAsync(() => client.SendAsync(new MemoryStream(new byte[1]), "x.bin")); + Assert.Empty(server.SavedFiles()); + } + + [Fact] + public async Task Plaintext_client_cannot_talk_to_a_tls_server() + { + using var certificate = CreateSelfSignedCertificate(); + using var server = new TestServer(o => + { + o.ServerCertificate = certificate; + o.IdleTimeout = TimeSpan.FromSeconds(2); + }); + var client = server.CreateClient(new EftClientOptions { Timeout = TimeSpan.FromSeconds(5) }); + + await Assert.ThrowsAnyAsync(() => client.SendAsync(new MemoryStream(new byte[1]), "x.bin")); + Assert.Empty(server.SavedFiles()); + } + + private static X509Certificate2 CreateSelfSignedCertificate() + { + using var key = RSA.Create(2048); + var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var san = new SubjectAlternativeNameBuilder(); + san.AddDnsName("localhost"); + request.CertificateExtensions.Add(san.Build()); + using X509Certificate2 ephemeral = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1)); + + // Round-trip through PKCS#12 so the private key is usable by SslStream on every OS. + byte[] pfx = ephemeral.Export(X509ContentType.Pfx); +#if NET9_0_OR_GREATER + return X509CertificateLoader.LoadPkcs12(pfx, null); +#else + return new X509Certificate2(pfx); +#endif + } +} diff --git a/EasyFileTransfer.Tests/TestServer.cs b/EasyFileTransfer.Tests/TestServer.cs new file mode 100644 index 0000000..d83449b --- /dev/null +++ b/EasyFileTransfer.Tests/TestServer.cs @@ -0,0 +1,64 @@ +using System.Collections.Concurrent; +using System.Net; + +namespace EasyFileTransfer.Tests; + +/// An on a random loopback port with its own temp directory. +internal sealed class TestServer : IDisposable +{ + public TestServer(Action? configure = null) + { + Root = Directory.CreateTempSubdirectory("eft-tests-").FullName; + SaveDirectory = Path.Combine(Root, "inbox"); + var options = new EftServerOptions + { + SaveDirectory = SaveDirectory, + Port = 0, + BindAddress = IPAddress.Loopback, + IdleTimeout = TimeSpan.FromSeconds(10), + }; + configure?.Invoke(options); + + Server = new EftServer(options); + Server.FileReceived += (_, e) => Received.Add(e); + Server.TransferFailed += (_, e) => Failures.Add(e); + Server.Start(); + } + + /// Parent of ; used to detect writes that escape it. + public string Root { get; } + + public string SaveDirectory { get; } + + public EftServer Server { get; } + + public int Port => Server.LocalEndPoint!.Port; + + public BlockingCollection Received { get; } = new(); + + public BlockingCollection Failures { get; } = new(); + + public EftClient CreateClient(EftClientOptions? options = null) => new("127.0.0.1", Port, options); + + public TransferFailedEventArgs NextFailure() + { + Assert.True(Failures.TryTake(out var failure, TimeSpan.FromSeconds(15)), "Expected the server to report a failed transfer."); + return failure!; + } + + public string[] SavedFiles() => Directory.GetFiles(SaveDirectory).Select(Path.GetFileName).ToArray()!; + + public string[] AllFilesUnderRoot() => Directory.GetFiles(Root, "*", SearchOption.AllDirectories); + + public void Dispose() + { + Server.Dispose(); + try + { + Directory.Delete(Root, recursive: true); + } + catch (IOException) + { + } + } +} diff --git a/EasyFileTransfer.Tests/TransferTests.cs b/EasyFileTransfer.Tests/TransferTests.cs new file mode 100644 index 0000000..0325377 --- /dev/null +++ b/EasyFileTransfer.Tests/TransferTests.cs @@ -0,0 +1,173 @@ +using System.Security.Cryptography; + +namespace EasyFileTransfer.Tests; + +public class TransferTests +{ + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(81920)] + [InlineData(5 * 1024 * 1024 + 7)] + public async Task Sends_file_intact(int size) + { + using var server = new TestServer(); + byte[] content = RandomNumberGenerator.GetBytes(size); + string source = Path.Combine(server.Root, "source.bin"); + await File.WriteAllBytesAsync(source, content); + + var reports = new List(); + await server.CreateClient().SendFileAsync(source, new SyncProgress(reports.Add)); + + Assert.True(server.Received.TryTake(out var received, TimeSpan.FromSeconds(10))); + Assert.Equal(Path.Combine(server.SaveDirectory, "source.bin"), received!.FilePath); + Assert.Equal("source.bin", received.RequestedFileName); + Assert.Equal(size, received.Length); + Assert.Equal(content, await File.ReadAllBytesAsync(received.FilePath)); + Assert.Equal(new[] { "source.bin" }, server.SavedFiles()); + Assert.Equal(100, reports[^1].Percentage); + Assert.Equal(size, reports[^1].BytesTransferred); + } + + [Fact] + public async Task Never_overwrites_an_existing_file() + { + using var server = new TestServer(); + await File.WriteAllTextAsync(Path.Combine(server.SaveDirectory, "a.txt"), "original"); + var client = server.CreateClient(); + + await client.SendAsync(new MemoryStream("first"u8.ToArray()), "a.txt"); + await client.SendAsync(new MemoryStream("second"u8.ToArray()), "a.txt"); + + Assert.Equal("original", await File.ReadAllTextAsync(Path.Combine(server.SaveDirectory, "a.txt"))); + Assert.Equal("first", await File.ReadAllTextAsync(Path.Combine(server.SaveDirectory, "a (1).txt"))); + Assert.Equal("second", await File.ReadAllTextAsync(Path.Combine(server.SaveDirectory, "a (2).txt"))); + } + + [Fact] + public async Task Handles_concurrent_transfers() + { + using var server = new TestServer(); + var client = server.CreateClient(); + + await Task.WhenAll(Enumerable.Range(0, 8).Select(i => + client.SendAsync(new MemoryStream(RandomNumberGenerator.GetBytes(200_000)), $"file{i}.bin"))); + + Assert.Equal(8, server.SavedFiles().Length); + } + + [Fact] + public async Task Cancellation_aborts_the_transfer_and_leaves_no_partial_file() + { + using var server = new TestServer(); + using var cts = new CancellationTokenSource(); + var progress = new SyncProgress(p => + { + if (p.BytesTransferred > 1_000_000) + { + cts.Cancel(); + } + }); + + await Assert.ThrowsAnyAsync(() => + server.CreateClient().SendAsync(new MemoryStream(new byte[50_000_000]), "big.bin", progress, cts.Token)); + + server.NextFailure(); + Assert.Empty(server.SavedFiles()); + Assert.Empty(Directory.GetFiles(server.SaveDirectory, "*", new EnumerationOptions { AttributesToSkip = 0 })); + } + + [Fact] + public async Task Server_can_be_stopped_and_restarted() + { + using var server = new TestServer(); + int port = server.Port; + await server.Server.StopAsync(); + Assert.False(server.Server.IsRunning); + + server.Server.Options.Port = port; + server.Server.Start(); + await server.CreateClient().SendAsync(new MemoryStream(new byte[10]), "after-restart.bin"); + Assert.Contains("after-restart.bin", server.SavedFiles()); + } + + [Fact] + public async Task Client_rejects_unsafe_file_names_before_connecting() + { + var client = new EftClient("127.0.0.1", 1); + var ex = await Assert.ThrowsAsync(() => client.SendAsync(new MemoryStream(), "../evil.txt")); + Assert.Equal(EftStatus.InvalidFileName, ex.Status); + } + + [Fact] + public async Task Client_times_out_against_a_silent_server() + { + using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + int port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + var client = new EftClient("127.0.0.1", port, new EftClientOptions { Timeout = TimeSpan.FromMilliseconds(500) }); + + await Assert.ThrowsAsync(() => client.SendAsync(new MemoryStream(new byte[1]), "x.bin")); + } + +#pragma warning disable CS0618 // Legacy API must keep working. + [Fact] + public async Task Legacy_api_still_works() + { + string root = Directory.CreateTempSubdirectory("eft-legacy-").FullName; + int port = GetFreePort(); + var server = new EftServer(root + Path.DirectorySeparatorChar, port); + var thread = new Thread(server.StartServer) { IsBackground = true }; + thread.Start(); + try + { + string source = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".txt"); + await File.WriteAllTextAsync(source, "legacy"); + await WaitUntilAsync(() => server.IsRunning); + + Model.Response response = EftClient.Send(source, "127.0.0.1", port); + + Assert.Equal(1, response.status); + Assert.Equal(100, EftClient.ProgressValue); + Assert.Equal("legacy", await File.ReadAllTextAsync(Path.Combine(root, Path.GetFileName(source)))); + File.Delete(source); + } + finally + { + server.Dispose(); + Assert.True(thread.Join(TimeSpan.FromSeconds(10))); + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void Legacy_send_reports_errors_instead_of_throwing() + { + Model.Response response = EftClient.Send("/does/not/exist.bin", "127.0.0.1", GetFreePort()); + Assert.Equal(-1, response.status); + Assert.StartsWith("Error: ", response.description); + } +#pragma warning restore CS0618 + + private static int GetFreePort() + { + using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + return ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + } + + private static async Task WaitUntilAsync(Func condition) + { + for (int i = 0; i < 100 && !condition(); i++) + { + await Task.Delay(50); + } + + Assert.True(condition()); + } +} + +internal sealed class SyncProgress(Action handler) : IProgress +{ + public void Report(EftProgress value) => handler(value); +} diff --git a/EasyFileTransfer.sln b/EasyFileTransfer.sln index 630a4f5..de8cc6f 100644 --- a/EasyFileTransfer.sln +++ b/EasyFileTransfer.sln @@ -9,6 +9,8 @@ Project("{9A19103F-16F7-32B4-A1A3-6F0E9A9B9E9E}") = "Sample.Server", "Sample.Ser EndProject Project("{9A19103F-16F7-32B4-A1A3-6F0E9A9B9E9E}") = "Sample.Client", "Sample.Client\Sample.Client.csproj", "{2638D1E6-BBBC-496E-B3B8-022B0F57A242}" EndProject +Project("{9A19103F-16F7-32B4-A1A3-6F0E9A9B9E9E}") = "EasyFileTransfer.Tests", "EasyFileTransfer.Tests\EasyFileTransfer.Tests.csproj", "{E9644CCA-D337-4E2A-B24E-27ADC8EA13F3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -27,6 +29,10 @@ Global {2638D1E6-BBBC-496E-B3B8-022B0F57A242}.Debug|Any CPU.Build.0 = Debug|Any CPU {2638D1E6-BBBC-496E-B3B8-022B0F57A242}.Release|Any CPU.ActiveCfg = Release|Any CPU {2638D1E6-BBBC-496E-B3B8-022B0F57A242}.Release|Any CPU.Build.0 = Release|Any CPU + {E9644CCA-D337-4E2A-B24E-27ADC8EA13F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E9644CCA-D337-4E2A-B24E-27ADC8EA13F3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E9644CCA-D337-4E2A-B24E-27ADC8EA13F3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E9644CCA-D337-4E2A-B24E-27ADC8EA13F3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/EasyFileTransfer/EasyFileTransfer.csproj b/EasyFileTransfer/EasyFileTransfer.csproj index 7129ef2..4727d37 100644 --- a/EasyFileTransfer/EasyFileTransfer.csproj +++ b/EasyFileTransfer/EasyFileTransfer.csproj @@ -9,21 +9,12 @@ true latest true - - CS1591;CS8600;CS8602;CS8618;CA2022 EasyFileTransfer - 0.2.0-preview.1 + 0.2.0-preview.2 EasyFileTransfer An easy way to transfer files of any size over a network using the TCP protocol. tcp;sockets;file-transfer;networking;large-files @@ -31,11 +22,15 @@ https://github.com/Jamalianpour/EasyFileTransfer README.md icon.png - Modernized build: SDK-style project, multi-targets netstandard2.0, net8.0 and net10.0. + Security rewrite: new versioned wire protocol with file-name validation (fixes path traversal), bounded reads, timeouts, connection limits, optional access-token authentication and TLS, and SHA-256 integrity checks. New async API (EftClient.SendFileAsync, EftServer.Start/StopAsync); the 0.1.x API remains as [Obsolete] wrappers. Not wire-compatible with 0.1.x. true snupkg + + + + diff --git a/EasyFileTransfer/EftClient.cs b/EasyFileTransfer/EftClient.cs index a0424a4..f802085 100644 --- a/EasyFileTransfer/EftClient.cs +++ b/EasyFileTransfer/EftClient.cs @@ -1,128 +1,291 @@ -using EasyFileTransfer.Model; +using EasyFileTransfer.Internal; +using EasyFileTransfer.Model; using System; -using System.Collections.Generic; using System.IO; -using System.Linq; +using System.Net.Security; using System.Net.Sockets; -using System.Text; +using System.Security.Cryptography; +using System.Threading; using System.Threading.Tasks; namespace EasyFileTransfer { + /// + /// Sends files to an . + /// + /// + /// + /// var client = new EftClient("192.168.1.10", 1300, new EftClientOptions { AccessToken = token }); + /// await client.SendFileAsync(@"C:\big.iso", new Progress<EftProgress>(p => Console.WriteLine(p.Percentage))); + /// + /// public class EftClient { + private readonly EftClientOptions _options; + /// - /// Percentage of file send progress + /// Creates a client for the server at :. /// - public static int ProgressValue; + /// Host name or IP address of the server. + /// Port the server listens on. + /// Optional settings such as the access token and TLS. + public EftClient(string host, int port, EftClientOptions? options = null) + { + if (string.IsNullOrWhiteSpace(host)) + { + throw new ArgumentException("Host is required.", nameof(host)); + } + + if (port < 1 || port > 65535) + { + throw new ArgumentOutOfRangeException(nameof(port)); + } + + _options = options ?? new EftClientOptions(); + _options.Validate(); + Host = host; + Port = port; + } + + /// Host name or IP address of the server. + public string Host { get; } + + /// Port the server listens on. + public int Port { get; } /// - /// send file + /// Sends the file at . The server stores it under the same file name. /// - /// file location that you want to send - /// IP Address of target system - /// Server listen to this port - /// An object with status and description - public static Response Send(string FilePath, string TargetIP, int Port) + /// Path of the file to send. + /// Receives progress updates; may be null. + /// Aborts the transfer. + /// The server rejected the file or the transfer failed its integrity check. + /// A network or disk error occurred. + /// A network operation took longer than . + public async Task SendFileAsync(string filePath, IProgress? progress = null, CancellationToken cancellationToken = default) { - try + if (string.IsNullOrEmpty(filePath)) { - string Selected_file = FilePath; - string File_name = Path.GetFileName(Selected_file); - FileStream fs = new FileStream(Selected_file, FileMode.Open); - TcpClient tc = new TcpClient(TargetIP, Port); - NetworkStream ns = tc.GetStream(); - byte[] data_tosend = CreateDataPacket(Encoding.UTF8.GetBytes("125"), Encoding.UTF8.GetBytes(File_name)); - ns.Write(data_tosend, 0, data_tosend.Length); - ns.Flush(); - bool loop_break = false; - while (true) + throw new ArgumentException("File path is required.", nameof(filePath)); + } + + using (var file = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, Protocol.BufferSize, useAsync: true)) + { + await SendAsync(file, Path.GetFileName(filePath), progress, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Sends the remaining contents of (from its current position to its end) + /// as a file called . + /// + /// A readable, seekable stream. + /// Name the server stores the file under. Must not contain path separators. + /// Receives progress updates; may be null. + /// Aborts the transfer. + /// The server rejected the file or the transfer failed its integrity check. + /// A network or disk error occurred. + /// A network operation took longer than . + public async Task SendAsync(Stream content, string fileName, IProgress? progress = null, CancellationToken cancellationToken = default) + { + if (content is null) + { + throw new ArgumentNullException(nameof(content)); + } + + if (!content.CanRead || !content.CanSeek) + { + throw new ArgumentException("The stream must be readable and seekable.", nameof(content)); + } + + if (!FileNameValidator.IsValid(fileName)) + { + throw new EftException(EftStatus.InvalidFileName, $"'{fileName}' is not a valid file name."); + } + + byte[] nameBytes = Protocol.StrictUtf8.GetBytes(fileName); + if (nameBytes.Length > Protocol.MaxFileNameBytes) + { + throw new EftException(EftStatus.InvalidFileName, "The file name is too long."); + } + + cancellationToken.ThrowIfCancellationRequested(); + long length = content.Length - content.Position; + + using (var connection = new Connection(new TcpClient(), _options.Timeout, cancellationToken)) + { + try + { + await TransferAsync(connection, content, length, nameBytes, progress).ConfigureAwait(false); + } + catch (Exception ex) when (!(ex is EftException)) { - if (ns.ReadByte() == 2) + Exception translated = connection.Translate(ex); + if (ReferenceEquals(translated, ex)) { - byte[] cmd_buffer = new byte[3]; - ns.Read(cmd_buffer, 0, cmd_buffer.Length); - byte[] recv_data = ReadStream(ns); - switch (Convert.ToInt32(Encoding.UTF8.GetString(cmd_buffer))) - { - case 126: - long recv_file_pointer = long.Parse(Encoding.UTF8.GetString(recv_data)); - if (recv_file_pointer != fs.Length) - { - fs.Seek(recv_file_pointer, SeekOrigin.Begin); - int temp_buffer_length = (int)(fs.Length - recv_file_pointer < 20000 ? fs.Length - recv_file_pointer : 20000); - byte[] temp_buffer = new byte[temp_buffer_length]; - fs.Read(temp_buffer, 0, temp_buffer.Length); - byte[] data_to_send = CreateDataPacket(Encoding.UTF8.GetBytes("127"), temp_buffer); - ns.Write(data_to_send, 0, data_to_send.Length); - ns.Flush(); - ProgressValue = (int)Math.Ceiling((double)recv_file_pointer / (double)fs.Length * 100); - } - else - { - byte[] data_to_send = CreateDataPacket(Encoding.UTF8.GetBytes("128"), Encoding.UTF8.GetBytes("Close")); - ns.Write(data_to_send, 0, data_to_send.Length); - ns.Flush(); - fs.Close(); - loop_break = true; - } - break; - default: - break; - } + throw; } - if (loop_break == true) + + throw translated; + } + } + } + + private async Task TransferAsync(Connection connection, Stream content, long length, byte[] nameBytes, IProgress? progress) + { + await ConnectAsync(connection).ConfigureAwait(false); + + // Server hello. + var hello = new byte[Protocol.HelloLength]; + await connection.ReadExactlyAsync(hello, 0, hello.Length).ConfigureAwait(false); + if (!Protocol.HasMagic(hello, 0)) + { + throw new EftException(EftStatus.ProtocolError, "The remote endpoint is not an EasyFileTransfer server."); + } + + if (hello[Protocol.MagicLength] != Protocol.Version) + { + throw new EftException(EftStatus.UnsupportedVersion, $"The server speaks protocol version {hello[Protocol.MagicLength]}; this client requires version {Protocol.Version}."); + } + + ThrowIfNotSuccess(hello[Protocol.MagicLength + 1]); + + bool authRequired = (hello[Protocol.MagicLength + 2] & Protocol.FlagAuthRequired) != 0; + if (authRequired && _options.AccessToken is null) + { + throw new EftException(EftStatus.AuthenticationFailed, "The server requires an access token. Set EftClientOptions.AccessToken."); + } + + var nonce = new byte[Protocol.NonceLength]; + Buffer.BlockCopy(hello, Protocol.MagicLength + 3, nonce, 0, nonce.Length); + + // Request. + var request = new byte[Protocol.RequestHeaderLength + nameBytes.Length + 8]; + Protocol.WriteMagic(request, 0); + request[Protocol.MagicLength] = Protocol.Version; + byte[] proof = Protocol.ComputeProof(_options.AccessToken, nonce); + Buffer.BlockCopy(proof, 0, request, Protocol.MagicLength + 1, Protocol.ProofLength); + Protocol.WriteUInt16(request, Protocol.RequestHeaderLength - 2, nameBytes.Length); + Buffer.BlockCopy(nameBytes, 0, request, Protocol.RequestHeaderLength, nameBytes.Length); + Protocol.WriteInt64(request, Protocol.RequestHeaderLength + nameBytes.Length, length); + await connection.WriteAsync(request, 0, request.Length).ConfigureAwait(false); + await connection.FlushAsync().ConfigureAwait(false); + + await ReadStatusAsync(connection).ConfigureAwait(false); + + // File content followed by its SHA-256. + var buffer = new byte[Protocol.BufferSize]; + long sent = 0; + int lastReportedPercent = -1; + using (var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256)) + { + progress?.Report(new EftProgress(0, length)); + while (sent < length) + { + int toRead = (int)Math.Min(buffer.Length, length - sent); + int read = await content.ReadAsync(buffer, 0, toRead, connection.Token).ConfigureAwait(false); + if (read == 0) { - ns.Close(); - return new Response { status = 1, description = "send successfully."}; + throw new EndOfStreamException("The source stream ended before all bytes were sent."); } + hash.AppendData(buffer, 0, read); + await connection.WriteAsync(buffer, 0, read).ConfigureAwait(false); + sent += read; + + int percent = (int)(sent * 100 / Math.Max(length, 1)); + if (progress != null && percent != lastReportedPercent) + { + lastReportedPercent = percent; + progress.Report(new EftProgress(sent, length)); + } } + + byte[] digest = hash.GetHashAndReset(); + await connection.WriteAsync(digest, 0, digest.Length).ConfigureAwait(false); + await connection.FlushAsync().ConfigureAwait(false); } - catch (Exception e) + + await ReadStatusAsync(connection).ConfigureAwait(false); + if (length == 0) { - return new Response { status = -1, description = "Error: " + e.Message}; + progress?.Report(new EftProgress(0, 0)); } } - private static byte[] ReadStream(NetworkStream ns) + private async Task ConnectAsync(Connection connection) { - byte[] data_buff = null; + TcpClient tcp = connection.TcpClient; + connection.ResetTimer(); + await tcp.ConnectAsync(Host, Port).ConfigureAwait(false); + tcp.NoDelay = true; + + Stream stream = tcp.GetStream(); + connection.Stream = stream; - int b = 0; - string buff_Length = ""; - while ((b = ns.ReadByte()) != 4) + if (_options.UseTls) { - buff_Length += (char)b; + var ssl = new SslStream(stream, leaveInnerStreamOpen: false, _options.ServerCertificateValidationCallback); + connection.Stream = ssl; + connection.ResetTimer(); + await ssl.AuthenticateAsClientAsync(_options.TlsTargetHost ?? Host).ConfigureAwait(false); } - int data_Length = Convert.ToInt32(buff_Length); - data_buff = new byte[data_Length]; - int byte_Read = 0; - int byte_Offset = 0; - while (byte_Offset < data_Length) + } + + private static async Task ReadStatusAsync(Connection connection) + { + var status = new byte[1]; + await connection.ReadExactlyAsync(status, 0, 1).ConfigureAwait(false); + ThrowIfNotSuccess(status[0]); + } + + private static void ThrowIfNotSuccess(byte status) + { + if (status != (byte)EftStatus.Success) { - byte_Read = ns.Read(data_buff, byte_Offset, data_Length - byte_Offset); - byte_Offset += byte_Read; + throw new EftException((EftStatus)status); } + } + + #region Legacy API (0.1.x) - return data_buff; + /// + /// Percentage of file send progress for the most recent call. + /// + [Obsolete("Shared static state is not thread-safe. Pass an IProgress to SendFileAsync instead.")] + public static int ProgressValue; + + /// + /// Sends a file and blocks until the transfer completes. + /// + /// file location that you want to send + /// IP Address of target system + /// Server listen to this port + /// An object with status and description + [Obsolete("Use new EftClient(host, port, options).SendFileAsync(path) instead.")] + public static Response Send(string FilePath, string TargetIP, int Port) + { + try + { + var progress = new SynchronousProgress(p => ProgressValue = (int)Math.Ceiling(p.Percentage)); + new EftClient(TargetIP, Port).SendFileAsync(FilePath, progress).GetAwaiter().GetResult(); + return new Response { status = 1, description = "send successfully." }; + } + catch (Exception e) + { + return new Response { status = -1, description = "Error: " + e.Message }; + } } - private static byte[] CreateDataPacket(byte[] cmd, byte[] data) + private sealed class SynchronousProgress : IProgress { - byte[] initialize = new byte[1]; - initialize[0] = 2; - byte[] separator = new byte[1]; - separator[0] = 4; - byte[] dataLength = Encoding.UTF8.GetBytes(Convert.ToString(data.Length)); - MemoryStream ms = new MemoryStream(); - ms.Write(initialize, 0, initialize.Length); - ms.Write(cmd, 0, cmd.Length); - ms.Write(dataLength, 0, dataLength.Length); - ms.Write(separator, 0, separator.Length); - ms.Write(data, 0, data.Length); - - return ms.ToArray(); + private readonly Action _handler; + + public SynchronousProgress(Action handler) => _handler = handler; + + public void Report(EftProgress value) => _handler(value); } + + #endregion } } diff --git a/EasyFileTransfer/EftClientOptions.cs b/EasyFileTransfer/EftClientOptions.cs new file mode 100644 index 0000000..9be0008 --- /dev/null +++ b/EasyFileTransfer/EftClientOptions.cs @@ -0,0 +1,50 @@ +using System; +using System.Net.Security; + +namespace EasyFileTransfer +{ + /// + /// Settings for . + /// + public sealed class EftClientOptions + { + /// + /// Shared secret expected by the server (). + /// The token itself is never sent; the client proves it knows the token with an + /// HMAC-SHA256 over a random challenge chosen by the server. + /// + public string? AccessToken { get; set; } + + /// + /// Encrypt the connection with TLS. The server must be configured with + /// . + /// + public bool UseTls { get; set; } + + /// + /// Host name the server certificate must match. Defaults to the host passed to + /// the constructor. + /// + public string? TlsTargetHost { get; set; } + + /// + /// Custom server certificate validation, for example to pin a self-signed + /// certificate. When null, the operating system's standard validation is used. + /// + public RemoteCertificateValidationCallback? ServerCertificateValidationCallback { get; set; } + + /// + /// Maximum time a single network operation (connect, handshake, read or write) + /// may take before the transfer is aborted. Defaults to 30 seconds. + /// + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); + + internal void Validate() + { + if (Timeout <= TimeSpan.Zero && Timeout != System.Threading.Timeout.InfiniteTimeSpan) + { + throw new ArgumentOutOfRangeException(nameof(Timeout), "Timeout must be positive or Timeout.InfiniteTimeSpan."); + } + } + } +} diff --git a/EasyFileTransfer/EftException.cs b/EasyFileTransfer/EftException.cs new file mode 100644 index 0000000..fb1d161 --- /dev/null +++ b/EasyFileTransfer/EftException.cs @@ -0,0 +1,53 @@ +using System; +using System.IO; + +namespace EasyFileTransfer +{ + /// + /// Thrown when a transfer is rejected or fails at the protocol level. + /// Network failures surface as or . + /// + public class EftException : IOException + { + /// Creates a new . + public EftException(EftStatus status) + : this(status, DescribeStatus(status)) + { + } + + /// Creates a new with a custom message. + public EftException(EftStatus status, string message) + : base(message) + { + Status = status; + } + + /// Creates a new with a custom message and inner exception. + public EftException(EftStatus status, string message, Exception innerException) + : base(message, innerException) + { + Status = status; + } + + /// The reason the transfer failed. + public EftStatus Status { get; } + + internal static string DescribeStatus(EftStatus status) + { + switch (status) + { + case EftStatus.Success: return "The operation completed successfully."; + case EftStatus.ServerBusy: return "The server is busy. Try again later."; + case EftStatus.AuthenticationFailed: return "The server rejected the access token."; + case EftStatus.InvalidFileName: return "The file name is not allowed."; + case EftStatus.FileTooLarge: return "The file is larger than the server allows."; + case EftStatus.InsufficientStorage: return "The server does not have enough free disk space."; + case EftStatus.IntegrityCheckFailed: return "The file was corrupted in transit (SHA-256 mismatch)."; + case EftStatus.ProtocolError: return "The peer sent an invalid message."; + case EftStatus.ServerError: return "The server failed to store the file."; + case EftStatus.UnsupportedVersion: return "The peer uses an unsupported protocol version."; + default: return "Unknown status " + (byte)status + "."; + } + } + } +} diff --git a/EasyFileTransfer/EftProgress.cs b/EasyFileTransfer/EftProgress.cs new file mode 100644 index 0000000..76d1506 --- /dev/null +++ b/EasyFileTransfer/EftProgress.cs @@ -0,0 +1,24 @@ +namespace EasyFileTransfer +{ + /// + /// Progress of a file transfer, reported through . + /// + public readonly struct EftProgress + { + /// Creates a new progress value. + public EftProgress(long bytesTransferred, long totalBytes) + { + BytesTransferred = bytesTransferred; + TotalBytes = totalBytes; + } + + /// Number of file bytes sent so far. + public long BytesTransferred { get; } + + /// Total size of the file in bytes. + public long TotalBytes { get; } + + /// Completion percentage between 0 and 100. + public double Percentage => TotalBytes == 0 ? 100 : BytesTransferred * 100.0 / TotalBytes; + } +} diff --git a/EasyFileTransfer/EftServer.cs b/EasyFileTransfer/EftServer.cs index d48a77f..527d0a3 100644 --- a/EasyFileTransfer/EftServer.cs +++ b/EasyFileTransfer/EftServer.cs @@ -1,144 +1,514 @@ -using System; -using System.Collections.Generic; +using EasyFileTransfer.Internal; +using System; +using System.Collections.Concurrent; using System.IO; -using System.Linq; using System.Net; +using System.Net.Security; using System.Net.Sockets; -using System.Text; +using System.Security.Cryptography; +using System.Threading; using System.Threading.Tasks; namespace EasyFileTransfer { - public class EftServer + /// + /// Receives files sent by and stores them in + /// . + /// + /// + /// + /// using var server = new EftServer(new EftServerOptions { SaveDirectory = @"C:\Inbox", AccessToken = token }); + /// server.FileReceived += (s, e) => Console.WriteLine($"Saved {e.FilePath}"); + /// server.Start(); + /// + /// + public class EftServer : IDisposable { - public string SaveTo; - public int Port; - TcpListener obj_server; - public EftServer(string SaveTo, int Port) + private static readonly TimeSpan BusyTimeout = TimeSpan.FromSeconds(5); + + private readonly object _gate = new object(); + private readonly ConcurrentDictionary _connections = new ConcurrentDictionary(); + private TcpListener? _listener; + private CancellationTokenSource? _stopCts; + private Task? _acceptLoop; + private string _saveDirectory = string.Empty; + private int _activeConnections; + + /// + /// Creates a server. Call to begin accepting files. + /// + public EftServer(EftServerOptions options) { - this.SaveTo = SaveTo; - this.Port = Port; - obj_server = new TcpListener(IPAddress.Any, Port); + Options = options ?? throw new ArgumentNullException(nameof(options)); + options.Validate(); } + /// The server's settings. Change them only while the server is stopped. + public EftServerOptions Options { get; } + + /// The address and port the server is listening on, or null when stopped. + public IPEndPoint? LocalEndPoint { get; private set; } + + /// Whether the server is accepting connections. + public bool IsRunning => _listener != null; + /// - /// Start EftServer and listening on port + /// Raised on a thread-pool thread after a file has been received, verified and saved. + /// Exceptions thrown by handlers are ignored. /// - public void StartServer() + public event EventHandler? FileReceived; + + /// + /// Raised on a thread-pool thread when a transfer is rejected or fails. Any partially + /// written data has already been deleted. Exceptions thrown by handlers are ignored. + /// + public event EventHandler? TransferFailed; + + /// + /// Starts listening for connections in the background and returns immediately. + /// + /// The server is already running. + /// The port could not be opened. + public void Start() { - obj_server.Start(); - while (true) + lock (_gate) { - TcpClient tc = obj_server.AcceptTcpClient(); - SocketHandler obj_hadler = new SocketHandler(tc, SaveTo); - System.Threading.Thread obj_thread = new System.Threading.Thread(obj_hadler.ProcessSocketRequest); - obj_thread.Start(); + if (_listener != null) + { + throw new InvalidOperationException("The server is already running."); + } + + Options.Validate(); + _saveDirectory = Path.GetFullPath(Options.SaveDirectory); + Directory.CreateDirectory(_saveDirectory); + + var listener = new TcpListener(Options.BindAddress, Options.Port); + if (Options.BindAddress.Equals(IPAddress.IPv6Any)) + { + listener.Server.DualMode = true; + } + + listener.Start(); + var stopCts = new CancellationTokenSource(); + _listener = listener; + _stopCts = stopCts; + LocalEndPoint = (IPEndPoint)listener.LocalEndpoint; + _acceptLoop = Task.Run(() => AcceptLoopAsync(listener, stopCts.Token)); } } - } - class SocketHandler - { - NetworkStream ns; - string SaveTo; - public SocketHandler(TcpClient tc, string SaveTo) + /// + /// Stops accepting connections, aborts transfers in progress and waits for them to finish. + /// + public async Task StopAsync() { - this.SaveTo = SaveTo; - ns = tc.GetStream(); + Task? acceptLoop; + CancellationTokenSource? stopCts; + lock (_gate) + { + if (_listener == null) + { + return; + } + + stopCts = _stopCts!; + acceptLoop = _acceptLoop; + stopCts.Cancel(); + _listener.Stop(); + _listener = null; + _stopCts = null; + _acceptLoop = null; + LocalEndPoint = null; + } + + if (acceptLoop != null) + { + await acceptLoop.ConfigureAwait(false); + } + + await Task.WhenAll(_connections.Keys).ConfigureAwait(false); + stopCts.Dispose(); } - public void ProcessSocketRequest() + /// Stops the server. See . + public void Dispose() { - FileStream fs = null; - long current_file_pointer = 0; - Boolean loop_break = false; - while (true) + StopAsync().GetAwaiter().GetResult(); + GC.SuppressFinalize(this); + } + + private async Task AcceptLoopAsync(TcpListener listener, CancellationToken stopToken) + { + while (!stopToken.IsCancellationRequested) + { + TcpClient client; + try + { + client = await listener.AcceptTcpClientAsync().ConfigureAwait(false); + } + catch (Exception) when (stopToken.IsCancellationRequested) + { + break; + } + catch (SocketException) + { + // A client that resets before the accept completes is not a server failure. + continue; + } + + Task connection = Task.Run(() => HandleConnectionAsync(client, stopToken)); + _connections.TryAdd(connection, 0); + _ = connection.ContinueWith(t => _connections.TryRemove(t, out _), CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + } + } + + private async Task HandleConnectionAsync(TcpClient client, CancellationToken stopToken) + { + var transfer = new Transfer(); + bool busy = Interlocked.Increment(ref _activeConnections) > Options.MaxConcurrentConnections; + var connection = new Connection(client, busy ? BusyTimeout : Options.IdleTimeout, stopToken); + Exception? failure = null; + try + { + transfer.RemoteEndPoint = client.Client.RemoteEndPoint as IPEndPoint; + client.NoDelay = true; + await ReceiveAsync(connection, busy, transfer).ConfigureAwait(false); + } + catch (Exception ex) + { + failure = connection.Translate(ex); + } + finally + { + connection.Dispose(); + Interlocked.Decrement(ref _activeConnections); + DeleteQuietly(transfer.TempPath); + } + + // Raised only after cleanup, so handlers never see a leftover temp file or a stale connection count. + if (failure != null && !stopToken.IsCancellationRequested) + { + RaiseTransferFailed(failure, transfer.RemoteEndPoint); + } + } + + private async Task ReceiveAsync(Connection connection, bool busy, Transfer transfer) + { + Stream stream = connection.TcpClient.GetStream(); + connection.Stream = stream; + if (Options.ServerCertificate != null) + { + var ssl = new SslStream(stream, leaveInnerStreamOpen: false); + connection.Stream = ssl; + connection.ResetTimer(); + await ssl.AuthenticateAsServerAsync(Options.ServerCertificate).ConfigureAwait(false); + } + + // Hello. + byte[] nonce = Protocol.CreateNonce(); + var hello = new byte[Protocol.HelloLength]; + Protocol.WriteMagic(hello, 0); + hello[Protocol.MagicLength] = Protocol.Version; + hello[Protocol.MagicLength + 1] = (byte)(busy ? EftStatus.ServerBusy : EftStatus.Success); + hello[Protocol.MagicLength + 2] = Options.AccessToken != null ? Protocol.FlagAuthRequired : (byte)0; + Buffer.BlockCopy(nonce, 0, hello, Protocol.MagicLength + 3, nonce.Length); + await connection.WriteAsync(hello, 0, hello.Length).ConfigureAwait(false); + await connection.FlushAsync().ConfigureAwait(false); + if (busy) + { + throw new EftException(EftStatus.ServerBusy); + } + + // Request header. Every length is checked before anything is allocated from it. + var header = new byte[Protocol.RequestHeaderLength]; + await connection.ReadExactlyAsync(header, 0, header.Length).ConfigureAwait(false); + if (!Protocol.HasMagic(header, 0)) + { + await RejectAsync(connection, EftStatus.ProtocolError).ConfigureAwait(false); + } + + if (header[Protocol.MagicLength] != Protocol.Version) + { + await RejectAsync(connection, EftStatus.UnsupportedVersion).ConfigureAwait(false); + } + + int nameLength = Protocol.ReadUInt16(header, Protocol.RequestHeaderLength - 2); + if (nameLength == 0 || nameLength > Protocol.MaxFileNameBytes) + { + await RejectAsync(connection, EftStatus.InvalidFileName).ConfigureAwait(false); + } + + var rest = new byte[nameLength + 8]; + await connection.ReadExactlyAsync(rest, 0, rest.Length).ConfigureAwait(false); + + if (Options.AccessToken != null) + { + byte[] expectedProof = Protocol.ComputeProof(Options.AccessToken, nonce); + if (!Protocol.FixedTimeEquals(header, Protocol.MagicLength + 1, expectedProof, Protocol.ProofLength)) + { + await RejectAsync(connection, EftStatus.AuthenticationFailed).ConfigureAwait(false); + } + } + + string fileName; + try + { + fileName = Protocol.StrictUtf8.GetString(rest, 0, nameLength); + } + catch (ArgumentException) + { + fileName = string.Empty; + } + + if (!FileNameValidator.IsValid(fileName) || !IsInsideSaveDirectory(fileName)) + { + await RejectAsync(connection, EftStatus.InvalidFileName).ConfigureAwait(false); + } + + long length = Protocol.ReadInt64(rest, nameLength); + if (length < 0) + { + await RejectAsync(connection, EftStatus.ProtocolError).ConfigureAwait(false); + } + + if (length > Options.MaxFileSize) + { + await RejectAsync(connection, EftStatus.FileTooLarge).ConfigureAwait(false); + } + + if (length > GetAvailableFreeSpace()) + { + await RejectAsync(connection, EftStatus.InsufficientStorage).ConfigureAwait(false); + } + + // Receive into a hidden temp file so a partial or corrupt upload never appears under + // its real name. FileMode.CreateNew never follows or overwrites an existing entry. + string tempPath = Path.Combine(_saveDirectory, "." + Guid.NewGuid().ToString("N") + ".eftpart"); + FileStream? tempFile = null; + try + { + tempFile = new FileStream(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, Protocol.BufferSize, useAsync: true); + transfer.TempPath = tempPath; + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + await RejectAsync(connection, EftStatus.ServerError, ex).ConfigureAwait(false); + } + + byte[] actualHash; + using (var file = tempFile!) { - //byte[] readPbValue = ReadStream(); - if (ns.ReadByte() == 2) + await WriteStatusAsync(connection, EftStatus.Success).ConfigureAwait(false); + + var buffer = new byte[Protocol.BufferSize]; + long remaining = length; + using (var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256)) { - byte[] cmd_buffer = new byte[3]; - ns.Read(cmd_buffer, 0, cmd_buffer.Length); - byte[] recv_data = ReadStream(); - switch (Convert.ToInt32(Encoding.UTF8.GetString(cmd_buffer))) + while (remaining > 0) { - case 101: - //download++; - break; - case 125: - { - fs = new FileStream(@"" + SaveTo + Encoding.UTF8.GetString(recv_data), FileMode.CreateNew); - byte[] data_to_send = CreateDataPacket(Encoding.UTF8.GetBytes("126"), Encoding.UTF8.GetBytes(Convert.ToString(current_file_pointer))); - ns.Write(data_to_send, 0, data_to_send.Length); - ns.Flush(); - } - break; - case 127: - { - fs.Seek(current_file_pointer, SeekOrigin.Begin); - fs.Write(recv_data, 0, recv_data.Length); - current_file_pointer = fs.Position; - byte[] data_to_send = CreateDataPacket(Encoding.UTF8.GetBytes("126"), Encoding.UTF8.GetBytes(Convert.ToString(current_file_pointer))); - ns.Write(data_to_send, 0, data_to_send.Length); - ns.Flush(); - } - break; - case 128: - { - fs.Close(); - loop_break = true; - } - break; - default: - break; + int read = await connection.ReadAsync(buffer, 0, (int)Math.Min(buffer.Length, remaining)).ConfigureAwait(false); + if (read == 0) + { + throw new EndOfStreamException("The client disconnected before sending the whole file."); + } + + hash.AppendData(buffer, 0, read); + await file.WriteAsync(buffer, 0, read, connection.Token).ConfigureAwait(false); + remaining -= read; } + + actualHash = hash.GetHashAndReset(); } - if (loop_break == true) + + await file.FlushAsync(connection.Token).ConfigureAwait(false); + } + + var expectedHash = new byte[Protocol.HashLength]; + await connection.ReadExactlyAsync(expectedHash, 0, expectedHash.Length).ConfigureAwait(false); + if (!Protocol.FixedTimeEquals(expectedHash, 0, actualHash, Protocol.HashLength)) + { + await RejectAsync(connection, EftStatus.IntegrityCheckFailed).ConfigureAwait(false); + } + + string finalPath; + try + { + finalPath = MoveToUniqueName(tempPath, fileName); + transfer.TempPath = null; + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + await RejectAsync(connection, EftStatus.ServerError, ex).ConfigureAwait(false); + throw; + } + + await WriteStatusAsync(connection, EftStatus.Success).ConfigureAwait(false); + RaiseFileReceived(new FileReceivedEventArgs(finalPath, fileName, length, transfer.RemoteEndPoint)); + } + + private static async Task WriteStatusAsync(Connection connection, EftStatus status) + { + await connection.WriteAsync(new[] { (byte)status }, 0, 1).ConfigureAwait(false); + await connection.FlushAsync().ConfigureAwait(false); + } + + /// Tells the client why its request was refused, then aborts the connection. + private static async Task RejectAsync(Connection connection, EftStatus status, Exception? innerException = null) + { + try + { + await WriteStatusAsync(connection, status).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException || ex is ObjectDisposedException || ex is OperationCanceledException) + { + // The client is gone; the rejection below is still what gets reported. + } + + throw innerException is null + ? new EftException(status) + : new EftException(status, EftException.DescribeStatus(status), innerException); + } + + /// Defense in depth: the validated name must resolve to a direct child of the save directory. + private bool IsInsideSaveDirectory(string fileName) + { + string fullPath = Path.GetFullPath(Path.Combine(_saveDirectory, fileName)); + string? parent = Path.GetDirectoryName(fullPath); + return parent != null + && string.Equals( + parent.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + _saveDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + StringComparison.Ordinal); + } + + private long GetAvailableFreeSpace() + { + try + { + return new DriveInfo(_saveDirectory).AvailableFreeSpace; + } + catch (Exception ex) when (ex is ArgumentException || ex is IOException || ex is UnauthorizedAccessException) + { + // Network shares and some mounts cannot be queried; let the write itself fail if space runs out. + return long.MaxValue; + } + } + + /// Moves the temp file to , appending " (n)" instead of overwriting. + private string MoveToUniqueName(string tempPath, string fileName) + { + string stem = Path.GetFileNameWithoutExtension(fileName); + string extension = Path.GetExtension(fileName); + for (int i = 0; i < 10000; i++) + { + string candidate = i == 0 ? fileName : $"{stem} ({i}){extension}"; + string destination = Path.Combine(_saveDirectory, candidate); + if (File.Exists(destination) || Directory.Exists(destination)) { - ns.Close(); - break; + continue; + } + + try + { + File.Move(tempPath, destination); + return destination; + } + catch (IOException) when (File.Exists(destination) || Directory.Exists(destination)) + { + // Another transfer claimed the name first. } } + + throw new IOException($"Could not find a free file name for '{fileName}'."); } - public byte[] ReadStream() + private static void DeleteQuietly(string? path) { - byte[] data_buff = null; + if (path == null) + { + return; + } + + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + // Best effort; the hidden .eftpart file can be removed manually. + } + } + + private void RaiseFileReceived(FileReceivedEventArgs args) + { + try + { + FileReceived?.Invoke(this, args); + } +#pragma warning disable CA1031 // A faulty event handler must not take the server down. + catch (Exception) +#pragma warning restore CA1031 + { + } + } - int b = 0; - string buff_Length = ""; - while ((b = ns.ReadByte()) != 4) + private void RaiseTransferFailed(Exception exception, IPEndPoint? remoteEndPoint) + { + try { - buff_Length += (char)b; + TransferFailed?.Invoke(this, new TransferFailedEventArgs(exception, remoteEndPoint)); } - int data_Length = Convert.ToInt32(buff_Length); - data_buff = new byte[data_Length]; - int byte_Read = 0; - int byte_Offset = 0; - while (byte_Offset < data_Length) +#pragma warning disable CA1031 // A faulty event handler must not take the server down. + catch (Exception) +#pragma warning restore CA1031 { - byte_Read = ns.Read(data_buff, byte_Offset, data_Length - byte_Offset); - byte_Offset += byte_Read; } + } - return data_buff; + #region Legacy API (0.1.x) + + /// + /// Creates a server that saves files to and listens on . + /// + [Obsolete("Use new EftServer(new EftServerOptions { SaveDirectory = ..., Port = ... }) instead.")] + public EftServer(string SaveTo, int Port) + : this(new EftServerOptions { SaveDirectory = SaveTo, Port = Port }) + { } - private byte[] CreateDataPacket(byte[] cmd, byte[] data) + /// Directory received files are saved to. + [Obsolete("Use Options.SaveDirectory instead.")] + public string SaveTo => Options.SaveDirectory; + + /// Port the server listens on. + [Obsolete("Use Options.Port instead.")] + public int Port => Options.Port; + + /// + /// Starts the server and blocks the calling thread until or + /// is called. + /// + [Obsolete("Use Start() (non-blocking) and StopAsync() instead.")] + public void StartServer() + { + Start(); + Task? acceptLoop; + lock (_gate) + { + acceptLoop = _acceptLoop; + } + + acceptLoop?.GetAwaiter().GetResult(); + } + + #endregion + + private sealed class Transfer { - byte[] initialize = new byte[1]; - initialize[0] = 2; - byte[] separator = new byte[1]; - separator[0] = 4; - byte[] dataLength = Encoding.UTF8.GetBytes(Convert.ToString(data.Length)); - MemoryStream ms = new MemoryStream(); - ms.Write(initialize, 0, initialize.Length); - ms.Write(cmd, 0, cmd.Length); - ms.Write(dataLength, 0, dataLength.Length); - ms.Write(separator, 0, separator.Length); - ms.Write(data, 0, data.Length); + public IPEndPoint? RemoteEndPoint { get; set; } - return ms.ToArray(); + /// Temp file to delete when the transfer does not complete. + public string? TempPath { get; set; } } } } diff --git a/EasyFileTransfer/EftServerOptions.cs b/EasyFileTransfer/EftServerOptions.cs new file mode 100644 index 0000000..9391e75 --- /dev/null +++ b/EasyFileTransfer/EftServerOptions.cs @@ -0,0 +1,93 @@ +using System; +using System.IO; +using System.Net; +using System.Security.Cryptography.X509Certificates; + +namespace EasyFileTransfer +{ + /// + /// Settings for . + /// + public sealed class EftServerOptions + { + /// + /// Directory received files are written to. It is created if it does not exist. + /// Files are never written outside this directory. + /// + public string SaveDirectory { get; set; } = string.Empty; + + /// TCP port to listen on. Use 0 to let the operating system pick a free port. + public int Port { get; set; } = 1300; + + /// + /// Local address to listen on. Defaults to (all IPv4 interfaces); + /// use to accept only local connections. + /// + public IPAddress BindAddress { get; set; } = IPAddress.Any; + + /// + /// Shared secret clients must know to upload files. When null (the default) + /// anyone who can reach the port can upload. Strongly recommended on any + /// network you do not fully trust; use a long random value. + /// + public string? AccessToken { get; set; } + + /// + /// Certificate (with private key) used to encrypt connections with TLS. When null + /// (the default) files travel over the network unencrypted. + /// + public X509Certificate? ServerCertificate { get; set; } + + /// Largest file, in bytes, the server accepts. null means no limit. + public long? MaxFileSize { get; set; } + + /// Maximum number of connections handled at the same time. Defaults to 10. + public int MaxConcurrentConnections { get; set; } = 10; + + /// + /// Maximum time the server waits on a single network operation before dropping the + /// connection. Defaults to 30 seconds. + /// + public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromSeconds(30); + + internal void Validate() + { + if (string.IsNullOrWhiteSpace(SaveDirectory)) + { + throw new ArgumentException("SaveDirectory is required.", nameof(SaveDirectory)); + } + + if (Port < IPEndPoint.MinPort || Port > IPEndPoint.MaxPort) + { + throw new ArgumentOutOfRangeException(nameof(Port)); + } + + if (BindAddress is null) + { + throw new ArgumentNullException(nameof(BindAddress)); + } + + if (AccessToken is not null && AccessToken.Length == 0) + { + throw new ArgumentException("AccessToken must not be empty. Use null to disable authentication.", nameof(AccessToken)); + } + + if (MaxFileSize < 0) + { + throw new ArgumentOutOfRangeException(nameof(MaxFileSize)); + } + + if (MaxConcurrentConnections < 1) + { + throw new ArgumentOutOfRangeException(nameof(MaxConcurrentConnections)); + } + + if (IdleTimeout <= TimeSpan.Zero && IdleTimeout != System.Threading.Timeout.InfiniteTimeSpan) + { + throw new ArgumentOutOfRangeException(nameof(IdleTimeout)); + } + + _ = Path.GetFullPath(SaveDirectory); + } + } +} diff --git a/EasyFileTransfer/EftStatus.cs b/EasyFileTransfer/EftStatus.cs new file mode 100644 index 0000000..800c081 --- /dev/null +++ b/EasyFileTransfer/EftStatus.cs @@ -0,0 +1,38 @@ +namespace EasyFileTransfer +{ + /// + /// Status codes exchanged between and . + /// + public enum EftStatus : byte + { + /// The operation completed successfully. + Success = 0, + + /// The server has reached its concurrent connection limit. + ServerBusy = 1, + + /// The access token was missing or did not match the server's token. + AuthenticationFailed = 2, + + /// The file name is empty, too long, or contains characters that are not allowed. + InvalidFileName = 3, + + /// The file exceeds the server's . + FileTooLarge = 4, + + /// The server does not have enough free disk space for the file. + InsufficientStorage = 5, + + /// The SHA-256 hash of the received data did not match the hash sent by the client. + IntegrityCheckFailed = 6, + + /// The peer sent data that does not follow the EasyFileTransfer protocol. + ProtocolError = 7, + + /// The server failed to store the file. + ServerError = 8, + + /// The peer speaks a protocol version this library does not support. + UnsupportedVersion = 9, + } +} diff --git a/EasyFileTransfer/FileReceivedEventArgs.cs b/EasyFileTransfer/FileReceivedEventArgs.cs new file mode 100644 index 0000000..402bbd4 --- /dev/null +++ b/EasyFileTransfer/FileReceivedEventArgs.cs @@ -0,0 +1,48 @@ +using System; +using System.Net; + +namespace EasyFileTransfer +{ + /// Data for the event. + public sealed class FileReceivedEventArgs : EventArgs + { + internal FileReceivedEventArgs(string filePath, string requestedFileName, long length, IPEndPoint? remoteEndPoint) + { + FilePath = filePath; + RequestedFileName = requestedFileName; + Length = length; + RemoteEndPoint = remoteEndPoint; + } + + /// Full path of the saved file. A suffix such as " (1)" is added when a file with the same name already exists. + public string FilePath { get; } + + /// File name sent by the client. + public string RequestedFileName { get; } + + /// Size of the file in bytes. + public long Length { get; } + + /// Address of the client that sent the file. + public IPEndPoint? RemoteEndPoint { get; } + } + + /// Data for the event. + public sealed class TransferFailedEventArgs : EventArgs + { + internal TransferFailedEventArgs(Exception exception, IPEndPoint? remoteEndPoint) + { + Exception = exception; + RemoteEndPoint = remoteEndPoint; + } + + /// + /// Why the transfer failed. An means the server rejected the + /// request; other exceptions are network or disk errors. + /// + public Exception Exception { get; } + + /// Address of the client, when known. + public IPEndPoint? RemoteEndPoint { get; } + } +} diff --git a/EasyFileTransfer/Internal/Connection.cs b/EasyFileTransfer/Internal/Connection.cs new file mode 100644 index 0000000..1e693fa --- /dev/null +++ b/EasyFileTransfer/Internal/Connection.cs @@ -0,0 +1,108 @@ +using System; +using System.IO; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace EasyFileTransfer.Internal +{ + /// + /// A TCP connection whose every operation is bounded by an idle timeout and a cancellation + /// token. When either fires the socket is closed, which unblocks pending I/O on all + /// target frameworks (older frameworks ignore the token once a read has started). + /// + internal sealed class Connection : IDisposable + { + private readonly TcpClient _client; + private readonly TimeSpan _timeout; + private readonly CancellationToken _outerToken; + private readonly CancellationTokenSource _cts; + private readonly CancellationTokenRegistration _registration; + + public Connection(TcpClient client, TimeSpan timeout, CancellationToken cancellationToken) + { + _client = client; + _timeout = timeout; + _outerToken = cancellationToken; + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _registration = _cts.Token.Register(state => ((TcpClient)state!).Dispose(), client); + } + + public TcpClient TcpClient => _client; + + public Stream? Stream { get; set; } + + public CancellationToken Token => _cts.Token; + + public bool TimedOut => _cts.IsCancellationRequested && !_outerToken.IsCancellationRequested; + + /// Starts (or restarts) the idle timer before the next operation. + public void ResetTimer() => _cts.CancelAfter(_timeout); + + public async Task ReadAsync(byte[] buffer, int offset, int count) + { + ResetTimer(); + return await Stream!.ReadAsync(buffer, offset, count, _cts.Token).ConfigureAwait(false); + } + + public async Task ReadExactlyAsync(byte[] buffer, int offset, int count) + { + while (count > 0) + { + int read = await ReadAsync(buffer, offset, count).ConfigureAwait(false); + if (read == 0) + { + throw new EndOfStreamException("The remote party closed the connection."); + } + + offset += read; + count -= read; + } + } + + public async Task WriteAsync(byte[] buffer, int offset, int count) + { + ResetTimer(); + await Stream!.WriteAsync(buffer, offset, count, _cts.Token).ConfigureAwait(false); + } + + public async Task FlushAsync() + { + ResetTimer(); + await Stream!.FlushAsync(_cts.Token).ConfigureAwait(false); + } + + /// + /// Converts the exception an aborted operation produced into the one callers expect: + /// for cancellation, + /// for the idle timer, and the original exception otherwise. + /// + public Exception Translate(Exception exception) + { + if (exception is EftException) + { + return exception; + } + + if (_outerToken.IsCancellationRequested) + { + return new OperationCanceledException("The transfer was canceled.", exception, _outerToken); + } + + if (TimedOut) + { + return new TimeoutException("The network operation timed out.", exception); + } + + return exception; + } + + public void Dispose() + { + _registration.Dispose(); + _cts.Dispose(); + Stream?.Dispose(); + _client.Dispose(); + } + } +} diff --git a/EasyFileTransfer/Internal/FileNameValidator.cs b/EasyFileTransfer/Internal/FileNameValidator.cs new file mode 100644 index 0000000..f1c7d01 --- /dev/null +++ b/EasyFileTransfer/Internal/FileNameValidator.cs @@ -0,0 +1,59 @@ +using System; + +namespace EasyFileTransfer.Internal +{ + /// + /// Decides whether a file name received from the network is safe to create inside the + /// save directory. The rules are the union of Windows and Unix restrictions so a name + /// accepted on one platform is accepted on all of them. + /// + internal static class FileNameValidator + { + public const int MaxLength = 255; + + private static readonly char[] ForbiddenChars = { '/', '\\', ':', '*', '?', '"', '<', '>', '|' }; + + private static readonly string[] ReservedNames = + { + "CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$", + "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "COM¹", "COM²", "COM³", + "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "LPT¹", "LPT²", "LPT³", + }; + + public static bool IsValid(string? name) + { + if (string.IsNullOrWhiteSpace(name) || name!.Length > MaxLength) + { + return false; + } + + // Windows silently strips trailing dots and spaces, which would let "a.txt." alias "a.txt"; + // this also rejects "." and "..". + char last = name[name.Length - 1]; + if (last == '.' || last == ' ') + { + return false; + } + + foreach (char c in name) + { + if (c < 0x20 || c == 0x7F || Array.IndexOf(ForbiddenChars, c) >= 0) + { + return false; + } + } + + int dot = name.IndexOf('.'); + string stem = (dot < 0 ? name : name.Substring(0, dot)).TrimEnd(' '); + foreach (string reserved in ReservedNames) + { + if (string.Equals(stem, reserved, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return true; + } + } +} diff --git a/EasyFileTransfer/Internal/Protocol.cs b/EasyFileTransfer/Internal/Protocol.cs new file mode 100644 index 0000000..31bfd13 --- /dev/null +++ b/EasyFileTransfer/Internal/Protocol.cs @@ -0,0 +1,110 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace EasyFileTransfer.Internal +{ + /// + /// Wire format of protocol version 2. All integers are big-endian. + /// + /// server -> client hello "EFT" | version:1 | status:1 | flags:1 | nonce:32 + /// client -> server request "EFT" | version:1 | proof:32 | nameLength:2 | name:UTF-8 | fileLength:8 + /// server -> client status:1 + /// client -> server file bytes (fileLength) | sha256:32 + /// server -> client status:1 + /// + /// proof = HMAC-SHA256(key: UTF-8 access token, data: nonce), or zeros when no token is used. + /// + internal static class Protocol + { + public const byte Version = 2; + public const int MagicLength = 3; + public const int NonceLength = 32; + public const int ProofLength = 32; + public const int HashLength = 32; + public const int MaxFileNameBytes = 1024; + public const int BufferSize = 81920; + public const byte FlagAuthRequired = 0x01; + + public const int HelloLength = MagicLength + 1 + 1 + 1 + NonceLength; + public const int RequestHeaderLength = MagicLength + 1 + ProofLength + 2; + + public static readonly UTF8Encoding StrictUtf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + + private static readonly byte[] Magic = { (byte)'E', (byte)'F', (byte)'T' }; + + public static void WriteMagic(byte[] buffer, int offset) => Buffer.BlockCopy(Magic, 0, buffer, offset, MagicLength); + + public static bool HasMagic(byte[] buffer, int offset) => + buffer[offset] == Magic[0] && buffer[offset + 1] == Magic[1] && buffer[offset + 2] == Magic[2]; + + public static byte[] CreateNonce() + { + var nonce = new byte[NonceLength]; + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(nonce); + } + + return nonce; + } + + public static byte[] ComputeProof(string? accessToken, byte[] nonce) + { + if (accessToken is null) + { + return new byte[ProofLength]; + } + + using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(accessToken))) + { + return hmac.ComputeHash(nonce); + } + } + + /// Compares two byte arrays in time that does not depend on their contents. + public static bool FixedTimeEquals(byte[] left, int leftOffset, byte[] right, int length) + { + if (right.Length != length || left.Length - leftOffset < length) + { + return false; + } + + int diff = 0; + for (int i = 0; i < length; i++) + { + diff |= left[leftOffset + i] ^ right[i]; + } + + return diff == 0; + } + + public static void WriteUInt16(byte[] buffer, int offset, int value) + { + buffer[offset] = (byte)(value >> 8); + buffer[offset + 1] = (byte)value; + } + + public static int ReadUInt16(byte[] buffer, int offset) => (buffer[offset] << 8) | buffer[offset + 1]; + + public static void WriteInt64(byte[] buffer, int offset, long value) + { + for (int i = 7; i >= 0; i--) + { + buffer[offset + i] = (byte)value; + value >>= 8; + } + } + + public static long ReadInt64(byte[] buffer, int offset) + { + long value = 0; + for (int i = 0; i < 8; i++) + { + value = (value << 8) | buffer[offset + i]; + } + + return value; + } + } +} diff --git a/EasyFileTransfer/Model/Response.cs b/EasyFileTransfer/Model/Response.cs index ccea682..4f912f2 100644 --- a/EasyFileTransfer/Model/Response.cs +++ b/EasyFileTransfer/Model/Response.cs @@ -1,14 +1,17 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System; namespace EasyFileTransfer.Model { + /// + /// Result of the legacy method. + /// + [Obsolete("Returned only by the legacy EftClient.Send method. SendFileAsync throws EftException on failure instead.")] public class Response { + /// 1 on success, -1 on failure. public int status { get; set; } - public string description { get; set; } + + /// Human-readable result. + public string description { get; set; } = string.Empty; } } diff --git a/README.md b/README.md index 87cf8cd..6d9a03c 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,168 @@ # Easy File Transfer -An easy way to transfer file with **any size** on network with tcp protocol. -## usage +[![CI](https://github.com/Jamalianpour/EasyFileTransfer/actions/workflows/ci.yml/badge.svg)](https://github.com/Jamalianpour/EasyFileTransfer/actions/workflows/ci.yml) +[![NuGet](https://img.shields.io/nuget/v/EasyFileTransfer)](https://www.nuget.org/packages/EasyFileTransfer) -1. install nuget package: -![Nuget](https://img.shields.io/nuget/v/EasyFileTransfer) -```nuget - Install-Package EasyFileTransfer -Version 0.1.5 +An easy way to transfer files of **any size** over a network using TCP. + +- Streams files of any size with constant memory use +- Async API with progress reporting and cancellation +- SHA-256 integrity check on every transfer +- Optional access-token authentication and TLS encryption +- Hardened server: file-name validation, size limits, connection limits and timeouts +- Targets .NET Standard 2.0, .NET 8 and .NET 10 + +## Install + +```shell +dotnet add package EasyFileTransfer --prerelease ``` -2. import the EasyFileTransfer library: +## Usage + +### Server + ```csharp using EasyFileTransfer; + +var server = new EftServer(new EftServerOptions +{ + SaveDirectory = @"C:\Inbox", + Port = 1300, + AccessToken = "a-long-random-secret", // recommended +}); + +server.FileReceived += (s, e) => Console.WriteLine($"Saved {e.FilePath} ({e.Length} bytes) from {e.RemoteEndPoint}"); +server.TransferFailed += (s, e) => Console.WriteLine($"Rejected {e.RemoteEndPoint}: {e.Exception.Message}"); + +server.Start(); // returns immediately; the server runs in the background +// ... +await server.StopAsync(); ``` -3. for server at first you should start the EftServer: +### Client + ```csharp -EftServer server = new EftServer(saveTo, port); -System.Threading.Thread thread = new System.Threading.Thread(server.StartServer); -thread.Start(); +using EasyFileTransfer; + +var client = new EftClient("192.168.1.10", 1300, new EftClientOptions +{ + AccessToken = "a-long-random-secret", +}); + +var progress = new Progress(p => Console.WriteLine($"{p.Percentage:0}%")); +await client.SendFileAsync(@"C:\big.iso", progress, cancellationToken); ``` -4. for client you can send file with this line: +`SendFileAsync` throws `EftException` (with a `Status` such as `AuthenticationFailed`, `FileTooLarge` or +`IntegrityCheckFailed`) when the server rejects a transfer, `TimeoutException` when the network stalls, +and `IOException` for other network errors. `SendAsync(stream, fileName)` sends any seekable stream. + +### Encrypting with TLS + +Give the server a certificate with a private key and turn on `UseTls` in the client: + ```csharp -EftClient.Send(Path/to/file, serverIP, serverPort); +var server = new EftServer(new EftServerOptions +{ + SaveDirectory = @"C:\Inbox", + AccessToken = token, + ServerCertificate = X509CertificateLoader.LoadPkcs12FromFile("server.pfx", password), +}); + +var client = new EftClient("fileserver.example.com", 1300, new EftClientOptions +{ + AccessToken = token, + UseTls = true, +}); ``` -it's finished. you can enjoy that. -don't forget star this repository. :) +For a self-signed certificate, pin it on the client instead of disabling validation: + +```csharp +const string expectedThumbprint = "3F1A..."; // server certificate's SHA-1 thumbprint + +var options = new EftClientOptions +{ + UseTls = true, + ServerCertificateValidationCallback = (_, cert, _, _) => + cert != null && cert.GetCertHashString() == expectedThumbprint, +}; +``` + +### Server options + +| Option | Default | Description | +| --- | --- | --- | +| `SaveDirectory` | (required) | Where received files are stored. Created if missing. | +| `Port` | `1300` | Port to listen on (`0` = any free port; see `LocalEndPoint`). | +| `BindAddress` | `IPAddress.Any` | Interface to listen on. Use `IPAddress.Loopback` for local-only. | +| `AccessToken` | `null` | Shared secret clients must know. `null` lets anyone upload. | +| `ServerCertificate` | `null` | Enables TLS. | +| `MaxFileSize` | `null` (no limit) | Largest accepted file in bytes. | +| `MaxConcurrentConnections` | `10` | Extra connections are refused with `ServerBusy`. | +| `IdleTimeout` | `30 s` | Connections that stall longer than this are dropped. | + +If a file with the same name already exists, the new file is saved as `name (1).ext`, `name (2).ext` and so on. +Existing files are never overwritten. + +## Security + +Version 0.2 replaces the 0.1.x wire protocol, which had serious security problems: + +- **Path traversal / arbitrary file write.** The 0.1.x server built the destination path by joining + the save directory with the file name the client sent. A name like `..\..\Windows\System32\evil.dll` + or an absolute path wrote anywhere the server process could write. File names are now validated + (no path separators, drive letters, `..`, control characters, alternate data streams or Windows + device names) and the resolved path must be directly inside `SaveDirectory`. +- **Remote crash (denial of service).** Any malformed packet, a duplicate file name, or a client + disconnecting at the wrong moment threw an unhandled exception on a background thread, which + terminates the whole server process. A client that disconnected mid-transfer could also leave + a thread spinning at 100% CPU forever. +- **Unbounded memory allocation.** The length field of each packet was trusted, so one packet could + make the server allocate up to 2 GB. +- **No authentication, encryption or integrity check.** Anyone on the network could upload files, + and data travelled in plain text with no way to detect corruption. +- **No limits.** No timeouts, connection limit or file size limit, and partial uploads were left + on disk. + +Recommendations: + +- Always set `AccessToken` (a long random value) unless the server is only reachable by trusted hosts. + The token is never sent over the network; the client answers an HMAC-SHA256 challenge instead. +- Use TLS on untrusted networks. Without it, file contents can be read, and an attacker who can + modify traffic can tamper with the transfer. +- Set `MaxFileSize` and point `SaveDirectory` at a dedicated folder. + +To report a vulnerability, see [SECURITY.md](SECURITY.md). + +## Upgrading from 0.1.x + +The 0.2 protocol is **not compatible** with 0.1.x. Upgrade the client and the server together. + +The 0.1.x API (`EftClient.Send`, `EftClient.ProgressValue`, `new EftServer(saveTo, port)` and +`StartServer()`) still compiles but is marked `[Obsolete]`. It now runs on the new protocol, so it +gets the same security fixes. Move to the async API when you can: + +| 0.1.x | 0.2 | +| --- | --- | +| `EftClient.Send(path, ip, port)` | `await new EftClient(ip, port).SendFileAsync(path)` | +| `EftClient.ProgressValue` | `IProgress` argument | +| `new EftServer(saveTo, port)` | `new EftServer(new EftServerOptions { SaveDirectory = saveTo, Port = port })` | +| `new Thread(server.StartServer).Start()` | `server.Start()` and `await server.StopAsync()` | +| `Response.status == 1` | No exception thrown | + +## Building + +```shell +dotnet build +dotnet test +``` + +The Windows Forms samples (`Sample.Server`, `Sample.Client`) build on any OS but run only on Windows. ## Screenshots -sample screenshots like this: | Server | Client | | ------------- | ------------- | | | | - - diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ed52dc9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,14 @@ +# Security Policy + +## Supported versions + +| Version | Supported | +| --- | --- | +| 0.2.x | Yes | +| 0.1.x | No. It allows remote file writes anywhere on the server and remote crashes. Upgrade. | + +## Reporting a vulnerability + +Please do not open a public issue. Report it privately through +[GitHub security advisories](https://github.com/Jamalianpour/EasyFileTransfer/security/advisories/new) +with steps to reproduce. You should get a response within a week. diff --git a/Sample.Client/Form1.Designer.cs b/Sample.Client/Form1.Designer.cs index 6a799e5..e2f1c29 100644 --- a/Sample.Client/Form1.Designer.cs +++ b/Sample.Client/Form1.Designer.cs @@ -35,6 +35,10 @@ private void InitializeComponent() this.label5 = new System.Windows.Forms.Label(); this.textBox2 = new System.Windows.Forms.TextBox(); this.openFileDialog1 = new System.Windows.Forms.OpenFileDialog(); + this.label6 = new System.Windows.Forms.Label(); + this.tokenBox = new System.Windows.Forms.TextBox(); + this.progressBar1 = new System.Windows.Forms.ProgressBar(); + this.statusLabel = new System.Windows.Forms.Label(); this.groupBox2.SuspendLayout(); this.SuspendLayout(); // @@ -45,9 +49,11 @@ private void InitializeComponent() this.groupBox2.Controls.Add(this.label4); this.groupBox2.Controls.Add(this.SnedButton); this.groupBox2.Controls.Add(this.label5); + this.groupBox2.Controls.Add(this.label6); + this.groupBox2.Controls.Add(this.tokenBox); this.groupBox2.Location = new System.Drawing.Point(12, 12); this.groupBox2.Name = "groupBox2"; - this.groupBox2.Size = new System.Drawing.Size(345, 140); + this.groupBox2.Size = new System.Drawing.Size(345, 165); this.groupBox2.TabIndex = 10; this.groupBox2.TabStop = false; this.groupBox2.Text = "send to"; @@ -71,7 +77,7 @@ private void InitializeComponent() // // SnedButton // - this.SnedButton.Location = new System.Drawing.Point(122, 90); + this.SnedButton.Location = new System.Drawing.Point(122, 115); this.SnedButton.Name = "SnedButton"; this.SnedButton.Size = new System.Drawing.Size(108, 38); this.SnedButton.TabIndex = 0; @@ -95,6 +101,38 @@ private void InitializeComponent() this.textBox2.Size = new System.Drawing.Size(256, 20); this.textBox2.TabIndex = 7; // + // label6 + // + this.label6.AutoSize = true; + this.label6.Location = new System.Drawing.Point(18, 89); + this.label6.Name = "label6"; + this.label6.Size = new System.Drawing.Size(39, 13); + this.label6.TabIndex = 8; + this.label6.Text = "token :"; + // + // tokenBox + // + this.tokenBox.Location = new System.Drawing.Point(63, 86); + this.tokenBox.Name = "tokenBox"; + this.tokenBox.Size = new System.Drawing.Size(256, 20); + this.tokenBox.TabIndex = 9; + this.tokenBox.UseSystemPasswordChar = true; + // + // progressBar1 + // + this.progressBar1.Location = new System.Drawing.Point(12, 183); + this.progressBar1.Name = "progressBar1"; + this.progressBar1.Size = new System.Drawing.Size(345, 20); + this.progressBar1.TabIndex = 11; + // + // statusLabel + // + this.statusLabel.AutoSize = true; + this.statusLabel.Location = new System.Drawing.Point(12, 210); + this.statusLabel.Name = "statusLabel"; + this.statusLabel.Size = new System.Drawing.Size(0, 13); + this.statusLabel.TabIndex = 12; + // // openFileDialog1 // this.openFileDialog1.FileName = "openFileDialog1"; @@ -103,14 +141,17 @@ private void InitializeComponent() // this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.ClientSize = new System.Drawing.Size(369, 162); + this.ClientSize = new System.Drawing.Size(369, 235); this.Controls.Add(this.groupBox2); + this.Controls.Add(this.progressBar1); + this.Controls.Add(this.statusLabel); this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.SizableToolWindow; this.Name = "Form1"; this.Text = "Client"; this.groupBox2.ResumeLayout(false); this.groupBox2.PerformLayout(); this.ResumeLayout(false); + this.PerformLayout(); } @@ -123,6 +164,10 @@ private void InitializeComponent() private System.Windows.Forms.Label label5; private System.Windows.Forms.TextBox textBox2; private System.Windows.Forms.OpenFileDialog openFileDialog1; + private System.Windows.Forms.Label label6; + private System.Windows.Forms.TextBox tokenBox; + private System.Windows.Forms.ProgressBar progressBar1; + private System.Windows.Forms.Label statusLabel; } } diff --git a/Sample.Client/Form1.cs b/Sample.Client/Form1.cs index 4c611cf..999cb58 100644 --- a/Sample.Client/Form1.cs +++ b/Sample.Client/Form1.cs @@ -1,12 +1,5 @@ -using EasyFileTransfer; +using EasyFileTransfer; using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Data; -using System.Drawing; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using System.Windows.Forms; namespace Sample.Client @@ -18,15 +11,42 @@ public Form1() InitializeComponent(); } - private void SnedButton_Click(object sender, EventArgs e) + private async void SnedButton_Click(object sender, EventArgs e) { - if (openFileDialog1.ShowDialog() == System.Windows.Forms.DialogResult.OK) + if (openFileDialog1.ShowDialog() != DialogResult.OK) { - EasyFileTransfer.Model.Response rsp = EftClient.Send(openFileDialog1.FileName, textBox2.Text, Convert.ToInt32(textBox1.Text)); - if (rsp.status == 1) + return; + } + + if (!int.TryParse(textBox1.Text, out int port)) + { + MessageBox.Show("Port must be a number.", "", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + SnedButton.Enabled = false; + statusLabel.Text = "sending..."; + try + { + var client = new EftClient(textBox2.Text.Trim(), port, new EftClientOptions { - MessageBox.Show("send successfully.", "", MessageBoxButtons.OK, MessageBoxIcon.Information); - } + AccessToken = string.IsNullOrEmpty(tokenBox.Text) ? null : tokenBox.Text, + }); + + // Progress raises its callback on the UI thread. + var progress = new Progress(p => progressBar1.Value = (int)p.Percentage); + await client.SendFileAsync(openFileDialog1.FileName, progress); + + statusLabel.Text = "send successfully."; + } + catch (Exception ex) + { + statusLabel.Text = "failed."; + MessageBox.Show(ex.Message, "Send failed", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + finally + { + SnedButton.Enabled = true; } } } diff --git a/Sample.Client/Program.cs b/Sample.Client/Program.cs index 9bdedb5..1941203 100644 --- a/Sample.Client/Program.cs +++ b/Sample.Client/Program.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; +using System; using System.Windows.Forms; namespace Sample.Client @@ -14,8 +11,7 @@ static class Program [STAThread] static void Main() { - Application.EnableVisualStyles(); - Application.SetCompatibleTextRenderingDefault(false); + ApplicationConfiguration.Initialize(); Application.Run(new Form1()); } } diff --git a/Sample.Client/Sample.Client.csproj b/Sample.Client/Sample.Client.csproj index 3309c04..99146db 100644 --- a/Sample.Client/Sample.Client.csproj +++ b/Sample.Client/Sample.Client.csproj @@ -1,91 +1,17 @@ - - - + + - Debug - AnyCPU - {2638D1E6-BBBC-496E-B3B8-022B0F57A242} WinExe - Sample.Client - Sample.Client - v4.8 - 512 - true - true - + net10.0-windows + true + disable + false + + true - - AnyCPU - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - AnyCPU - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - - - - - - - - - - - - - - - Form - - - Form1.cs - - - - - Form1.cs - - - ResXFileCodeGenerator - Resources.Designer.cs - Designer - - - True - Resources.resx - True - - - SettingsSingleFileGenerator - Settings.Designer.cs - - - True - Settings.settings - True - - - - - + - - {095B5958-8DA4-4098-A485-935D7271431D} - EasyFileTransfer - + - - \ No newline at end of file + + diff --git a/Sample.Server/Form1.Designer.cs b/Sample.Server/Form1.Designer.cs index 3817b04..2747d46 100644 --- a/Sample.Server/Form1.Designer.cs +++ b/Sample.Server/Form1.Designer.cs @@ -40,6 +40,9 @@ private void InitializeComponent() this.label4 = new System.Windows.Forms.Label(); this.status = new System.Windows.Forms.Label(); this.folderBrowserDialog1 = new System.Windows.Forms.FolderBrowserDialog(); + this.label6 = new System.Windows.Forms.Label(); + this.tokenBox = new System.Windows.Forms.TextBox(); + this.lastFile = new System.Windows.Forms.Label(); this.groupBox1.SuspendLayout(); this.SuspendLayout(); // @@ -56,7 +59,7 @@ private void InitializeComponent() // label3 // this.label3.AutoSize = true; - this.label3.Location = new System.Drawing.Point(45, 135); + this.label3.Location = new System.Drawing.Point(45, 166); this.label3.Name = "label3"; this.label3.Size = new System.Drawing.Size(40, 13); this.label3.TabIndex = 13; @@ -70,9 +73,11 @@ private void InitializeComponent() this.groupBox1.Controls.Add(this.saveTo); this.groupBox1.Controls.Add(this.Port); this.groupBox1.Controls.Add(this.StartButton); + this.groupBox1.Controls.Add(this.label6); + this.groupBox1.Controls.Add(this.tokenBox); this.groupBox1.Location = new System.Drawing.Point(7, 3); this.groupBox1.Name = "groupBox1"; - this.groupBox1.Size = new System.Drawing.Size(345, 124); + this.groupBox1.Size = new System.Drawing.Size(345, 155); this.groupBox1.TabIndex = 11; this.groupBox1.TabStop = false; // @@ -123,7 +128,7 @@ private void InitializeComponent() // label5 // this.label5.AutoSize = true; - this.label5.Location = new System.Drawing.Point(4, 133); + this.label5.Location = new System.Drawing.Point(4, 164); this.label5.Name = "label5"; this.label5.Size = new System.Drawing.Size(44, 13); this.label5.TabIndex = 14; @@ -132,7 +137,7 @@ private void InitializeComponent() // label4 // this.label4.AutoSize = true; - this.label4.Location = new System.Drawing.Point(285, 135); + this.label4.Location = new System.Drawing.Point(285, 166); this.label4.Name = "label4"; this.label4.Size = new System.Drawing.Size(41, 13); this.label4.TabIndex = 12; @@ -142,28 +147,53 @@ private void InitializeComponent() // this.status.AutoSize = true; this.status.ForeColor = System.Drawing.Color.Red; - this.status.Location = new System.Drawing.Point(324, 135); + this.status.Location = new System.Drawing.Point(324, 166); this.status.Name = "status"; this.status.RightToLeft = System.Windows.Forms.RightToLeft.Yes; this.status.Size = new System.Drawing.Size(21, 13); this.status.TabIndex = 10; this.status.Text = "Off"; // + // label6 + // + this.label6.AutoSize = true; + this.label6.Location = new System.Drawing.Point(20, 126); + this.label6.Name = "label6"; + this.label6.Size = new System.Drawing.Size(39, 13); + this.label6.TabIndex = 7; + this.label6.Text = "token :"; + // + // tokenBox + // + this.tokenBox.Location = new System.Drawing.Point(65, 123); + this.tokenBox.Name = "tokenBox"; + this.tokenBox.Size = new System.Drawing.Size(230, 20); + this.tokenBox.TabIndex = 8; + this.tokenBox.UseSystemPasswordChar = true; + // + // lastFile + // + this.lastFile.AutoSize = true; + this.lastFile.Location = new System.Drawing.Point(4, 188); + this.lastFile.Name = "lastFile"; + this.lastFile.Size = new System.Drawing.Size(0, 13); + this.lastFile.TabIndex = 15; + // // Form1 // this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.ClientSize = new System.Drawing.Size(361, 154); + this.ClientSize = new System.Drawing.Size(361, 210); this.Controls.Add(this.label3); this.Controls.Add(this.groupBox1); this.Controls.Add(this.label5); this.Controls.Add(this.label4); this.Controls.Add(this.status); + this.Controls.Add(this.lastFile); this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.SizableToolWindow; this.Name = "Form1"; this.Text = "Server"; - this.Load += new System.EventHandler(this.Form1_Load); - this.Leave += new System.EventHandler(this.Form1_Leave); + this.FormClosing += new System.Windows.Forms.FormClosingEventHandler(this.Form1_FormClosing); this.groupBox1.ResumeLayout(false); this.groupBox1.PerformLayout(); this.ResumeLayout(false); @@ -185,6 +215,9 @@ private void InitializeComponent() private System.Windows.Forms.Label label4; private System.Windows.Forms.Label status; private System.Windows.Forms.FolderBrowserDialog folderBrowserDialog1; + private System.Windows.Forms.Label label6; + private System.Windows.Forms.TextBox tokenBox; + private System.Windows.Forms.Label lastFile; } } diff --git a/Sample.Server/Form1.cs b/Sample.Server/Form1.cs index 257d572..7ef1d8f 100644 --- a/Sample.Server/Form1.cs +++ b/Sample.Server/Form1.cs @@ -1,19 +1,22 @@ -using System; +using EasyFileTransfer; +using System; using System.Drawing; -using System.Windows.Forms; +using System.IO; using System.Net; using System.Net.Sockets; -using EasyFileTransfer; -using System.IO; +using System.Windows.Forms; namespace Sample.Server { public partial class Form1 : Form { + private EftServer server; + public Form1() { InitializeComponent(); label3.Text = GetLocalIPAddress(); + saveTo.Text = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"); } public static string GetLocalIPAddress() @@ -26,41 +29,59 @@ public static string GetLocalIPAddress() return ip.ToString(); } } - throw new Exception("No network adapters with an IPv4 address in the system!"); + + return "unknown"; } - private void StartButton_Click(object sender, EventArgs e) + private async void StartButton_Click(object sender, EventArgs e) { + if (server != null) + { + await server.StopAsync(); + server = null; + SetRunning(false); + return; + } + try { - EftServer server = new EftServer(saveTo.Text, Convert.ToInt32(Port.Text)); - System.Threading.Thread obj_thread = new System.Threading.Thread(server.StartServer); - obj_thread.Start(); - status.ForeColor = Color.Green; - status.Text = "Online"; + server = new EftServer(new EftServerOptions + { + SaveDirectory = saveTo.Text, + Port = Convert.ToInt32(Port.Text), + AccessToken = string.IsNullOrEmpty(tokenBox.Text) ? null : tokenBox.Text, + }); + server.FileReceived += (s, args) => BeginInvoke(new Action(() => lastFile.Text = "received: " + Path.GetFileName(args.FilePath))); + server.TransferFailed += (s, args) => BeginInvoke(new Action(() => lastFile.Text = "failed: " + args.Exception.Message)); + server.Start(); + SetRunning(true); } catch (Exception ex) { + server = null; MessageBox.Show(ex.Message); } } + private void SetRunning(bool running) + { + status.ForeColor = running ? Color.Green : Color.Red; + status.Text = running ? "Online" : "Off"; + StartButton.Text = running ? "Stop Server" : "Start Server"; + saveTo.Enabled = Port.Enabled = tokenBox.Enabled = button1.Enabled = !running; + } + private void button1_Click(object sender, EventArgs e) { if (folderBrowserDialog1.ShowDialog() == DialogResult.OK) { - saveTo.Text = folderBrowserDialog1.SelectedPath + @"\"; + saveTo.Text = folderBrowserDialog1.SelectedPath; } } - private void Form1_Load(object sender, EventArgs e) - { - } - - private void Form1_Leave(object sender, EventArgs e) + private void Form1_FormClosing(object sender, FormClosingEventArgs e) { - this.Dispose(); - Application.Exit(); + server?.Dispose(); } } } diff --git a/Sample.Server/Program.cs b/Sample.Server/Program.cs index 683999e..55bae92 100644 --- a/Sample.Server/Program.cs +++ b/Sample.Server/Program.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; +using System; using System.Windows.Forms; namespace Sample.Server @@ -14,8 +11,7 @@ static class Program [STAThread] static void Main() { - Application.EnableVisualStyles(); - Application.SetCompatibleTextRenderingDefault(false); + ApplicationConfiguration.Initialize(); Application.Run(new Form1()); } } diff --git a/Sample.Server/Sample.Server.csproj b/Sample.Server/Sample.Server.csproj index 61e9cf6..99146db 100644 --- a/Sample.Server/Sample.Server.csproj +++ b/Sample.Server/Sample.Server.csproj @@ -1,91 +1,17 @@ - - - + + - Debug - AnyCPU - {B1F70903-1BA5-4D9A-B0BC-BC66A3F1ED91} WinExe - Sample.Server - Sample.Server - v4.8 - 512 - true - true - + net10.0-windows + true + disable + false + + true - - AnyCPU - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - AnyCPU - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - - - - - - - - - - - - - - - Form - - - Form1.cs - - - - - Form1.cs - - - ResXFileCodeGenerator - Resources.Designer.cs - Designer - - - True - Resources.resx - True - - - SettingsSingleFileGenerator - Settings.Designer.cs - - - True - Settings.settings - True - - - - - + - - {095B5958-8DA4-4098-A485-935D7271431D} - EasyFileTransfer - + - - \ No newline at end of file + +