summaryrefslogtreecommitdiff
path: root/src/Agent.Windows/Recovery/RecoveryManager.cs
blob: df69a5ee0b203c9fdeeb2fdfd731fcfde8b3799e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
using System.Security.AccessControl;
using System.Security.Cryptography;
using Agent.Windows.Security;
using Agent.Windows.Network;

namespace Agent.Windows.Recovery;

public sealed record RecoveryResult(int JournalsRecovered, int ResourcesRecovered, IReadOnlyList<string> Errors)
{
    public bool IsClean => Errors.Count == 0;
}

public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink audit)
{
    public RecoveryResult RecoverAll()
    {
        if (!Directory.Exists(journalDirectory)) return new(0, 0, []);
        int journals = 0;
        int resources = 0;
        var errors = new List<string>();
        foreach (string path in Directory.EnumerateFiles(journalDirectory, "*.journal.json").Order(StringComparer.Ordinal))
        {
            journals++;
            try
            {
                TaskResourceJournal journal = TaskResourceJournal.Open(path);
                foreach (JournalEntry entry in journal.Entries.Reverse())
                {
                    if (entry.State == JournalEntryState.Cleaned) continue;
                    if (entry.State == JournalEntryState.Applied) RecoverEntry(journal.TaskId, entry);
                    CleanupPrepared(entry);
                    journal.MarkCleaned(entry.Id);
                    resources++;
                }
                journal.CompleteAndDelete();
                audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "recovery.complete", "clean", new Dictionary<string, string>()));
            }
            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException)
            {
                errors.Add($"{path}: {ex.Message}");
            }
        }
        return new(journals, resources, errors);
    }

    private static void RecoverEntry(Guid taskId, JournalEntry entry)
    {
        switch (entry.Kind)
        {
            case "file.replace": RecoverFile(entry); break;
            case "file.delete": RecoverDeletedFile(entry); break;
            case "acl": RecoverAcl(entry); break;
            case "wfp.dynamic": break; // BFE removes every object when the originating dynamic session ends.
            case "network.loopback_exemption": RecoverLoopback(entry); break;
            default: throw new InvalidDataException($"Unknown journal resource kind '{entry.Kind}' for task '{taskId}'.");
        }
    }

    private static void RecoverLoopback(JournalEntry entry)
    {
        if (!bool.Parse(entry.Data["originally_present"])) LoopbackExemption.Remove(new System.Security.Principal.SecurityIdentifier(entry.Target));
    }

    private static void RecoverFile(JournalEntry entry)
    {
        string replacement = entry.Data["replacement_sha256"];
        if (!File.Exists(entry.Target) || !Hash(entry.Target).Equals(replacement, StringComparison.Ordinal))
            throw new InvalidOperationException($"File '{entry.Target}' changed after crash; automatic rollback refused.");
        if (bool.Parse(entry.Data["existed"]))
        {
            byte[] original = entry.Data["backup_path"].Length > 0 ? File.ReadAllBytes(entry.Data["backup_path"]) : Convert.FromBase64String(entry.Data["original_base64"]);
            if (!Convert.ToHexString(SHA256.HashData(original)).Equals(entry.Data["original_sha256"], StringComparison.Ordinal)) throw new InvalidDataException("Recovery backup hash mismatch.");
            string temporary = entry.Target + ".recovery.tmp";
            File.WriteAllBytes(temporary, original);
            File.Move(temporary, entry.Target, overwrite: true);
        }
        else File.Delete(entry.Target);
    }

    private static void RecoverAcl(JournalEntry entry)
    {
        byte[] descriptor = Convert.FromBase64String(entry.Data["security_descriptor"]);
        FileSystemSecurity security = Directory.Exists(entry.Target) ? new DirectorySecurity() : new FileSecurity();
        security.SetSecurityDescriptorBinaryForm(descriptor, AccessControlSections.Access);
        if (security is DirectorySecurity directory) FileSystemAclExtensions.SetAccessControl(new DirectoryInfo(entry.Target), directory);
        else FileSystemAclExtensions.SetAccessControl(new FileInfo(entry.Target), (FileSecurity)security);
    }

    private static void RecoverDeletedFile(JournalEntry entry)
    {
        if (File.Exists(entry.Target)) throw new InvalidOperationException($"Deleted path '{entry.Target}' was recreated after crash; automatic recovery refused.");
        byte[] original = entry.Data["backup_path"].Length > 0 ? File.ReadAllBytes(entry.Data["backup_path"]) : Convert.FromBase64String(entry.Data["original_base64"]);
        if (!Convert.ToHexString(SHA256.HashData(original)).Equals(entry.Data["original_sha256"], StringComparison.Ordinal)) throw new InvalidDataException("Delete recovery backup hash mismatch.");
        File.WriteAllBytes(entry.Target, original);
    }

    private static void CleanupPrepared(JournalEntry entry)
    {
        if (entry.Kind == "network.loopback_exemption" && !bool.Parse(entry.Data["originally_present"]))
            LoopbackExemption.Remove(new System.Security.Principal.SecurityIdentifier(entry.Target));
        if (entry.Data.TryGetValue("temporary_path", out string? temporary) && temporary.Length > 0) File.Delete(temporary);
        if (entry.Data.TryGetValue("backup_path", out string? backup) && backup.Length > 0)
        {
            File.Delete(backup);
            string? parent = System.IO.Path.GetDirectoryName(backup);
            if (parent is not null && Directory.Exists(parent) && !Directory.EnumerateFileSystemEntries(parent).Any()) Directory.Delete(parent);
        }
    }
    private static string Hash(string target)
    {
        using FileStream stream = File.OpenRead(target);
        return Convert.ToHexString(SHA256.HashData(stream));
    }
}