135 lines
5.5 KiB
C#
135 lines
5.5 KiB
C#
using System.Security.AccessControl;
|
|
using System.Security.Cryptography;
|
|
using System.Security.Principal;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace Zcbot.WindowsNode;
|
|
|
|
internal sealed class NodeConfigStore(NodePaths paths)
|
|
{
|
|
private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("zcbot.windows-node.v1");
|
|
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
|
|
|
internal bool Exists => File.Exists(paths.ConfigPath);
|
|
internal string ConfigPath => paths.ConfigPath;
|
|
|
|
internal void Save(NodeConfig config)
|
|
{
|
|
Directory.CreateDirectory(paths.RootDirectory);
|
|
RestrictDirectory(paths.RootDirectory);
|
|
var protectedToken = ProtectedData.Protect(
|
|
Encoding.UTF8.GetBytes(config.NodeToken), Entropy, DataProtectionScope.LocalMachine);
|
|
var stored = new StoredNodeConfig(
|
|
config.ServerUrl.AbsoluteUri,
|
|
config.NodeId,
|
|
config.InstallId,
|
|
config.NodeName,
|
|
Convert.ToBase64String(protectedToken),
|
|
config.HeartbeatSeconds,
|
|
config.Capabilities);
|
|
|
|
var temporaryPath = paths.ConfigPath + ".tmp";
|
|
File.WriteAllText(temporaryPath, JsonSerializer.Serialize(stored, JsonOptions), Encoding.UTF8);
|
|
RestrictFile(temporaryPath);
|
|
File.Move(temporaryPath, paths.ConfigPath, overwrite: false);
|
|
RestrictFile(paths.ConfigPath);
|
|
}
|
|
|
|
internal NodeConfig Load()
|
|
{
|
|
if (!Exists)
|
|
{
|
|
throw new NodeConfigurationException(
|
|
"Node is not registered. Run the enroll command first.");
|
|
}
|
|
try
|
|
{
|
|
var stored = JsonSerializer.Deserialize<StoredNodeConfig>(
|
|
File.ReadAllText(paths.ConfigPath, Encoding.UTF8))
|
|
?? throw new NodeConfigurationException("Node configuration is empty.");
|
|
var token = Encoding.UTF8.GetString(ProtectedData.Unprotect(
|
|
Convert.FromBase64String(stored.ProtectedNodeToken),
|
|
Entropy,
|
|
DataProtectionScope.LocalMachine));
|
|
if (stored.NodeId == Guid.Empty
|
|
|| stored.InstallId == Guid.Empty
|
|
|| string.IsNullOrWhiteSpace(stored.NodeName)
|
|
|| string.IsNullOrWhiteSpace(token)
|
|
|| stored.Capabilities.Count == 0
|
|
|| stored.Capabilities.Any(
|
|
item => !NodeAdapterRegistry.InstalledCapabilities.Contains(
|
|
item, StringComparer.Ordinal)))
|
|
{
|
|
throw new NodeConfigurationException("Node configuration contains an invalid identity or capability.");
|
|
}
|
|
return new NodeConfig(
|
|
NodeUri.NormalizeServerUrl(stored.ServerUrl),
|
|
stored.NodeId,
|
|
stored.InstallId,
|
|
stored.NodeName,
|
|
token,
|
|
Math.Clamp(stored.HeartbeatSeconds, 5, 300),
|
|
stored.Capabilities);
|
|
}
|
|
catch (NodeConfigurationException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception exception) when (
|
|
exception is CryptographicException
|
|
or FormatException
|
|
or IOException
|
|
or JsonException
|
|
or UnauthorizedAccessException)
|
|
{
|
|
throw new NodeConfigurationException(
|
|
$"Node configuration cannot be loaded: {exception.Message}");
|
|
}
|
|
}
|
|
|
|
internal void DeleteLocalIdentity()
|
|
{
|
|
if (File.Exists(paths.ConfigPath))
|
|
{
|
|
File.Delete(paths.ConfigPath);
|
|
}
|
|
var temporaryPath = paths.ConfigPath + ".tmp";
|
|
if (File.Exists(temporaryPath))
|
|
{
|
|
File.Delete(temporaryPath);
|
|
}
|
|
}
|
|
|
|
private static void RestrictDirectory(string path)
|
|
{
|
|
var identity = WindowsIdentity.GetCurrent();
|
|
var user = identity.User
|
|
?? throw new NodeConfigurationException("Current Windows account has no security identifier.");
|
|
var security = new DirectorySecurity();
|
|
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
|
|
security.AddAccessRule(new FileSystemAccessRule(
|
|
user, FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
|
|
PropagationFlags.None, AccessControlType.Allow));
|
|
security.AddAccessRule(new FileSystemAccessRule(
|
|
new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null),
|
|
FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
|
|
PropagationFlags.None, AccessControlType.Allow));
|
|
new DirectoryInfo(path).SetAccessControl(security);
|
|
}
|
|
|
|
private static void RestrictFile(string path)
|
|
{
|
|
var identity = WindowsIdentity.GetCurrent();
|
|
var user = identity.User
|
|
?? throw new NodeConfigurationException("Current Windows account has no security identifier.");
|
|
var security = new FileSecurity();
|
|
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
|
|
security.AddAccessRule(new FileSystemAccessRule(user, FileSystemRights.FullControl, AccessControlType.Allow));
|
|
security.AddAccessRule(new FileSystemAccessRule(
|
|
new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null),
|
|
FileSystemRights.FullControl, AccessControlType.Allow));
|
|
new FileInfo(path).SetAccessControl(security);
|
|
}
|
|
}
|