LANCommander/LANCommander.Server.Services/ModuleService.cs
2026-07-24 18:00:32 -05:00

480 lines
18 KiB
C#

using System.IO.Compression;
using System.Management.Automation.Language;
using System.Text.RegularExpressions;
using LANCommander.SDK;
using LANCommander.Server.Models;
namespace LANCommander.Server.Services
{
public sealed class ModuleService(SettingsProvider<Settings.Settings> settingsProvider)
{
private static readonly Regex FunctionNamePattern =
new(@"^[A-Za-z][A-Za-z0-9]*-[A-Za-z][A-Za-z0-9]*$", RegexOptions.Compiled);
// Standard set of approved PowerShell verbs (Get-Verb), grouped by verb group.
private static readonly IReadOnlyList<VerbGroup> ApprovedVerbGroups = new[]
{
new VerbGroup("Common", new[]
{
"Add", "Clear", "Close", "Copy", "Enter", "Exit", "Find", "Format", "Get", "Hide", "Join",
"Lock", "Move", "New", "Open", "Optimize", "Pop", "Push", "Redo", "Remove", "Rename", "Reset",
"Resize", "Search", "Select", "Set", "Show", "Skip", "Split", "Step", "Switch", "Undo", "Unlock", "Watch",
}),
new VerbGroup("Communications", new[]
{
"Connect", "Disconnect", "Read", "Receive", "Send", "Write",
}),
new VerbGroup("Data", new[]
{
"Backup", "Checkpoint", "Compare", "Compress", "Convert", "ConvertFrom", "ConvertTo", "Dismount",
"Edit", "Expand", "Export", "Group", "Import", "Initialize", "Limit", "Merge", "Mount", "Out",
"Publish", "Restore", "Save", "Sync", "Unpublish", "Update",
}),
new VerbGroup("Diagnostic", new[]
{
"Debug", "Measure", "Ping", "Repair", "Resolve", "Test", "Trace",
}),
new VerbGroup("Lifecycle", new[]
{
"Approve", "Assert", "Build", "Complete", "Confirm", "Deny", "Deploy", "Disable", "Enable",
"Install", "Invoke", "Register", "Request", "Restart", "Resume", "Start", "Stop", "Submit",
"Suspend", "Uninstall", "Unregister", "Wait",
}),
new VerbGroup("Security", new[]
{
"Block", "Grant", "Protect", "Revoke", "Unblock", "Unprotect",
}),
new VerbGroup("Other", new[]
{
"Use",
}),
};
private static readonly HashSet<string> ApprovedVerbs =
new(ApprovedVerbGroups.SelectMany(g => g.Verbs), StringComparer.OrdinalIgnoreCase);
private string GetStoragePath()
{
var storagePath = settingsProvider.CurrentValue.Server.Scripts.Modules.StoragePath;
if (string.IsNullOrWhiteSpace(storagePath))
{
storagePath = AppPaths.GetConfigPath("Modules");
settingsProvider.Update(s =>
{
s.Server.Scripts.Modules.StoragePath = storagePath;
});
}
storagePath = AppPaths.ResolveStorageLocationPath(storagePath);
if (!Directory.Exists(storagePath))
Directory.CreateDirectory(storagePath);
return storagePath;
}
private string GetModuleDirectory(string name) => Path.Combine(GetStoragePath(), name);
private static string GetManifestPath(string moduleDirectory, string name) =>
Path.Combine(moduleDirectory, $"{name}.psd1");
private static string GetLoaderPath(string moduleDirectory, string name) =>
Path.Combine(moduleDirectory, $"{name}.psm1");
private static string GetVisibilityDirectory(string moduleDirectory, FunctionVisibility visibility) =>
Path.Combine(moduleDirectory, visibility == FunctionVisibility.Public ? "Public" : "Private");
public IEnumerable<Module> GetModules()
{
var storagePath = GetStoragePath();
return Directory
.GetDirectories(storagePath)
.Select(d => GetModule(Path.GetFileName(d)))
.Where(m => m != null)
.OrderBy(m => m.Name);
}
public Module GetModule(string name)
{
var moduleDirectory = GetModuleDirectory(name);
if (!Directory.Exists(moduleDirectory))
return null;
var manifestPath = GetManifestPath(moduleDirectory, name);
var module = new Module
{
Name = name,
Manifest = File.Exists(manifestPath) ? File.ReadAllText(manifestPath) : string.Empty,
Functions = new List<ModuleFunction>(),
};
foreach (var visibility in new[] { FunctionVisibility.Public, FunctionVisibility.Private })
{
var directory = GetVisibilityDirectory(moduleDirectory, visibility);
if (!Directory.Exists(directory))
continue;
foreach (var file in Directory.GetFiles(directory, "*.ps1").OrderBy(f => f))
{
module.Functions.Add(new ModuleFunction
{
Name = Path.GetFileNameWithoutExtension(file),
Visibility = visibility,
Content = File.ReadAllText(file),
});
}
}
return module;
}
public bool ModuleExists(string name) => Directory.Exists(GetModuleDirectory(name));
public Module CreateScaffold(string name)
{
return new Module
{
Name = name,
Manifest = GetDefaultManifest(name),
Functions = new List<ModuleFunction>
{
new()
{
Name = "Get-Example",
Visibility = FunctionVisibility.Public,
Content = GetDefaultFunctionContent("Get-Example"),
},
},
};
}
public void SaveModule(Module module)
{
var moduleDirectory = GetModuleDirectory(module.Name);
if (!Directory.Exists(moduleDirectory))
Directory.CreateDirectory(moduleDirectory);
File.WriteAllText(GetManifestPath(moduleDirectory, module.Name), module.Manifest ?? string.Empty);
File.WriteAllText(GetLoaderPath(moduleDirectory, module.Name), GetLoaderScript());
var desiredFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var visibility in new[] { FunctionVisibility.Public, FunctionVisibility.Private })
{
var directory = GetVisibilityDirectory(moduleDirectory, visibility);
Directory.CreateDirectory(directory);
foreach (var function in module.Functions.Where(f => f.Visibility == visibility))
{
if (string.IsNullOrWhiteSpace(function.Name))
continue;
var path = Path.Combine(directory, $"{function.Name}.ps1");
File.WriteAllText(path, function.Content ?? string.Empty);
desiredFiles.Add(path);
}
}
// Prune function files that were removed or moved between Public/Private
foreach (var visibility in new[] { FunctionVisibility.Public, FunctionVisibility.Private })
{
var directory = GetVisibilityDirectory(moduleDirectory, visibility);
if (!Directory.Exists(directory))
continue;
foreach (var file in Directory.GetFiles(directory, "*.ps1"))
{
if (!desiredFiles.Contains(file))
File.Delete(file);
}
}
}
public void RenameModule(string oldName, string newName)
{
if (string.Equals(oldName, newName, StringComparison.Ordinal))
return;
var module = GetModule(oldName);
if (module == null)
return;
DeleteModule(oldName);
module.Name = newName;
SaveModule(module);
}
public void DeleteModule(string name)
{
var moduleDirectory = GetModuleDirectory(name);
if (Directory.Exists(moduleDirectory))
Directory.Delete(moduleDirectory, true);
}
public string GetModulesArchive()
{
var storagePath = GetStoragePath();
var archivePath = Path.Combine(Path.GetTempPath(), $"LANCommander.Modules.{Guid.NewGuid()}.zip");
ZipFile.CreateFromDirectory(storagePath, archivePath, CompressionLevel.Optimal, false);
return archivePath;
}
public ModuleManifest ParseManifest(string manifest)
{
var result = new ModuleManifest();
if (string.IsNullOrWhiteSpace(manifest))
return result;
var ast = Parser.ParseInput(manifest, out _, out _);
var hashtable = ast
.Find(a => a is HashtableAst, false) as HashtableAst;
if (hashtable == null)
return result;
foreach (var pair in hashtable.KeyValuePairs)
{
if (pair.Item1 is not StringConstantExpressionAst keyAst)
continue;
var value = GetScalarString(pair.Item2);
if (value == null)
continue;
switch (keyAst.Value)
{
case "RootModule":
result.RootModule = value;
break;
case "ModuleVersion":
result.ModuleVersion = value;
break;
case "GUID":
result.Guid = value;
break;
case "Author":
result.Author = value;
break;
case "CompanyName":
result.CompanyName = value;
break;
case "Copyright":
result.Copyright = value;
break;
case "Description":
result.Description = value;
break;
case "PowerShellVersion":
result.PowerShellVersion = value;
break;
}
}
return result;
}
public string GenerateManifest(ModuleManifest manifest, IEnumerable<string> functionNames, IEnumerable<string> aliasNames)
{
var functions = functionNames?.Distinct().OrderBy(n => n).ToList() ?? new List<string>();
var aliases = aliasNames?.Distinct().OrderBy(n => n).ToList() ?? new List<string>();
var builder = new System.Text.StringBuilder();
builder.Append("@{\n");
builder.Append($" RootModule = '{Escape(manifest.RootModule)}'\n");
builder.Append($" ModuleVersion = '{Escape(manifest.ModuleVersion)}'\n");
builder.Append($" GUID = '{Escape(manifest.Guid)}'\n");
if (!string.IsNullOrWhiteSpace(manifest.Author))
builder.Append($" Author = '{Escape(manifest.Author)}'\n");
if (!string.IsNullOrWhiteSpace(manifest.CompanyName))
builder.Append($" CompanyName = '{Escape(manifest.CompanyName)}'\n");
if (!string.IsNullOrWhiteSpace(manifest.Copyright))
builder.Append($" Copyright = '{Escape(manifest.Copyright)}'\n");
if (!string.IsNullOrWhiteSpace(manifest.Description))
builder.Append($" Description = '{Escape(manifest.Description)}'\n");
if (!string.IsNullOrWhiteSpace(manifest.PowerShellVersion))
builder.Append($" PowerShellVersion = '{Escape(manifest.PowerShellVersion)}'\n");
builder.Append($" FunctionsToExport = {FormatArray(functions)}\n");
builder.Append(" CmdletsToExport = @()\n");
builder.Append(" VariablesToExport = @()\n");
builder.Append($" AliasesToExport = {FormatArray(aliases)}\n");
builder.Append("}\n");
return builder.ToString();
}
public IReadOnlyList<string> GetExportedFunctions(Module module)
{
if (module?.Functions == null)
return [];
return module.Functions
.Where(f => f.Visibility == FunctionVisibility.Public && !string.IsNullOrWhiteSpace(f.Name))
.Select(f => f.Name)
.Distinct()
.OrderBy(n => n)
.ToList();
}
public IReadOnlyList<string> GetExportedAliases(Module module)
{
if (module?.Functions == null)
return [];
var aliases = new List<string>();
foreach (var function in module.Functions)
aliases.AddRange(GetAliasesFromScript(function.Content));
return aliases.Distinct().OrderBy(a => a).ToList();
}
public IEnumerable<(string Name, string Synopsis, string Module)> GetPublicFunctionCompletions()
{
foreach (var module in GetModules())
{
foreach (var function in module.Functions.Where(f =>
f.Visibility == FunctionVisibility.Public && !string.IsNullOrWhiteSpace(f.Name)))
{
yield return (function.Name, ExtractSynopsis(function.Content), module.Name);
}
}
}
private static string ExtractSynopsis(string content)
{
if (string.IsNullOrWhiteSpace(content))
return null;
var match = Regex.Match(content, @"\.SYNOPSIS\s*\r?\n\s*(?<synopsis>.+)");
return match.Success ? match.Groups["synopsis"].Value.Trim() : null;
}
public IReadOnlyList<VerbGroup> GetApprovedVerbGroups() =>
ApprovedVerbGroups
.Select(g => new VerbGroup(g.Name, g.Verbs.OrderBy(v => v, StringComparer.OrdinalIgnoreCase).ToArray()))
.ToList();
public bool IsValidFunctionName(string name) =>
!string.IsNullOrWhiteSpace(name) && FunctionNamePattern.IsMatch(name);
public bool IsApprovedVerb(string name)
{
if (string.IsNullOrWhiteSpace(name))
return false;
var dash = name.IndexOf('-');
if (dash <= 0)
return false;
return ApprovedVerbs.Contains(name.Substring(0, dash));
}
private static IEnumerable<string> GetAliasesFromScript(string script)
{
if (string.IsNullOrWhiteSpace(script))
yield break;
var ast = Parser.ParseInput(script, out _, out _);
foreach (var command in ast.FindAll(a => a is CommandAst, true).Cast<CommandAst>())
{
var name = command.GetCommandName();
if (!string.Equals(name, "Set-Alias", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(name, "New-Alias", StringComparison.OrdinalIgnoreCase))
continue;
var elements = command.CommandElements;
for (var i = 1; i < elements.Count; i++)
{
if (elements[i] is CommandParameterAst parameter &&
string.Equals(parameter.ParameterName, "Name", StringComparison.OrdinalIgnoreCase) &&
i + 1 < elements.Count)
{
var value = GetScalarString(elements[i + 1]);
if (value != null)
yield return value;
break;
}
if (elements[i] is StringConstantExpressionAst positional && i == 1)
{
yield return positional.Value;
break;
}
}
}
}
private static string GetScalarString(Ast valueAst)
{
return valueAst switch
{
StringConstantExpressionAst s => s.Value,
ExpandableStringExpressionAst e => e.Value,
ConstantExpressionAst c => c.Value?.ToString(),
_ => null,
};
}
private static string FormatArray(IReadOnlyList<string> values)
{
if (values.Count == 0)
return "@()";
return "@(" + string.Join(", ", values.Select(v => $"'{Escape(v)}'")) + ")";
}
private static string Escape(string value) =>
(value ?? string.Empty).Replace("'", "''");
private static string GetDefaultManifest(string name) =>
$"@{{\n RootModule = '{name}.psm1'\n ModuleVersion = '1.0.0'\n GUID = '{Guid.NewGuid()}'\n FunctionsToExport = '*'\n}}\n";
private static string GetDefaultFunctionContent(string name) =>
$"function {name} {{\n [CmdletBinding()]\n param()\n\n \"Hello from a LANCommander module\"\n}}\n";
// Loader that dot-sources every function file and exports only the public ones.
private static string GetLoaderScript() =>
"$Public = @(Get-ChildItem -Path \"$PSScriptRoot\\Public\\*.ps1\" -ErrorAction SilentlyContinue)\n" +
"$Private = @(Get-ChildItem -Path \"$PSScriptRoot\\Private\\*.ps1\" -ErrorAction SilentlyContinue)\n" +
"\n" +
"foreach ($file in @($Public + $Private)) {\n" +
" try { . $file.FullName }\n" +
" catch { Write-Error \"Failed to import function $($file.FullName): $_\" }\n" +
"}\n" +
"\n" +
"Export-ModuleMember -Function $Public.BaseName\n";
}
}