summaryrefslogtreecommitdiff
path: root/src/Agent.Windows/Recovery/RecoveryManager.cs
blob: 40a7923784b989f8e64e8b7a94facbb79bb9a598 (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
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
using System.Security.AccessControl;
using System.Security.Cryptography;
using Agent.Windows.Security;
using Agent.Windows.Network;
using Agent.Admin.Client;

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, IElevatedNetworkSessionFactory? elevatedNetwork = null)
{
    public RecoveryResult RecoverAll() => RecoverAllAsync(CancellationToken.None).GetAwaiter().GetResult();

    public async Task<RecoveryResult> RecoverAllAsync(CancellationToken cancellationToken)
    {
        if (!Directory.Exists(journalDirectory)) return new(0, 0, []);
        int journals = 0;
        int resources = 0;
        var errors = new List<string>();
        string[] paths = Directory.EnumerateFiles(journalDirectory, "*.journal.json").Order(StringComparer.Ordinal).ToArray();
        bool requiresElevation = paths.Select(TaskResourceJournal.Open).SelectMany(static item => item.Entries)
            .Any(static entry => entry.State != JournalEntryState.Cleaned && entry.Kind == "network.loopback_exemption");
        IElevatedNetworkSession? elevated = null;
        if (requiresElevation)
        {
            if (elevatedNetwork is null) return new(0, 0, ["Persistent network Recovery requires the elevated helper."]);
            audit.Write(new(DateTimeOffset.UtcNow, Guid.Empty, "sandbox.elevation.request", "requested",
                new Dictionary<string, string> { ["reason"] = "startup_recovery" }));
            try { elevated = await elevatedNetwork.StartAsync(Guid.Empty, cancellationToken).ConfigureAwait(false); }
            catch (AdminOperationException ex)
            {
                audit.Write(new(DateTimeOffset.UtcNow, Guid.Empty,
                    ex.Code == "sandbox.ipc_rejected" ? "sandbox.ipc.reject" : "sandbox.elevation.deny", "failed",
                    new Dictionary<string, string> { ["reason"] = "startup_recovery", ["message"] = ex.Message }, ErrorCode: ex.Code));
                throw;
            }
            audit.Write(new(DateTimeOffset.UtcNow, Guid.Empty, "sandbox.elevation.success", "connected",
                new Dictionary<string, string> { ["reason"] = "startup_recovery" }));
        }
        try
        {
        foreach (string path in paths)
        {
            journals++;
            try
            {
                TaskResourceJournal journal = TaskResourceJournal.Open(path);
                audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "recovery.start", "started", new Dictionary<string, string>()));
                foreach (JournalEntry entry in journal.Entries.Reverse())
                {
                    if (entry.State == JournalEntryState.Cleaned) continue;
                    if (entry.State == JournalEntryState.Applied) await RecoverEntryAsync(journal.TaskId, entry, elevated, cancellationToken).ConfigureAwait(false);
                    await CleanupPreparedAsync(entry, elevated, cancellationToken).ConfigureAwait(false);
                    journal.MarkCleaned(entry.Id);
                    resources++;
                }
                journal.CompleteAndDelete();
                audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "recovery.end", "clean", new Dictionary<string, string>()));
            }
            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException)
            {
                errors.Add($"{path}: {ex.Message}");
            }
        }
        }
        finally { if (elevated is not null) await elevated.DisposeAsync().ConfigureAwait(false); }
        return new(journals, resources, errors);
    }

    private static async Task RecoverEntryAsync(Guid taskId, JournalEntry entry, IElevatedNetworkSession? elevated, CancellationToken cancellationToken)
    {
        switch (entry.Kind)
        {
            case "file.replace": RecoverFile(entry); break;
            case "file.delete": RecoverDeletedFile(entry); break;
            case "file.rename": RecoverRenamedFile(entry); break;
            case "dir.mkdir": RecoverMkdir(entry); break;
            case "dir.rmdir": RecoverRmdir(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": await RecoverLoopbackAsync(entry, elevated, cancellationToken).ConfigureAwait(false); break;
            default: throw new InvalidDataException($"Unknown journal resource kind '{entry.Kind}' for task '{taskId}'.");
        }
    }

    private static async Task RecoverLoopbackAsync(JournalEntry entry, IElevatedNetworkSession? elevated, CancellationToken cancellationToken)
    {
        if (elevated is null) throw new InvalidOperationException("Persistent network Recovery requires the elevated helper.");
        await elevated.RecoverLoopbackAsync(entry.Target, bool.Parse(entry.Data["originally_present"]), cancellationToken).ConfigureAwait(false);
    }

    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 RecoverRenamedFile(JournalEntry entry)
    {
        string destination = entry.Data["destination_path"];
        string sourceHash = entry.Data["source_sha256"];
        if (!File.Exists(destination)) return; // Already rolled back or destination missing — skip.
        if (!Hash(destination).Equals(sourceHash, StringComparison.Ordinal))
            throw new InvalidOperationException($"Renamed destination '{destination}' hash does not match journal record; automatic recovery refused.");
        if (File.Exists(entry.Target))
            throw new InvalidOperationException($"Source path '{entry.Target}' was recreated after crash; automatic recovery refused.");
        File.Move(destination, entry.Target);
    }

    private static void RecoverMkdir(JournalEntry entry)
    {
        if (!Directory.Exists(entry.Target)) return;
        if (Directory.EnumerateFileSystemEntries(entry.Target, "*", SearchOption.TopDirectoryOnly).Any())
            throw new InvalidOperationException($"Directory '{entry.Target}' created by mkdir is not empty; automatic rollback refused.");
        Directory.Delete(entry.Target, recursive: false);
    }

    private static void RecoverRmdir(JournalEntry entry)
    {
        if (Directory.Exists(entry.Target)) throw new InvalidOperationException($"Directory '{entry.Target}' was recreated after crash; automatic recovery refused.");
        Directory.CreateDirectory(entry.Target);
    }

    private static async Task CleanupPreparedAsync(JournalEntry entry, IElevatedNetworkSession? elevated, CancellationToken cancellationToken)
    {
        if (entry.Kind == "network.loopback_exemption" && entry.State != JournalEntryState.Applied)
            await RecoverLoopbackAsync(entry, elevated, cancellationToken).ConfigureAwait(false);
        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));
    }
}