diff options
Diffstat (limited to 'docker/auto-backup')
-rw-r--r-- | docker/auto-backup/.dockerignore | 2 | ||||
-rw-r--r-- | docker/auto-backup/AutoBackup/.dockerignore | 2 | ||||
-rw-r--r-- | docker/auto-backup/AutoBackup/.gitignore | 2 | ||||
-rw-r--r-- | docker/auto-backup/AutoBackup/AutoBackup.csproj | 10 | ||||
-rw-r--r-- | docker/auto-backup/AutoBackup/Program.cs | 121 | ||||
-rw-r--r-- | docker/auto-backup/AutoBackup/TencentCloudCOS.cs | 211 | ||||
-rw-r--r-- | docker/auto-backup/Dockerfile | 24 | ||||
-rwxr-xr-x | docker/auto-backup/daemon.bash | 57 |
8 files changed, 429 insertions, 0 deletions
diff --git a/docker/auto-backup/.dockerignore b/docker/auto-backup/.dockerignore new file mode 100644 index 0000000..7a09751 --- /dev/null +++ b/docker/auto-backup/.dockerignore @@ -0,0 +1,2 @@ +AutoBackup/bin +AutoBackup/obj diff --git a/docker/auto-backup/AutoBackup/.dockerignore b/docker/auto-backup/AutoBackup/.dockerignore new file mode 100644 index 0000000..7de5508 --- /dev/null +++ b/docker/auto-backup/AutoBackup/.dockerignore @@ -0,0 +1,2 @@ +obj +bin diff --git a/docker/auto-backup/AutoBackup/.gitignore b/docker/auto-backup/AutoBackup/.gitignore new file mode 100644 index 0000000..7de5508 --- /dev/null +++ b/docker/auto-backup/AutoBackup/.gitignore @@ -0,0 +1,2 @@ +obj +bin diff --git a/docker/auto-backup/AutoBackup/AutoBackup.csproj b/docker/auto-backup/AutoBackup/AutoBackup.csproj new file mode 100644 index 0000000..694035b --- /dev/null +++ b/docker/auto-backup/AutoBackup/AutoBackup.csproj @@ -0,0 +1,10 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <OutputType>Exe</OutputType> + <TargetFramework>net9.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + </PropertyGroup> + +</Project> diff --git a/docker/auto-backup/AutoBackup/Program.cs b/docker/auto-backup/AutoBackup/Program.cs new file mode 100644 index 0000000..c2e7a0d --- /dev/null +++ b/docker/auto-backup/AutoBackup/Program.cs @@ -0,0 +1,121 @@ +// Read args to determine what file to upload
+
+const string DefaultUploadFilePath = "/tmp/data.tar.xz";
+string uploadFilePath = DefaultUploadFilePath;
+string? uploadDestinationPath = null;
+if (args.Length == 0)
+{
+ Console.WriteLine("You don't specify the file to upload, will upload /tmp/data.tar.xz by default.");
+ Console.WriteLine("You don't specify the destination to upload, will use timestamp with proper file extension.");
+}
+else if (args.Length == 1)
+{
+ if (args[0].Length == 0)
+ {
+ Console.Error.WriteLine("File to upload can't be empty string.");
+ Environment.Exit(2);
+ }
+ uploadFilePath = args[0];
+ Console.WriteLine("You don't specify the destination to upload, will use timestamp with proper file extension.");
+}
+else if (args.Length == 2)
+{
+ if (args[0].Length == 0)
+ {
+ Console.Error.WriteLine("File to upload can't be empty string.");
+ Environment.Exit(2);
+ }
+
+ if (args[1].Length == 0)
+ {
+ Console.Error.WriteLine("Destination to upload can't be empty string.");
+ Environment.Exit(2);
+ }
+
+ uploadFilePath = args[0];
+ uploadDestinationPath = args[1];
+}
+else
+{
+ // Write to stderr
+ Console.Error.WriteLine("You can only specify one optional file and one optional destination to upload.");
+ Environment.Exit(2);
+}
+
+// Check the upload exists
+if (!File.Exists(uploadFilePath))
+{
+ Console.Error.WriteLine($"The file {uploadFilePath} doesn't exist.");
+ Environment.Exit(3);
+}
+
+// Check the upload file is not a directory
+if (File.GetAttributes(uploadFilePath).HasFlag(FileAttributes.Directory))
+{
+ Console.Error.WriteLine($"The file {uploadFilePath} is a directory.");
+ Environment.Exit(4);
+}
+
+// Check the upload file is not bigger than 5G
+if (new FileInfo(uploadFilePath).Length > 5L * 1024L * 1024L * 1024L)
+{
+ Console.Error.WriteLine($"The file {uploadFilePath} is bigger than 5G, which is not support now.");
+ Environment.Exit(5);
+}
+
+// Get config from environment variables
+var configNameList = new List<string>{
+ "CRUPEST_AUTO_BACKUP_COS_SECRET_ID",
+ "CRUPEST_AUTO_BACKUP_COS_SECRET_KEY",
+ "CRUPEST_AUTO_BACKUP_COS_REGION",
+ "CRUPEST_AUTO_BACKUP_BUCKET_NAME"
+};
+
+var config = new Dictionary<string, string>();
+foreach (var configName in configNameList)
+{
+ var configValue = Environment.GetEnvironmentVariable(configName);
+ if (configValue is null)
+ {
+ Console.Error.WriteLine($"Environment variable {configName} is required.");
+ Environment.Exit(5);
+ }
+ config.Add(configName, configValue);
+}
+
+var region = config["CRUPEST_AUTO_BACKUP_COS_REGION"];
+var secretId = config["CRUPEST_AUTO_BACKUP_COS_SECRET_ID"];
+var secretKey = config["CRUPEST_AUTO_BACKUP_COS_SECRET_KEY"];
+var bucketName = config["CRUPEST_AUTO_BACKUP_BUCKET_NAME"];
+
+var credentials = new TencentCloudCOSHelper.Credentials(secretId, secretKey);
+
+if (uploadDestinationPath is null)
+{
+ var uploadFileName = Path.GetFileName(uploadFilePath);
+ var firstDotPosition = uploadFileName.IndexOf('.');
+ uploadDestinationPath = DateTime.Now.ToString("s");
+ if (firstDotPosition != -1)
+ {
+ uploadDestinationPath += uploadFileName.Substring(firstDotPosition + 1);
+ }
+}
+
+Console.WriteLine($"Upload file source: {uploadFilePath}");
+Console.WriteLine($"Upload COS region: {config["CRUPEST_AUTO_BACKUP_COS_REGION"]}");
+Console.WriteLine($"Upload bucket name: {config["CRUPEST_AUTO_BACKUP_BUCKET_NAME"]}");
+Console.WriteLine($"Upload file destination: {uploadDestinationPath}");
+
+await using var fileStream = new FileStream(uploadFilePath, FileMode.Open, FileAccess.Read);
+
+// 上传对象
+try
+{
+ await TencentCloudCOSHelper.PutObject(credentials, region, bucketName, uploadDestinationPath, fileStream);
+ Console.WriteLine("Upload completed!");
+}
+catch (Exception e)
+{
+ Console.Error.WriteLine("Exception: " + e);
+ Environment.Exit(6);
+}
diff --git a/docker/auto-backup/AutoBackup/TencentCloudCOS.cs b/docker/auto-backup/AutoBackup/TencentCloudCOS.cs new file mode 100644 index 0000000..28d032c --- /dev/null +++ b/docker/auto-backup/AutoBackup/TencentCloudCOS.cs @@ -0,0 +1,211 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; + + +public static class TencentCloudCOSHelper +{ + public class Credentials + { + public Credentials(string secretId, string secretKey) + { + SecretId = secretId; + SecretKey = secretKey; + } + + public string SecretId { get; } + public string SecretKey { get; } + } + + public class RequestInfo + { + public RequestInfo(string method, string urlPathname, IEnumerable<KeyValuePair<string, string>> parameters, IEnumerable<KeyValuePair<string, string>> headers) + { + Method = method; + UrlPathname = urlPathname; + Parameters = new Dictionary<string, string>(parameters); + Headers = new Dictionary<string, string>(headers); + } + + public string Method { get; } + public string UrlPathname { get; } + public IReadOnlyDictionary<string, string> Parameters { get; } + public IReadOnlyDictionary<string, string> Headers { get; } + } + + public class TimeDuration + { + public TimeDuration(DateTimeOffset start, DateTimeOffset end) + { + if (start > end) + { + throw new ArgumentException("Start time must be earlier than end time."); + } + + Start = start; + End = end; + } + + public DateTimeOffset Start { get; } + public DateTimeOffset End { get; } + } + + public static string GenerateSign(Credentials credentials, RequestInfo request, TimeDuration signValidTime) + { + List<(string key, string value)> Transform(IEnumerable<KeyValuePair<string, string>> raw) + { + if (raw == null) + return new List<(string key, string value)>(); + + var sorted = raw.Select(p => (key: WebUtility.UrlEncode(p.Key.ToLower()), value: WebUtility.UrlEncode(p.Value))).ToList(); + sorted.Sort((left, right) => string.CompareOrdinal(left.key, right.key)); + return sorted; + } + + var transformedParameters = Transform(request.Parameters); + var transformedHeaders = Transform(request.Headers); + + + const string signAlgorithm = "sha1"; + + static string ByteArrayToString(byte[] bytes) + { + return BitConverter.ToString(bytes).Replace("-", "").ToLower(); + } + + var keyTime = $"{signValidTime.Start.ToUnixTimeSeconds().ToString()};{signValidTime.End.ToUnixTimeSeconds().ToString()}"; + using HMACSHA1 hmac = new HMACSHA1(Encoding.ASCII.GetBytes(credentials.SecretKey)); + var signKey = ByteArrayToString(hmac.ComputeHash(Encoding.UTF8.GetBytes(keyTime))); + + static string Join(IEnumerable<(string key, string value)> raw) + { + return string.Join('&', raw.Select(p => string.Concat(p.key, "=", p.value))); + } + + var httpParameters = Join(transformedParameters); + var urlParamList = string.Join(';', transformedParameters.Select(p => p.key)); + var httpHeaders = Join(transformedHeaders); + var headerList = string.Join(';', transformedHeaders.Select(h => h.key)); + + var httpString = new StringBuilder() + .Append(request.Method.ToLower()).Append('\n') + .Append(request.UrlPathname).Append('\n') + .Append(httpParameters).Append('\n') + .Append(httpHeaders).Append('\n') + .ToString(); + + using var sha1 = SHA1.Create(); + string Sha1(string data) + { + var result = sha1.ComputeHash(Encoding.UTF8.GetBytes(data)); + return ByteArrayToString(result); + } + + var stringToSign = new StringBuilder() + .Append(signAlgorithm).Append('\n') + .Append(keyTime).Append('\n') + .Append(Sha1(httpString)).Append('\n') + .ToString(); + + hmac.Key = Encoding.UTF8.GetBytes(signKey); + var signature = ByteArrayToString(hmac.ComputeHash( + Encoding.UTF8.GetBytes(stringToSign))); + + + List<(string, string)> result = new List<(string, string)>(); + result.Add(("q-sign-algorithm", signAlgorithm)); + result.Add(("q-ak", credentials.SecretId)); + result.Add(("q-sign-time", keyTime)); + result.Add(("q-key-time", keyTime)); + result.Add(("q-header-list", headerList)); + result.Add(("q-url-param-list", urlParamList)); + result.Add(("q-signature", signature)); + return Join(result); + } + + private static string GetHost(string bucket, string region) + { + return $"{bucket}.cos.{region}.myqcloud.com"; + } + + public static async Task<bool> IsObjectExists(Credentials credentials, string region, string bucket, string key) + { + var host = GetHost(bucket, region); + var encodedKey = WebUtility.UrlEncode(key); + + using var request = new HttpRequestMessage(); + request.Method = HttpMethod.Head; + request.RequestUri = new Uri($"https://{host}/{encodedKey}"); + request.Headers.Host = host; + request.Headers.Date = DateTimeOffset.Now; + request.Headers.TryAddWithoutValidation("Authorization", GenerateSign(credentials, new RequestInfo( + "head", "/" + key, new Dictionary<string, string>(), + new Dictionary<string, string> + { + ["Host"] = host + } + ), new TimeDuration(DateTimeOffset.Now, DateTimeOffset.Now.AddMinutes(5)))); + + using var client = new HttpClient(); + using var response = await client.SendAsync(request); + + if (response.IsSuccessStatusCode) + return true; + if (response.StatusCode == HttpStatusCode.NotFound) + return false; + + throw new Exception($"Unknown response code. {response.ToString()}"); + } + + public static async Task PutObject(Credentials credentials, string region, string bucket, string key, Stream dataStream) + { + if (!dataStream.CanSeek) + { + throw new ArgumentException("Data stream must be seekable."); + } + + if (dataStream.Seek(0, SeekOrigin.End) > 5L * 1024L * 1024L * 1024L) + { + throw new ArgumentException("Data stream must be smaller than 5GB."); + } + + dataStream.Seek(0, SeekOrigin.Begin); + + var host = GetHost(bucket, region); + var encodedKey = WebUtility.UrlEncode(key); + using var md5Handler = MD5.Create(); + var md5 = Convert.ToBase64String(await md5Handler.ComputeHashAsync(dataStream)); + + dataStream.Seek(0, SeekOrigin.Begin); + + const string kContentMD5HeaderName = "Content-MD5"; + + using var httpRequest = new HttpRequestMessage() + { + Method = HttpMethod.Put, + RequestUri = new Uri($"https://{host}/{encodedKey}") + }; + httpRequest.Headers.Host = host; + httpRequest.Headers.Date = DateTimeOffset.Now; + + using var httpContent = new StreamContent(dataStream); + httpContent.Headers.Add(kContentMD5HeaderName, md5); + httpRequest.Content = httpContent; + + var signedHeaders = new Dictionary<string, string> + { + ["Host"] = host, + [kContentMD5HeaderName] = md5 + }; + + httpRequest.Headers.TryAddWithoutValidation("Authorization", GenerateSign(credentials, new RequestInfo( + "put", "/" + key, new Dictionary<string, string>(), signedHeaders + ), new TimeDuration(DateTimeOffset.Now, DateTimeOffset.Now.AddMinutes(10)))); + + using var client = new HttpClient(); + using var response = await client.SendAsync(httpRequest); + + if (!response.IsSuccessStatusCode) + throw new Exception($"Not success status code: {response.StatusCode}\n{await response.Content.ReadAsStringAsync()}"); + } +}
\ No newline at end of file diff --git a/docker/auto-backup/Dockerfile b/docker/auto-backup/Dockerfile new file mode 100644 index 0000000..c7ff4fc --- /dev/null +++ b/docker/auto-backup/Dockerfile @@ -0,0 +1,24 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build +COPY AutoBackup /AutoBackup +WORKDIR /AutoBackup +RUN dotnet publish AutoBackup.csproj --configuration Release --output ./publish/ -r linux-x64 --self-contained false + +FROM mcr.microsoft.com/dotnet/runtime:9.0-alpine +RUN apk add --no-cache tini coreutils bash tar xz +ARG CRUPEST_AUTO_BACKUP_INIT_DELAY=0 +ARG CRUPEST_AUTO_BACKUP_INTERVAL=1d +ARG CRUPEST_AUTO_BACKUP_COS_SECRET_ID +ARG CRUPEST_AUTO_BACKUP_COS_SECRET_KEY +ARG CRUPEST_AUTO_BACKUP_COS_REGION +ARG CRUPEST_AUTO_BACKUP_BUCKET_NAME +ENV CRUPEST_AUTO_BACKUP_INIT_DELAY=${CRUPEST_AUTO_BACKUP_INIT_DELAY} +ENV CRUPEST_AUTO_BACKUP_INTERVAL=${CRUPEST_AUTO_BACKUP_INTERVAL} +ENV CRUPEST_AUTO_BACKUP_COS_SECRET_ID=${CRUPEST_AUTO_BACKUP_COS_SECRET_ID} +ENV CRUPEST_AUTO_BACKUP_COS_SECRET_KEY=${CRUPEST_AUTO_BACKUP_COS_SECRET_KEY} +ENV CRUPEST_AUTO_BACKUP_COS_REGION=${CRUPEST_AUTO_BACKUP_COS_REGION} +ENV CRUPEST_AUTO_BACKUP_BUCKET_NAME=${CRUPEST_AUTO_BACKUP_BUCKET_NAME} +VOLUME [ "/data" ] +COPY daemon.bash /daemon.bash +COPY --from=build /AutoBackup/publish /AutoBackup +ENTRYPOINT ["tini", "--"] +CMD [ "/daemon.bash" ] diff --git a/docker/auto-backup/daemon.bash b/docker/auto-backup/daemon.bash new file mode 100755 index 0000000..a4dd5dc --- /dev/null +++ b/docker/auto-backup/daemon.bash @@ -0,0 +1,57 @@ +#!/usr/bin/env bash + +set -e + +# Check I'm root. +if [[ $EUID -ne 0 ]]; then + echo "This script must be run as root" 1>&2 + exit 1 +fi + + +# Check xz, tar and coscmd +xz --version +tar --version + +function backup { + # Output "Begin backup..." in yellow and restore default + echo -e "\e[0;103m\e[K\e[1mBegin backup..." "\e[0m" + + # Get current time and convert it to YYYY-MM-DDTHH:MM:SSZ + current_time=$(date +%Y-%m-%dT%H:%M:%SZ) + echo "Current time: $current_time" + + echo "Create tar.xz for data..." + + # tar and xz /data to tmp + tar -cJf /tmp/data.tar.xz -C / data + + # Output /tmp/data.tar.xz size + du -h /tmp/data.tar.xz | cut -f1 | xargs echo "Size of data.tar.xz:" + + destination="${current_time}.tar.xz" + + # upload to remote + dotnet /AutoBackup/AutoBackup.dll /tmp/data.tar.xz "$destination" + + echo "Remove tmp file..." + # remove tmp + rm /tmp/data.tar.xz + + echo "$destination" >> /data/backup.log + + # echo "Backup finished!" in green and restore default + echo -e "\e[0;102m\e[K\e[1mFinish backup!\e[0m" +} + +echo "Initial delay: $CRUPEST_AUTO_BACKUP_INIT_DELAY" +sleep "$CRUPEST_AUTO_BACKUP_INIT_DELAY" + +# forever loop +while true; do + backup + + # sleep for CRUPEST_AUTO_BACKUP_INTERVAL + echo "Sleep for $CRUPEST_AUTO_BACKUP_INTERVAL for next backup..." + sleep "$CRUPEST_AUTO_BACKUP_INTERVAL" +done |