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  | 
