initial release
This commit is contained in:
25
.dockerignore
Normal file
25
.dockerignore
Normal file
@@ -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
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
/packages/
|
||||||
|
riderModule.iml
|
||||||
|
/_ReSharper.Caches/
|
||||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@@ -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"]
|
||||||
19
FtpThing.csproj
Normal file
19
FtpThing.csproj
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Cronos" Version="0.11.1" />
|
||||||
|
<PackageReference Include="Serilog" Version="4.3.1-dev-02395" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.File" Version="8.0.0-dev-02318" />
|
||||||
|
<PackageReference Include="SSH.NET" Version="2025.1.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
6
FtpThing.slnx
Normal file
6
FtpThing.slnx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/Solution Items/">
|
||||||
|
<File Path="compose.yaml" />
|
||||||
|
</Folder>
|
||||||
|
<Project Path="FtpThing.csproj" />
|
||||||
|
</Solution>
|
||||||
149
Program.cs
Normal file
149
Program.cs
Normal file
@@ -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<Config>(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);
|
||||||
Reference in New Issue
Block a user