From 1c73d4bfc7647e13a60ec651fece346fd3ab2ebe Mon Sep 17 00:00:00 2001 From: malaltot <65951650+malaltot@users.noreply.github.com> Date: Mon, 29 Jun 2026 08:32:43 +0200 Subject: [PATCH] feat: Add remote script execution engine and UI management --- .../Services/AgentHubClient.cs | 136 ++ .../Services/AgentRpcService.cs | 13 + ControlR.ApiClient/ControlrApi.Endpoints.cs | 13 + ControlR.ApiClient/ControlrApi.cs | 5 +- .../Implementations/ControlrApi.Scripts.cs | 84 ++ .../Services/DesktopClientRpcService.cs | 221 +++ ControlR.Web.Client/ClientRoutes.cs | 5 +- .../Components/Dashboard.razor | 7 +- .../Components/Dashboard.razor.cs | 28 + .../Components/Dialogs/RunScriptDialog.razor | 87 ++ .../Dialogs/RunScriptDialog.razor.cs | 202 +++ .../Dialogs/SelectScriptDialog.razor | 34 + .../Dialogs/SelectScriptDialog.razor.cs | 71 + .../DeviceAccess/DeviceAccessNavMenu.razor | 6 +- .../Components/Layout/NavMenu.razor | 8 +- .../Pages/DeviceAccess/ExecuteScript.razor | 77 ++ .../Pages/DeviceAccess/ExecuteScript.razor.cs | 148 ++ .../Components/Pages/ScriptLogs.razor | 115 ++ .../Components/Pages/ScriptLogs.razor.cs | 65 + .../Components/Pages/Scripts.razor | 90 ++ .../Components/Pages/Scripts.razor.cs | 175 +++ .../Services/ViewerHubClient.cs | 5 + ControlR.Web.Server/Api/ScriptsController.cs | 310 +++++ ControlR.Web.Server/ControlR.Web.Server.json | 616 +++++++++ ControlR.Web.Server/Data/AppDb.cs | 58 + ControlR.Web.Server/Data/Entities/Script.cs | 22 + .../Data/Entities/ScriptExecution.cs | 27 + ControlR.Web.Server/Data/Entities/Tenant.cs | 4 +- .../20260626121936_UpdateModels.Designer.cs | 1231 +++++++++++++++++ .../Migrations/20260626121936_UpdateModels.cs | 109 ++ .../Data/Migrations/AppDbModelSnapshot.cs | 139 ++ .../Extensions/EntityToDtoExtensions.cs | 31 +- ControlR.Web.Server/Hubs/AgentHub.cs | 31 + ControlR.Web.Server/Hubs/HubGroupNames.cs | 7 +- ControlR.Web.Server/Hubs/ViewerHub.cs | 13 + Directory.Build.props | 1 + Directory.Packages.props | 2 +- .../Constants/HttpConstants.cs | 1 + .../Dtos/IpcDtos/ExecuteScriptIpcDto.cs | 11 + .../Dtos/IpcDtos/ScriptOutputIpcDto.cs | 11 + .../Dtos/ServerApi/ExecuteScriptRequestDto.cs | 9 + .../Dtos/ServerApi/ScriptCreateRequestDto.cs | 10 + .../Dtos/ServerApi/ScriptDto.cs | 12 + .../Dtos/ServerApi/ScriptExecutionDto.cs | 17 + .../Enums/ScriptRunAs.cs | 8 + .../Enums/ScriptStatus.cs | 10 + .../Enums/ShellType.cs | 8 + .../Hubs/Clients/IAgentHubClient.cs | 1 + .../Hubs/Clients/IViewerHubClient.cs | 1 + .../Hubs/IAgentHub.cs | 1 + .../Hubs/IViewerHub.cs | 1 + .../Interfaces/IAgentRpcService.cs | 1 + .../Interfaces/IDesktopClientRpcService.cs | 1 + .../Services/ViewerHubClient.cs | 5 + .../ScriptsControllerTests.cs | 103 ++ .../TestAgentHubClient.cs | 6 + 56 files changed, 4404 insertions(+), 9 deletions(-) create mode 100644 ControlR.ApiClient/Implementations/ControlrApi.Scripts.cs create mode 100644 ControlR.Web.Client/Components/Dialogs/RunScriptDialog.razor create mode 100644 ControlR.Web.Client/Components/Dialogs/RunScriptDialog.razor.cs create mode 100644 ControlR.Web.Client/Components/Dialogs/SelectScriptDialog.razor create mode 100644 ControlR.Web.Client/Components/Dialogs/SelectScriptDialog.razor.cs create mode 100644 ControlR.Web.Client/Components/Pages/DeviceAccess/ExecuteScript.razor create mode 100644 ControlR.Web.Client/Components/Pages/DeviceAccess/ExecuteScript.razor.cs create mode 100644 ControlR.Web.Client/Components/Pages/ScriptLogs.razor create mode 100644 ControlR.Web.Client/Components/Pages/ScriptLogs.razor.cs create mode 100644 ControlR.Web.Client/Components/Pages/Scripts.razor create mode 100644 ControlR.Web.Client/Components/Pages/Scripts.razor.cs create mode 100644 ControlR.Web.Server/Api/ScriptsController.cs create mode 100644 ControlR.Web.Server/Data/Entities/Script.cs create mode 100644 ControlR.Web.Server/Data/Entities/ScriptExecution.cs create mode 100644 ControlR.Web.Server/Data/Migrations/20260626121936_UpdateModels.Designer.cs create mode 100644 ControlR.Web.Server/Data/Migrations/20260626121936_UpdateModels.cs create mode 100644 Libraries/ControlR.Libraries.Api.Contracts/Dtos/IpcDtos/ExecuteScriptIpcDto.cs create mode 100644 Libraries/ControlR.Libraries.Api.Contracts/Dtos/IpcDtos/ScriptOutputIpcDto.cs create mode 100644 Libraries/ControlR.Libraries.Api.Contracts/Dtos/ServerApi/ExecuteScriptRequestDto.cs create mode 100644 Libraries/ControlR.Libraries.Api.Contracts/Dtos/ServerApi/ScriptCreateRequestDto.cs create mode 100644 Libraries/ControlR.Libraries.Api.Contracts/Dtos/ServerApi/ScriptDto.cs create mode 100644 Libraries/ControlR.Libraries.Api.Contracts/Dtos/ServerApi/ScriptExecutionDto.cs create mode 100644 Libraries/ControlR.Libraries.Api.Contracts/Enums/ScriptRunAs.cs create mode 100644 Libraries/ControlR.Libraries.Api.Contracts/Enums/ScriptStatus.cs create mode 100644 Libraries/ControlR.Libraries.Api.Contracts/Enums/ShellType.cs create mode 100644 Tests/ControlR.Web.Server.Tests/ScriptsControllerTests.cs diff --git a/ControlR.Agent.Common/Services/AgentHubClient.cs b/ControlR.Agent.Common/Services/AgentHubClient.cs index ba9fb356d..3f1146d43 100644 --- a/ControlR.Agent.Common/Services/AgentHubClient.cs +++ b/ControlR.Agent.Common/Services/AgentHubClient.cs @@ -11,6 +11,7 @@ using ControlR.Libraries.Api.Contracts.Dtos.ServerApi; using ControlR.Libraries.Shared.Helpers; using ControlR.Libraries.Api.Contracts.Hubs.Clients; +using ControlR.Libraries.Api.Contracts.Enums; using ControlR.Libraries.Ipc.Interfaces; using ControlR.Libraries.Signalr.Client.Extensions; using Microsoft.AspNetCore.SignalR; @@ -916,6 +917,141 @@ public async Task ValidateFilePath(ValidateFilePath } } + public async Task ExecuteScript(Guid executionId, string scriptContent, ShellType shellType, ScriptRunAs runAs) + { + if (runAs == ScriptRunAs.CurrentUser || runAs == ScriptRunAs.CurrentUserElevated) + { + Task.Run(async () => + { + try + { + var sessions = await _desktopSessionProvider.GetActiveDesktopClients(); + if (sessions == null || sessions.Length == 0) + { + _logger.LogWarning("No active user session found to execute script {ExecutionId} as current user.", executionId); + await _hubConnection.Server.SendScriptOutput(executionId, string.Empty, "Agent Error: No active user session found to execute script as current user." + Environment.NewLine, true, -1); + return; + } + + var session = sessions[0]; + if (!_ipcServerStore.TryGetServer(session.ProcessId, out var ipcServer)) + { + _logger.LogWarning("No IPC server found for process ID {ProcessId} for script {ExecutionId}.", session.ProcessId, executionId); + await _hubConnection.Server.SendScriptOutput(executionId, string.Empty, $"Agent Error: No IPC connection to interactive user session (Process {session.ProcessId})." + Environment.NewLine, true, -1); + return; + } + + await ipcServer.Server.Client.ExecuteScript(new ExecuteScriptIpcDto(executionId, scriptContent, shellType, runAs)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error forwarding script {ExecutionId} to desktop client", executionId); + await _hubConnection.Server.SendScriptOutput(executionId, string.Empty, $"Agent Error: {ex.Message}" + Environment.NewLine, true, -1); + } + }).Forget(); + return; + } + + Task.Run(async () => + { + string? tempFilePath = null; + try + { + var ext = shellType switch + { + ShellType.PowerShell => ".ps1", + ShellType.Cmd => ".bat", + ShellType.Bash => ".sh", + _ => ".txt" + }; + tempFilePath = Path.Combine(Path.GetTempPath(), $"controlr_script_{executionId}{ext}"); + await File.WriteAllTextAsync(tempFilePath, scriptContent); + + string fileName; + string arguments; + + if (shellType == ShellType.PowerShell) + { + fileName = _systemEnvironment.IsWindows() ? "powershell.exe" : "pwsh"; + arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"{tempFilePath}\""; + } + else if (shellType == ShellType.Cmd) + { + fileName = "cmd.exe"; + arguments = $"/c \"{tempFilePath}\""; + } + else // Bash + { + fileName = "/bin/bash"; + arguments = $"\"{tempFilePath}\""; + } + + var startInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = new Process { StartInfo = startInfo }; + + process.OutputDataReceived += async (sender, e) => + { + if (e.Data != null) + { + await _hubConnection.Server.SendScriptOutput(executionId, e.Data + Environment.NewLine, string.Empty, false, null); + } + }; + + process.ErrorDataReceived += async (sender, e) => + { + if (e.Data != null) + { + await _hubConnection.Server.SendScriptOutput(executionId, string.Empty, e.Data + Environment.NewLine, false, null); + } + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + var completed = await process.WaitForExitAsync().WaitAsync(TimeSpan.FromMinutes(5)).ContinueWith(t => t.IsCompletedSuccessfully); + + if (!completed) + { + process.Kill(true); + await _hubConnection.Server.SendScriptOutput(executionId, string.Empty, "Script execution timed out.", true, -1); + } + else + { + await _hubConnection.Server.SendScriptOutput(executionId, string.Empty, string.Empty, true, process.ExitCode); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error executing script {ExecutionId}", executionId); + await _hubConnection.Server.SendScriptOutput(executionId, string.Empty, $"Agent Error: {ex.Message}", true, -1); + } + finally + { + if (tempFilePath != null && File.Exists(tempFilePath)) + { + try + { + File.Delete(tempFilePath); + } + catch + { + // Ignore + } + } + } + }).Forget(); + } + private async Task EnsureDesktopClientPermissionGranted( IDesktopClientRpcService desktopClient, int targetProcessId, diff --git a/ControlR.Agent.Common/Services/AgentRpcService.cs b/ControlR.Agent.Common/Services/AgentRpcService.cs index f006704e7..68b647fd7 100644 --- a/ControlR.Agent.Common/Services/AgentRpcService.cs +++ b/ControlR.Agent.Common/Services/AgentRpcService.cs @@ -1,4 +1,5 @@ using ControlR.Libraries.Ipc.Interfaces; +using ControlR.Libraries.Api.Contracts.Dtos.IpcDtos; namespace ControlR.Agent.Common.Services; @@ -32,4 +33,16 @@ public async Task SendChatResponse(ChatResponseIpcDto dto) return false; } } + + public async Task SendScriptOutput(ScriptOutputIpcDto dto) + { + try + { + await _hubConnection.Server.SendScriptOutput(dto.ExecutionId, dto.StdOut, dto.StdErr, dto.IsFinished, dto.ExitCode); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while forwarding script output for execution {ExecutionId} to server", dto.ExecutionId); + } + } } diff --git a/ControlR.ApiClient/ControlrApi.Endpoints.cs b/ControlR.ApiClient/ControlrApi.Endpoints.cs index df03837fe..36a5d2039 100644 --- a/ControlR.ApiClient/ControlrApi.Endpoints.cs +++ b/ControlR.ApiClient/ControlrApi.Endpoints.cs @@ -200,3 +200,16 @@ public interface IServerVersionApi { Task> GetCurrentServerVersion(CancellationToken cancellationToken = default); } + +public interface IScriptsApi +{ + Task> CreateScript(ScriptCreateRequestDto request, CancellationToken cancellationToken = default); + Task> GetAllScripts(CancellationToken cancellationToken = default); + Task> GetScript(Guid id, CancellationToken cancellationToken = default); + Task> UpdateScript(Guid id, ScriptCreateRequestDto request, CancellationToken cancellationToken = default); + Task DeleteScript(Guid id, CancellationToken cancellationToken = default); + Task> ExecuteScript(Guid id, Guid[] deviceIds, ScriptRunAs runAs = ScriptRunAs.System, CancellationToken cancellationToken = default); + Task> ExecuteAdHocScript(ExecuteScriptRequestDto request, CancellationToken cancellationToken = default); + Task> GetScriptExecution(Guid executionId, CancellationToken cancellationToken = default); + Task> GetAllExecutions(CancellationToken cancellationToken = default); +} diff --git a/ControlR.ApiClient/ControlrApi.cs b/ControlR.ApiClient/ControlrApi.cs index 437b1ea60..2d1877406 100644 --- a/ControlR.ApiClient/ControlrApi.cs +++ b/ControlR.ApiClient/ControlrApi.cs @@ -33,6 +33,7 @@ public interface IControlrApi IUsersApi Users { get; } IUserServerSettingsApi UserServerSettings { get; } IUserTagsApi UserTags { get; } + IScriptsApi Scripts { get; } } public partial class ControlrApi( @@ -67,7 +68,8 @@ public partial class ControlrApi( IUserTagsApi, IUsersApi, IAgentVersionApi, - IServerVersionApi + IServerVersionApi, + IScriptsApi { private readonly ControlrApiClientAuthState _authState = authState; private readonly IBearerTokenRefresher _bearerTokenRefresher = bearerTokenRefresher; @@ -98,6 +100,7 @@ public partial class ControlrApi( public ITestEmailApi TestEmail => this; public IUserPreferencesApi UserPreferences => this; public IUserRolesApi UserRoles => this; + public IScriptsApi Scripts => this; public IUsersApi Users => this; public IUserServerSettingsApi UserServerSettings => this; public IUserTagsApi UserTags => this; diff --git a/ControlR.ApiClient/Implementations/ControlrApi.Scripts.cs b/ControlR.ApiClient/Implementations/ControlrApi.Scripts.cs new file mode 100644 index 000000000..05ba6d261 --- /dev/null +++ b/ControlR.ApiClient/Implementations/ControlrApi.Scripts.cs @@ -0,0 +1,84 @@ +using System.Net.Http.Json; +using ControlR.Libraries.Api.Contracts.Constants; +using ControlR.Libraries.Api.Contracts.Dtos; +using ControlR.Libraries.Api.Contracts.Dtos.ServerApi; + +using ControlR.Libraries.Api.Contracts.Enums; + +namespace ControlR.ApiClient; + +public partial class ControlrApi +{ + async Task> IScriptsApi.CreateScript(ScriptCreateRequestDto request, CancellationToken cancellationToken) + { + return await ExecuteApiCall(async () => + { + using var response = await _client.PostAsJsonAsync(HttpConstants.ScriptsEndpoint, request, cancellationToken); + await response.EnsureSuccessStatusCodeWithDetails(); + return await response.Content.ReadFromJsonAsync(cancellationToken); + }); + } + + async Task> IScriptsApi.GetAllScripts(CancellationToken cancellationToken) + { + return await ExecuteApiCall(async () => + await _client.GetFromJsonAsync(HttpConstants.ScriptsEndpoint, cancellationToken)); + } + + async Task> IScriptsApi.GetScript(Guid id, CancellationToken cancellationToken) + { + return await ExecuteApiCall(async () => + await _client.GetFromJsonAsync($"{HttpConstants.ScriptsEndpoint}/{id}", cancellationToken)); + } + + async Task> IScriptsApi.UpdateScript(Guid id, ScriptCreateRequestDto request, CancellationToken cancellationToken) + { + return await ExecuteApiCall(async () => + { + using var response = await _client.PutAsJsonAsync($"{HttpConstants.ScriptsEndpoint}/{id}", request, cancellationToken); + await response.EnsureSuccessStatusCodeWithDetails(); + return await response.Content.ReadFromJsonAsync(cancellationToken); + }); + } + + async Task IScriptsApi.DeleteScript(Guid id, CancellationToken cancellationToken) + { + return await ExecuteApiCall(async () => + { + using var response = await _client.DeleteAsync($"{HttpConstants.ScriptsEndpoint}/{id}", cancellationToken); + await response.EnsureSuccessStatusCodeWithDetails(); + }); + } + + async Task> IScriptsApi.ExecuteScript(Guid id, Guid[] deviceIds, ScriptRunAs runAs, CancellationToken cancellationToken) + { + return await ExecuteApiCall(async () => + { + using var response = await _client.PostAsJsonAsync($"{HttpConstants.ScriptsEndpoint}/{id}/execute?runAs={runAs}", deviceIds, cancellationToken); + await response.EnsureSuccessStatusCodeWithDetails(); + return await response.Content.ReadFromJsonAsync(cancellationToken); + }); + } + + async Task> IScriptsApi.ExecuteAdHocScript(ExecuteScriptRequestDto request, CancellationToken cancellationToken) + { + return await ExecuteApiCall(async () => + { + using var response = await _client.PostAsJsonAsync($"{HttpConstants.ScriptsEndpoint}/execute-adhoc", request, cancellationToken); + await response.EnsureSuccessStatusCodeWithDetails(); + return await response.Content.ReadFromJsonAsync(cancellationToken); + }); + } + + async Task> IScriptsApi.GetScriptExecution(Guid executionId, CancellationToken cancellationToken) + { + return await ExecuteApiCall(async () => + await _client.GetFromJsonAsync($"{HttpConstants.ScriptsEndpoint}/executions/{executionId}", cancellationToken)); + } + + async Task> IScriptsApi.GetAllExecutions(CancellationToken cancellationToken) + { + return await ExecuteApiCall(async () => + await _client.GetFromJsonAsync($"{HttpConstants.ScriptsEndpoint}/executions", cancellationToken)); + } +} diff --git a/ControlR.DesktopClient/Services/DesktopClientRpcService.cs b/ControlR.DesktopClient/Services/DesktopClientRpcService.cs index 5172ec06f..2af792a65 100644 --- a/ControlR.DesktopClient/Services/DesktopClientRpcService.cs +++ b/ControlR.DesktopClient/Services/DesktopClientRpcService.cs @@ -1,9 +1,14 @@ +using System.IO; +using System.Diagnostics; using Avalonia.Controls.ApplicationLifetimes; using ControlR.DesktopClient.Common.Services; +using ControlR.DesktopClient.Common.ServiceInterfaces; using ControlR.Libraries.Ipc.Interfaces; using ControlR.Libraries.Api.Contracts.Dtos.HubDtos; using ControlR.Libraries.Api.Contracts.Dtos.IpcDtos; +using ControlR.Libraries.Api.Contracts.Enums; using ControlR.Libraries.Shared.Primitives; +using ControlR.Libraries.Shared.Helpers; using Microsoft.Extensions.Logging; namespace ControlR.DesktopClient.Services; @@ -15,6 +20,7 @@ public class DesktopClientRpcService( IDesktopPreviewProvider desktopPreviewService, IRemoteControlHostManager remoteControlHostManager, IControlledApplicationLifetime appLifetime, + IIpcClientAccessor ipcClientAccessor, ILogger logger) : IDesktopClientRpcService { private readonly IControlledApplicationLifetime _appLifetime = appLifetime; @@ -24,6 +30,7 @@ public class DesktopClientRpcService( private readonly ILogger _logger = logger; private readonly IRemoteControlHostManager _remoteControlHostManager = remoteControlHostManager; private readonly IServiceProvider _serviceProvider = serviceProvider; + private readonly IIpcClientAccessor _ipcClientAccessor = ipcClientAccessor; public async Task CheckOsPermissions(CheckOsPermissionsIpcDto dto) { @@ -204,4 +211,218 @@ public async Task ShutdownDesktopClient(ShutdownCommandDto dto) } } + public async Task ExecuteScript(ExecuteScriptIpcDto dto) + { + Task.Run(async () => + { + string? tempFilePath = null; + string? stdoutPath = null; + string? stderrPath = null; + try + { + var ext = dto.ShellType switch + { + ShellType.PowerShell => ".ps1", + ShellType.Cmd => ".bat", + ShellType.Bash => ".sh", + _ => ".txt" + }; + tempFilePath = Path.Combine(Path.GetTempPath(), $"controlr_script_{dto.ExecutionId}{ext}"); + await File.WriteAllTextAsync(tempFilePath, dto.ScriptContent); + + var runElevated = dto.RunAs == ScriptRunAs.CurrentUserElevated; + var isWindows = OperatingSystem.IsWindows(); + + var isAlreadyElevated = false; + if (isWindows) + { +#pragma warning disable CA1416 + using var identity = System.Security.Principal.WindowsIdentity.GetCurrent(); + var principal = new System.Security.Principal.WindowsPrincipal(identity); + isAlreadyElevated = principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator); +#pragma warning restore CA1416 + } + + var needUacElevation = runElevated && isWindows && !isAlreadyElevated; + + string fileName; + string arguments; + + if (needUacElevation) + { + stdoutPath = Path.Combine(Path.GetTempPath(), $"controlr_script_{dto.ExecutionId}_out.txt"); + stderrPath = Path.Combine(Path.GetTempPath(), $"controlr_script_{dto.ExecutionId}_err.txt"); + + if (dto.ShellType == ShellType.PowerShell) + { + fileName = "powershell.exe"; + arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command \"& {{ & '{tempFilePath}' }} > '{stdoutPath}' 2> '{stderrPath}'\""; + } + else // Cmd + { + fileName = "cmd.exe"; + arguments = $"/c \"\"{tempFilePath}\" > \"{stdoutPath}\" 2> \"{stderrPath}\"\""; + } + } + else + { + if (dto.ShellType == ShellType.PowerShell) + { + fileName = isWindows ? "powershell.exe" : "pwsh"; + arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"{tempFilePath}\""; + } + else if (dto.ShellType == ShellType.Cmd) + { + fileName = "cmd.exe"; + arguments = $"/c \"{tempFilePath}\""; + } + else // Bash + { + fileName = "/bin/bash"; + arguments = $"\"{tempFilePath}\""; + } + } + + var startInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + UseShellExecute = needUacElevation, + CreateNoWindow = true + }; + + if (needUacElevation) + { + startInfo.Verb = "runas"; + } + else + { + startInfo.RedirectStandardOutput = true; + startInfo.RedirectStandardError = true; + } + + using var process = new Process { StartInfo = startInfo }; + + if (!needUacElevation) + { + process.OutputDataReceived += async (sender, e) => + { + if (e.Data != null) + { + await SendOutputChunk(dto.ExecutionId, e.Data + Environment.NewLine, string.Empty, false, null); + } + }; + + process.ErrorDataReceived += async (sender, e) => + { + if (e.Data != null) + { + await SendOutputChunk(dto.ExecutionId, string.Empty, e.Data + Environment.NewLine, false, null); + } + }; + } + + process.Start(); + + if (!needUacElevation) + { + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + } + + if (needUacElevation) + { + var stdoutFileOffset = 0L; + var stderrFileOffset = 0L; + + while (!process.HasExited) + { + await Task.Delay(500); + stdoutFileOffset = await ReadNewFileContent(dto.ExecutionId, stdoutPath, stdoutFileOffset, isError: false); + stderrFileOffset = await ReadNewFileContent(dto.ExecutionId, stderrPath, stderrFileOffset, isError: true); + } + + await Task.Delay(100); + stdoutFileOffset = await ReadNewFileContent(dto.ExecutionId, stdoutPath, stdoutFileOffset, isError: false); + stderrFileOffset = await ReadNewFileContent(dto.ExecutionId, stderrPath, stderrFileOffset, isError: true); + } + else + { + await process.WaitForExitAsync(); + } + + await SendOutputChunk(dto.ExecutionId, string.Empty, string.Empty, true, process.ExitCode); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error executing script {ExecutionId} on DesktopClient", dto.ExecutionId); + await SendOutputChunk(dto.ExecutionId, string.Empty, $"DesktopClient Error: {ex.Message}" + Environment.NewLine, true, -1); + } + finally + { + DeleteFileSafe(tempFilePath); + DeleteFileSafe(stdoutPath); + DeleteFileSafe(stderrPath); + } + }).Forget(); + } + + private async Task ReadNewFileContent(Guid executionId, string? path, long offset, bool isError) + { + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + { + return offset; + } + + try + { + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + if (fs.Length > offset) + { + fs.Seek(offset, SeekOrigin.Begin); + using var reader = new StreamReader(fs); + var content = await reader.ReadToEndAsync(); + if (!string.IsNullOrEmpty(content)) + { + if (isError) + { + await SendOutputChunk(executionId, string.Empty, content, false, null); + } + else + { + await SendOutputChunk(executionId, content, string.Empty, false, null); + } + } + return fs.Length; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read output file {Path}", path); + } + return offset; + } + + private void DeleteFileSafe(string? path) + { + if (!string.IsNullOrEmpty(path) && File.Exists(path)) + { + try { File.Delete(path); } catch { } + } + } + + private async Task SendOutputChunk(Guid executionId, string stdout, string stderr, bool isFinished, int? exitCode) + { + if (_ipcClientAccessor.TryGetClient(out var connection)) + { + try + { + await connection.Server.SendScriptOutput(new ScriptOutputIpcDto(executionId, stdout, stderr, isFinished, exitCode)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send script output chunk via IPC."); + } + } + } } diff --git a/ControlR.Web.Client/ClientRoutes.cs b/ControlR.Web.Client/ClientRoutes.cs index cc94a2c64..fc40dff7f 100644 --- a/ControlR.Web.Client/ClientRoutes.cs +++ b/ControlR.Web.Client/ClientRoutes.cs @@ -1,4 +1,4 @@ -namespace ControlR.Web.Client; +namespace ControlR.Web.Client; public static class ClientRoutes { @@ -11,6 +11,7 @@ public static class ClientRoutes public const string DeviceAccessRemoteLogs = $"{DeviceAccess}/remote-logs"; public const string DeviceAccessTerminal = $"{DeviceAccess}/terminal"; public const string DeviceAccessVncRelay = $"{DeviceAccess}/vnc-relay"; + public const string DeviceAccessScripts = $"{DeviceAccess}/scripts"; public const string Home = "/"; public const string InstallerKeys = "/installer-keys"; public const string Invite = "/invite"; @@ -24,5 +25,7 @@ public static class ClientRoutes public const string ServerSettings = "/server-settings"; public const string ServerStats = "/server-stats"; public const string Settings = "/settings"; + public const string Scripts = "/scripts"; + public const string ScriptLogs = "/script-logs"; public const string TenantSettings = "/tenant-settings"; } diff --git a/ControlR.Web.Client/Components/Dashboard.razor b/ControlR.Web.Client/Components/Dashboard.razor index bf7a492ea..0d0bba1a4 100644 --- a/ControlR.Web.Client/Components/Dashboard.razor +++ b/ControlR.Web.Client/Components/Dashboard.razor @@ -1,4 +1,4 @@ -
+
@if (!_loading && _anyDevicesForUser == false) { @@ -80,6 +80,11 @@ @if (context.Item.Dto.IsOnline) { + + Run Script + + Refresh diff --git a/ControlR.Web.Client/Components/Dashboard.razor.cs b/ControlR.Web.Client/Components/Dashboard.razor.cs index 62d45c59c..ba87a831f 100644 --- a/ControlR.Web.Client/Components/Dashboard.razor.cs +++ b/ControlR.Web.Client/Components/Dashboard.razor.cs @@ -1,6 +1,8 @@ using Microsoft.AspNetCore.SignalR.Client; using System.Collections.Immutable; using System.Runtime.Versioning; +using ControlR.Libraries.Api.Contracts.Dtos.ServerApi; +using ControlR.Libraries.Api.Contracts.Enums; namespace ControlR.Web.Client.Components; @@ -187,6 +189,32 @@ private async Task LaunchRemoteControl(DeviceViewModel device) } } + private async Task RunScript(DeviceViewModel device) + { + var parameters = new DialogParameters + { + { x => x.Device, device.Dto } + }; + + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small, FullWidth = true }; + var dialog = await DialogService.ShowAsync($"Run Script: {device.Dto.Name}", parameters, options); + var result = await dialog.Result; + + if (result is not null && !result.Canceled && result.Data is (ScriptDto script, ScriptRunAs runAs)) + { + var runParams = new DialogParameters + { + { x => x.Script, script }, + { x => x.TargetDevices, new List { device.Dto } }, + { x => x.RunAs, runAs }, + { x => x.AutoExecute, true } + }; + + var runOptions = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + await DialogService.ShowAsync($"Run Script Console: {script.Name}", runParams, runOptions); + } + } + private async Task> LoadServerData(GridState state, CancellationToken cancellationToken) { if (_loading) diff --git a/ControlR.Web.Client/Components/Dialogs/RunScriptDialog.razor b/ControlR.Web.Client/Components/Dialogs/RunScriptDialog.razor new file mode 100644 index 000000000..21f14104c --- /dev/null +++ b/ControlR.Web.Client/Components/Dialogs/RunScriptDialog.razor @@ -0,0 +1,87 @@ + + + Run Script: @Script?.Name + + + @if (_loading) + { + + } + else if (!_running) + { + + System (Background Service account) + CurrentUser (Interactive user desktop session) + CurrentUserElevated (Prompt for UAC elevation in desktop session) + + Select the target devices to run this script on: + + + Name + Platform + Status + + + @device.Name + @device.Platform + + + @(device.IsOnline ? "Online" : "Offline") + + + + + } + else + { + + + + Targets + @foreach (var exec in _executions) + { + +
+ @exec.DeviceName + + @exec.Status + +
+
+ } +
+
+ + Console Output +
+ @if (_selectedDeviceExecutionId.HasValue && _outputs.TryGetValue(_selectedDeviceExecutionId.Value, out var output)) + { + @output.ToString() + } + else + { + // Select a device to view output + } +
+
+
+ } +
+ + @if (!_running) + { + Cancel + Execute + } + else + { + Close + } + +
diff --git a/ControlR.Web.Client/Components/Dialogs/RunScriptDialog.razor.cs b/ControlR.Web.Client/Components/Dialogs/RunScriptDialog.razor.cs new file mode 100644 index 000000000..43884a81a --- /dev/null +++ b/ControlR.Web.Client/Components/Dialogs/RunScriptDialog.razor.cs @@ -0,0 +1,202 @@ +using ControlR.Libraries.Api.Contracts.Dtos.ServerApi; +using ControlR.Libraries.Api.Contracts.Enums; +using ControlR.Web.Client.StateManagement.Stores; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.SignalR.Client; +using MudBlazor; +using System.Collections.Concurrent; + +namespace ControlR.Web.Client.Components.Dialogs; + +public partial class RunScriptDialog : ComponentBase, IDisposable +{ + [CascadingParameter] + public required IMudDialogInstance MudDialog { get; init; } + + [Parameter] + public ScriptDto? Script { get; set; } + + [Parameter] + public List? TargetDevices { get; set; } + + [Parameter] + public ScriptRunAs RunAs { get; set; } = ScriptRunAs.System; + + [Parameter] + public bool AutoExecute { get; set; } + + // Inject dependencies + [Inject] + public required IControlrApi ControlrApi { get; init; } + + [Inject] + public required IHubConnection ViewerHub { get; init; } + + [Inject] + public required IMessenger Messenger { get; init; } + + [Inject] + public required ISnackbar Snackbar { get; init; } + + [Inject] + public required IDeviceStore DeviceStore { get; init; } + + // State fields + private bool _loading = true; + private bool _running; + private List _allDevices = []; + private HashSet _selectedDevices = []; + private ScriptRunAs _runAs = ScriptRunAs.System; + + // Running executions tracking + private List _executions = []; + private readonly ConcurrentDictionary _outputs = new(); + private ScriptExecutionDto? _selectedExecution; + private Guid? _selectedDeviceExecutionId; + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + Messenger.Register>(this, HandleScriptOutput); + + _runAs = RunAs; + + try + { + await DeviceStore.Refresh(); + _allDevices = [.. DeviceStore.Items]; + + if (TargetDevices is not null && TargetDevices.Count > 0) + { + // Match target devices to the ones in _allDevices to preserve reference equality if needed + _selectedDevices = _allDevices.Where(d => TargetDevices.Any(td => td.Id == d.Id)).ToHashSet(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Failed to load devices: {ex.Message}", Severity.Error); + } + finally + { + _loading = false; + } + + if (AutoExecute && _selectedDevices.Count > 0) + { + await Execute(); + } + } + + private async Task Execute() + { + if (_selectedDevices.Count == 0) + { + Snackbar.Add("Please select at least one device.", Severity.Warning); + return; + } + + if (Script is null) + { + return; + } + + _running = true; + _executions = []; + _outputs.Clear(); + + try + { + var deviceIds = _selectedDevices.Select(x => x.Id).ToArray(); + var result = await ControlrApi.Scripts.ExecuteScript(Script.Id, deviceIds, _runAs); + + if (result.IsSuccess && result.Value is not null) + { + _executions = [.. result.Value]; + + foreach (var exec in _executions) + { + _outputs[exec.Id] = new System.Text.StringBuilder(exec.StdErr); // Initialize with offline/startup logs if any + + if (exec.Status == ScriptStatus.Running) + { + // Subscribe to real-time SignalR logs + await ViewerHub.Server.WatchScriptExecution(exec.Id); + } + } + + if (_executions.Count > 0) + { + _selectedExecution = _executions[0]; + _selectedDeviceExecutionId = _executions[0].Id; + } + } + else + { + Snackbar.Add("Failed to start script execution.", Severity.Error); + _running = false; + } + } + catch (Exception ex) + { + Snackbar.Add($"Error starting execution: {ex.Message}", Severity.Error); + _running = false; + } + } + + private async Task HandleScriptOutput(object sender, DtoReceivedMessage<(Guid ExecutionId, string StdOutChunk, string StdErrChunk, bool IsFinished, int? ExitCode)> message) + { + var data = message.Dto; + if (!_outputs.ContainsKey(data.ExecutionId)) + { + return; + } + + var sb = _outputs[data.ExecutionId]; + + if (!string.IsNullOrEmpty(data.StdOutChunk)) + { + sb.Append(data.StdOutChunk); + } + + if (!string.IsNullOrEmpty(data.StdErrChunk)) + { + sb.Append("[ERROR] ").Append(data.StdErrChunk); + } + + // Update execution status in local list + var idx = _executions.FindIndex(x => x.Id == data.ExecutionId); + if (idx != -1) + { + var current = _executions[idx]; + var newStatus = current.Status; + if (data.IsFinished) + { + newStatus = (data.ExitCode == 0) ? ScriptStatus.Succeeded : ScriptStatus.Failed; + } + + _executions[idx] = current with { + Status = newStatus, + ExitCode = data.ExitCode, + FinishedAt = data.IsFinished ? DateTimeOffset.Now : null + }; + } + + await InvokeAsync(StateHasChanged); + } + + private void OnSelectedExecutionChanged(ScriptExecutionDto val) + { + _selectedExecution = val; + _selectedDeviceExecutionId = val?.Id; + } + + private void Close() + { + MudDialog.Close(DialogResult.Ok(true)); + } + + public void Dispose() + { + Messenger.UnregisterAll(this); + } +} diff --git a/ControlR.Web.Client/Components/Dialogs/SelectScriptDialog.razor b/ControlR.Web.Client/Components/Dialogs/SelectScriptDialog.razor new file mode 100644 index 000000000..37144a935 --- /dev/null +++ b/ControlR.Web.Client/Components/Dialogs/SelectScriptDialog.razor @@ -0,0 +1,34 @@ + + + Run Script on Device: @Device?.Name + + + @if (_loading) + { + + } + else if (_scripts.Count == 0) + { + No scripts found. Please create scripts in the Scripts tab first. + } + else + { + + @foreach (var script in _scripts) + { + @script.Name (@script.ShellType) + } + + + + System (Background Service account) + CurrentUser (Interactive user desktop session) + CurrentUserElevated (Prompt for UAC elevation in desktop session) + + } + + + Cancel + Run + + diff --git a/ControlR.Web.Client/Components/Dialogs/SelectScriptDialog.razor.cs b/ControlR.Web.Client/Components/Dialogs/SelectScriptDialog.razor.cs new file mode 100644 index 000000000..ed662c20c --- /dev/null +++ b/ControlR.Web.Client/Components/Dialogs/SelectScriptDialog.razor.cs @@ -0,0 +1,71 @@ +using ControlR.Libraries.Api.Contracts.Dtos.ServerApi; +using ControlR.Libraries.Api.Contracts.Enums; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace ControlR.Web.Client.Components.Dialogs; + +public partial class SelectScriptDialog : ComponentBase +{ + [CascadingParameter] + public required IMudDialogInstance MudDialog { get; init; } + + [Parameter] + public DeviceResponseDto? Device { get; set; } + + [Inject] + public required IControlrApi ControlrApi { get; init; } + + [Inject] + public required ISnackbar Snackbar { get; init; } + + private bool _loading = true; + private List _scripts = []; + private ScriptDto? _selectedScript; + private ScriptRunAs _runAs = ScriptRunAs.System; + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + try + { + var result = await ControlrApi.Scripts.GetAllScripts(); + if (result.IsSuccess && result.Value is not null) + { + _scripts = [.. result.Value]; + if (_scripts.Count > 0) + { + _selectedScript = _scripts[0]; + } + } + else + { + Snackbar.Add("Failed to load scripts.", Severity.Error); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _loading = false; + } + } + + private void Confirm() + { + if (_selectedScript is null) + { + return; + } + + MudDialog.Close(DialogResult.Ok((_selectedScript, _runAs))); + } + + private void Cancel() + { + MudDialog.Cancel(); + } +} diff --git a/ControlR.Web.Client/Components/Layout/DeviceAccess/DeviceAccessNavMenu.razor b/ControlR.Web.Client/Components/Layout/DeviceAccess/DeviceAccessNavMenu.razor index 37d266aa0..b2692ab3f 100644 --- a/ControlR.Web.Client/Components/Layout/DeviceAccess/DeviceAccessNavMenu.razor +++ b/ControlR.Web.Client/Components/Layout/DeviceAccess/DeviceAccessNavMenu.razor @@ -1,4 +1,4 @@ -@inject ILazyInjector DeviceAccessState +@inject ILazyInjector DeviceAccessState @@ -29,6 +29,10 @@ VNC Relay + + Run Script + + @* Processes diff --git a/ControlR.Web.Client/Components/Layout/NavMenu.razor b/ControlR.Web.Client/Components/Layout/NavMenu.razor index 333bd3317..61b70ce33 100644 --- a/ControlR.Web.Client/Components/Layout/NavMenu.razor +++ b/ControlR.Web.Client/Components/Layout/NavMenu.razor @@ -1,4 +1,4 @@ -@implements IDisposable +@implements IDisposable @inject AuthenticationStateProvider AuthState @inject NavigationManager NavMan @@ -39,6 +39,12 @@ Permissions + + Scripts + + + Script Logs + Invite diff --git a/ControlR.Web.Client/Components/Pages/DeviceAccess/ExecuteScript.razor b/ControlR.Web.Client/Components/Pages/DeviceAccess/ExecuteScript.razor new file mode 100644 index 000000000..caca8876f --- /dev/null +++ b/ControlR.Web.Client/Components/Pages/DeviceAccess/ExecuteScript.razor @@ -0,0 +1,77 @@ +@layout DeviceAccessLayout +@attribute [Route(ClientRoutes.DeviceAccessScripts)] +@attribute [Authorize] +@using ControlR.Web.Client.Components.Layout.DeviceAccess + +
+ Run Script on Device + + + + + @if (_loading) + { + + } + else if (_scripts.Count == 0) + { + No scripts found. Please create scripts in the Scripts panel first. + } + else + { + + @foreach (var script in _scripts) + { + @script.Name (@script.ShellType) + } + + + @if (_selectedScript is not null) + { + + } + + + System (Service) + CurrentUser (Interactive Desktop) + CurrentUserElevated (Interactive Desktop + UAC) + + +
+ + @(_running ? "Running..." : "Run Script") + +
+ } +
+
+ + +
+ Console Output + @if (_executionStatus.HasValue) + { + + @_executionStatus + + } +
+
+ @if (!string.IsNullOrEmpty(_consoleOutput)) + { + @_consoleOutput + } + else + { + // Script output will appear here after execution starts... + } +
+
+
+
diff --git a/ControlR.Web.Client/Components/Pages/DeviceAccess/ExecuteScript.razor.cs b/ControlR.Web.Client/Components/Pages/DeviceAccess/ExecuteScript.razor.cs new file mode 100644 index 000000000..e855b43ef --- /dev/null +++ b/ControlR.Web.Client/Components/Pages/DeviceAccess/ExecuteScript.razor.cs @@ -0,0 +1,148 @@ +using ControlR.Libraries.Api.Contracts.Dtos.ServerApi; +using ControlR.Libraries.Api.Contracts.Enums; +using ControlR.Libraries.Viewer.Common.State; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.SignalR.Client; +using MudBlazor; + +namespace ControlR.Web.Client.Components.Pages.DeviceAccess; + +public partial class ExecuteScript : ComponentBase, IDisposable +{ + [SupplyParameterFromQuery] + public required Guid DeviceId { get; init; } + + [Inject] + public required IDeviceState DeviceState { get; init; } + + [Inject] + public required IControlrApi ControlrApi { get; init; } + + [Inject] + public required IHubConnection ViewerHub { get; init; } + + [Inject] + public required IMessenger Messenger { get; init; } + + [Inject] + public required ISnackbar Snackbar { get; init; } + + private bool _loading = true; + private bool _running; + private List _scripts = []; + private ScriptDto? _selectedScript; + private ScriptRunAs _runAs = ScriptRunAs.System; + + private string _consoleOutput = string.Empty; + private ScriptStatus? _executionStatus; + private Guid? _currentExecutionId; + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + Messenger.Register>(this, HandleScriptOutput); + + try + { + var result = await ControlrApi.Scripts.GetAllScripts(); + if (result.IsSuccess && result.Value is not null) + { + _scripts = [.. result.Value]; + if (_scripts.Count > 0) + { + _selectedScript = _scripts[0]; + } + } + else + { + Snackbar.Add("Failed to load scripts.", Severity.Error); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _loading = false; + } + } + + private async Task Execute() + { + if (_selectedScript is null) + { + return; + } + + _running = true; + _consoleOutput = string.Empty; + _executionStatus = ScriptStatus.Running; + + try + { + var result = await ControlrApi.Scripts.ExecuteScript(_selectedScript.Id, [DeviceId], _runAs); + + if (result.IsSuccess && result.Value is not null && result.Value.Length > 0) + { + var exec = result.Value[0]; + _currentExecutionId = exec.Id; + _consoleOutput = exec.StdErr; // If offline, will contain warning + + if (exec.Status == ScriptStatus.Running) + { + await ViewerHub.Server.WatchScriptExecution(exec.Id); + } + else + { + _executionStatus = exec.Status; + _running = false; + } + } + else + { + Snackbar.Add("Failed to start script execution.", Severity.Error); + _executionStatus = ScriptStatus.Failed; + _running = false; + } + } + catch (Exception ex) + { + Snackbar.Add($"Error executing script: {ex.Message}", Severity.Error); + _executionStatus = ScriptStatus.Failed; + _running = false; + } + } + + private async Task HandleScriptOutput(object sender, DtoReceivedMessage<(Guid ExecutionId, string StdOutChunk, string StdErrChunk, bool IsFinished, int? ExitCode)> message) + { + var data = message.Dto; + if (_currentExecutionId != data.ExecutionId) + { + return; + } + + if (!string.IsNullOrEmpty(data.StdOutChunk)) + { + _consoleOutput += data.StdOutChunk; + } + + if (!string.IsNullOrEmpty(data.StdErrChunk)) + { + _consoleOutput += $"[ERROR] {data.StdErrChunk}"; + } + + if (data.IsFinished) + { + _executionStatus = (data.ExitCode == 0) ? ScriptStatus.Succeeded : ScriptStatus.Failed; + _running = false; + } + + await InvokeAsync(StateHasChanged); + } + + public void Dispose() + { + Messenger.UnregisterAll(this); + } +} diff --git a/ControlR.Web.Client/Components/Pages/ScriptLogs.razor b/ControlR.Web.Client/Components/Pages/ScriptLogs.razor new file mode 100644 index 000000000..40a524528 --- /dev/null +++ b/ControlR.Web.Client/Components/Pages/ScriptLogs.razor @@ -0,0 +1,115 @@ +@attribute [Route(ClientRoutes.ScriptLogs)] +@attribute [Authorize(Roles = RoleNames.TenantAdministrator)] + +Script Execution Logs + + + Script Execution Logs + + +@if (_loading) +{ + +} +else +{ + + Refresh History + + + @if (_executions.Count == 0) + { + No script executions recorded yet. + } + else + { + + + Script Name + Device + Run By + Started At + Status + Exit Code + Actions + + + @execution.ScriptName + @execution.DeviceName + @execution.ExecutedByUserId + @execution.StartedAt.ToLocalTime().ToString("g") + + + @execution.Status + + + @(execution.ExitCode?.ToString() ?? "-") + + View Logs + + + + } +} + + + + Execution Details: @_selectedExecution?.ScriptName + + + @if (_selectedExecution is not null) + { + + + Device: @_selectedExecution.DeviceName + Started: @_selectedExecution.StartedAt.ToLocalTime().ToString("g") + @if (_selectedExecution.FinishedAt.HasValue) + { + Finished: @_selectedExecution.FinishedAt.Value.ToLocalTime().ToString("g") + } + + + Status: @_selectedExecution.Status + Exit Code: @(_selectedExecution.ExitCode?.ToString() ?? "N/A") + + + + +
+ @if (string.IsNullOrEmpty(_selectedExecution.StdOut)) + { + // No standard output recorded + } + else + { + @_selectedExecution.StdOut + } +
+
+ +
+ @if (string.IsNullOrEmpty(_selectedExecution.StdErr)) + { + // No errors recorded + } + else + { + @_selectedExecution.StdErr + } +
+
+
+
+
+ } +
+ + Close + +
diff --git a/ControlR.Web.Client/Components/Pages/ScriptLogs.razor.cs b/ControlR.Web.Client/Components/Pages/ScriptLogs.razor.cs new file mode 100644 index 000000000..a0f05ba02 --- /dev/null +++ b/ControlR.Web.Client/Components/Pages/ScriptLogs.razor.cs @@ -0,0 +1,65 @@ +using ControlR.Libraries.Api.Contracts.Dtos.ServerApi; +using ControlR.Libraries.Api.Contracts.Enums; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace ControlR.Web.Client.Components.Pages; + +public partial class ScriptLogs : ComponentBase +{ + private List _executions = []; + private bool _loading = true; + + // Selected execution detail modal/view + private ScriptExecutionDto? _selectedExecution; + private bool _detailOpen; + + [Inject] + public required IControlrApi ControlrApi { get; init; } + + [Inject] + public required ISnackbar Snackbar { get; init; } + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + await LoadExecutions(); + } + + private async Task LoadExecutions() + { + _loading = true; + try + { + var result = await ControlrApi.Scripts.GetAllExecutions(); + if (result.IsSuccess && result.Value is not null) + { + _executions = [.. result.Value]; + } + else + { + Snackbar.Add("Failed to load execution history.", Severity.Error); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _loading = false; + } + } + + private void OpenDetail(ScriptExecutionDto execution) + { + _selectedExecution = execution; + _detailOpen = true; + } + + private void CloseDetail() + { + _detailOpen = false; + _selectedExecution = null; + } +} diff --git a/ControlR.Web.Client/Components/Pages/Scripts.razor b/ControlR.Web.Client/Components/Pages/Scripts.razor new file mode 100644 index 000000000..dfabf2492 --- /dev/null +++ b/ControlR.Web.Client/Components/Pages/Scripts.razor @@ -0,0 +1,90 @@ +@attribute [Route(ClientRoutes.Scripts)] +@attribute [Authorize(Roles = RoleNames.TenantAdministrator)] + +Scripts + + + Remote Scripts + + +@if (_loading) +{ + +} +else if (_isEditing) +{ + + + + @(_editingScriptId.HasValue ? "Edit Script" : "Create Script") + + + + + + + + + + PowerShell + Cmd (Windows) + Bash (Linux/Mac) + + + + + + + + + + + + + + + Save + Cancel + + +} +else +{ + + Create Script + + + @if (_scripts.Count == 0) + { + No scripts configured yet. Click "Create Script" to add your first script. + } + else + { + + @foreach (var script in _scripts) + { + + + + + @script.Name + @script.ShellType + Timeout: @script.TimeoutSeconds s + + + + @script.Description + + + Run +
+ + +
+
+
+
+ } +
+ } +} diff --git a/ControlR.Web.Client/Components/Pages/Scripts.razor.cs b/ControlR.Web.Client/Components/Pages/Scripts.razor.cs new file mode 100644 index 000000000..9238ddc53 --- /dev/null +++ b/ControlR.Web.Client/Components/Pages/Scripts.razor.cs @@ -0,0 +1,175 @@ +using ControlR.Libraries.Api.Contracts.Dtos.ServerApi; +using ControlR.Libraries.Api.Contracts.Enums; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace ControlR.Web.Client.Components.Pages; + +public partial class Scripts : ComponentBase +{ + private List _scripts = []; + private bool _loading = true; + + // Form fields for create/edit + private bool _isEditing; + private Guid? _editingScriptId; + private string _scriptName = string.Empty; + private string _scriptDescription = string.Empty; + private string _scriptCode = string.Empty; + private ShellType _shellType = ShellType.PowerShell; + private int _timeoutSeconds = 300; + + [Inject] + public required IControlrApi ControlrApi { get; init; } + + [Inject] + public required ISnackbar Snackbar { get; init; } + + [Inject] + public required IDialogService DialogService { get; init; } + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + await LoadScripts(); + } + + private async Task LoadScripts() + { + _loading = true; + try + { + var result = await ControlrApi.Scripts.GetAllScripts(); + if (result.IsSuccess && result.Value is not null) + { + _scripts = [.. result.Value]; + } + else + { + Snackbar.Add("Failed to load scripts.", Severity.Error); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _loading = false; + } + } + + private void OpenCreateForm() + { + _isEditing = true; + _editingScriptId = null; + _scriptName = string.Empty; + _scriptDescription = string.Empty; + _scriptCode = string.Empty; + _shellType = ShellType.PowerShell; + _timeoutSeconds = 300; + } + + private void OpenEditForm(ScriptDto script) + { + _isEditing = true; + _editingScriptId = script.Id; + _scriptName = script.Name; + _scriptDescription = script.Description; + _scriptCode = script.CodeContent; + _shellType = script.ShellType; + _timeoutSeconds = script.TimeoutSeconds; + } + + private void CancelEdit() + { + _isEditing = false; + } + + private async Task SaveScript() + { + if (string.IsNullOrWhiteSpace(_scriptName) || string.IsNullOrWhiteSpace(_scriptCode)) + { + Snackbar.Add("Name and script content are required.", Severity.Warning); + return; + } + + try + { + var request = new ScriptCreateRequestDto( + _scriptName, + _scriptDescription, + _scriptCode, + _shellType, + _timeoutSeconds); + + ApiResult result; + + if (_editingScriptId.HasValue) + { + result = await ControlrApi.Scripts.UpdateScript(_editingScriptId.Value, request); + } + else + { + result = await ControlrApi.Scripts.CreateScript(request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Script saved successfully.", Severity.Success); + _isEditing = false; + await LoadScripts(); + } + else + { + Snackbar.Add("Failed to save script.", Severity.Error); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + } + + private async Task DeleteScript(Guid id) + { + var confirmed = await DialogService.ShowMessageBoxAsync( + "Confirm Delete", + "Are you sure you want to delete this script?", + yesText: "Delete", cancelText: "Cancel"); + + if (confirmed != true) + { + return; + } + + try + { + var result = await ControlrApi.Scripts.DeleteScript(id); + if (result.IsSuccess) + { + Snackbar.Add("Script deleted successfully.", Severity.Success); + await LoadScripts(); + } + else + { + Snackbar.Add("Failed to delete script.", Severity.Error); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + } + + private async Task OpenRunDialog(ScriptDto script) + { + var parameters = new DialogParameters + { + { x => x.Script, script } + }; + + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + await DialogService.ShowAsync($"Run Script: {script.Name}", parameters, options); + } +} diff --git a/ControlR.Web.Client/Services/ViewerHubClient.cs b/ControlR.Web.Client/Services/ViewerHubClient.cs index adffa19cb..978d96363 100644 --- a/ControlR.Web.Client/Services/ViewerHubClient.cs +++ b/ControlR.Web.Client/Services/ViewerHubClient.cs @@ -44,4 +44,9 @@ public async Task ReceiveTerminalOutput(TerminalOutputDto output) { await _messenger.Send(new DtoReceivedMessage(output)); } + + public async Task ReceiveScriptOutput(Guid executionId, string stdoutChunk, string stderrChunk, bool isFinished, int? exitCode) + { + await _messenger.Send(new DtoReceivedMessage<(Guid ExecutionId, string StdOutChunk, string StdErrChunk, bool IsFinished, int? ExitCode)>((executionId, stdoutChunk, stderrChunk, isFinished, exitCode))); + } } \ No newline at end of file diff --git a/ControlR.Web.Server/Api/ScriptsController.cs b/ControlR.Web.Server/Api/ScriptsController.cs new file mode 100644 index 000000000..74a27ca2c --- /dev/null +++ b/ControlR.Web.Server/Api/ScriptsController.cs @@ -0,0 +1,310 @@ +using ControlR.Libraries.Api.Contracts.Constants; +using ControlR.Libraries.Api.Contracts.Dtos.ServerApi; +using ControlR.Libraries.Api.Contracts.Enums; +using ControlR.Libraries.Api.Contracts.Hubs.Clients; +using ControlR.Web.Server.Data; +using ControlR.Web.Server.Data.Entities; +using ControlR.Web.Server.Hubs; +using ControlR.Web.Server.Extensions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; + +namespace ControlR.Web.Server.Api; + +[Route(HttpConstants.ScriptsEndpoint)] +[ApiController] +[Authorize] +public class ScriptsController : ControllerBase +{ + [HttpPost] + [Authorize(Roles = RoleNames.TenantAdministrator)] + public async Task> CreateScript( + [FromServices] AppDb appDb, + [FromBody] ScriptCreateRequestDto dto) + { + if (!User.TryGetTenantId(out var tenantId)) + { + return NotFound("User tenant not found."); + } + + var script = new Script + { + TenantId = tenantId, + Name = dto.Name, + Description = dto.Description, + CodeContent = dto.CodeContent, + ShellType = dto.ShellType, + TimeoutSeconds = dto.TimeoutSeconds + }; + + await appDb.Scripts.AddAsync(script); + await appDb.SaveChangesAsync(); + + return Ok(script.ToDto()); + } + + [HttpGet] + [Authorize(Roles = RoleNames.TenantAdministrator)] + public async Task> GetAllScripts([FromServices] AppDb appDb) + { + var scripts = await appDb.Scripts + .AsNoTracking() + .OrderBy(x => x.Name) + .ToListAsync(); + + return Ok(scripts.Select(x => x.ToDto()).ToArray()); + } + + [HttpGet("{id:guid}")] + [Authorize(Roles = RoleNames.TenantAdministrator)] + public async Task> GetScript( + [FromServices] AppDb appDb, + [FromRoute] Guid id) + { + if (!User.TryGetTenantId(out var tenantId)) + { + return NotFound("User tenant not found."); + } + + var script = await appDb.Scripts + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == id && x.TenantId == tenantId); + + if (script is null) + { + return NotFound(); + } + + return Ok(script.ToDto()); + } + + [HttpPut("{id:guid}")] + [Authorize(Roles = RoleNames.TenantAdministrator)] + public async Task> UpdateScript( + [FromServices] AppDb appDb, + [FromRoute] Guid id, + [FromBody] ScriptCreateRequestDto dto) + { + if (!User.TryGetTenantId(out var tenantId)) + { + return NotFound("User tenant not found."); + } + + var script = await appDb.Scripts + .FirstOrDefaultAsync(x => x.Id == id && x.TenantId == tenantId); + + if (script is null) + { + return NotFound(); + } + + script.Name = dto.Name; + script.Description = dto.Description; + script.CodeContent = dto.CodeContent; + script.ShellType = dto.ShellType; + script.TimeoutSeconds = dto.TimeoutSeconds; + + await appDb.SaveChangesAsync(); + + return Ok(script.ToDto()); + } + + [HttpDelete("{id:guid}")] + [Authorize(Roles = RoleNames.TenantAdministrator)] + public async Task DeleteScript( + [FromServices] AppDb appDb, + [FromRoute] Guid id) + { + if (!User.TryGetTenantId(out var tenantId)) + { + return NotFound("User tenant not found."); + } + + var script = await appDb.Scripts + .FirstOrDefaultAsync(x => x.Id == id && x.TenantId == tenantId); + + if (script is null) + { + return NotFound(); + } + + appDb.Scripts.Remove(script); + await appDb.SaveChangesAsync(); + + return NoContent(); + } + + [HttpPost("{id:guid}/execute")] + [Authorize(Roles = RoleNames.TenantAdministrator)] + public async Task> ExecuteScript( + [FromServices] AppDb appDb, + [FromServices] IHubContext agentHub, + [FromServices] TimeProvider timeProvider, + [FromRoute] Guid id, + [FromBody] Guid[] deviceIds, + [FromQuery] ScriptRunAs runAs = ScriptRunAs.System) + { + if (!User.TryGetTenantId(out var tenantId) || !User.TryGetUserId(out var userId)) + { + return NotFound("User context not found."); + } + + var script = await appDb.Scripts + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == id && x.TenantId == tenantId); + + if (script is null) + { + return NotFound("Script not found."); + } + + var executions = new List(); + + foreach (var deviceId in deviceIds) + { + var device = await appDb.Devices + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == deviceId && x.TenantId == tenantId); + + if (device is null) + { + continue; + } + + var execution = new ScriptExecution + { + TenantId = tenantId, + ScriptId = script.Id, + DeviceId = deviceId, + ExecutedByUserId = userId.ToString(), + StartedAt = timeProvider.GetLocalNow(), + Status = device.IsOnline ? ScriptStatus.Running : ScriptStatus.Offline, + StdOut = string.Empty, + StdErr = device.IsOnline ? string.Empty : "Device is offline." + }; + + await appDb.ScriptExecutions.AddAsync(execution); + await appDb.SaveChangesAsync(); + + // Fetch with relations for returning DTO + var savedExecution = await appDb.ScriptExecutions + .Include(x => x.Script) + .Include(x => x.Device) + .FirstAsync(x => x.Id == execution.Id); + + executions.Add(savedExecution.ToDto()); + + if (device.IsOnline && !string.IsNullOrEmpty(device.ConnectionId)) + { + // Fire and forget script invocation on agent via SignalR + _ = agentHub.Clients.Client(device.ConnectionId) + .ExecuteScript(execution.Id, script.CodeContent, script.ShellType, runAs); + } + } + + return Ok(executions.ToArray()); + } + + [HttpPost("execute-adhoc")] + [Authorize(Roles = RoleNames.TenantAdministrator)] + public async Task> ExecuteAdHocScript( + [FromServices] AppDb appDb, + [FromServices] IHubContext agentHub, + [FromServices] TimeProvider timeProvider, + [FromBody] ExecuteScriptRequestDto request) + { + if (!User.TryGetTenantId(out var tenantId) || !User.TryGetUserId(out var userId)) + { + return NotFound("User context not found."); + } + + if (string.IsNullOrWhiteSpace(request.AdHocScriptContent) || !request.ShellType.HasValue) + { + return BadRequest("Script content and shell type are required."); + } + + var executions = new List(); + + foreach (var deviceId in request.DeviceIds) + { + var device = await appDb.Devices + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == deviceId && x.TenantId == tenantId); + + if (device is null) + { + continue; + } + + var execution = new ScriptExecution + { + TenantId = tenantId, + ScriptId = null, + DeviceId = deviceId, + ExecutedByUserId = userId.ToString(), + StartedAt = timeProvider.GetLocalNow(), + Status = device.IsOnline ? ScriptStatus.Running : ScriptStatus.Offline, + StdOut = string.Empty, + StdErr = device.IsOnline ? string.Empty : "Device is offline." + }; + + await appDb.ScriptExecutions.AddAsync(execution); + await appDb.SaveChangesAsync(); + + var savedExecution = await appDb.ScriptExecutions + .Include(x => x.Device) + .FirstAsync(x => x.Id == execution.Id); + + executions.Add(savedExecution.ToDto()); + + if (device.IsOnline && !string.IsNullOrEmpty(device.ConnectionId)) + { + _ = agentHub.Clients.Client(device.ConnectionId) + .ExecuteScript(execution.Id, request.AdHocScriptContent, request.ShellType.Value, request.RunAs); + } + } + + return Ok(executions.ToArray()); + } + + [HttpGet("executions/{executionId:guid}")] + [Authorize(Roles = RoleNames.TenantAdministrator)] + public async Task> GetScriptExecution( + [FromServices] AppDb appDb, + [FromRoute] Guid executionId) + { + if (!User.TryGetTenantId(out var tenantId)) + { + return NotFound("User tenant not found."); + } + + var execution = await appDb.ScriptExecutions + .AsNoTracking() + .Include(x => x.Script) + .Include(x => x.Device) + .FirstOrDefaultAsync(x => x.Id == executionId && x.TenantId == tenantId); + + if (execution is null) + { + return NotFound(); + } + + return Ok(execution.ToDto()); + } + + [HttpGet("executions")] + [Authorize(Roles = RoleNames.TenantAdministrator)] + public async Task> GetAllExecutions([FromServices] AppDb appDb) + { + var executions = await appDb.ScriptExecutions + .AsNoTracking() + .Include(x => x.Script) + .Include(x => x.Device) + .OrderByDescending(x => x.StartedAt) + .ToListAsync(); + + return Ok(executions.Select(x => x.ToDto()).ToArray()); + } +} diff --git a/ControlR.Web.Server/ControlR.Web.Server.json b/ControlR.Web.Server/ControlR.Web.Server.json index b01e13524..4648e71c8 100644 --- a/ControlR.Web.Server/ControlR.Web.Server.json +++ b/ControlR.Web.Server/ControlR.Web.Server.json @@ -2228,6 +2228,435 @@ } } }, + "/api/scripts": { + "post": { + "tags": [ + "Scripts" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScriptCreateRequestDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ScriptCreateRequestDto" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ScriptCreateRequestDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ScriptDto" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScriptDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ScriptDto" + } + } + } + } + } + }, + "get": { + "tags": [ + "Scripts" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScriptDto" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScriptDto" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScriptDto" + } + } + } + } + } + } + } + }, + "/api/scripts/{id}": { + "get": { + "tags": [ + "Scripts" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ScriptDto" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScriptDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ScriptDto" + } + } + } + } + } + }, + "put": { + "tags": [ + "Scripts" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScriptCreateRequestDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ScriptCreateRequestDto" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ScriptCreateRequestDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ScriptDto" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScriptDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ScriptDto" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Scripts" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/scripts/{id}/execute": { + "post": { + "tags": [ + "Scripts" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "runAs", + "in": "query", + "schema": { + "default": 0, + "$ref": "#/components/schemas/ScriptRunAs" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "application/*+json": { + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScriptExecutionDto" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScriptExecutionDto" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScriptExecutionDto" + } + } + } + } + } + } + } + }, + "/api/scripts/execute-adhoc": { + "post": { + "tags": [ + "Scripts" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteScriptRequestDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteScriptRequestDto" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ExecuteScriptRequestDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScriptExecutionDto" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScriptExecutionDto" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScriptExecutionDto" + } + } + } + } + } + } + } + }, + "/api/scripts/executions/{executionId}": { + "get": { + "tags": [ + "Scripts" + ], + "parameters": [ + { + "name": "executionId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ScriptExecutionDto" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScriptExecutionDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ScriptExecutionDto" + } + } + } + } + } + } + }, + "/api/scripts/executions": { + "get": { + "tags": [ + "Scripts" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScriptExecutionDto" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScriptExecutionDto" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScriptExecutionDto" + } + } + } + } + } + } + } + }, "/api/server-alert": { "get": { "tags": [ @@ -4684,6 +5113,42 @@ } } }, + "ExecuteScriptRequestDto": { + "required": [ + "deviceIds", + "adHocScriptContent", + "shellType" + ], + "type": "object", + "properties": { + "deviceIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "adHocScriptContent": { + "type": [ + "null", + "string" + ] + }, + "shellType": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ShellType" + } + ] + }, + "runAs": { + "$ref": "#/components/schemas/ScriptRunAs" + } + } + }, "FileDeleteRequestDto": { "required": [ "deviceId", @@ -5184,6 +5649,151 @@ "linux-x64" ] }, + "ScriptCreateRequestDto": { + "required": [ + "name", + "description", + "codeContent", + "shellType", + "timeoutSeconds" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "codeContent": { + "type": "string" + }, + "shellType": { + "$ref": "#/components/schemas/ShellType" + }, + "timeoutSeconds": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": "integer", + "format": "int32" + } + } + }, + "ScriptDto": { + "required": [ + "id", + "name", + "description", + "codeContent", + "shellType", + "timeoutSeconds", + "createdAt" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "codeContent": { + "type": "string" + }, + "shellType": { + "$ref": "#/components/schemas/ShellType" + }, + "timeoutSeconds": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": "integer", + "format": "int32" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ScriptExecutionDto": { + "required": [ + "id", + "scriptId", + "scriptName", + "deviceId", + "deviceName", + "executedByUserId", + "startedAt", + "finishedAt", + "status", + "stdOut", + "stdErr", + "exitCode" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "scriptId": { + "type": [ + "null", + "string" + ], + "format": "uuid" + }, + "scriptName": { + "type": "string" + }, + "deviceId": { + "type": "string", + "format": "uuid" + }, + "deviceName": { + "type": "string" + }, + "executedByUserId": { + "type": "string" + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "finishedAt": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "status": { + "$ref": "#/components/schemas/ScriptStatus" + }, + "stdOut": { + "type": "string" + }, + "stdErr": { + "type": "string" + }, + "exitCode": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer" + ], + "format": "int32" + } + } + }, + "ScriptRunAs": { + "type": "integer" + }, + "ScriptStatus": { + "type": "integer" + }, "ServerAlertRequestDto": { "required": [ "message", @@ -5286,6 +5896,9 @@ } } }, + "ShellType": { + "type": "integer" + }, "SystemPlatform": { "type": "integer" }, @@ -5817,6 +6430,9 @@ { "name": "Roles" }, + { + "name": "Scripts" + }, { "name": "ServerAlert" }, diff --git a/ControlR.Web.Server/Data/AppDb.cs b/ControlR.Web.Server/Data/AppDb.cs index 67af41323..d643f10fe 100644 --- a/ControlR.Web.Server/Data/AppDb.cs +++ b/ControlR.Web.Server/Data/AppDb.cs @@ -22,6 +22,8 @@ public AppDb(DbContextOptions options) : base(options) public DbSet DataProtectionKeys { get; set; } public DbSet Devices { get; init; } public DbSet PersonalAccessTokens { get; init; } + public DbSet