├── .github ├── FUNDING.yml └── workflows │ ├── build-binaries.yml │ └── build.yml ├── .gitignore ├── Dockerfile ├── Infrastructure └── GenericLogEnricher.cs ├── LICENSE ├── Models ├── Enums │ ├── ExitCodeEnum.cs │ └── LogLevelEnum.cs ├── Google │ └── ServiceAccount.cs ├── NotificationConfig.cs ├── RCloneCommands │ └── RCloneServiceAccountCommand.cs ├── RCloneConfig.cs ├── RCloneRCCommandResult.cs ├── RCloneRCResult.cs ├── RCloneRCResultAccount.cs ├── RemoteInfo.cs └── SARotateConfig.cs ├── Program.cs ├── Properties ├── PublishProfiles │ └── FolderProfile.pubxml └── launchSettings.json ├── README.md ├── SARotate.cs ├── SARotate.csproj ├── SARotate.sln ├── ShellHelper.cs └── appsettings.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [saltydk, Visorask] 2 | -------------------------------------------------------------------------------- /.github/workflows/build-binaries.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | on: 3 | release: 4 | types: [created] 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | arch: [linux-x64, linux-musl-x64, linux-arm, linux-arm64] 12 | steps: 13 | - name: Checkout GitHub actions 14 | uses: actions/checkout@v3 15 | 16 | - name: Setup .NET Core 17 | uses: actions/setup-dotnet@v3 18 | with: 19 | dotnet-version: 6.0.x 20 | 21 | - name: Install dependencies 22 | run: dotnet restore 23 | 24 | - name: Set version var 25 | id: vars 26 | run: echo "version=$(echo ${GITHUB_REF_NAME/refs\/tags\//} | cut -c2-)" >> $GITHUB_OUTPUT 27 | 28 | - name: Publish application 29 | run: dotnet publish -c Release -o "/home/runner/work/publish" -r ${{ matrix.arch }} --self-contained true -p:PublishTrimmed=true -p:PublishSingleFile=true -p:Version=${{ steps.vars.outputs.version }} 30 | 31 | - name: Upload Binary 32 | uses: actions/upload-release-asset@v1 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | with: 36 | upload_url: ${{ github.event.release.upload_url }} 37 | asset_path: /home/runner/work/publish/SARotate 38 | asset_name: SARotate-${{ matrix.arch }} 39 | asset_content_type: binary/octet-stream 40 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | schedule: 5 | - cron: '20 20 * * *' 6 | workflow_dispatch: 7 | 8 | jobs: 9 | docker: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Set up QEMU 13 | uses: docker/setup-qemu-action@v2 14 | 15 | - name: Set up Docker Buildx 16 | uses: docker/setup-buildx-action@v2 17 | 18 | - name: Login to DockerHub 19 | uses: docker/login-action@v2 20 | with: 21 | username: ${{ secrets.DOCKERHUB_USERNAME }} 22 | password: ${{ secrets.DOCKERHUB_TOKEN }} 23 | 24 | - name: Build and push 25 | id: docker_build 26 | uses: docker/build-push-action@v3 27 | with: 28 | platforms: linux/amd64 29 | push: true 30 | tags: | 31 | saltydk/sarotate:latest 32 | # - uses: sarisia/actions-status-discord@v1 33 | # if: always() 34 | # with: 35 | # webhook: ${{ secrets.DISCORD_WEBHOOK }} 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Download this file using PowerShell v3 under Windows with the following comand: 2 | # Invoke-WebRequest https://gist.githubusercontent.com/kmorcinek/2710267/raw/ -OutFile .gitignore 3 | # or wget: 4 | # wget --no-check-certificate http://gist.githubusercontent.com/kmorcinek/2710267/raw/.gitignore 5 | 6 | # User-specific files 7 | *.suo 8 | *.user 9 | *.sln.docstates 10 | 11 | # Build results 12 | [Dd]ebug/ 13 | [Rr]elease/ 14 | x64/ 15 | [Bb]in/ 16 | [Oo]bj/ 17 | # build folder is nowadays used for build scripts and should not be ignored 18 | #build/ 19 | 20 | # NuGet Packages 21 | *.nupkg 22 | # The packages folder can be ignored because of Package Restore 23 | **/packages/* 24 | # except build/, which is used as an MSBuild target. 25 | !**/packages/build/ 26 | # Uncomment if necessary however generally it will be regenerated when needed 27 | #!**/packages/repositories.config 28 | 29 | # MSTest test Results 30 | [Tt]est[Rr]esult*/ 31 | [Bb]uild[Ll]og.* 32 | 33 | *_i.c 34 | *_p.c 35 | *.ilk 36 | *.meta 37 | *.obj 38 | *.pch 39 | *.pdb 40 | *.pgc 41 | *.pgd 42 | *.rsp 43 | *.sbr 44 | *.tlb 45 | *.tli 46 | *.tlh 47 | *.tmp 48 | *.tmp_proj 49 | *.log 50 | *.vspscc 51 | *.vssscc 52 | .builds 53 | *.pidb 54 | *.log 55 | *.scc 56 | 57 | # OS generated files # 58 | .DS_Store* 59 | Icon? 60 | 61 | # Visual C++ cache files 62 | ipch/ 63 | *.aps 64 | *.ncb 65 | *.opensdf 66 | *.sdf 67 | *.cachefile 68 | 69 | # Visual Studio profiler 70 | *.psess 71 | *.vsp 72 | *.vspx 73 | 74 | # Guidance Automation Toolkit 75 | *.gpState 76 | 77 | # ReSharper is a .NET coding add-in 78 | _ReSharper*/ 79 | *.[Rr]e[Ss]harper 80 | 81 | # TeamCity is a build add-in 82 | _TeamCity* 83 | 84 | # DotCover is a Code Coverage Tool 85 | *.dotCover 86 | 87 | # NCrunch 88 | *.ncrunch* 89 | .*crunch*.local.xml 90 | 91 | # Installshield output folder 92 | [Ee]xpress/ 93 | 94 | # DocProject is a documentation generator add-in 95 | DocProject/buildhelp/ 96 | DocProject/Help/*.HxT 97 | DocProject/Help/*.HxC 98 | DocProject/Help/*.hhc 99 | DocProject/Help/*.hhk 100 | DocProject/Help/*.hhp 101 | DocProject/Help/Html2 102 | DocProject/Help/html 103 | 104 | # Click-Once directory 105 | publish/ 106 | 107 | # Publish Web Output 108 | *.Publish.xml 109 | 110 | # Windows Azure Build Output 111 | csx 112 | *.build.csdef 113 | 114 | # Windows Store app package directory 115 | AppPackages/ 116 | 117 | # Others 118 | *.Cache 119 | ClientBin/ 120 | [Ss]tyle[Cc]op.* 121 | ~$* 122 | *~ 123 | *.dbmdl 124 | *.[Pp]ublish.xml 125 | *.pfx 126 | *.publishsettings 127 | modulesbin/ 128 | tempbin/ 129 | 130 | # EPiServer Site file (VPP) 131 | AppData/ 132 | 133 | # RIA/Silverlight projects 134 | Generated_Code/ 135 | 136 | # Backup & report files from converting an old project file to a newer 137 | # Visual Studio version. Backup files are not needed, because we have git ;-) 138 | _UpgradeReport_Files/ 139 | Backup*/ 140 | UpgradeLog*.XML 141 | UpgradeLog*.htm 142 | 143 | # vim 144 | *.txt~ 145 | *.swp 146 | *.swo 147 | 148 | # svn 149 | .svn 150 | 151 | # CVS - Source Control 152 | **/CVS/ 153 | 154 | # Remainings from resolvings conflicts in Source Control 155 | *.orig 156 | 157 | # SQL Server files 158 | **/App_Data/*.mdf 159 | **/App_Data/*.ldf 160 | **/App_Data/*.sdf 161 | 162 | 163 | #LightSwitch generated files 164 | GeneratedArtifacts/ 165 | _Pvt_Extensions/ 166 | ModelManifest.xml 167 | 168 | # ========================= 169 | # Windows detritus 170 | # ========================= 171 | 172 | # Windows image file caches 173 | Thumbs.db 174 | ehthumbs.db 175 | 176 | # Folder config file 177 | Desktop.ini 178 | 179 | # Recycle Bin used on file shares 180 | $RECYCLE.BIN/ 181 | 182 | # Mac desktop service store files 183 | .DS_Store 184 | 185 | # SASS Compiler cache 186 | .sass-cache 187 | 188 | # Visual Studio 2014 CTP 189 | **/*.sln.ide 190 | 191 | # Visual Studio temp something 192 | .vs/ 193 | 194 | # dotnet stuff 195 | project.lock.json 196 | 197 | # VS 2015+ 198 | *.vc.vc.opendb 199 | *.vc.db 200 | 201 | # Rider 202 | .idea/ 203 | 204 | # Visual Studio Code 205 | .vscode/ 206 | 207 | # Output folder used by Webpack or other FE stuff 208 | **/node_modules/* 209 | **/wwwroot/* 210 | 211 | # SpecFlow specific 212 | *.feature.cs 213 | *.feature.xlsx.* 214 | *.Specs_*.html 215 | 216 | ##### 217 | # End of core ignore list, below put you custom 'per project' settings (patterns or path) 218 | ##### 219 | 220 | 221 | config.yaml 222 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:jammy 2 | 3 | ARG DEBIAN_FRONTEND="noninteractive" 4 | 5 | ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT="1" 6 | 7 | VOLUME ["/config"] 8 | 9 | # install packages 10 | RUN apt update && \ 11 | apt install -y --no-install-recommends --no-install-suggests \ 12 | ca-certificates jq curl \ 13 | locales tzdata python3 python3-pip && \ 14 | # generate locale 15 | locale-gen en_US.UTF-8 && \ 16 | # clean up 17 | apt autoremove -y && \ 18 | apt clean && \ 19 | rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/* 20 | 21 | # install packages 22 | RUN pip3 install apprise 23 | 24 | RUN mkdir "/app" && curl -L -o /app/SARotate $(curl -Ls https://api.github.com/repos/saltydk/sarotate/releases/latest | grep "browser_download_url" | cut -d '"' -f 4 | grep linux-x64) && \ 25 | chmod -R u=rwX,go=rX "/app" && \ 26 | chmod +x /app/SARotate 27 | 28 | WORKDIR /config 29 | CMD /app/SARotate -v 30 | -------------------------------------------------------------------------------- /Infrastructure/GenericLogEnricher.cs: -------------------------------------------------------------------------------- 1 | using Serilog.Core; 2 | using Serilog.Events; 3 | using System; 4 | 5 | namespace SARotate.Infrastructure 6 | { 7 | public class GenericLogEnricher : ILogEventEnricher 8 | { 9 | public const string OSVersionPropertyName = "OSVersion"; 10 | public const string BaseDirectoryPropertyName = "BaseDirectory"; 11 | public const string MachineNamePropertyName = "MachineName"; 12 | 13 | public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) 14 | { 15 | if (logEvent == null) 16 | { 17 | throw new ArgumentNullException("logEvent"); 18 | } 19 | 20 | AddProperty(logEvent, OSVersionPropertyName, GetOSVersion()); 21 | AddProperty(logEvent, MachineNamePropertyName, GetMachineName()); 22 | AddProperty(logEvent, BaseDirectoryPropertyName, GetBaseDirectory()); 23 | } 24 | 25 | private void AddProperty(LogEvent logEvent, string propertyName, string value) 26 | { 27 | var property = new LogEventProperty(propertyName, new ScalarValue(value)); 28 | logEvent.AddPropertyIfAbsent(property); 29 | } 30 | 31 | public string GetOSVersion() 32 | { 33 | return Environment.OSVersion.ToString(); 34 | } 35 | 36 | public string GetMachineName() 37 | { 38 | return Environment.MachineName; 39 | } 40 | 41 | public string GetBaseDirectory() 42 | { 43 | try 44 | { 45 | return AppDomain.CurrentDomain.BaseDirectory; 46 | } 47 | catch 48 | { 49 | return string.Empty; 50 | 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 salty 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Models/Enums/ExitCodeEnum.cs: -------------------------------------------------------------------------------- 1 | namespace SARotate.Models.Enums 2 | { 3 | public enum ExitCode 4 | { 5 | Success = 0 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Models/Enums/LogLevelEnum.cs: -------------------------------------------------------------------------------- 1 | namespace SARotate.Models.Enums 2 | { 3 | public enum LogLevel 4 | { 5 | Debug = 0, 6 | Information = 1, 7 | Warning = 2, 8 | Error = 3, 9 | Critical = 4 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Models/Google/ServiceAccount.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using Newtonsoft.Json; 3 | 4 | namespace SARotate.Models.Google 5 | { 6 | public class ServiceAccount 7 | { 8 | [JsonProperty("project_id")] 9 | public string ProjectId { get; set; } 10 | [JsonProperty("client_email")] 11 | public string ClientEmail { get; set; } 12 | [JsonIgnore] 13 | public string? FilePath { get; set; } 14 | [JsonIgnore] 15 | public string? FileName => FilePath?.Split("/").Last(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Models/NotificationConfig.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace SARotate.Models 4 | { 5 | public class NotificationConfig 6 | { 7 | [YamlDotNet.Serialization.YamlMember(Alias = "errors_only")] 8 | public bool AppriseNotificationsErrorsOnly { get; set; } = true; 9 | [YamlDotNet.Serialization.YamlMember(Alias = "apprise")] 10 | public List AppriseServices { get; set; } = new List(); 11 | [YamlDotNet.Serialization.YamlMember(Alias = "include_hostname")] 12 | public bool AppriseNotificationsIncludeHostname { get; set; } = false; 13 | } 14 | } -------------------------------------------------------------------------------- /Models/RCloneCommands/RCloneServiceAccountCommand.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | 3 | namespace SARotate.Models.RCloneCommands 4 | { 5 | public class Opt 6 | { 7 | [JsonProperty("service_account_file")] 8 | 9 | public string service_account_file { get; set; } 10 | } 11 | 12 | public class RCloneServiceAccountCommand 13 | { 14 | [JsonProperty("command")] 15 | public string command { get; set; } 16 | [JsonProperty("fs")] 17 | 18 | public string fs { get; set; } 19 | [JsonProperty("opt")] 20 | 21 | public Opt opt { get; set; } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Models/RCloneConfig.cs: -------------------------------------------------------------------------------- 1 | namespace SARotate.Models 2 | { 3 | public class RCloneConfig 4 | { 5 | [YamlDotNet.Serialization.YamlMember(Alias = "sleeptime")] 6 | public int SleepTime { get; set; } = 5; 7 | } 8 | } -------------------------------------------------------------------------------- /Models/RCloneRCCommandResult.cs: -------------------------------------------------------------------------------- 1 | namespace SARotate.Models 2 | { 3 | public class RCloneRCCommandResult 4 | { 5 | public RCloneRCResult Result { get; set; } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Models/RCloneRCResult.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | 3 | namespace SARotate.Models 4 | { 5 | public class RCloneRCResult 6 | { 7 | [JsonProperty("service_account_file")] 8 | public RCloneRCResultAccount ServiceAccountFile { get; set; } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Models/RCloneRCResultAccount.cs: -------------------------------------------------------------------------------- 1 | namespace SARotate.Models 2 | { 3 | public class RCloneRCResultAccount 4 | { 5 | public string Current { get; set; } 6 | public string Previous { get; set; } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Models/RemoteInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using Newtonsoft.Json; 3 | 4 | namespace SARotate.Models 5 | { 6 | public class RemoteInfo 7 | { 8 | private string _address; 9 | 10 | [YamlDotNet.Serialization.YamlMember(Alias = "address")] 11 | public string Address 12 | { 13 | get => _address; 14 | set 15 | { 16 | if (!value.ToLower().Contains("http")) 17 | { 18 | _address = "http://" + value; 19 | } 20 | else 21 | { 22 | _address = value; 23 | } 24 | } 25 | } 26 | [YamlDotNet.Serialization.YamlMember(Alias = "user")] 27 | public string User { get; set; } = ""; 28 | [YamlDotNet.Serialization.YamlMember(Alias = "pass")] 29 | public string Pass { get; set; } = ""; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Models/SARotateConfig.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using YamlDotNet.Serialization; 5 | using YamlDotNet.Serialization.NamingConventions; 6 | 7 | namespace SARotate.Models 8 | { 9 | public class SARotateConfig 10 | { 11 | [YamlMember(Alias = "rclone")] 12 | public RCloneConfig RCloneConfig { get; set; } = new RCloneConfig(); 13 | 14 | [YamlMember(Alias = "remotes")] 15 | public Dictionary> RemoteConfig { get; set; } 16 | 17 | /// 18 | /// svcAcctGroup absolute path -> remote -> connection info 19 | /// 20 | [YamlMember(Alias = "notification")] public NotificationConfig NotificationConfig { get; set; } = new NotificationConfig(); 21 | 22 | // ReSharper disable once InconsistentNaming 23 | public static SARotateConfig? ParseSARotateYamlConfig(string configAbsolutePath) 24 | { 25 | if (!File.Exists(configAbsolutePath)) 26 | { 27 | Console.WriteLine($"Could not access config file at '{configAbsolutePath}'."); 28 | return null; 29 | } 30 | 31 | using (var streamReader = new StreamReader(configAbsolutePath)) 32 | { 33 | string fileContent = streamReader.ReadToEnd(); 34 | 35 | try 36 | { 37 | IDeserializer deserializer = new DeserializerBuilder() 38 | .WithNamingConvention(UnderscoredNamingConvention.Instance) 39 | .IgnoreUnmatchedProperties() 40 | .Build(); 41 | 42 | return deserializer.Deserialize(fileContent); 43 | } 44 | catch (Exception) 45 | { 46 | Console.WriteLine("Config file invalid format. Check https://github.com/saltydk/SARotate/blob/main/README.md"); 47 | return null; 48 | } 49 | } 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Net.Http; 6 | using System.Reflection; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | using CommandLine; 10 | using Microsoft.Extensions.Configuration; 11 | using Microsoft.Extensions.DependencyInjection; 12 | using Microsoft.Extensions.Hosting; 13 | using SARotate.Infrastructure; 14 | using SARotate.Models; 15 | using SARotate.Models.Enums; 16 | using Serilog; 17 | using Serilog.Core; 18 | using Serilog.Events; 19 | 20 | namespace SARotate 21 | { 22 | internal class Program 23 | { 24 | private static IConfiguration _configuration; 25 | private static HttpClient _httpClient = new HttpClient(); 26 | 27 | public class Options 28 | { 29 | [Option('v', "verbose", Required = false, HelpText = "Set output to verbose messages.")] 30 | public bool Verbose { get; set; } 31 | [Option('c', "config", Required = false, HelpText = "Set path to config.")] 32 | public string? Config { get; set; } 33 | [Option('l', "logfile", Required = false, HelpText = "Set path for log file.")] 34 | public string? LogFile { get; set; } 35 | } 36 | 37 | private static async Task Main(string[] args) 38 | { 39 | var cts = new CancellationTokenSource(); 40 | 41 | AppDomain.CurrentDomain.ProcessExit += (s, e) => 42 | { 43 | Log.Information("SARotate stopped"); 44 | Log.CloseAndFlush(); 45 | 46 | cts.Cancel(); 47 | }; 48 | 49 | using IHost host = CreateHostBuilder(args, cts).Build(); 50 | 51 | var assembly = Assembly.GetExecutingAssembly(); 52 | var informationVersion = assembly.GetCustomAttribute().InformationalVersion; 53 | 54 | Log.Information($"SARotate Version {informationVersion} started"); 55 | 56 | await host.RunAsync(cts.Token); 57 | Log.CloseAndFlush(); 58 | } 59 | 60 | private static IHostBuilder CreateHostBuilder(string[] args, CancellationTokenSource cts) 61 | { 62 | string cwd = Directory.GetCurrentDirectory(); 63 | 64 | _configuration = new ConfigurationBuilder() 65 | .SetBasePath(cwd) 66 | .AddCommandLine(args) 67 | .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) 68 | .Build(); 69 | 70 | (string? configAbsolutePath, string? logFilePath, bool verboseFlagExists) = ParseArguments(args); 71 | 72 | SARotateConfig? saRotateConfig = SARotateConfig.ParseSARotateYamlConfig(configAbsolutePath ?? cwd + "/config.yaml"); 73 | 74 | if (saRotateConfig == null) 75 | { 76 | Environment.Exit(-1); 77 | } 78 | 79 | 80 | Logger logger = CreateLogger(cwd, logFilePath, verboseFlagExists); 81 | 82 | return Host.CreateDefaultBuilder() 83 | .ConfigureHostConfiguration(configHost => 84 | { 85 | configHost.SetBasePath(Directory.GetCurrentDirectory()); 86 | }) 87 | .ConfigureServices(services => 88 | { 89 | services.AddHttpClient(); 90 | services.AddHostedService(); 91 | services.AddSingleton(saRotateConfig); 92 | services.AddSingleton(_configuration); 93 | services.AddSingleton(cts); 94 | }) 95 | .UseSerilog(logger); 96 | } 97 | 98 | private static (string? configAbsolutePath, string? logFilePath, bool verboseFlagExists) ParseArguments(string[] args) 99 | { 100 | var verboseFlagExists = false; 101 | string? configAbsolutePath = null; 102 | string? logFilePath = null; 103 | 104 | Parser.Default 105 | .ParseArguments(args) 106 | .WithParsed(o => 107 | { 108 | configAbsolutePath = o.Config; 109 | logFilePath = o.LogFile; 110 | verboseFlagExists = o.Verbose; 111 | }) 112 | .WithNotParsed(errs => 113 | { 114 | List errors = errs.ToList(); 115 | 116 | if (!errors.Any(err => err.Tag is ErrorType.HelpRequestedError or ErrorType.VersionRequestedError)) 117 | { 118 | foreach (Error error in errors) 119 | { 120 | Console.WriteLine("argument parsing error: " + error); 121 | } 122 | 123 | Console.WriteLine("Passed in unknown flag, exiting."); 124 | } 125 | 126 | Environment.Exit(-1); 127 | }); 128 | 129 | return (configAbsolutePath ?? _configuration["config"], logFilePath, verboseFlagExists); 130 | } 131 | 132 | private static Logger CreateLogger(string cwd, string? logFilePath, bool verboseFlagExists) 133 | { 134 | string logPath = logFilePath ?? _configuration["Serilog:WriteTo:0:Args:configure:0:Args:path"] ?? cwd + "/sarotate.log"; 135 | string minimumLogLevelConfig = verboseFlagExists ? "Verbose" : _configuration["Serilog:WriteTo:0:Args:configure:0:Args:restrictedToMinimumLevel"] ?? "Information"; 136 | string rollingIntervalConfig = _configuration["Serilog:WriteTo:0:Args:configure:0:Args:rollingInterval"] ?? "Day"; 137 | int fileSizeLimitBytes = int.Parse(_configuration["Serilog:WriteTo:0:Args:configure:0:Args:fileSizeLimitBytes"] ?? "5000000"); 138 | int retainedFileCountLimit = int.Parse(_configuration["Serilog:WriteTo:0:Args:configure:0:Args:retainedFileCountLimit"] ?? "5"); 139 | 140 | LogEventLevel minimumLogEventLevel = ConvertMinimumLogLevelConfigToLogEventLevel(minimumLogLevelConfig); 141 | PersistentFileRollingInterval rollingInterval = ConvertRollingIntervalConfigValueToEnum(rollingIntervalConfig); 142 | 143 | Logger logger = new LoggerConfiguration() 144 | .Enrich.FromLogContext() 145 | .Enrich.WithProperty("Application", "SARotate") 146 | .Enrich.With() 147 | .MinimumLevel.ControlledBy(new LoggingLevelSwitch(minimumLogEventLevel)) 148 | .WriteTo.PersistentFile(logPath, 149 | fileSizeLimitBytes: fileSizeLimitBytes, 150 | persistentFileRollingInterval: rollingInterval, 151 | retainedFileCountLimit: retainedFileCountLimit) 152 | .WriteTo.Async(a => a.Console(outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:j}{NewLine}{Exception}")) 153 | .CreateLogger(); 154 | 155 | Log.Logger = logger; 156 | 157 | return logger; 158 | } 159 | 160 | private static PersistentFileRollingInterval ConvertRollingIntervalConfigValueToEnum(string rollingInterval) 161 | { 162 | return rollingInterval.ToLower() switch 163 | { 164 | "infinite" => PersistentFileRollingInterval.Infinite, 165 | "year" => PersistentFileRollingInterval.Year, 166 | "month" => PersistentFileRollingInterval.Month, 167 | "day" => PersistentFileRollingInterval.Day, 168 | "hour" => PersistentFileRollingInterval.Hour, 169 | "minute" => PersistentFileRollingInterval.Minute, 170 | _ => PersistentFileRollingInterval.Day 171 | }; 172 | } 173 | 174 | private static LogEventLevel ConvertMinimumLogLevelConfigToLogEventLevel(string minimumLogLevel) 175 | { 176 | return minimumLogLevel.ToLower() switch 177 | { 178 | "verbose" => LogEventLevel.Verbose, 179 | "debug" => LogEventLevel.Debug, 180 | "information" => LogEventLevel.Information, 181 | "error" => LogEventLevel.Error, 182 | "fatal" => LogEventLevel.Fatal, 183 | _ => LogEventLevel.Information, 184 | }; 185 | } 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /Properties/PublishProfiles/FolderProfile.pubxml: -------------------------------------------------------------------------------- 1 |  2 | 5 | 6 | 7 | Release 8 | Any CPU 9 | bin\Release\net5.0\publish\ 10 | FileSystem 11 | net5.0 12 | linux-x64 13 | true 14 | True 15 | True 16 | 17 | -------------------------------------------------------------------------------- /Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "WSL2": { 4 | "commandName": "WSL2", 5 | "distributionName": "" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SARotate 2 | [![Discord](https://img.shields.io/discord/853755447970758686)](https://discord.gg/ugfKXpFND8) 3 | 4 | For rotating Google Service Accounts to spread the API load in an attempt to avoid Rclone mount file access problems with heavy API traffic. 5 | 6 | Parses the specified Service Account files and automatically identifies the projects that they are a part of and rotates between projects where possible to spread API usage across projects. 7 | 8 | Heavily inspired by [SARotate](https://github.com/Visorask/SARotate) by [Visorask](https://github.com/Visorask) and with his permission we kept the name. 9 | 10 | ## Requirements: 11 | [Rclone](https://github.com/rclone/rclone) v1.55 or newer. 12 | Rclone mount with remote control enabled and authentication either disabled or using a known user/password. 13 | Google Service Accounts placed in a directory. 14 | 15 | [Apprise](https://github.com/caronc/apprise) - For notications if enabled. 16 | 17 | ## Installation: 18 | Assumes you have fulfilled the above requirements. For information on Rclone remote control you can go [here](https://rclone.org/rc/). For help creating a lot of service accounts quickly you can use [safire](https://github.com/88lex/safire) or [sa-gen](https://github.com/88lex/sa-gen) which are both projects by [Lex](https://github.com/88lex). 19 | 20 | We'll be using /opt/sarotate as the directory in this example. The below example assumes your user owns /opt already so change the commands accordingly if that isn't the case for your setup. Folder location was chosen due to this project having connections to [Saltbox](https://github.com/saltyorg/Saltbox) which uses /opt for apps in the ecosystem around it. 21 | 22 | Create a directory for SARotate and enter it: 23 | ```shell 24 | mkdir /opt/sarotate && cd /opt/sarotate 25 | ``` 26 | Download the latest Linux x64 binary: (Options for the last grep include linux-arm, linux-arm64, linux-musl-x64 and linux-x64) 27 | ```shell 28 | curl -L "$(curl -Ls https://api.github.com/repos/saltydk/sarotate/releases/latest | grep "browser_download_url" | cut -d '"' -f 4 | grep "linux-x64")" -o SARotate 29 | ``` 30 | 31 | ```shell 32 | chmod +x SARotate 33 | ``` 34 | 35 | Place a config.yaml in the same directory as the binary with the configuration described in the next section. 36 | 37 | 38 | ## Configuration: 39 | Program expects a config.yaml in the working directory unless a custom path is specified. 40 | ```yaml 41 | rclone: 42 | sleeptime: 300 43 | 44 | remotes: 45 | '/opt/sa': 46 | seedbox-drive: 47 | address: localhost:5623 48 | user: username 49 | pass: password 50 | '/opt/sa2': 51 | Movies: 52 | address: localhost:5629 53 | user: username 54 | pass: password 55 | Movies-4K: 56 | address: localhost:5629 57 | user: username 58 | pass: password 59 | Movies-Danish: 60 | address: localhost:5629 61 | user: username 62 | pass: password 63 | TV: 64 | address: localhost:5629 65 | user: username 66 | pass: password 67 | TV-4K: 68 | address: localhost:5629 69 | user: username 70 | pass: password 71 | TV-Anime: 72 | address: localhost:5629 73 | user: username 74 | pass: password 75 | 76 | notification: 77 | errors_only: y 78 | include_hostname: n 79 | apprise: 80 | - 'discord://' 81 | ``` 82 | 83 | ###### Rclone section: 84 | ```yaml 85 | rclone: 86 | sleeptime: 300 # Delay between service account rotation 87 | ``` 88 | 89 | ###### Remotes section: 90 | 91 | Note that multiple remotes that use the same folder of SAs all go under that folder heading. 92 | 93 | ```yaml 94 | remotes: 95 | '/opt/sa': # Folder containing service accounts 96 | seedbox-drive: # Name of remote specified in rclone.conf (case sensitive) 97 | address: localhost:5623 # IP/hostname and port used for rclone remote control 98 | user: username # Optional - Set if you have enabled Rclone authentication 99 | pass: password # Optional - Set if you have enabled Rclone authentication 100 | '/opt/sa2': # Can add additional folder + remote pairings as needed 101 | Movies: # Name of remote specified in rclone.conf (case sensitive) 102 | address: localhost:5629 # IP/hostname and port used for rclone remote control 103 | user: username # Optional - Set if you have enabled Rclone authentication 104 | pass: password # Optional - Set if you have enabled Rclone authentication 105 | Movies-4K: # Name of remote specified in rclone.conf (case sensitive) 106 | address: localhost:5629 # IP/hostname and port used for rclone remote control 107 | user: username # Optional - Set if you have enabled Rclone authentication 108 | pass: password # Optional - Set if you have enabled Rclone authentication 109 | Movies-Danish: # Name of remote specified in rclone.conf (case sensitive) 110 | address: localhost:5629 # IP/hostname and port used for rclone remote control 111 | user: username # Optional - Set if you have enabled Rclone authentication 112 | pass: password # Optional - Set if you have enabled Rclone authentication 113 | TV: # Name of remote specified in rclone.conf (case sensitive) 114 | address: localhost:5629 # IP/hostname and port used for rclone remote control 115 | user: username # Optional - Set if you have enabled Rclone authentication 116 | pass: password # Optional - Set if you have enabled Rclone authentication 117 | TV-4K: # Name of remote specified in rclone.conf (case sensitive) 118 | address: localhost:5629 # IP/hostname and port used for rclone remote control 119 | user: username # Optional - Set if you have enabled Rclone authentication 120 | pass: password # Optional - Set if you have enabled Rclone authentication 121 | TV-Anime: # Name of remote specified in rclone.conf (case sensitive) 122 | address: localhost:5629 # IP/hostname and port used for rclone remote control 123 | user: username # Optional - Set if you have enabled Rclone authentication 124 | pass: password # Optional - Set if you have enabled Rclone authentication 125 | ``` 126 | 127 | ###### Notifications section: 128 | ```yaml 129 | notification: 130 | errors_only: y # If you only want errors posted to apprise notications 131 | include_hostname: y # If you want to include the machine hostname in notifications 132 | apprise: # List of apprise notifications. Add one or as many as you want 133 | - 'discord://' 134 | ``` 135 | Look [here](https://github.com/caronc/apprise) for apprise instructions. 136 | 137 | Set to empty string to disable 138 | ```yaml 139 | notification: 140 | errors_only: y 141 | apprise: 142 | - '' 143 | ``` 144 | 145 | Before setting up the service below you should run SARotate manually and make sure it works. 146 | 147 | ## Service Example: 148 | ```ini 149 | [Unit] 150 | Description=sarotate 151 | After=network-online.target 152 | 153 | [Service] 154 | User=user 155 | Group=user 156 | Type=simple 157 | WorkingDirectory=/opt/sarotate/ 158 | ExecStart=/opt/sarotate/SARotate 159 | 160 | [Install] 161 | WantedBy=default.target 162 | ``` 163 | Edit the user and group to your existing user and if you want to avoid initial notification errors on boot it is probably a good idea to edit the After=network-online.target to the services used by your mount(s). 164 | 165 | You can install the above service example by placing the edited contents in a service file: 166 | ```shell 167 | sudo nano /etc/systemd/system/sarotate.service 168 | ``` 169 | Then you can enable (starts on boot) and start the service: 170 | ```shell 171 | sudo systemctl enable sarotate.service && sudo systemctl start sarotate.service 172 | ``` 173 | 174 | ## Donations: 175 | | Developers | Roles | Methods | 176 | |:----------------------------------------|:----------------|:--------------------------------------------------------------------------------------------------| 177 | | [salty](https://github.com/saltydk) | Developer | [GitHub Sponsors](https://github.com/sponsors/saltydk); [Paypal](https://www.paypal.me/saltydk); | 178 | | [Visorask](https://github.com/Visorask) | Original Author | [GitHub Sponsors](https://github.com/sponsors/Visorask); [Paypal](https://paypal.me/RRussell603); | 179 | -------------------------------------------------------------------------------- /SARotate.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Net.Http; 6 | using System.Net.Http.Headers; 7 | using System.Text; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | using Microsoft.Extensions.Configuration; 11 | using Microsoft.Extensions.Hosting; 12 | using Microsoft.Extensions.Logging; 13 | using Newtonsoft.Json; 14 | using SARotate.Models; 15 | using SARotate.Models.Enums; 16 | using SARotate.Models.Google; 17 | using SARotate.Models.RCloneCommands; 18 | using LogLevel = SARotate.Models.Enums.LogLevel; 19 | 20 | namespace SARotate 21 | { 22 | // ReSharper disable once InconsistentNaming 23 | public class SARotate : IHostedService 24 | { 25 | private readonly IConfiguration _configuration; 26 | private readonly ILogger _logger; 27 | private readonly CancellationTokenSource _cancellationTokenSource; 28 | // ReSharper disable once InconsistentNaming 29 | private readonly SARotateConfig _SARotateConfig; 30 | private readonly IHttpClientFactory _httpClientFactory; 31 | 32 | 33 | private static int _minimumMajorVersion = 1; 34 | private static int _minimumMinorVersion = 55; 35 | private static int _minimumPatchVersion = 0; 36 | private static string _minimumVersionString = "v" + _minimumMajorVersion + "." + _minimumMinorVersion + "." + _minimumPatchVersion; 37 | 38 | public SARotate(IConfiguration configuration, ILogger logger, CancellationTokenSource cancellationTokenSource, 39 | SARotateConfig SARotateConfig, IHttpClientFactory httpClientFactory) 40 | { 41 | _configuration = configuration; 42 | _SARotateConfig = SARotateConfig; 43 | _httpClientFactory = httpClientFactory; 44 | _logger = logger; 45 | _cancellationTokenSource = cancellationTokenSource; 46 | } 47 | 48 | public async Task StartAsync(CancellationToken cancellationToken) 49 | { 50 | try 51 | { 52 | Dictionary>? serviceAccountUsageOrderByGroup = await GenerateServiceAccountUsageOrderByGroup(_SARotateConfig); 53 | 54 | if (serviceAccountUsageOrderByGroup == null) 55 | { 56 | throw new ArgumentException("Service accounts not found"); 57 | } 58 | 59 | 60 | await RunSwappingService(_SARotateConfig, serviceAccountUsageOrderByGroup, cancellationToken); 61 | } 62 | catch (Exception e) 63 | { 64 | await SendAppriseNotification(_SARotateConfig, e.Message, LogLevel.Error); 65 | 66 | if (!_cancellationTokenSource.IsCancellationRequested) 67 | { 68 | LogMessage($"Fatal error, shutting down. Error: {e.Message}", LogLevel.Critical); 69 | _cancellationTokenSource.Cancel(); 70 | } 71 | } 72 | } 73 | 74 | public Task StopAsync(CancellationToken cancellationToken) 75 | { 76 | if (!_cancellationTokenSource.IsCancellationRequested) 77 | { 78 | _cancellationTokenSource.Cancel(); 79 | } 80 | 81 | return Task.CompletedTask; 82 | } 83 | 84 | private async Task>?> GenerateServiceAccountUsageOrderByGroup(SARotateConfig yamlConfigContent) 85 | { 86 | var serviceAccountUsageOrderByGroup = new Dictionary>(); 87 | 88 | foreach ((string serviceAccountsDirectoryAbsolutePath, Dictionary remotes) in yamlConfigContent.RemoteConfig) 89 | { 90 | 91 | foreach (string remote in remotes.Keys) 92 | { 93 | string? remoteRcloneHost = remotes[remote].Address; 94 | 95 | if (!remoteRcloneHost.ToLower().Contains("http")) 96 | { 97 | remoteRcloneHost = "http://" + remoteRcloneHost; 98 | } 99 | 100 | var remoteVersionUri = new Uri($"{remoteRcloneHost}/core/version"); 101 | bool validRcloneVersion = await CheckValidRcloneVersion(remoteVersionUri, remote, remotes[remote]); 102 | 103 | if (!validRcloneVersion) 104 | { 105 | LogMessage("Ignoring remote: " + remote); 106 | LogMessage("Rclone versions below " + _minimumVersionString + " are unsupported."); 107 | remotes.Remove(remote); 108 | } 109 | 110 | } 111 | 112 | List? svcAccts = await ParseSvcAccts(serviceAccountsDirectoryAbsolutePath); 113 | 114 | if (svcAccts == null || !svcAccts.Any()) 115 | { 116 | return null; 117 | } 118 | 119 | List svcAcctsUsageOrder = OrderServiceAccountsForUsage(svcAccts); 120 | ServiceAccount? earliestSvcAcctUsed = null; 121 | ServiceAccount? latestSvcAcctUsed = null; 122 | 123 | foreach (string remote in remotes.Keys) 124 | { 125 | string? previousServiceAccountUsed = await FindPreviousServiceAccountUsedForRemote(remote, remotes[remote]); 126 | 127 | if (string.IsNullOrEmpty(previousServiceAccountUsed)) 128 | { 129 | LogMessage("unable to find previous service account used for remote " + remote); 130 | continue; 131 | } 132 | 133 | ServiceAccount? serviceAccount = svcAcctsUsageOrder.FirstOrDefault(sa => sa.FileName == previousServiceAccountUsed); 134 | 135 | if (serviceAccount == null) 136 | { 137 | LogMessage("unable to find local file " + previousServiceAccountUsed); 138 | LogMessage("group accounts " + string.Join(",", svcAcctsUsageOrder.Select(sa => sa.FilePath))); 139 | continue; 140 | } 141 | 142 | if (earliestSvcAcctUsed == null || latestSvcAcctUsed == null) 143 | { 144 | earliestSvcAcctUsed = serviceAccount; 145 | latestSvcAcctUsed = serviceAccount; 146 | } 147 | else 148 | { 149 | int indexOfSvcAcct = svcAcctsUsageOrder.IndexOf(serviceAccount); 150 | int indexOfCurrentEarliestSvcAcct = svcAcctsUsageOrder.IndexOf(earliestSvcAcctUsed); 151 | int indexOfCurrentLatestSvcAcct = svcAcctsUsageOrder.IndexOf(latestSvcAcctUsed); 152 | 153 | if (indexOfSvcAcct < indexOfCurrentEarliestSvcAcct) 154 | { 155 | earliestSvcAcctUsed = serviceAccount; 156 | } 157 | 158 | if (indexOfCurrentLatestSvcAcct < indexOfSvcAcct) 159 | { 160 | latestSvcAcctUsed = serviceAccount; 161 | } 162 | } 163 | } 164 | 165 | if (earliestSvcAcctUsed != null && latestSvcAcctUsed != null) 166 | { 167 | int indexOfEarliestSvcAcct = svcAcctsUsageOrder.IndexOf(earliestSvcAcctUsed); 168 | int indexOfLatestSvcAcct = svcAcctsUsageOrder.IndexOf(latestSvcAcctUsed); 169 | 170 | bool serviceAccountListLooped = remotes.Keys.Count < indexOfLatestSvcAcct - indexOfEarliestSvcAcct; 171 | 172 | var svcAcctsToReEnqueue = new List(); 173 | 174 | int indexOfCutoffForReEnqueue = serviceAccountListLooped ? indexOfEarliestSvcAcct + 1 : indexOfLatestSvcAcct + 1; 175 | 176 | List? accountsToRemove = svcAcctsUsageOrder.GetRange(0, indexOfCutoffForReEnqueue); 177 | 178 | svcAcctsToReEnqueue.AddRange(accountsToRemove); 179 | 180 | svcAcctsUsageOrder.RemoveRange(0, indexOfCutoffForReEnqueue); 181 | svcAcctsUsageOrder.AddRange(svcAcctsToReEnqueue); 182 | } 183 | 184 | serviceAccountUsageOrderByGroup.Add(serviceAccountsDirectoryAbsolutePath, svcAcctsUsageOrder); 185 | } 186 | 187 | return serviceAccountUsageOrderByGroup; 188 | } 189 | 190 | private async Task CheckValidRcloneVersion(Uri rcloneVersionEndpoint, string remote, RemoteInfo remoteInfo) 191 | { 192 | var request = new HttpRequestMessage(HttpMethod.Post, rcloneVersionEndpoint); 193 | 194 | if (!string.IsNullOrEmpty(remoteInfo.User) && !string.IsNullOrEmpty(remoteInfo.Pass)) 195 | { 196 | request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes(remoteInfo.User + ":" + remoteInfo.Pass))); 197 | LogMessage("Adding user and password to RClone api request"); 198 | } 199 | 200 | HttpClient client = _httpClientFactory.CreateClient(); 201 | 202 | HttpResponseMessage response = await client.SendAsync(request); 203 | 204 | string resultContent = await response.Content.ReadAsStringAsync(); 205 | 206 | LogMessage("resultFromVersion:::: " + resultContent); 207 | 208 | dynamic? versionResponse = JsonConvert.DeserializeObject(resultContent); 209 | dynamic? decomposed = versionResponse?.decomposed; 210 | int majorVersion = decomposed != null ? decomposed[0] : -1; 211 | int minorVersion = decomposed != null ? decomposed[1] : -1; 212 | int patchVersion = decomposed != null ? decomposed[2] : -1; 213 | 214 | LogMessage($"Version from RClone endpoint of remote {remote} is {majorVersion + "." + minorVersion + "." + patchVersion}", LogLevel.Information); 215 | 216 | return majorVersion == _minimumMajorVersion && minorVersion >= _minimumMinorVersion && patchVersion >= _minimumPatchVersion; 217 | } 218 | 219 | private async Task FindPreviousServiceAccountUsedForRemote(string remote, RemoteInfo remoteInfo) 220 | { 221 | string rcloneApiUri = remoteInfo.Address; 222 | rcloneApiUri += rcloneApiUri.EndsWith("/") ? "backend/command" : "/backend/command"; 223 | 224 | var request = new HttpRequestMessage(HttpMethod.Post, rcloneApiUri); 225 | 226 | if (!string.IsNullOrEmpty(remoteInfo.User) && !string.IsNullOrEmpty(remoteInfo.Pass)) 227 | { 228 | request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes(remoteInfo.User + ":" + remoteInfo.Pass))); 229 | LogMessage("Adding user and password to RClone api request"); 230 | } 231 | 232 | var command = new RCloneServiceAccountCommand 233 | { 234 | command = "get", 235 | fs = remote+":", 236 | opt = new Opt 237 | { 238 | service_account_file = "" 239 | } 240 | }; 241 | 242 | request.Content = new StringContent(JsonConvert.SerializeObject(command)); 243 | request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); 244 | request.Headers.Add("Accept", "*/*"); 245 | 246 | HttpClient client = _httpClientFactory.CreateClient(); 247 | 248 | HttpResponseMessage response = await client.SendAsync(request); 249 | 250 | dynamic? resultContent = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); 251 | 252 | string? serviceAccountFile = resultContent?.result?.service_account_file; 253 | 254 | LogMessage("serviceaccountfile - " + resultContent); 255 | 256 | if (string.IsNullOrEmpty(serviceAccountFile)) 257 | { 258 | LogMessage("could not find service_account_file line"); 259 | return null; 260 | } 261 | 262 | string? serviceAccount = serviceAccountFile.Split("/").LastOrDefault(); 263 | 264 | if (!string.IsNullOrEmpty(serviceAccount)) 265 | { 266 | LogMessage(serviceAccount); 267 | 268 | return serviceAccount; 269 | } 270 | 271 | LogMessage("could not find service_account_file SA name"); 272 | return null; 273 | } 274 | 275 | private List OrderServiceAccountsForUsage(IEnumerable svcaccts) 276 | { 277 | var svcAcctsUsageOrder = new List(); 278 | List> serviceAccountsByProject = svcaccts 279 | .OrderBy(c => c.ClientEmail) 280 | .GroupBy(c => c.ProjectId) 281 | .ToList(); 282 | 283 | int largestNumberOfServiceAccounts = GetMaxNumberOfServiceAccountsForProject(serviceAccountsByProject); 284 | 285 | for (var i = 0; i < largestNumberOfServiceAccounts; i++) 286 | { 287 | foreach (var projectWithServiceAccounts in serviceAccountsByProject) 288 | { 289 | ServiceAccount? account = projectWithServiceAccounts.ElementAtOrDefault(i); 290 | if (account != null) 291 | { 292 | svcAcctsUsageOrder.Add(account); 293 | } 294 | } 295 | } 296 | 297 | return svcAcctsUsageOrder; 298 | } 299 | 300 | private int GetMaxNumberOfServiceAccountsForProject(IEnumerable> serviceAccountsByProject) 301 | { 302 | List<(IGrouping project, int noServiceAccounts)> counts = serviceAccountsByProject 303 | .Select(project => (project, project.Count())) 304 | .ToList(); 305 | 306 | int largestNoServiceAccounts = counts.Max(x => x.noServiceAccounts); 307 | IEnumerable projectsWithLessServiceAccounts = counts.Where(c => c.noServiceAccounts < largestNoServiceAccounts).Select(a => a.project.Key); 308 | 309 | if (projectsWithLessServiceAccounts.Any()) 310 | { 311 | IEnumerable projectsWithMostServiceAccounts = counts 312 | .Where(c => c.noServiceAccounts == largestNoServiceAccounts) 313 | .Select(a => a.project.Key); 314 | const string logMessage = "amount of service accounts in projects {projects1} is lower than projects {projects2}"; 315 | LogMessage(logMessage, LogLevel.Warning, projectsWithLessServiceAccounts, projectsWithMostServiceAccounts); 316 | } 317 | 318 | return largestNoServiceAccounts; 319 | } 320 | 321 | private async Task?> ParseSvcAccts(string serviceAccountDirectory) 322 | { 323 | var accountCollections = new List(); 324 | 325 | if (!Directory.Exists(serviceAccountDirectory)) 326 | { 327 | return null; 328 | } 329 | 330 | IEnumerable filePaths = Directory.EnumerateFiles(serviceAccountDirectory, "*", new EnumerationOptions() { RecurseSubdirectories = true }); 331 | 332 | foreach (string filePath in filePaths) 333 | { 334 | if (!filePath.ToLower().EndsWith(".json")) 335 | { 336 | continue; 337 | } 338 | 339 | try 340 | { 341 | using var streamReader = new StreamReader(filePath); 342 | 343 | string fileJson = await streamReader.ReadToEndAsync(); 344 | 345 | ServiceAccount account = JsonConvert.DeserializeObject(fileJson) ?? throw new ArgumentException("service account file structure is bad"); 346 | account.FilePath = filePath; 347 | 348 | accountCollections.Add(account); 349 | 350 | } 351 | catch (Exception) 352 | { 353 | const string logMessage = "service account json file {filePath} is invalid"; 354 | LogMessage(logMessage, LogLevel.Error, filePath); 355 | } 356 | } 357 | 358 | return accountCollections; 359 | } 360 | 361 | private async Task RunSwappingService( 362 | SARotateConfig yamlConfigContent, 363 | Dictionary> serviceAccountUsageOrderByGroup, 364 | CancellationToken cancellationToken) 365 | { 366 | var swapServiceAccounts = true; 367 | while (swapServiceAccounts) 368 | { 369 | swapServiceAccounts &= !cancellationToken.IsCancellationRequested; 370 | 371 | foreach ((string serviceAccountGroupAbsolutePath, List serviceAccountsForGroup) in serviceAccountUsageOrderByGroup) 372 | { 373 | if (!swapServiceAccounts) 374 | { 375 | return; 376 | } 377 | 378 | Dictionary remoteConfig = yamlConfigContent.RemoteConfig[serviceAccountGroupAbsolutePath]; 379 | 380 | foreach (string remote in remoteConfig.Keys) 381 | { 382 | ServiceAccount nextServiceAccount = serviceAccountsForGroup.First(); 383 | string rcloneApiUri = remoteConfig[remote].Address; 384 | 385 | if (!rcloneApiUri.ToLower().Contains("http")) 386 | { 387 | rcloneApiUri = "http://" + rcloneApiUri; 388 | } 389 | 390 | rcloneApiUri += rcloneApiUri.EndsWith("/") ? "backend/command" : "/backend/command"; 391 | 392 | var request = new HttpRequestMessage(HttpMethod.Post, rcloneApiUri); 393 | 394 | if (!string.IsNullOrEmpty(remoteConfig[remote].User) && !string.IsNullOrEmpty(remoteConfig[remote].Pass)) 395 | { 396 | request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes(remoteConfig[remote].User + ":" + remoteConfig[remote].Pass))); 397 | LogMessage("Adding user and password to RClone api request"); 398 | } 399 | 400 | var command = new RCloneServiceAccountCommand 401 | { 402 | command = "set", 403 | fs = remote + ":", 404 | opt = new Opt 405 | { 406 | service_account_file = nextServiceAccount.FilePath ?? throw new ArgumentNullException() 407 | } 408 | }; 409 | 410 | request.Content = new StringContent(JsonConvert.SerializeObject(command)); 411 | request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); 412 | request.Headers.Add("Accept", "*/*"); 413 | 414 | HttpClient client = _httpClientFactory.CreateClient(); 415 | 416 | HttpResponseMessage response = await client.SendAsync(request); 417 | 418 | dynamic? resultContent = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); 419 | 420 | string? currentAccountFile = resultContent?.result?.service_account_file.current; 421 | string? previousAccountFile = resultContent?.result?.service_account_file.previous; 422 | 423 | LogMessage("serviceaccountfile - " + resultContent); 424 | 425 | if (string.IsNullOrEmpty(currentAccountFile)) 426 | { 427 | await SendAppriseNotification(yamlConfigContent, $"Could not swap service account for remote {remote}", LogLevel.Error); 428 | } 429 | else 430 | { 431 | serviceAccountsForGroup.Remove(nextServiceAccount); 432 | serviceAccountsForGroup.Add(nextServiceAccount); 433 | 434 | await LogRCloneServiceAccountSwapResult(yamlConfigContent, remote, Convert.ToString(resultContent), previousAccountFile, currentAccountFile); 435 | } 436 | } 437 | } 438 | 439 | int timeoutMs = yamlConfigContent.RCloneConfig.SleepTime * 1000; 440 | 441 | try 442 | { 443 | await Task.Delay(timeoutMs, cancellationToken); 444 | } 445 | catch (TaskCanceledException) 446 | { 447 | //added to catch exception from ctrl+c program cancellation 448 | } 449 | } 450 | } 451 | 452 | private async Task LogRCloneServiceAccountSwapResult( 453 | SARotateConfig yamlConfigContent, 454 | string remote, 455 | string responseMessage, 456 | string previousServiceAccount, 457 | string currentServiceAccount) 458 | { 459 | string? currentFile = currentServiceAccount.Split("/").LastOrDefault(); 460 | string? previousFile = previousServiceAccount.Split("/").LastOrDefault(); 461 | 462 | string logMessage = $"Switching remote {remote} from service account {previousFile} to {currentFile} for {yamlConfigContent.RCloneConfig.SleepTime} seconds"; 463 | LogMessage(logMessage, LogLevel.Information); 464 | LogMessage(responseMessage); 465 | await SendAppriseNotification(yamlConfigContent, logMessage); 466 | } 467 | 468 | private async Task SendAppriseNotification(SARotateConfig yamlConfigContent, string logMessage, LogLevel logLevel = LogLevel.Debug) 469 | { 470 | if (yamlConfigContent.NotificationConfig.AppriseNotificationsErrorsOnly && yamlConfigContent.NotificationConfig.AppriseServices.Any() && logLevel < LogLevel.Error) 471 | { 472 | LogMessage($"apprise notification not sent due to errors_only notifications: {logMessage}", logLevel); 473 | } 474 | else if (yamlConfigContent.NotificationConfig.AppriseServices.Any(svc => !string.IsNullOrWhiteSpace(svc))) 475 | { 476 | string appriseCommand = $"apprise -vv "; 477 | string escapedLogMessage = logMessage.Replace("'", "´").Replace("\"", "´"); 478 | string hostName = yamlConfigContent.NotificationConfig.AppriseNotificationsIncludeHostname ? $" on { System.Net.Dns.GetHostName()}" : ""; 479 | 480 | appriseCommand += $"-b '{escapedLogMessage}{hostName}' "; 481 | 482 | foreach (var appriseService in yamlConfigContent.NotificationConfig.AppriseServices.Where(svc => !string.IsNullOrWhiteSpace(svc))) 483 | { 484 | appriseCommand += $"'{appriseService}' "; 485 | } 486 | 487 | (string result, int exitCode) = await appriseCommand.Bash(); 488 | 489 | if (exitCode != (int)ExitCode.Success) 490 | { 491 | LogMessage($"Unable to send apprise notification: {logMessage}", LogLevel.Error); 492 | LogMessage($"Apprise failure: {result}"); 493 | } 494 | else 495 | { 496 | LogMessage($"sent apprise notification: {logMessage}"); 497 | } 498 | } 499 | } 500 | 501 | private void LogMessage(string message, LogLevel level = LogLevel.Debug, params object[] args) 502 | { 503 | switch (level) 504 | { 505 | case LogLevel.Debug: 506 | _logger.LogDebug(message, args); 507 | break; 508 | case LogLevel.Information: 509 | _logger.LogInformation(message, args); 510 | break; 511 | case LogLevel.Warning: 512 | _logger.LogWarning(message, args); 513 | break; 514 | case LogLevel.Error: 515 | _logger.LogError(message, args); 516 | break; 517 | case LogLevel.Critical: 518 | _logger.LogCritical(message, args); 519 | break; 520 | default: 521 | throw new ArgumentOutOfRangeException(nameof(level), level, null); 522 | } 523 | } 524 | } 525 | } 526 | -------------------------------------------------------------------------------- /SARotate.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | net6.0 6 | AnyCPU 7 | enable 8 | 9 | 10 | 11 | ;NU1605; 12 | 8618;1701;1702 13 | 14 | 15 | 16 | 2.0.5 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | PreserveNewest 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /SARotate.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.31229.75 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SARotate", "SARotate.csproj", "{11C72504-B2BF-46A9-A814-C4E735203D0C}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Debug|x64 = Debug|x64 12 | Release|Any CPU = Release|Any CPU 13 | Release|x64 = Release|x64 14 | EndGlobalSection 15 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 16 | {11C72504-B2BF-46A9-A814-C4E735203D0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 17 | {11C72504-B2BF-46A9-A814-C4E735203D0C}.Debug|Any CPU.Build.0 = Debug|Any CPU 18 | {11C72504-B2BF-46A9-A814-C4E735203D0C}.Debug|x64.ActiveCfg = Debug|x64 19 | {11C72504-B2BF-46A9-A814-C4E735203D0C}.Debug|x64.Build.0 = Debug|x64 20 | {11C72504-B2BF-46A9-A814-C4E735203D0C}.Release|Any CPU.ActiveCfg = Release|Any CPU 21 | {11C72504-B2BF-46A9-A814-C4E735203D0C}.Release|Any CPU.Build.0 = Release|Any CPU 22 | {11C72504-B2BF-46A9-A814-C4E735203D0C}.Release|x64.ActiveCfg = Release|x64 23 | {11C72504-B2BF-46A9-A814-C4E735203D0C}.Release|x64.Build.0 = Release|x64 24 | EndGlobalSection 25 | GlobalSection(SolutionProperties) = preSolution 26 | HideSolutionNode = FALSE 27 | EndGlobalSection 28 | GlobalSection(ExtensibilityGlobals) = postSolution 29 | SolutionGuid = {18EA5D6D-A0B0-4262-84EE-F69962A91030} 30 | EndGlobalSection 31 | EndGlobal 32 | -------------------------------------------------------------------------------- /ShellHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.Threading.Tasks; 4 | 5 | namespace SARotate 6 | { 7 | public static class ShellHelper 8 | { 9 | public static Task<(string result, int exitCode)> Bash(this string cmd) 10 | { 11 | var source = new TaskCompletionSource<(string, int)>(); 12 | string escapedArgs = cmd.Replace("\"", "\\\""); 13 | var process = new Process 14 | { 15 | StartInfo = new ProcessStartInfo 16 | { 17 | FileName = "bash", 18 | Arguments = $"-c \"{escapedArgs}\"", 19 | RedirectStandardOutput = true, 20 | RedirectStandardError = true, 21 | UseShellExecute = false, 22 | CreateNoWindow = true 23 | }, 24 | EnableRaisingEvents = true 25 | }; 26 | process.Exited += (sender, args) => 27 | { 28 | if (process.ExitCode == 0) 29 | { 30 | string output = process.StandardOutput.ReadToEnd(); 31 | string result = $"STDOUT:{output}"; 32 | 33 | source.SetResult((result, process.ExitCode)); 34 | } 35 | else 36 | { 37 | string error = process.StandardError.ReadToEnd(); 38 | string result = $"STDERR:{error}"; 39 | 40 | source.SetResult((result, process.ExitCode)); 41 | } 42 | 43 | process.Dispose(); 44 | }; 45 | 46 | try 47 | { 48 | process.Start(); 49 | } 50 | catch (Exception e) 51 | { 52 | source.SetException(e); 53 | } 54 | 55 | return source.Task; 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": "/mnt/e/projects/SARotate/config.yaml", 3 | "Serilog": { 4 | "WriteTo": [ 5 | { 6 | "Args": { 7 | "configure": [ 8 | { 9 | "Name": "File", 10 | "Args": { 11 | "restrictedToMinimumLevel": "Verbose", 12 | "path": "/home/shadowspy/logs/log.txt", 13 | "rollingInterval": "Day", 14 | "fileSizeLimitBytes": 5000000, 15 | "rollOnFileSizeLimit": true, 16 | "retainedFileCountLimit": 5 17 | } 18 | } 19 | ] 20 | } 21 | } 22 | ] 23 | } 24 | } 25 | --------------------------------------------------------------------------------