From aa76cc9969e8a2730caed9dbeac41f08cfedb794 Mon Sep 17 00:00:00 2001 From: Boxfriend Date: Tue, 16 Dec 2025 16:43:13 -0500 Subject: [PATCH] initial release --- .dockerignore | 25 ++++++++ .gitignore | 5 ++ Dockerfile | 21 +++++++ FtpThing.csproj | 19 ++++++ FtpThing.slnx | 6 ++ Program.cs | 149 ++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 225 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 FtpThing.csproj create mode 100644 FtpThing.slnx create mode 100644 Program.cs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cd967fc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..add57be --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..264ef8e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base +USER $APP_UID +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["FtpThing.csproj", "./"] +RUN dotnet restore "FtpThing.csproj" +COPY . . +WORKDIR "/src/" +RUN dotnet build "./FtpThing.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./FtpThing.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "FtpThing.dll"] diff --git a/FtpThing.csproj b/FtpThing.csproj new file mode 100644 index 0000000..dbb283e --- /dev/null +++ b/FtpThing.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + enable + enable + Linux + + + + + + + + + + + diff --git a/FtpThing.slnx b/FtpThing.slnx new file mode 100644 index 0000000..d031798 --- /dev/null +++ b/FtpThing.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..9b04bca --- /dev/null +++ b/Program.cs @@ -0,0 +1,149 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Cronos; +using Renci.SshNet; +using Renci.SshNet.Sftp; +using Serilog; + +var resetEvent = new ManualResetEvent(false); +using var tokenSource = new CancellationTokenSource(); + +Console.CancelKeyPress += (sender, eventArgs) => +{ + resetEvent?.Set(); + tokenSource?.Cancel(); + eventArgs.Cancel = true; + Log.CloseAndFlush(); +}; + +SetupLogging(); + +var config = LoadConfig(); +Log.Information("Configuration file loaded. Beginning initial grab."); +await RunJob(config, tokenSource.Token); +Log.Information("Initial grab complete. Initializing schedule."); +Task.Run(() => ScheduleJobs(config, tokenSource.Token)); + +resetEvent.WaitOne(); + +return; + +static void SetupLogging() +{ + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .WriteTo.Console() + .WriteTo.File("log.txt", + rollingInterval: RollingInterval.Day, + rollOnFileSizeLimit: true, + retainedFileCountLimit: 10) + .CreateLogger(); +} + +static Config LoadConfig() +{ + var serializationOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + WriteIndented = true + }; + + if (!File.Exists("config.json")) + { + Log.Fatal("No config file found, creating dummy config."); + CreateConfig(serializationOptions); + System.Environment.Exit(1); + } + + var configFile = File.ReadAllText("config.json"); + return JsonSerializer.Deserialize(configFile, serializationOptions); +} + +static void CreateConfig(JsonSerializerOptions options) +{ + var dummyGrab = new GrabTarget(@"/path/to/source", @"/path/to/destination"); + var dummySFtp = new SFtpTarget("ftp.domain.example", 22, "username", false, "password"); + var config = new Config(dummySFtp, [dummyGrab, dummyGrab], "* * * * *"); + File.WriteAllText("config.json", JsonSerializer.Serialize(config, options)); +} + +static async Task ScheduleJobs(Config config, CancellationToken token) +{ + var schedule = CronExpression.Parse(config.Schedule); + + while (!token.IsCancellationRequested) + { + var now = DateTimeOffset.Now; + var nextJob = schedule.GetNextOccurrence(now, TimeZoneInfo.Local); + + if (!nextJob.HasValue) break; + + Log.Information($"Next scheduled scan at {nextJob.Value}"); + var delay = nextJob.Value - now; + if (delay > TimeSpan.Zero) + { + await Task.Delay(delay, token); + } + + await RunJob(config, token); + } +} + +static async Task RunJob(Config config, CancellationToken token) +{ + using var client = GetClient(config.FTP); + await client.ConnectAsync(token); + Log.Information($"Connected to {config.FTP.Host}:{config.FTP.Port} as {config.FTP.UserName}"); + foreach (var target in config.Targets) + { + Log.Information($"Beginning grab operation for '{target.Source}' to '{target.Destination}'"); + await RecurseDirectory(client, target.Source, target.Destination, false, token); + Log.Information($"Ending grab operation for '{target.Source}' to '{target.Destination}'"); + } +} + +static async Task RecurseDirectory(SftpClient client, string source, string destination, bool deleteDirectory, CancellationToken token) +{ + Log.Information($"Scanning directory '{source}'"); + await foreach (var item in client.ListDirectoryAsync(source, token)) + { + if (item.Name is ".." or ".") continue; + + if (item.IsDirectory) + { + var newPath = Path.Combine(destination, item.Name); + await RecurseDirectory(client, item.FullName, newPath, true, token); + continue; + } + + await DownloadFile(client, item, destination, token); + Log.Information($"Deleting '{item.Name}'"); + await client.DeleteAsync(item.FullName, token); + } + + if (deleteDirectory) + { + await client.DeleteDirectoryAsync(source, token); + Log.Information($"Deleted directory '{source}'"); + } +} + +static async Task DownloadFile(SftpClient client, ISftpFile item, string destination, CancellationToken token) +{ + Directory.CreateDirectory(destination); + await using var stream = File.Open(Path.Combine(destination, item.Name), FileMode.Create, FileAccess.Write); + Log.Information($"Downloading '{item.FullName}' to '{stream.Name}'"); + await client.DownloadFileAsync(item.FullName, stream, token); +} + +static SftpClient GetClient(SFtpTarget target) +{ + return !target.usePrivateKey ? + new SftpClient(target.Host, target.Port, target.UserName, target.Password) + : new SftpClient(target.Host, target.Port, target.UserName, new PrivateKeyFile(target.KeyFile)); +} + + +public record Config(SFtpTarget FTP, GrabTarget[] Targets, string Schedule); +public record SFtpTarget(string Host, int Port, string UserName, bool usePrivateKey, string? Password = null, string? KeyFile = null); +public record GrabTarget(string Source, string Destination); \ No newline at end of file