release orchestrator v1 draft and build fixes
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
using System.Text.Json;
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
|
||||
namespace StellaOps.Plugin.Sandbox.Communication;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC-based plugin bridge implementation.
|
||||
/// </summary>
|
||||
public sealed class GrpcPluginBridge : IGrpcPluginBridge
|
||||
{
|
||||
private readonly ILogger<GrpcPluginBridge> _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
private GrpcChannel? _channel;
|
||||
private PluginBridge.PluginBridgeClient? _client;
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsConnected => _channel != null && _channel.State == ConnectivityState.Ready;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new gRPC plugin bridge.
|
||||
/// </summary>
|
||||
public GrpcPluginBridge(ILogger<GrpcPluginBridge> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ConnectAsync(string socketPath, CancellationToken ct)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var address = GetAddress(socketPath);
|
||||
|
||||
_logger.LogDebug("Connecting to plugin at {Address}", address);
|
||||
|
||||
var handler = new SocketsHttpHandler
|
||||
{
|
||||
ConnectCallback = async (context, token) =>
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
// Named pipe connection
|
||||
var pipeStream = new System.IO.Pipes.NamedPipeClientStream(
|
||||
".",
|
||||
socketPath.Replace("\\\\.\\pipe\\", ""),
|
||||
System.IO.Pipes.PipeDirection.InOut,
|
||||
System.IO.Pipes.PipeOptions.Asynchronous);
|
||||
|
||||
await pipeStream.ConnectAsync(token);
|
||||
return pipeStream;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Unix socket connection
|
||||
var socket = new System.Net.Sockets.Socket(
|
||||
System.Net.Sockets.AddressFamily.Unix,
|
||||
System.Net.Sockets.SocketType.Stream,
|
||||
System.Net.Sockets.ProtocolType.Unspecified);
|
||||
|
||||
var endpoint = new System.Net.Sockets.UnixDomainSocketEndPoint(socketPath);
|
||||
await socket.ConnectAsync(endpoint, token);
|
||||
|
||||
return new System.Net.Sockets.NetworkStream(socket, ownsSocket: true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_channel = GrpcChannel.ForAddress(address, new GrpcChannelOptions
|
||||
{
|
||||
HttpHandler = handler,
|
||||
DisposeHttpClient = true
|
||||
});
|
||||
|
||||
_client = new PluginBridge.PluginBridgeClient(_channel);
|
||||
|
||||
// Wait for connection
|
||||
await _channel.ConnectAsync(ct);
|
||||
|
||||
_logger.LogDebug("Connected to plugin at {Address}", address);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DisconnectAsync(CancellationToken ct)
|
||||
{
|
||||
if (_channel != null)
|
||||
{
|
||||
await _channel.ShutdownAsync();
|
||||
_channel.Dispose();
|
||||
_channel = null;
|
||||
_client = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task InitializePluginAsync(
|
||||
Abstractions.Manifest.PluginManifest manifest,
|
||||
CancellationToken ct)
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, _jsonOptions);
|
||||
var configJson = string.Empty; // Configuration is handled separately
|
||||
|
||||
var request = new InitializeRequest
|
||||
{
|
||||
ManifestJson = manifestJson,
|
||||
ConfigJson = configJson,
|
||||
SandboxId = string.Empty // Will be set by the bridge
|
||||
};
|
||||
|
||||
var response = await _client!.InitializeAsync(request, cancellationToken: ct);
|
||||
|
||||
if (!response.Success)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin initialization failed: {response.Error}");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Plugin {PluginId} initialized successfully", manifest.Info.Id);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ShutdownPluginAsync(CancellationToken ct)
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
var request = new ShutdownRequest
|
||||
{
|
||||
TimeoutMs = 30000,
|
||||
Reason = "Host requested shutdown"
|
||||
};
|
||||
|
||||
var response = await _client!.ShutdownAsync(request, cancellationToken: ct);
|
||||
|
||||
if (!response.Success)
|
||||
{
|
||||
_logger.LogWarning("Plugin shutdown reported failure: {Message}", response.Error);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<T> InvokeAsync<T>(
|
||||
string operationName,
|
||||
object? parameters,
|
||||
CancellationToken ct)
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
var request = new InvokeRequest
|
||||
{
|
||||
Operation = operationName,
|
||||
CorrelationId = Guid.NewGuid().ToString("N")
|
||||
};
|
||||
|
||||
if (parameters != null)
|
||||
{
|
||||
request.ParametersJson = JsonSerializer.Serialize(parameters, _jsonOptions);
|
||||
}
|
||||
|
||||
var response = await _client!.InvokeAsync(request, cancellationToken: ct);
|
||||
|
||||
if (!response.Success)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Operation '{operationName}' failed: {response.Error}");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(response.ResultJson))
|
||||
{
|
||||
return default!;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<T>(response.ResultJson, _jsonOptions)!;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<TEvent> InvokeStreamingAsync<TEvent>(
|
||||
string operationName,
|
||||
object? parameters,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
var request = new InvokeRequest
|
||||
{
|
||||
Operation = operationName,
|
||||
CorrelationId = Guid.NewGuid().ToString("N")
|
||||
};
|
||||
|
||||
if (parameters != null)
|
||||
{
|
||||
request.ParametersJson = JsonSerializer.Serialize(parameters, _jsonOptions);
|
||||
}
|
||||
|
||||
using var call = _client!.InvokeStreaming(request, cancellationToken: ct);
|
||||
|
||||
await foreach (var evt in call.ResponseStream.ReadAllAsync(ct))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(evt.PayloadJson))
|
||||
{
|
||||
yield return JsonSerializer.Deserialize<TEvent>(evt.PayloadJson, _jsonOptions)!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct)
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
var request = new HealthCheckRequest
|
||||
{
|
||||
IncludeDetails = true
|
||||
};
|
||||
|
||||
var response = await _client!.HealthCheckAsync(request, cancellationToken: ct);
|
||||
|
||||
var status = response.Status.ToLowerInvariant() switch
|
||||
{
|
||||
"healthy" => HealthStatus.Healthy,
|
||||
"degraded" => HealthStatus.Degraded,
|
||||
"unhealthy" => HealthStatus.Unhealthy,
|
||||
_ => HealthStatus.Unknown
|
||||
};
|
||||
|
||||
var details = new Dictionary<string, object>();
|
||||
if (!string.IsNullOrEmpty(response.DetailsJson))
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<Dictionary<string, object>>(
|
||||
response.DetailsJson, _jsonOptions);
|
||||
if (parsed != null)
|
||||
{
|
||||
foreach (var kvp in parsed)
|
||||
{
|
||||
details[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new HealthCheckResult(
|
||||
status,
|
||||
response.Message,
|
||||
TimeSpan.FromMilliseconds(response.DurationMs),
|
||||
details);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
_channel?.Dispose();
|
||||
}
|
||||
|
||||
private void EnsureConnected()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (_client == null)
|
||||
{
|
||||
throw new InvalidOperationException("Not connected to plugin");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetAddress(string socketPath)
|
||||
{
|
||||
// gRPC requires an http:// address even for Unix sockets
|
||||
// The actual connection is handled by the custom handler
|
||||
return "http://localhost";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
|
||||
namespace StellaOps.Plugin.Sandbox.Communication;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for gRPC communication with sandboxed plugins.
|
||||
/// </summary>
|
||||
public interface IGrpcPluginBridge : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Connect to the plugin host at the specified address.
|
||||
/// </summary>
|
||||
/// <param name="address">Socket path or address to connect to.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task ConnectAsync(string address, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Disconnect from the plugin host.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task DisconnectAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the plugin with its manifest.
|
||||
/// </summary>
|
||||
/// <param name="manifest">Plugin manifest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task InitializePluginAsync(PluginManifest manifest, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Request graceful shutdown of the plugin.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task ShutdownPluginAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Perform a health check on the plugin.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Health check result.</returns>
|
||||
Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Invoke an operation on the plugin.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Result type.</typeparam>
|
||||
/// <param name="operationName">Name of the operation.</param>
|
||||
/// <param name="parameters">Operation parameters.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Operation result.</returns>
|
||||
Task<T> InvokeAsync<T>(string operationName, object? parameters, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Invoke a streaming operation on the plugin.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEvent">Event type.</typeparam>
|
||||
/// <param name="operationName">Name of the operation.</param>
|
||||
/// <param name="parameters">Operation parameters.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Async enumerable of events.</returns>
|
||||
IAsyncEnumerable<TEvent> InvokeStreamingAsync<TEvent>(string operationName, object? parameters, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Whether currently connected to the plugin.
|
||||
/// </summary>
|
||||
bool IsConnected { get; }
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package stellaops.plugin.bridge;
|
||||
|
||||
option csharp_namespace = "StellaOps.Plugin.Sandbox.Communication";
|
||||
|
||||
// Service for communicating with sandboxed plugins.
|
||||
service PluginBridge {
|
||||
// Initialize the plugin with its manifest and configuration.
|
||||
rpc Initialize(InitializeRequest) returns (InitializeResponse);
|
||||
|
||||
// Request graceful shutdown of the plugin.
|
||||
rpc Shutdown(ShutdownRequest) returns (ShutdownResponse);
|
||||
|
||||
// Perform a health check on the plugin.
|
||||
rpc HealthCheck(HealthCheckRequest) returns (HealthCheckResponse);
|
||||
|
||||
// Invoke a plugin operation.
|
||||
rpc Invoke(InvokeRequest) returns (InvokeResponse);
|
||||
|
||||
// Invoke a streaming plugin operation.
|
||||
rpc InvokeStreaming(InvokeRequest) returns (stream StreamingEvent);
|
||||
|
||||
// Stream log entries from the plugin.
|
||||
rpc StreamLogs(LogStreamRequest) returns (stream LogEntry);
|
||||
|
||||
// Get plugin capabilities.
|
||||
rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse);
|
||||
}
|
||||
|
||||
// Request to initialize a plugin.
|
||||
message InitializeRequest {
|
||||
// JSON-serialized plugin manifest.
|
||||
string manifest_json = 1;
|
||||
|
||||
// JSON-serialized plugin configuration.
|
||||
string config_json = 2;
|
||||
|
||||
// Sandbox ID for correlation.
|
||||
string sandbox_id = 3;
|
||||
}
|
||||
|
||||
// Response from plugin initialization.
|
||||
message InitializeResponse {
|
||||
// Whether initialization succeeded.
|
||||
bool success = 1;
|
||||
|
||||
// Error message if initialization failed.
|
||||
string error = 2;
|
||||
|
||||
// Plugin version.
|
||||
string version = 3;
|
||||
|
||||
// Capabilities provided by the plugin.
|
||||
repeated string capabilities = 4;
|
||||
}
|
||||
|
||||
// Request for graceful shutdown.
|
||||
message ShutdownRequest {
|
||||
// Maximum time in milliseconds to wait for shutdown.
|
||||
int32 timeout_ms = 1;
|
||||
|
||||
// Reason for shutdown.
|
||||
string reason = 2;
|
||||
}
|
||||
|
||||
// Response from shutdown request.
|
||||
message ShutdownResponse {
|
||||
// Whether shutdown was successful.
|
||||
bool success = 1;
|
||||
|
||||
// Error message if shutdown failed.
|
||||
string error = 2;
|
||||
}
|
||||
|
||||
// Request for health check.
|
||||
message HealthCheckRequest {
|
||||
// Whether to include detailed diagnostics.
|
||||
bool include_details = 1;
|
||||
}
|
||||
|
||||
// Response from health check.
|
||||
message HealthCheckResponse {
|
||||
// Health status: healthy, degraded, unhealthy.
|
||||
string status = 1;
|
||||
|
||||
// Optional message describing the status.
|
||||
string message = 2;
|
||||
|
||||
// Duration of the health check in milliseconds.
|
||||
int32 duration_ms = 3;
|
||||
|
||||
// JSON-serialized additional details.
|
||||
string details_json = 4;
|
||||
}
|
||||
|
||||
// Request to invoke a plugin operation.
|
||||
message InvokeRequest {
|
||||
// Name of the operation to invoke.
|
||||
string operation = 1;
|
||||
|
||||
// JSON-serialized operation parameters.
|
||||
string parameters_json = 2;
|
||||
|
||||
// Maximum time in milliseconds for the operation.
|
||||
int32 timeout_ms = 3;
|
||||
|
||||
// Correlation ID for tracking.
|
||||
string correlation_id = 4;
|
||||
}
|
||||
|
||||
// Response from an operation invocation.
|
||||
message InvokeResponse {
|
||||
// Whether the operation succeeded.
|
||||
bool success = 1;
|
||||
|
||||
// JSON-serialized result.
|
||||
string result_json = 2;
|
||||
|
||||
// Error message if operation failed.
|
||||
string error = 3;
|
||||
|
||||
// Error code if operation failed.
|
||||
string error_code = 4;
|
||||
|
||||
// Execution time in milliseconds.
|
||||
int32 execution_time_ms = 5;
|
||||
}
|
||||
|
||||
// Event from a streaming operation.
|
||||
message StreamingEvent {
|
||||
// Type of event.
|
||||
string event_type = 1;
|
||||
|
||||
// JSON-serialized event payload.
|
||||
string payload_json = 2;
|
||||
|
||||
// Unix timestamp in milliseconds.
|
||||
int64 timestamp_unix_ms = 3;
|
||||
|
||||
// Sequence number for ordering.
|
||||
int64 sequence = 4;
|
||||
}
|
||||
|
||||
// Request to stream logs.
|
||||
message LogStreamRequest {
|
||||
// Minimum log level to stream.
|
||||
string min_level = 1;
|
||||
|
||||
// Maximum number of historical entries to include.
|
||||
int32 history_count = 2;
|
||||
}
|
||||
|
||||
// A log entry from the plugin.
|
||||
message LogEntry {
|
||||
// Unix timestamp in milliseconds.
|
||||
int64 timestamp_unix_ms = 1;
|
||||
|
||||
// Log level.
|
||||
string level = 2;
|
||||
|
||||
// Log message.
|
||||
string message = 3;
|
||||
|
||||
// JSON-serialized structured properties.
|
||||
string properties_json = 4;
|
||||
|
||||
// Logger category/name.
|
||||
string category = 5;
|
||||
|
||||
// Exception details if present.
|
||||
string exception = 6;
|
||||
}
|
||||
|
||||
// Request to get plugin capabilities.
|
||||
message GetCapabilitiesRequest {}
|
||||
|
||||
// Response with plugin capabilities.
|
||||
message GetCapabilitiesResponse {
|
||||
// List of capability types provided.
|
||||
repeated CapabilityInfo capabilities = 1;
|
||||
}
|
||||
|
||||
// Information about a single capability.
|
||||
message CapabilityInfo {
|
||||
// Capability type.
|
||||
string type = 1;
|
||||
|
||||
// Capability ID.
|
||||
string id = 2;
|
||||
|
||||
// JSON-serialized capability configuration schema.
|
||||
string config_schema_json = 3;
|
||||
|
||||
// JSON-serialized capability input schema.
|
||||
string input_schema_json = 4;
|
||||
|
||||
// JSON-serialized capability output schema.
|
||||
string output_schema_json = 5;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Sandbox.Communication;
|
||||
using StellaOps.Plugin.Sandbox.Network;
|
||||
using StellaOps.Plugin.Sandbox.Process;
|
||||
using StellaOps.Plugin.Sandbox.Resources;
|
||||
|
||||
namespace StellaOps.Plugin.Sandbox.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering sandbox services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds plugin sandbox services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureOptions">Optional configuration for plugin process manager.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddPluginSandbox(
|
||||
this IServiceCollection services,
|
||||
Action<PluginProcessManagerOptions>? configureOptions = null)
|
||||
{
|
||||
// Configure options
|
||||
if (configureOptions != null)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
}
|
||||
else
|
||||
{
|
||||
services.Configure<PluginProcessManagerOptions>(_ => { });
|
||||
}
|
||||
|
||||
// Register TimeProvider if not already registered
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
// Register process manager
|
||||
services.AddSingleton<IPluginProcessManager, PluginProcessManager>();
|
||||
|
||||
// Register network policy enforcer
|
||||
services.AddSingleton<INetworkPolicyEnforcer, NetworkPolicyEnforcer>();
|
||||
|
||||
// Register resource limiter (platform-specific)
|
||||
services.AddSingleton<IResourceLimiter>(sp =>
|
||||
{
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return new WindowsResourceLimiter(
|
||||
loggerFactory.CreateLogger<WindowsResourceLimiter>());
|
||||
}
|
||||
else
|
||||
{
|
||||
return new LinuxResourceLimiter(
|
||||
loggerFactory.CreateLogger<LinuxResourceLimiter>());
|
||||
}
|
||||
});
|
||||
|
||||
// Register sandbox factory
|
||||
services.AddSingleton<ISandboxFactory, SandboxFactory>();
|
||||
|
||||
// Register gRPC bridge as transient (one per sandbox)
|
||||
services.AddTransient<IGrpcPluginBridge, GrpcPluginBridge>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds plugin sandbox services with custom resource limiter.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResourceLimiter">The resource limiter type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddPluginSandbox<TResourceLimiter>(
|
||||
this IServiceCollection services)
|
||||
where TResourceLimiter : class, IResourceLimiter
|
||||
{
|
||||
services.AddPluginSandbox();
|
||||
|
||||
// Replace resource limiter registration
|
||||
services.AddSingleton<IResourceLimiter, TResourceLimiter>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds plugin sandbox services with custom network policy enforcer.
|
||||
/// </summary>
|
||||
/// <typeparam name="TNetworkEnforcer">The network policy enforcer type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddPluginSandboxWithNetworkEnforcer<TNetworkEnforcer>(
|
||||
this IServiceCollection services)
|
||||
where TNetworkEnforcer : class, INetworkPolicyEnforcer
|
||||
{
|
||||
services.AddPluginSandbox();
|
||||
|
||||
// Replace network enforcer registration
|
||||
services.AddSingleton<INetworkPolicyEnforcer, TNetworkEnforcer>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
namespace StellaOps.Plugin.Sandbox.Filesystem;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for filesystem access control in sandboxes.
|
||||
/// </summary>
|
||||
public interface IFilesystemPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Check if read access is allowed to a path.
|
||||
/// </summary>
|
||||
/// <param name="path">Path to check.</param>
|
||||
/// <returns>True if read access is allowed.</returns>
|
||||
bool CanRead(string path);
|
||||
|
||||
/// <summary>
|
||||
/// Check if write access is allowed to a path.
|
||||
/// </summary>
|
||||
/// <param name="path">Path to check.</param>
|
||||
/// <returns>True if write access is allowed.</returns>
|
||||
bool CanWrite(string path);
|
||||
|
||||
/// <summary>
|
||||
/// Check if a path is blocked.
|
||||
/// </summary>
|
||||
/// <param name="path">Path to check.</param>
|
||||
/// <returns>True if the path is blocked.</returns>
|
||||
bool IsBlocked(string path);
|
||||
|
||||
/// <summary>
|
||||
/// Get the sandboxed working directory.
|
||||
/// </summary>
|
||||
/// <returns>Path to the sandbox working directory.</returns>
|
||||
string GetWorkingDirectory();
|
||||
|
||||
/// <summary>
|
||||
/// Get the current write usage in bytes.
|
||||
/// </summary>
|
||||
/// <returns>Total bytes written.</returns>
|
||||
long GetWriteUsage();
|
||||
|
||||
/// <summary>
|
||||
/// Check if write quota is exceeded.
|
||||
/// </summary>
|
||||
/// <returns>True if write quota is exceeded.</returns>
|
||||
bool IsWriteQuotaExceeded();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of filesystem policy enforcement.
|
||||
/// </summary>
|
||||
public sealed class SandboxedFilesystem : IFilesystemPolicy
|
||||
{
|
||||
private readonly FilesystemPolicy _policy;
|
||||
private readonly string _workingDirectory;
|
||||
private long _bytesWritten;
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new sandboxed filesystem policy.
|
||||
/// </summary>
|
||||
/// <param name="policy">Policy configuration.</param>
|
||||
/// <param name="workingDirectory">Working directory for the sandbox.</param>
|
||||
public SandboxedFilesystem(FilesystemPolicy policy, string workingDirectory)
|
||||
{
|
||||
_policy = policy;
|
||||
_workingDirectory = workingDirectory;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRead(string path)
|
||||
{
|
||||
var normalizedPath = Path.GetFullPath(path);
|
||||
|
||||
if (IsBlocked(normalizedPath))
|
||||
return false;
|
||||
|
||||
// Allow reads from working directory
|
||||
if (normalizedPath.StartsWith(_workingDirectory, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
// Allow reads from explicitly allowed read-only paths
|
||||
foreach (var readOnlyPath in _policy.ReadOnlyPaths)
|
||||
{
|
||||
if (normalizedPath.StartsWith(Path.GetFullPath(readOnlyPath), StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
// Allow reads from writable paths
|
||||
foreach (var writablePath in _policy.WritablePaths)
|
||||
{
|
||||
if (normalizedPath.StartsWith(Path.GetFullPath(writablePath), StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanWrite(string path)
|
||||
{
|
||||
var normalizedPath = Path.GetFullPath(path);
|
||||
|
||||
if (IsBlocked(normalizedPath))
|
||||
return false;
|
||||
|
||||
if (IsWriteQuotaExceeded())
|
||||
return false;
|
||||
|
||||
// Allow writes to working directory
|
||||
if (normalizedPath.StartsWith(_workingDirectory, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
// Allow writes to explicitly writable paths
|
||||
foreach (var writablePath in _policy.WritablePaths)
|
||||
{
|
||||
if (normalizedPath.StartsWith(Path.GetFullPath(writablePath), StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsBlocked(string path)
|
||||
{
|
||||
var normalizedPath = Path.GetFullPath(path);
|
||||
|
||||
foreach (var blockedPath in _policy.BlockedPaths)
|
||||
{
|
||||
if (normalizedPath.StartsWith(Path.GetFullPath(blockedPath), StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetWorkingDirectory() => _workingDirectory;
|
||||
|
||||
/// <inheritdoc />
|
||||
public long GetWriteUsage()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _bytesWritten;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsWriteQuotaExceeded()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _bytesWritten >= _policy.MaxWriteBytes;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record bytes written.
|
||||
/// </summary>
|
||||
/// <param name="bytes">Number of bytes written.</param>
|
||||
public void RecordWrite(long bytes)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_bytesWritten += bytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
85
src/Plugin/StellaOps.Plugin.Sandbox/ISandbox.cs
Normal file
85
src/Plugin/StellaOps.Plugin.Sandbox/ISandbox.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
using StellaOps.Plugin.Sandbox.Resources;
|
||||
|
||||
namespace StellaOps.Plugin.Sandbox;
|
||||
|
||||
/// <summary>
|
||||
/// Provides isolated execution environment for untrusted plugins.
|
||||
/// </summary>
|
||||
public interface ISandbox : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this sandbox instance.
|
||||
/// </summary>
|
||||
string Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Current sandbox state.
|
||||
/// </summary>
|
||||
SandboxState State { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Current resource usage statistics.
|
||||
/// </summary>
|
||||
ResourceUsage CurrentUsage { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Start the sandbox and load the plugin.
|
||||
/// </summary>
|
||||
/// <param name="manifest">Plugin manifest to load.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task StartAsync(PluginManifest manifest, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Stop the sandbox gracefully within the specified timeout.
|
||||
/// </summary>
|
||||
/// <param name="timeout">Maximum time to wait for graceful shutdown.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task StopAsync(TimeSpan timeout, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Execute an operation in the sandbox and return the result.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Result type.</typeparam>
|
||||
/// <param name="operationName">Name of the operation to execute.</param>
|
||||
/// <param name="parameters">Operation parameters.</param>
|
||||
/// <param name="timeout">Maximum time to wait for the operation.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The operation result.</returns>
|
||||
Task<T> ExecuteAsync<T>(
|
||||
string operationName,
|
||||
object? parameters,
|
||||
TimeSpan timeout,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Execute a streaming operation in the sandbox.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEvent">Event type for the stream.</typeparam>
|
||||
/// <param name="operationName">Name of the operation to execute.</param>
|
||||
/// <param name="parameters">Operation parameters.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Async enumerable of events.</returns>
|
||||
IAsyncEnumerable<TEvent> ExecuteStreamingAsync<TEvent>(
|
||||
string operationName,
|
||||
object? parameters,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Perform health check on the sandboxed plugin.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Health check result.</returns>
|
||||
Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when sandbox state changes.
|
||||
/// </summary>
|
||||
event EventHandler<SandboxStateChangedEventArgs>? StateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when resource limits are approached.
|
||||
/// </summary>
|
||||
event EventHandler<ResourceWarningEventArgs>? ResourceWarning;
|
||||
}
|
||||
27
src/Plugin/StellaOps.Plugin.Sandbox/ISandboxFactory.cs
Normal file
27
src/Plugin/StellaOps.Plugin.Sandbox/ISandboxFactory.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
namespace StellaOps.Plugin.Sandbox;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating sandbox instances.
|
||||
/// </summary>
|
||||
public interface ISandboxFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a new sandbox with the specified configuration.
|
||||
/// </summary>
|
||||
/// <param name="configuration">Sandbox configuration.</param>
|
||||
/// <returns>A new sandbox instance.</returns>
|
||||
ISandbox Create(SandboxConfiguration configuration);
|
||||
|
||||
/// <summary>
|
||||
/// Create a new sandbox with default configuration.
|
||||
/// </summary>
|
||||
/// <returns>A new sandbox instance with default settings.</returns>
|
||||
ISandbox CreateDefault();
|
||||
|
||||
/// <summary>
|
||||
/// Create a sandbox with configuration appropriate for the given trust level.
|
||||
/// </summary>
|
||||
/// <param name="trustLevel">Trust level of the plugin.</param>
|
||||
/// <returns>A sandbox configured for the trust level.</returns>
|
||||
ISandbox CreateForTrustLevel(Abstractions.PluginTrustLevel trustLevel);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace StellaOps.Plugin.Sandbox.Network;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for enforcing network policies on sandboxes.
|
||||
/// </summary>
|
||||
public interface INetworkPolicyEnforcer
|
||||
{
|
||||
/// <summary>
|
||||
/// Apply a network policy to a sandbox.
|
||||
/// </summary>
|
||||
/// <param name="sandboxId">Sandbox identifier.</param>
|
||||
/// <param name="policy">Network policy to apply.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task ApplyPolicyAsync(string sandboxId, NetworkPolicy policy, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Remove network policy from a sandbox.
|
||||
/// </summary>
|
||||
/// <param name="sandboxId">Sandbox identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task RemovePolicyAsync(string sandboxId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Check if a connection is allowed by the policy.
|
||||
/// </summary>
|
||||
/// <param name="sandboxId">Sandbox identifier.</param>
|
||||
/// <param name="host">Target host.</param>
|
||||
/// <param name="port">Target port.</param>
|
||||
/// <returns>True if the connection is allowed.</returns>
|
||||
bool IsAllowed(string sandboxId, string host, int port);
|
||||
|
||||
/// <summary>
|
||||
/// Get the current policy for a sandbox.
|
||||
/// </summary>
|
||||
/// <param name="sandboxId">Sandbox identifier.</param>
|
||||
/// <returns>The active policy, or null if not found.</returns>
|
||||
NetworkPolicy? GetPolicy(string sandboxId);
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Plugin.Sandbox.Network;
|
||||
|
||||
/// <summary>
|
||||
/// Network policy enforcer implementation.
|
||||
/// Uses iptables on Linux, Windows Firewall on Windows.
|
||||
/// </summary>
|
||||
public sealed class NetworkPolicyEnforcer : INetworkPolicyEnforcer
|
||||
{
|
||||
private readonly ILogger<NetworkPolicyEnforcer> _logger;
|
||||
private readonly ConcurrentDictionary<string, NetworkPolicy> _policies = new();
|
||||
private readonly ConcurrentDictionary<string, List<string>> _ruleIds = new();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new network policy enforcer.
|
||||
/// </summary>
|
||||
public NetworkPolicyEnforcer(ILogger<NetworkPolicyEnforcer> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ApplyPolicyAsync(string sandboxId, NetworkPolicy policy, CancellationToken ct)
|
||||
{
|
||||
_policies[sandboxId] = policy;
|
||||
var ruleIds = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
await ApplyLinuxPolicyAsync(sandboxId, policy, ruleIds, ct);
|
||||
}
|
||||
else if (OperatingSystem.IsWindows())
|
||||
{
|
||||
await ApplyWindowsPolicyAsync(sandboxId, policy, ruleIds, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Network policy enforcement not supported on this platform for sandbox {SandboxId}",
|
||||
sandboxId);
|
||||
}
|
||||
|
||||
_ruleIds[sandboxId] = ruleIds;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Applied network policy to sandbox {SandboxId}: AllowedHosts={AllowedHosts}, BlockedPorts={BlockedPorts}",
|
||||
sandboxId,
|
||||
policy.AllowedHosts.Count,
|
||||
policy.BlockedPorts.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to apply network policy to sandbox {SandboxId}", sandboxId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RemovePolicyAsync(string sandboxId, CancellationToken ct)
|
||||
{
|
||||
_policies.TryRemove(sandboxId, out _);
|
||||
|
||||
if (!_ruleIds.TryRemove(sandboxId, out var ruleIds))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
await RemoveLinuxRulesAsync(ruleIds, ct);
|
||||
}
|
||||
else if (OperatingSystem.IsWindows())
|
||||
{
|
||||
await RemoveWindowsRulesAsync(ruleIds, ct);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Removed network policy for sandbox {SandboxId}", sandboxId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to remove network policy for sandbox {SandboxId}", sandboxId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAllowed(string sandboxId, string host, int port)
|
||||
{
|
||||
if (!_policies.TryGetValue(sandboxId, out var policy))
|
||||
return true; // No policy = allow all
|
||||
|
||||
// Check blocked ports
|
||||
if (policy.BlockedPorts.Contains(port))
|
||||
return false;
|
||||
|
||||
// Check if network is disabled
|
||||
if (!policy.AllowAllHosts && policy.AllowedHosts.Count == 0)
|
||||
return false;
|
||||
|
||||
// If no allowed hosts specified, allow all (except blocked)
|
||||
if (policy.AllowedHosts.Count == 0)
|
||||
return true;
|
||||
|
||||
// Check allowed hosts
|
||||
foreach (var pattern in policy.AllowedHosts)
|
||||
{
|
||||
if (MatchesHostPattern(host, pattern))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public NetworkPolicy? GetPolicy(string sandboxId)
|
||||
{
|
||||
return _policies.GetValueOrDefault(sandboxId);
|
||||
}
|
||||
|
||||
private async Task ApplyLinuxPolicyAsync(
|
||||
string sandboxId,
|
||||
NetworkPolicy policy,
|
||||
List<string> ruleIds,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var chainName = $"STELLAOPS_{SanitizeForChain(sandboxId)}";
|
||||
|
||||
// Create custom chain
|
||||
await RunCommandAsync("iptables", $"-N {chainName}", ct, ignoreError: true);
|
||||
ruleIds.Add($"chain:{chainName}");
|
||||
|
||||
if (!policy.AllowAllHosts && policy.AllowedHosts.Count == 0)
|
||||
{
|
||||
// Block all outbound traffic
|
||||
await RunCommandAsync("iptables",
|
||||
$"-A {chainName} -j DROP",
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Block specific ports
|
||||
foreach (var port in policy.BlockedPorts)
|
||||
{
|
||||
await RunCommandAsync("iptables",
|
||||
$"-A {chainName} -p tcp --dport {port} -j DROP",
|
||||
ct);
|
||||
await RunCommandAsync("iptables",
|
||||
$"-A {chainName} -p udp --dport {port} -j DROP",
|
||||
ct);
|
||||
}
|
||||
|
||||
// Allow specific hosts (if specified)
|
||||
if (policy.AllowedHosts.Count > 0)
|
||||
{
|
||||
// Default drop, then allow specific
|
||||
foreach (var host in policy.AllowedHosts)
|
||||
{
|
||||
if (TryResolveHost(host, out var ip))
|
||||
{
|
||||
await RunCommandAsync("iptables",
|
||||
$"-A {chainName} -d {ip} -j ACCEPT",
|
||||
ct);
|
||||
}
|
||||
}
|
||||
await RunCommandAsync("iptables",
|
||||
$"-A {chainName} -j DROP",
|
||||
ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Allow all (except blocked ports above)
|
||||
await RunCommandAsync("iptables",
|
||||
$"-A {chainName} -j ACCEPT",
|
||||
ct);
|
||||
}
|
||||
|
||||
// Add jump to custom chain from OUTPUT
|
||||
await RunCommandAsync("iptables",
|
||||
$"-A OUTPUT -j {chainName}",
|
||||
ct);
|
||||
ruleIds.Add($"jump:{chainName}");
|
||||
}
|
||||
|
||||
private async Task RemoveLinuxRulesAsync(List<string> ruleIds, CancellationToken ct)
|
||||
{
|
||||
foreach (var ruleId in ruleIds.AsEnumerable().Reverse())
|
||||
{
|
||||
if (ruleId.StartsWith("jump:", StringComparison.Ordinal))
|
||||
{
|
||||
var chainName = ruleId[5..];
|
||||
await RunCommandAsync("iptables",
|
||||
$"-D OUTPUT -j {chainName}",
|
||||
ct, ignoreError: true);
|
||||
}
|
||||
else if (ruleId.StartsWith("chain:", StringComparison.Ordinal))
|
||||
{
|
||||
var chainName = ruleId[6..];
|
||||
await RunCommandAsync("iptables",
|
||||
$"-F {chainName}",
|
||||
ct, ignoreError: true);
|
||||
await RunCommandAsync("iptables",
|
||||
$"-X {chainName}",
|
||||
ct, ignoreError: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplyWindowsPolicyAsync(
|
||||
string sandboxId,
|
||||
NetworkPolicy policy,
|
||||
List<string> ruleIds,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var rulePrefix = $"StellaOps_Sandbox_{SanitizeForRule(sandboxId)}";
|
||||
|
||||
if (!policy.AllowAllHosts && policy.AllowedHosts.Count == 0)
|
||||
{
|
||||
// Block all outbound
|
||||
var ruleName = $"{rulePrefix}_BlockAll";
|
||||
await RunCommandAsync("netsh",
|
||||
$"advfirewall firewall add rule name=\"{ruleName}\" dir=out action=block",
|
||||
ct);
|
||||
ruleIds.Add(ruleName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Block specific ports
|
||||
var portIndex = 0;
|
||||
foreach (var port in policy.BlockedPorts)
|
||||
{
|
||||
var ruleName = $"{rulePrefix}_BlockPort{portIndex++}";
|
||||
await RunCommandAsync("netsh",
|
||||
$"advfirewall firewall add rule name=\"{ruleName}\" dir=out action=block protocol=tcp remoteport={port}",
|
||||
ct);
|
||||
ruleIds.Add(ruleName);
|
||||
|
||||
ruleName = $"{rulePrefix}_BlockPort{portIndex++}";
|
||||
await RunCommandAsync("netsh",
|
||||
$"advfirewall firewall add rule name=\"{ruleName}\" dir=out action=block protocol=udp remoteport={port}",
|
||||
ct);
|
||||
ruleIds.Add(ruleName);
|
||||
}
|
||||
|
||||
// Allow specific hosts (if specified)
|
||||
if (policy.AllowedHosts.Count > 0)
|
||||
{
|
||||
var hostIndex = 0;
|
||||
foreach (var host in policy.AllowedHosts)
|
||||
{
|
||||
if (TryResolveHost(host, out var ip))
|
||||
{
|
||||
var ruleName = $"{rulePrefix}_AllowHost{hostIndex++}";
|
||||
await RunCommandAsync("netsh",
|
||||
$"advfirewall firewall add rule name=\"{ruleName}\" dir=out action=allow remoteip={ip}",
|
||||
ct);
|
||||
ruleIds.Add(ruleName);
|
||||
}
|
||||
}
|
||||
|
||||
// Block all other
|
||||
var blockRuleName = $"{rulePrefix}_BlockOther";
|
||||
await RunCommandAsync("netsh",
|
||||
$"advfirewall firewall add rule name=\"{blockRuleName}\" dir=out action=block",
|
||||
ct);
|
||||
ruleIds.Add(blockRuleName);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveWindowsRulesAsync(List<string> ruleIds, CancellationToken ct)
|
||||
{
|
||||
foreach (var ruleName in ruleIds)
|
||||
{
|
||||
await RunCommandAsync("netsh",
|
||||
$"advfirewall firewall delete rule name=\"{ruleName}\"",
|
||||
ct, ignoreError: true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunCommandAsync(
|
||||
string command,
|
||||
string arguments,
|
||||
CancellationToken ct,
|
||||
bool ignoreError = false)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = command,
|
||||
Arguments = arguments,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = new System.Diagnostics.Process { StartInfo = startInfo };
|
||||
|
||||
try
|
||||
{
|
||||
process.Start();
|
||||
await process.WaitForExitAsync(ct);
|
||||
|
||||
if (process.ExitCode != 0 && !ignoreError)
|
||||
{
|
||||
var error = await process.StandardError.ReadToEndAsync(ct);
|
||||
_logger.LogWarning(
|
||||
"Command '{Command} {Arguments}' failed with exit code {ExitCode}: {Error}",
|
||||
command, arguments, process.ExitCode, error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ignoreError)
|
||||
{
|
||||
_logger.LogDebug(ex, "Command '{Command} {Arguments}' failed (ignored)", command, arguments);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool MatchesHostPattern(string host, string pattern)
|
||||
{
|
||||
// Simple wildcard matching
|
||||
if (pattern == "*")
|
||||
return true;
|
||||
|
||||
if (pattern.StartsWith("*.", StringComparison.Ordinal))
|
||||
{
|
||||
var suffix = pattern[1..];
|
||||
return host.EndsWith(suffix, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return string.Equals(host, pattern, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool TryResolveHost(string host, out string ip)
|
||||
{
|
||||
ip = string.Empty;
|
||||
|
||||
// Check if already an IP
|
||||
if (IPAddress.TryParse(host, out var address))
|
||||
{
|
||||
ip = address.ToString();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Skip wildcards
|
||||
if (host.Contains('*'))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var addresses = Dns.GetHostAddresses(host);
|
||||
if (addresses.Length > 0)
|
||||
{
|
||||
ip = addresses[0].ToString();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// DNS resolution failed
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string SanitizeForChain(string input)
|
||||
{
|
||||
return Regex.Replace(input, "[^a-zA-Z0-9_]", "_")[..Math.Min(input.Length, 20)];
|
||||
}
|
||||
|
||||
private static string SanitizeForRule(string input)
|
||||
{
|
||||
return Regex.Replace(input, "[^a-zA-Z0-9_-]", "_");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using StellaOps.Plugin.Sandbox.Resources;
|
||||
using SystemProcess = System.Diagnostics.Process;
|
||||
|
||||
namespace StellaOps.Plugin.Sandbox.Process;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for managing plugin host processes.
|
||||
/// </summary>
|
||||
public interface IPluginProcessManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Start a new plugin host process.
|
||||
/// </summary>
|
||||
/// <param name="request">Process start request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The started process.</returns>
|
||||
Task<SystemProcess> StartAsync(ProcessStartRequest request, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Stop a plugin host process gracefully.
|
||||
/// </summary>
|
||||
/// <param name="process">Process to stop.</param>
|
||||
/// <param name="timeout">Maximum time to wait for graceful shutdown.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task StopAsync(SystemProcess process, TimeSpan timeout, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Kill a plugin host process immediately.
|
||||
/// </summary>
|
||||
/// <param name="process">Process to kill.</param>
|
||||
Task KillAsync(SystemProcess process);
|
||||
|
||||
/// <summary>
|
||||
/// Get the path to the plugin host executable.
|
||||
/// </summary>
|
||||
/// <returns>Path to the executable.</returns>
|
||||
string GetHostExecutablePath();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to start a plugin host process.
|
||||
/// </summary>
|
||||
public sealed record ProcessStartRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to the plugin assembly.
|
||||
/// </summary>
|
||||
public required string PluginAssemblyPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin entry point type name.
|
||||
/// </summary>
|
||||
public string? EntryPoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Working directory for the process.
|
||||
/// </summary>
|
||||
public required string WorkingDirectory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Socket path for IPC communication.
|
||||
/// </summary>
|
||||
public required string SocketPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Resource configuration to apply.
|
||||
/// </summary>
|
||||
public required ResourceConfiguration ResourceConfiguration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment variables to set.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> EnvironmentVariables { get; init; } =
|
||||
new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// Arguments to pass to the host process.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Arguments { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SystemProcess = System.Diagnostics.Process;
|
||||
|
||||
namespace StellaOps.Plugin.Sandbox.Process;
|
||||
|
||||
/// <summary>
|
||||
/// Options for the plugin process manager.
|
||||
/// </summary>
|
||||
public sealed class PluginProcessManagerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to the plugin host executable.
|
||||
/// If not specified, will look for StellaOps.Plugin.Host in the application directory.
|
||||
/// </summary>
|
||||
public string? HostExecutablePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Default timeout for graceful shutdown.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultShutdownTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plugin process manager implementation.
|
||||
/// </summary>
|
||||
public sealed class PluginProcessManager : IPluginProcessManager
|
||||
{
|
||||
private readonly PluginProcessManagerOptions _options;
|
||||
private readonly ILogger<PluginProcessManager> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin process manager.
|
||||
/// </summary>
|
||||
public PluginProcessManager(
|
||||
IOptions<PluginProcessManagerOptions> options,
|
||||
ILogger<PluginProcessManager> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SystemProcess> StartAsync(ProcessStartRequest request, CancellationToken ct)
|
||||
{
|
||||
var hostPath = GetHostExecutablePath();
|
||||
|
||||
if (!File.Exists(hostPath))
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
$"Plugin host executable not found at {hostPath}");
|
||||
}
|
||||
|
||||
var arguments = BuildArguments(request);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = hostPath,
|
||||
Arguments = arguments,
|
||||
WorkingDirectory = request.WorkingDirectory,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardInput = true
|
||||
};
|
||||
|
||||
// Set environment variables
|
||||
foreach (var kvp in request.EnvironmentVariables)
|
||||
{
|
||||
startInfo.Environment[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
// Add standard sandbox environment variables
|
||||
startInfo.Environment["STELLAOPS_SANDBOX_MODE"] = "true";
|
||||
startInfo.Environment["STELLAOPS_PLUGIN_SOCKET"] = request.SocketPath;
|
||||
startInfo.Environment["STELLAOPS_PLUGIN_ASSEMBLY"] = request.PluginAssemblyPath;
|
||||
|
||||
if (!string.IsNullOrEmpty(request.EntryPoint))
|
||||
{
|
||||
startInfo.Environment["STELLAOPS_PLUGIN_ENTRYPOINT"] = request.EntryPoint;
|
||||
}
|
||||
|
||||
var process = new SystemProcess { StartInfo = startInfo };
|
||||
|
||||
// Set up output handling
|
||||
process.OutputDataReceived += (_, e) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(e.Data))
|
||||
{
|
||||
_logger.LogDebug("[Plugin:{Pid}] {Output}", process.Id, e.Data);
|
||||
}
|
||||
};
|
||||
|
||||
process.ErrorDataReceived += (_, e) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(e.Data))
|
||||
{
|
||||
_logger.LogWarning("[Plugin:{Pid}] {Error}", process.Id, e.Data);
|
||||
}
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
if (!process.Start())
|
||||
{
|
||||
throw new InvalidOperationException("Failed to start plugin host process");
|
||||
}
|
||||
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Started plugin host process {Pid} for assembly {Assembly}",
|
||||
process.Id,
|
||||
Path.GetFileName(request.PluginAssemblyPath));
|
||||
|
||||
return Task.FromResult(process);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to start plugin host process");
|
||||
process.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(SystemProcess process, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
if (process.HasExited)
|
||||
{
|
||||
_logger.LogDebug("Process {Pid} already exited", process.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Try graceful shutdown first via stdin
|
||||
if (!process.StandardInput.BaseStream.CanWrite)
|
||||
{
|
||||
_logger.LogDebug("Cannot write to stdin for process {Pid}", process.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
await process.StandardInput.WriteLineAsync("SHUTDOWN");
|
||||
await process.StandardInput.FlushAsync(ct);
|
||||
}
|
||||
|
||||
// Wait for graceful exit
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(timeout);
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeoutCts.Token);
|
||||
_logger.LogDebug("Process {Pid} exited gracefully with code {ExitCode}",
|
||||
process.Id, process.ExitCode);
|
||||
return;
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning("Process {Pid} did not exit gracefully within timeout", process.Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error during graceful shutdown of process {Pid}", process.Id);
|
||||
}
|
||||
|
||||
// Force kill if still running
|
||||
await KillAsync(process);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task KillAsync(SystemProcess process)
|
||||
{
|
||||
if (process.HasExited)
|
||||
return Task.CompletedTask;
|
||||
|
||||
try
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
_logger.LogWarning("Killed process {Pid}", process.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to kill process {Pid}", process.Id);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetHostExecutablePath()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_options.HostExecutablePath))
|
||||
{
|
||||
return _options.HostExecutablePath;
|
||||
}
|
||||
|
||||
var appDir = AppContext.BaseDirectory;
|
||||
var hostName = OperatingSystem.IsWindows()
|
||||
? "StellaOps.Plugin.Host.exe"
|
||||
: "StellaOps.Plugin.Host";
|
||||
|
||||
var hostPath = Path.Combine(appDir, hostName);
|
||||
|
||||
// Also check in tools subdirectory
|
||||
if (!File.Exists(hostPath))
|
||||
{
|
||||
hostPath = Path.Combine(appDir, "tools", hostName);
|
||||
}
|
||||
|
||||
return hostPath;
|
||||
}
|
||||
|
||||
private static string BuildArguments(ProcessStartRequest request)
|
||||
{
|
||||
var args = new List<string>
|
||||
{
|
||||
"--assembly", QuoteArgument(request.PluginAssemblyPath),
|
||||
"--socket", QuoteArgument(request.SocketPath),
|
||||
"--workdir", QuoteArgument(request.WorkingDirectory)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(request.EntryPoint))
|
||||
{
|
||||
args.Add("--entrypoint");
|
||||
args.Add(QuoteArgument(request.EntryPoint));
|
||||
}
|
||||
|
||||
// Add custom arguments
|
||||
foreach (var arg in request.Arguments)
|
||||
{
|
||||
args.Add(arg);
|
||||
}
|
||||
|
||||
return string.Join(" ", args);
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string arg)
|
||||
{
|
||||
if (arg.Contains(' ') || arg.Contains('"'))
|
||||
{
|
||||
return $"\"{arg.Replace("\"", "\\\"")}\"";
|
||||
}
|
||||
return arg;
|
||||
}
|
||||
}
|
||||
473
src/Plugin/StellaOps.Plugin.Sandbox/ProcessSandbox.cs
Normal file
473
src/Plugin/StellaOps.Plugin.Sandbox/ProcessSandbox.cs
Normal file
@@ -0,0 +1,473 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
using StellaOps.Plugin.Sandbox.Communication;
|
||||
using StellaOps.Plugin.Sandbox.Filesystem;
|
||||
using StellaOps.Plugin.Sandbox.Network;
|
||||
using StellaOps.Plugin.Sandbox.Process;
|
||||
using StellaOps.Plugin.Sandbox.Resources;
|
||||
using SystemProcess = System.Diagnostics.Process;
|
||||
|
||||
namespace StellaOps.Plugin.Sandbox;
|
||||
|
||||
/// <summary>
|
||||
/// Process-based sandbox implementation for untrusted plugins.
|
||||
/// </summary>
|
||||
public sealed class ProcessSandbox : ISandbox
|
||||
{
|
||||
private readonly SandboxConfiguration _config;
|
||||
private readonly IPluginProcessManager _processManager;
|
||||
private readonly IGrpcPluginBridge _bridge;
|
||||
private readonly IResourceLimiter _resourceLimiter;
|
||||
private readonly INetworkPolicyEnforcer _networkEnforcer;
|
||||
private readonly ILogger<ProcessSandbox> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private SystemProcess? _process;
|
||||
private SandboxState _state = SandboxState.Created;
|
||||
private ResourceUsage _currentUsage = ResourceUsage.Empty;
|
||||
private CancellationTokenSource? _monitoringCts;
|
||||
private Task? _monitoringTask;
|
||||
private string? _workingDirectory;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Id { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public SandboxState State => _state;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ResourceUsage CurrentUsage => _currentUsage;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<SandboxStateChangedEventArgs>? StateChanged;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<ResourceWarningEventArgs>? ResourceWarning;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new process sandbox.
|
||||
/// </summary>
|
||||
public ProcessSandbox(
|
||||
string id,
|
||||
SandboxConfiguration config,
|
||||
IPluginProcessManager processManager,
|
||||
IGrpcPluginBridge bridge,
|
||||
IResourceLimiter resourceLimiter,
|
||||
INetworkPolicyEnforcer networkEnforcer,
|
||||
ILogger<ProcessSandbox> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
Id = id;
|
||||
_config = config;
|
||||
_processManager = processManager;
|
||||
_bridge = bridge;
|
||||
_resourceLimiter = resourceLimiter;
|
||||
_networkEnforcer = networkEnforcer;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(PluginManifest manifest, CancellationToken ct)
|
||||
{
|
||||
TransitionState(SandboxState.Starting);
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Create isolated working directory
|
||||
_workingDirectory = PrepareWorkingDirectory(manifest);
|
||||
|
||||
// 2. Configure resource limits
|
||||
var resourceConfig = _resourceLimiter.CreateConfiguration(_config.ResourceLimits);
|
||||
|
||||
// 3. Configure network policy
|
||||
await _networkEnforcer.ApplyPolicyAsync(Id, _config.NetworkPolicy, ct);
|
||||
|
||||
// 4. Start the plugin host process
|
||||
var socketPath = GetSocketPath();
|
||||
_process = await _processManager.StartAsync(new ProcessStartRequest
|
||||
{
|
||||
PluginAssemblyPath = manifest.AssemblyPath!,
|
||||
EntryPoint = manifest.EntryPoint,
|
||||
WorkingDirectory = _workingDirectory,
|
||||
SocketPath = socketPath,
|
||||
ResourceConfiguration = resourceConfig,
|
||||
EnvironmentVariables = _config.EnvironmentVariables
|
||||
}, ct);
|
||||
|
||||
// 5. Apply resource limits to the process
|
||||
await _resourceLimiter.ApplyLimitsAsync(_process, resourceConfig, ct);
|
||||
|
||||
// 6. Wait for the process to be ready and connect
|
||||
await WaitForReadyAsync(socketPath, ct);
|
||||
|
||||
// 7. Initialize the plugin
|
||||
await _bridge.InitializePluginAsync(manifest, ct);
|
||||
|
||||
// 8. Start resource monitoring
|
||||
StartResourceMonitoring();
|
||||
|
||||
TransitionState(SandboxState.Running);
|
||||
|
||||
_logger.LogInformation("Sandbox {Id} started for plugin {PluginId}",
|
||||
Id, manifest.Info.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to start sandbox {Id}", Id);
|
||||
TransitionState(SandboxState.Failed, ex.Message);
|
||||
await CleanupAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
if (_state is SandboxState.Stopped or SandboxState.Failed or SandboxState.Killed)
|
||||
return;
|
||||
|
||||
TransitionState(SandboxState.Stopping);
|
||||
|
||||
try
|
||||
{
|
||||
// Stop monitoring
|
||||
_monitoringCts?.Cancel();
|
||||
if (_monitoringTask != null)
|
||||
{
|
||||
try { await _monitoringTask; } catch { /* Ignore */ }
|
||||
}
|
||||
|
||||
// 1. Signal graceful shutdown via gRPC
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(timeout);
|
||||
|
||||
try
|
||||
{
|
||||
if (_bridge.IsConnected)
|
||||
{
|
||||
await _bridge.ShutdownPluginAsync(timeoutCts.Token);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("Sandbox {Id} did not shutdown gracefully, killing", Id);
|
||||
}
|
||||
|
||||
// 2. Disconnect bridge
|
||||
await _bridge.DisconnectAsync(ct);
|
||||
|
||||
// 3. Stop the process
|
||||
if (_process != null)
|
||||
{
|
||||
await _processManager.StopAsync(_process, timeout, ct);
|
||||
}
|
||||
|
||||
// 4. Cleanup resources
|
||||
await CleanupAsync();
|
||||
|
||||
TransitionState(SandboxState.Stopped);
|
||||
|
||||
_logger.LogInformation("Sandbox {Id} stopped", Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error stopping sandbox {Id}", Id);
|
||||
TransitionState(SandboxState.Failed, ex.Message);
|
||||
await CleanupAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<T> ExecuteAsync<T>(
|
||||
string operationName,
|
||||
object? parameters,
|
||||
TimeSpan timeout,
|
||||
CancellationToken ct)
|
||||
{
|
||||
EnsureRunning();
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(timeout);
|
||||
|
||||
try
|
||||
{
|
||||
return await _bridge.InvokeAsync<T>(operationName, parameters, timeoutCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException($"Operation '{operationName}' timed out after {timeout}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<TEvent> ExecuteStreamingAsync<TEvent>(
|
||||
string operationName,
|
||||
object? parameters,
|
||||
[EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
EnsureRunning();
|
||||
|
||||
await foreach (var evt in _bridge.InvokeStreamingAsync<TEvent>(operationName, parameters, ct))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct)
|
||||
{
|
||||
if (_state != SandboxState.Running)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy($"Sandbox is in state {_state}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(_config.Timeouts.HealthCheckTimeout);
|
||||
|
||||
var result = await _bridge.HealthCheckAsync(timeoutCts.Token);
|
||||
|
||||
// Add resource usage to details
|
||||
var details = new Dictionary<string, object>(result.Details ?? new Dictionary<string, object>())
|
||||
{
|
||||
["sandboxId"] = Id,
|
||||
["memoryUsageMb"] = _currentUsage.MemoryUsageMb,
|
||||
["cpuUsagePercent"] = _currentUsage.CpuUsagePercent
|
||||
};
|
||||
|
||||
return result with { Details = details };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_state == SandboxState.Running)
|
||||
{
|
||||
await StopAsync(_config.Timeouts.ShutdownTimeout, CancellationToken.None);
|
||||
}
|
||||
|
||||
_bridge.Dispose();
|
||||
_monitoringCts?.Dispose();
|
||||
}
|
||||
|
||||
private void EnsureRunning()
|
||||
{
|
||||
if (_state != SandboxState.Running)
|
||||
{
|
||||
throw new InvalidOperationException($"Sandbox is not running (state: {_state})");
|
||||
}
|
||||
}
|
||||
|
||||
private void TransitionState(SandboxState newState, string? reason = null)
|
||||
{
|
||||
var oldState = _state;
|
||||
_state = newState;
|
||||
|
||||
_logger.LogDebug("Sandbox {Id} state changed: {OldState} -> {NewState} ({Reason})",
|
||||
Id, oldState, newState, reason ?? "N/A");
|
||||
|
||||
StateChanged?.Invoke(this, new SandboxStateChangedEventArgs
|
||||
{
|
||||
OldState = oldState,
|
||||
NewState = newState,
|
||||
Reason = reason
|
||||
});
|
||||
}
|
||||
|
||||
private string PrepareWorkingDirectory(PluginManifest manifest)
|
||||
{
|
||||
var workDir = _config.WorkingDirectory
|
||||
?? Path.Combine(Path.GetTempPath(), "stellaops-sandbox", Id);
|
||||
|
||||
if (Directory.Exists(workDir))
|
||||
Directory.Delete(workDir, recursive: true);
|
||||
|
||||
Directory.CreateDirectory(workDir);
|
||||
|
||||
// Copy plugin files to sandbox directory
|
||||
if (!string.IsNullOrEmpty(manifest.AssemblyPath))
|
||||
{
|
||||
var pluginDir = Path.GetDirectoryName(manifest.AssemblyPath);
|
||||
if (!string.IsNullOrEmpty(pluginDir) && Directory.Exists(pluginDir))
|
||||
{
|
||||
CopyDirectory(pluginDir, workDir);
|
||||
}
|
||||
}
|
||||
|
||||
return workDir;
|
||||
}
|
||||
|
||||
private async Task CleanupAsync()
|
||||
{
|
||||
// Cleanup network policy
|
||||
try
|
||||
{
|
||||
await _networkEnforcer.RemovePolicyAsync(Id, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to cleanup network policy for sandbox {Id}", Id);
|
||||
}
|
||||
|
||||
// Cleanup resource limits
|
||||
if (_process != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _resourceLimiter.RemoveLimitsAsync(_process, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to cleanup resource limits for sandbox {Id}", Id);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup working directory
|
||||
CleanupWorkingDirectory();
|
||||
}
|
||||
|
||||
private void CleanupWorkingDirectory()
|
||||
{
|
||||
var workDir = _workingDirectory
|
||||
?? Path.Combine(Path.GetTempPath(), "stellaops-sandbox", Id);
|
||||
|
||||
if (Directory.Exists(workDir))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(workDir, recursive: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to cleanup sandbox directory {WorkDir}", workDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string GetSocketPath()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return $"\\\\.\\pipe\\stellaops-sandbox-{Id}";
|
||||
}
|
||||
else
|
||||
{
|
||||
return Path.Combine(Path.GetTempPath(), $"stellaops-sandbox-{Id}.sock");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WaitForReadyAsync(string socketPath, CancellationToken ct)
|
||||
{
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(_config.Timeouts.StartupTimeout);
|
||||
|
||||
while (!timeoutCts.IsCancellationRequested)
|
||||
{
|
||||
if (_process?.HasExited == true)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin process exited with code {_process.ExitCode}");
|
||||
}
|
||||
|
||||
// Try to connect
|
||||
try
|
||||
{
|
||||
await _bridge.ConnectAsync(socketPath, timeoutCts.Token);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
// Not ready yet, wait and retry
|
||||
await Task.Delay(100, timeoutCts.Token);
|
||||
}
|
||||
}
|
||||
|
||||
throw new TimeoutException("Plugin process did not become ready in time");
|
||||
}
|
||||
|
||||
private void StartResourceMonitoring()
|
||||
{
|
||||
_monitoringCts = new CancellationTokenSource();
|
||||
_monitoringTask = Task.Run(async () =>
|
||||
{
|
||||
while (!_monitoringCts.Token.IsCancellationRequested && _state == SandboxState.Running)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_process != null && !_process.HasExited)
|
||||
{
|
||||
_currentUsage = await _resourceLimiter.GetUsageAsync(_process, _monitoringCts.Token);
|
||||
|
||||
// Check thresholds
|
||||
CheckResourceThreshold(ResourceType.Memory,
|
||||
_currentUsage.MemoryUsageMb,
|
||||
_config.ResourceLimits.MaxMemoryMb);
|
||||
|
||||
CheckResourceThreshold(ResourceType.Cpu,
|
||||
_currentUsage.CpuUsagePercent,
|
||||
_config.ResourceLimits.MaxCpuPercent);
|
||||
|
||||
// Check if limits exceeded
|
||||
var limitCheck = await _resourceLimiter.CheckLimitsAsync(
|
||||
_process, _config.ResourceLimits, _monitoringCts.Token);
|
||||
|
||||
if (limitCheck.IsExceeded)
|
||||
{
|
||||
_logger.LogWarning("Sandbox {Id} exceeded resource limit: {Message}",
|
||||
Id, limitCheck.Message);
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(1000, _monitoringCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error monitoring resources for sandbox {Id}", Id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void CheckResourceThreshold(ResourceType resource, double current, double max)
|
||||
{
|
||||
if (max <= 0) return;
|
||||
|
||||
var percent = (current / max) * 100;
|
||||
if (percent >= 80)
|
||||
{
|
||||
ResourceWarning?.Invoke(this, new ResourceWarningEventArgs
|
||||
{
|
||||
Resource = resource,
|
||||
CurrentUsagePercent = percent,
|
||||
ThresholdPercent = 80
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyDirectory(string source, string destination)
|
||||
{
|
||||
foreach (var dir in Directory.GetDirectories(source, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
Directory.CreateDirectory(dir.Replace(source, destination));
|
||||
}
|
||||
|
||||
foreach (var file in Directory.GetFiles(source, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
File.Copy(file, file.Replace(source, destination), overwrite: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using SystemProcess = System.Diagnostics.Process;
|
||||
|
||||
namespace StellaOps.Plugin.Sandbox.Resources;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for applying and monitoring resource limits on processes.
|
||||
/// </summary>
|
||||
public interface IResourceLimiter
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a resource configuration from the specified limits.
|
||||
/// </summary>
|
||||
/// <param name="limits">Resource limits to configure.</param>
|
||||
/// <returns>Platform-specific resource configuration.</returns>
|
||||
ResourceConfiguration CreateConfiguration(ResourceLimits limits);
|
||||
|
||||
/// <summary>
|
||||
/// Apply resource limits to a process.
|
||||
/// </summary>
|
||||
/// <param name="process">Process to limit.</param>
|
||||
/// <param name="config">Resource configuration.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task ApplyLimitsAsync(SystemProcess process, ResourceConfiguration config, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get current resource usage for a process.
|
||||
/// </summary>
|
||||
/// <param name="process">Process to monitor.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Current resource usage.</returns>
|
||||
Task<ResourceUsage> GetUsageAsync(SystemProcess process, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Remove resource limits from a process.
|
||||
/// </summary>
|
||||
/// <param name="process">Process to unlimit.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task RemoveLimitsAsync(SystemProcess process, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Check if the process has exceeded any limits.
|
||||
/// </summary>
|
||||
/// <param name="process">Process to check.</param>
|
||||
/// <param name="limits">Limits to check against.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>True if any limit is exceeded.</returns>
|
||||
Task<LimitCheckResult> CheckLimitsAsync(SystemProcess process, ResourceLimits limits, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a limit check.
|
||||
/// </summary>
|
||||
public sealed record LimitCheckResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether any limit was exceeded.
|
||||
/// </summary>
|
||||
public required bool IsExceeded { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Which resource exceeded its limit, if any.
|
||||
/// </summary>
|
||||
public ResourceType? ExceededResource { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current value of the exceeded resource.
|
||||
/// </summary>
|
||||
public double? CurrentValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Limit value that was exceeded.
|
||||
/// </summary>
|
||||
public double? LimitValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Message describing the exceeded limit.
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Result indicating no limits were exceeded.
|
||||
/// </summary>
|
||||
public static LimitCheckResult Ok => new() { IsExceeded = false };
|
||||
|
||||
/// <summary>
|
||||
/// Create a result for an exceeded limit.
|
||||
/// </summary>
|
||||
public static LimitCheckResult Exceeded(ResourceType resource, double current, double limit) => new()
|
||||
{
|
||||
IsExceeded = true,
|
||||
ExceededResource = resource,
|
||||
CurrentValue = current,
|
||||
LimitValue = limit,
|
||||
Message = $"{resource} limit exceeded: {current:F2} > {limit:F2}"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SystemProcess = System.Diagnostics.Process;
|
||||
|
||||
namespace StellaOps.Plugin.Sandbox.Resources;
|
||||
|
||||
/// <summary>
|
||||
/// Linux cgroups v2 resource limiter implementation.
|
||||
/// </summary>
|
||||
public sealed class LinuxResourceLimiter : IResourceLimiter
|
||||
{
|
||||
private const string CgroupBasePath = "/sys/fs/cgroup";
|
||||
private const string StellaOpsCgroupName = "stellaops-sandbox";
|
||||
|
||||
private readonly ILogger<LinuxResourceLimiter> _logger;
|
||||
private readonly Dictionary<int, string> _processCgroups = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Linux resource limiter.
|
||||
/// </summary>
|
||||
public LinuxResourceLimiter(ILogger<LinuxResourceLimiter> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ResourceConfiguration CreateConfiguration(ResourceLimits limits)
|
||||
{
|
||||
var cpuQuotaUs = limits.MaxCpuPercent > 0
|
||||
? (long)(limits.MaxCpuPercent / 100.0 * 100_000)
|
||||
: 0;
|
||||
|
||||
return new ResourceConfiguration
|
||||
{
|
||||
MemoryLimitBytes = limits.MaxMemoryMb * 1024 * 1024,
|
||||
CpuQuotaUs = cpuQuotaUs,
|
||||
CpuPeriodUs = 100_000,
|
||||
MaxProcesses = limits.MaxProcesses,
|
||||
MaxOpenFiles = limits.MaxOpenFiles
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ApplyLimitsAsync(
|
||||
SystemProcess process,
|
||||
ResourceConfiguration config,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!OperatingSystem.IsLinux())
|
||||
{
|
||||
_logger.LogWarning("LinuxResourceLimiter called on non-Linux platform");
|
||||
return;
|
||||
}
|
||||
|
||||
var cgroupPath = Path.Combine(CgroupBasePath, $"{StellaOpsCgroupName}-{process.Id}");
|
||||
|
||||
try
|
||||
{
|
||||
// Create cgroup directory
|
||||
if (!Directory.Exists(cgroupPath))
|
||||
{
|
||||
Directory.CreateDirectory(cgroupPath);
|
||||
}
|
||||
|
||||
// Configure memory limit
|
||||
if (config.MemoryLimitBytes > 0)
|
||||
{
|
||||
await WriteControlFileAsync(
|
||||
cgroupPath,
|
||||
"memory.max",
|
||||
config.MemoryLimitBytes.ToString(CultureInfo.InvariantCulture),
|
||||
ct);
|
||||
|
||||
// Also set high watermark for throttling before kill
|
||||
var highMark = (long)(config.MemoryLimitBytes * 0.9);
|
||||
await WriteControlFileAsync(
|
||||
cgroupPath,
|
||||
"memory.high",
|
||||
highMark.ToString(CultureInfo.InvariantCulture),
|
||||
ct);
|
||||
}
|
||||
|
||||
// Configure CPU limit
|
||||
if (config.CpuQuotaUs > 0)
|
||||
{
|
||||
await WriteControlFileAsync(
|
||||
cgroupPath,
|
||||
"cpu.max",
|
||||
$"{config.CpuQuotaUs} {config.CpuPeriodUs}",
|
||||
ct);
|
||||
}
|
||||
|
||||
// Configure process limit
|
||||
if (config.MaxProcesses > 0)
|
||||
{
|
||||
await WriteControlFileAsync(
|
||||
cgroupPath,
|
||||
"pids.max",
|
||||
config.MaxProcesses.ToString(CultureInfo.InvariantCulture),
|
||||
ct);
|
||||
}
|
||||
|
||||
// Add process to cgroup
|
||||
await WriteControlFileAsync(
|
||||
cgroupPath,
|
||||
"cgroup.procs",
|
||||
process.Id.ToString(CultureInfo.InvariantCulture),
|
||||
ct);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_processCgroups[process.Id] = cgroupPath;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Applied cgroup limits to process {ProcessId}: Memory={MemoryBytes}B, CPU quota={CpuQuotaUs}us",
|
||||
process.Id,
|
||||
config.MemoryLimitBytes,
|
||||
config.CpuQuotaUs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to apply cgroup limits to process {ProcessId}", process.Id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RemoveLimitsAsync(SystemProcess process, CancellationToken ct)
|
||||
{
|
||||
if (!OperatingSystem.IsLinux())
|
||||
return;
|
||||
|
||||
string? cgroupPath;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_processCgroups.TryGetValue(process.Id, out cgroupPath))
|
||||
return;
|
||||
|
||||
_processCgroups.Remove(process.Id);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Move process to root cgroup first (if still running)
|
||||
if (!process.HasExited)
|
||||
{
|
||||
await WriteControlFileAsync(
|
||||
CgroupBasePath,
|
||||
"cgroup.procs",
|
||||
process.Id.ToString(CultureInfo.InvariantCulture),
|
||||
ct);
|
||||
}
|
||||
|
||||
// Remove cgroup directory
|
||||
if (Directory.Exists(cgroupPath))
|
||||
{
|
||||
Directory.Delete(cgroupPath);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Removed cgroup for process {ProcessId}", process.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to cleanup cgroup for process {ProcessId}", process.Id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ResourceUsage> GetUsageAsync(SystemProcess process, CancellationToken ct)
|
||||
{
|
||||
if (!OperatingSystem.IsLinux())
|
||||
{
|
||||
return GetFallbackUsage(process);
|
||||
}
|
||||
|
||||
string? cgroupPath;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_processCgroups.TryGetValue(process.Id, out cgroupPath))
|
||||
{
|
||||
return GetFallbackUsage(process);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Read memory usage
|
||||
var memoryCurrentStr = await ReadControlFileAsync(cgroupPath, "memory.current", ct);
|
||||
var memoryCurrent = long.Parse(memoryCurrentStr.Trim(), CultureInfo.InvariantCulture);
|
||||
|
||||
// Calculate CPU percentage (simplified - would need time delta for accurate calculation)
|
||||
var cpuPercent = 0.0; // Requires tracking over time
|
||||
|
||||
return new ResourceUsage
|
||||
{
|
||||
MemoryUsageMb = memoryCurrent / (1024.0 * 1024.0),
|
||||
CpuUsagePercent = cpuPercent,
|
||||
ProcessCount = process.Threads.Count,
|
||||
OpenFileHandles = GetHandleCount(process),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read cgroup stats for process {ProcessId}", process.Id);
|
||||
return GetFallbackUsage(process);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LimitCheckResult> CheckLimitsAsync(
|
||||
SystemProcess process,
|
||||
ResourceLimits limits,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var usage = await GetUsageAsync(process, ct);
|
||||
|
||||
// Check memory
|
||||
if (limits.MaxMemoryMb > 0 && usage.MemoryUsageMb > limits.MaxMemoryMb)
|
||||
{
|
||||
return LimitCheckResult.Exceeded(
|
||||
ResourceType.Memory,
|
||||
usage.MemoryUsageMb,
|
||||
limits.MaxMemoryMb);
|
||||
}
|
||||
|
||||
// Check CPU
|
||||
if (limits.MaxCpuPercent > 0 && usage.CpuUsagePercent > limits.MaxCpuPercent)
|
||||
{
|
||||
return LimitCheckResult.Exceeded(
|
||||
ResourceType.Cpu,
|
||||
usage.CpuUsagePercent,
|
||||
limits.MaxCpuPercent);
|
||||
}
|
||||
|
||||
// Check processes
|
||||
if (limits.MaxProcesses > 0 && usage.ProcessCount > limits.MaxProcesses)
|
||||
{
|
||||
return LimitCheckResult.Exceeded(
|
||||
ResourceType.Cpu, // Use Cpu as proxy for process limits
|
||||
usage.ProcessCount,
|
||||
limits.MaxProcesses);
|
||||
}
|
||||
|
||||
return LimitCheckResult.Ok;
|
||||
}
|
||||
|
||||
private static async Task WriteControlFileAsync(
|
||||
string cgroupPath,
|
||||
string fileName,
|
||||
string value,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var filePath = Path.Combine(cgroupPath, fileName);
|
||||
await File.WriteAllTextAsync(filePath, value, ct);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadControlFileAsync(
|
||||
string cgroupPath,
|
||||
string fileName,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var filePath = Path.Combine(cgroupPath, fileName);
|
||||
return await File.ReadAllTextAsync(filePath, ct);
|
||||
}
|
||||
|
||||
private static ResourceUsage GetFallbackUsage(SystemProcess process)
|
||||
{
|
||||
try
|
||||
{
|
||||
process.Refresh();
|
||||
return new ResourceUsage
|
||||
{
|
||||
MemoryUsageMb = process.WorkingSet64 / (1024.0 * 1024.0),
|
||||
CpuUsagePercent = 0, // Can't easily get without tracking
|
||||
ProcessCount = process.Threads.Count,
|
||||
OpenFileHandles = GetHandleCount(process),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return ResourceUsage.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetHandleCount(SystemProcess process)
|
||||
{
|
||||
try
|
||||
{
|
||||
return process.HandleCount;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
namespace StellaOps.Plugin.Sandbox.Resources;
|
||||
|
||||
/// <summary>
|
||||
/// Current resource usage statistics for a sandbox.
|
||||
/// </summary>
|
||||
public sealed record ResourceUsage
|
||||
{
|
||||
/// <summary>
|
||||
/// Memory usage in megabytes.
|
||||
/// </summary>
|
||||
public double MemoryUsageMb { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CPU usage as a percentage.
|
||||
/// </summary>
|
||||
public double CpuUsagePercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of active processes/threads.
|
||||
/// </summary>
|
||||
public int ProcessCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Disk usage in bytes.
|
||||
/// </summary>
|
||||
public long DiskUsageBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Network bytes received.
|
||||
/// </summary>
|
||||
public long NetworkBytesIn { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Network bytes sent.
|
||||
/// </summary>
|
||||
public long NetworkBytesOut { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of open file handles.
|
||||
/// </summary>
|
||||
public int OpenFileHandles { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when this usage snapshot was taken.
|
||||
/// </summary>
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Empty resource usage.
|
||||
/// </summary>
|
||||
public static ResourceUsage Empty => new()
|
||||
{
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resource configuration for process limits.
|
||||
/// </summary>
|
||||
public sealed record ResourceConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Memory limit in bytes.
|
||||
/// </summary>
|
||||
public long MemoryLimitBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CPU quota in microseconds.
|
||||
/// </summary>
|
||||
public long CpuQuotaUs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CPU period in microseconds.
|
||||
/// </summary>
|
||||
public long CpuPeriodUs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of processes.
|
||||
/// </summary>
|
||||
public int MaxProcesses { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of open files.
|
||||
/// </summary>
|
||||
public int MaxOpenFiles { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
using SystemProcess = System.Diagnostics.Process;
|
||||
|
||||
namespace StellaOps.Plugin.Sandbox.Resources;
|
||||
|
||||
/// <summary>
|
||||
/// Windows Job Object resource limiter implementation.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class WindowsResourceLimiter : IResourceLimiter, IDisposable
|
||||
{
|
||||
private readonly ILogger<WindowsResourceLimiter> _logger;
|
||||
private readonly Dictionary<int, SafeFileHandle> _jobHandles = new();
|
||||
private readonly object _lock = new();
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Windows resource limiter.
|
||||
/// </summary>
|
||||
public WindowsResourceLimiter(ILogger<WindowsResourceLimiter> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ResourceConfiguration CreateConfiguration(ResourceLimits limits)
|
||||
{
|
||||
var cpuQuotaUs = limits.MaxCpuPercent > 0
|
||||
? (long)(limits.MaxCpuPercent / 100.0 * 100_000)
|
||||
: 0;
|
||||
|
||||
return new ResourceConfiguration
|
||||
{
|
||||
MemoryLimitBytes = limits.MaxMemoryMb * 1024 * 1024,
|
||||
CpuQuotaUs = cpuQuotaUs,
|
||||
CpuPeriodUs = 100_000,
|
||||
MaxProcesses = limits.MaxProcesses,
|
||||
MaxOpenFiles = limits.MaxOpenFiles
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ApplyLimitsAsync(
|
||||
SystemProcess process,
|
||||
ResourceConfiguration config,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
_logger.LogWarning("WindowsResourceLimiter called on non-Windows platform");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var jobName = $"StellaOps_Sandbox_{process.Id}";
|
||||
|
||||
try
|
||||
{
|
||||
// Create Job Object
|
||||
var jobHandle = NativeMethods.CreateJobObject(IntPtr.Zero, jobName);
|
||||
if (jobHandle.IsInvalid)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to create Job Object: {Marshal.GetLastWin32Error()}");
|
||||
}
|
||||
|
||||
// Configure limits
|
||||
var extendedInfo = new NativeMethods.JOBOBJECT_EXTENDED_LIMIT_INFORMATION();
|
||||
extendedInfo.BasicLimitInformation.LimitFlags = 0;
|
||||
|
||||
// Memory limit
|
||||
if (config.MemoryLimitBytes > 0)
|
||||
{
|
||||
extendedInfo.ProcessMemoryLimit = (UIntPtr)config.MemoryLimitBytes;
|
||||
extendedInfo.JobMemoryLimit = (UIntPtr)config.MemoryLimitBytes;
|
||||
extendedInfo.BasicLimitInformation.LimitFlags |=
|
||||
NativeMethods.JOB_OBJECT_LIMIT_PROCESS_MEMORY |
|
||||
NativeMethods.JOB_OBJECT_LIMIT_JOB_MEMORY;
|
||||
}
|
||||
|
||||
// Process limit
|
||||
if (config.MaxProcesses > 0)
|
||||
{
|
||||
extendedInfo.BasicLimitInformation.ActiveProcessLimit = (uint)config.MaxProcesses;
|
||||
extendedInfo.BasicLimitInformation.LimitFlags |=
|
||||
NativeMethods.JOB_OBJECT_LIMIT_ACTIVE_PROCESS;
|
||||
}
|
||||
|
||||
// Apply extended limits
|
||||
var extendedInfoSize = Marshal.SizeOf<NativeMethods.JOBOBJECT_EXTENDED_LIMIT_INFORMATION>();
|
||||
var success = NativeMethods.SetInformationJobObject(
|
||||
jobHandle,
|
||||
NativeMethods.JobObjectInfoType.ExtendedLimitInformation,
|
||||
ref extendedInfo,
|
||||
extendedInfoSize);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
jobHandle.Dispose();
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to set Job Object limits: {Marshal.GetLastWin32Error()}");
|
||||
}
|
||||
|
||||
// Configure CPU rate control (Windows 8+)
|
||||
if (config.CpuQuotaUs > 0)
|
||||
{
|
||||
var cpuRate = (uint)(config.CpuQuotaUs / (double)config.CpuPeriodUs * 10000);
|
||||
var cpuInfo = new NativeMethods.JOBOBJECT_CPU_RATE_CONTROL_INFORMATION
|
||||
{
|
||||
ControlFlags = NativeMethods.JOB_OBJECT_CPU_RATE_CONTROL_ENABLE |
|
||||
NativeMethods.JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP,
|
||||
CpuRate = cpuRate
|
||||
};
|
||||
|
||||
var cpuInfoSize = Marshal.SizeOf<NativeMethods.JOBOBJECT_CPU_RATE_CONTROL_INFORMATION>();
|
||||
NativeMethods.SetInformationJobObject(
|
||||
jobHandle,
|
||||
NativeMethods.JobObjectInfoType.CpuRateControlInformation,
|
||||
ref cpuInfo,
|
||||
cpuInfoSize);
|
||||
// CPU rate control may fail on older Windows versions - non-fatal
|
||||
}
|
||||
|
||||
// Assign process to Job Object
|
||||
success = NativeMethods.AssignProcessToJobObject(jobHandle, process.Handle);
|
||||
if (!success)
|
||||
{
|
||||
jobHandle.Dispose();
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to assign process to Job Object: {Marshal.GetLastWin32Error()}");
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_jobHandles[process.Id] = jobHandle;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Applied Job Object limits to process {ProcessId}: Memory={MemoryBytes}B",
|
||||
process.Id,
|
||||
config.MemoryLimitBytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to apply Job Object limits to process {ProcessId}", process.Id);
|
||||
throw;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RemoveLimitsAsync(SystemProcess process, CancellationToken ct)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
return Task.CompletedTask;
|
||||
|
||||
SafeFileHandle? jobHandle;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_jobHandles.TryGetValue(process.Id, out jobHandle))
|
||||
return Task.CompletedTask;
|
||||
|
||||
_jobHandles.Remove(process.Id);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Terminate job object (will terminate all processes in job)
|
||||
NativeMethods.TerminateJobObject(jobHandle, 0);
|
||||
jobHandle.Dispose();
|
||||
|
||||
_logger.LogDebug("Removed Job Object for process {ProcessId}", process.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to cleanup Job Object for process {ProcessId}", process.Id);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ResourceUsage> GetUsageAsync(SystemProcess process, CancellationToken ct)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return Task.FromResult(ResourceUsage.Empty);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
process.Refresh();
|
||||
return Task.FromResult(new ResourceUsage
|
||||
{
|
||||
MemoryUsageMb = process.WorkingSet64 / (1024.0 * 1024.0),
|
||||
CpuUsagePercent = GetCpuUsage(process),
|
||||
ProcessCount = process.Threads.Count,
|
||||
OpenFileHandles = process.HandleCount,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Task.FromResult(ResourceUsage.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LimitCheckResult> CheckLimitsAsync(
|
||||
SystemProcess process,
|
||||
ResourceLimits limits,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var usage = await GetUsageAsync(process, ct);
|
||||
|
||||
// Check memory
|
||||
if (limits.MaxMemoryMb > 0 && usage.MemoryUsageMb > limits.MaxMemoryMb)
|
||||
{
|
||||
return LimitCheckResult.Exceeded(
|
||||
ResourceType.Memory,
|
||||
usage.MemoryUsageMb,
|
||||
limits.MaxMemoryMb);
|
||||
}
|
||||
|
||||
// Check CPU
|
||||
if (limits.MaxCpuPercent > 0 && usage.CpuUsagePercent > limits.MaxCpuPercent)
|
||||
{
|
||||
return LimitCheckResult.Exceeded(
|
||||
ResourceType.Cpu,
|
||||
usage.CpuUsagePercent,
|
||||
limits.MaxCpuPercent);
|
||||
}
|
||||
|
||||
return LimitCheckResult.Ok;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes resources.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var handle in _jobHandles.Values)
|
||||
{
|
||||
handle.Dispose();
|
||||
}
|
||||
_jobHandles.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private static double GetCpuUsage(SystemProcess process)
|
||||
{
|
||||
// Simplified CPU usage - accurate measurement requires time-based sampling
|
||||
try
|
||||
{
|
||||
return process.TotalProcessorTime.TotalMilliseconds /
|
||||
(Environment.ProcessorCount * process.TotalProcessorTime.TotalMilliseconds + 1) * 100;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static class NativeMethods
|
||||
{
|
||||
public const uint JOB_OBJECT_LIMIT_PROCESS_MEMORY = 0x00000100;
|
||||
public const uint JOB_OBJECT_LIMIT_JOB_MEMORY = 0x00000200;
|
||||
public const uint JOB_OBJECT_LIMIT_ACTIVE_PROCESS = 0x00000008;
|
||||
public const uint JOB_OBJECT_CPU_RATE_CONTROL_ENABLE = 0x00000001;
|
||||
public const uint JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP = 0x00000004;
|
||||
|
||||
public enum JobObjectInfoType
|
||||
{
|
||||
BasicLimitInformation = 2,
|
||||
ExtendedLimitInformation = 9,
|
||||
CpuRateControlInformation = 15
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct JOBOBJECT_BASIC_LIMIT_INFORMATION
|
||||
{
|
||||
public long PerProcessUserTimeLimit;
|
||||
public long PerJobUserTimeLimit;
|
||||
public uint LimitFlags;
|
||||
public UIntPtr MinimumWorkingSetSize;
|
||||
public UIntPtr MaximumWorkingSetSize;
|
||||
public uint ActiveProcessLimit;
|
||||
public UIntPtr Affinity;
|
||||
public uint PriorityClass;
|
||||
public uint SchedulingClass;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct IO_COUNTERS
|
||||
{
|
||||
public ulong ReadOperationCount;
|
||||
public ulong WriteOperationCount;
|
||||
public ulong OtherOperationCount;
|
||||
public ulong ReadTransferCount;
|
||||
public ulong WriteTransferCount;
|
||||
public ulong OtherTransferCount;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
|
||||
{
|
||||
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
|
||||
public IO_COUNTERS IoInfo;
|
||||
public UIntPtr ProcessMemoryLimit;
|
||||
public UIntPtr JobMemoryLimit;
|
||||
public UIntPtr PeakProcessMemoryUsed;
|
||||
public UIntPtr PeakJobMemoryUsed;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct JOBOBJECT_CPU_RATE_CONTROL_INFORMATION
|
||||
{
|
||||
public uint ControlFlags;
|
||||
public uint CpuRate;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
public static extern SafeFileHandle CreateJobObject(IntPtr lpJobAttributes, string? lpName);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern bool SetInformationJobObject(
|
||||
SafeFileHandle hJob,
|
||||
JobObjectInfoType infoType,
|
||||
ref JOBOBJECT_EXTENDED_LIMIT_INFORMATION lpJobObjectInfo,
|
||||
int cbJobObjectInfoLength);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern bool SetInformationJobObject(
|
||||
SafeFileHandle hJob,
|
||||
JobObjectInfoType infoType,
|
||||
ref JOBOBJECT_CPU_RATE_CONTROL_INFORMATION lpJobObjectInfo,
|
||||
int cbJobObjectInfoLength);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern bool AssignProcessToJobObject(SafeFileHandle hJob, IntPtr hProcess);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern bool TerminateJobObject(SafeFileHandle hJob, uint uExitCode);
|
||||
}
|
||||
}
|
||||
243
src/Plugin/StellaOps.Plugin.Sandbox/SandboxConfiguration.cs
Normal file
243
src/Plugin/StellaOps.Plugin.Sandbox/SandboxConfiguration.cs
Normal file
@@ -0,0 +1,243 @@
|
||||
namespace StellaOps.Plugin.Sandbox;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for plugin sandbox.
|
||||
/// </summary>
|
||||
public sealed record SandboxConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Resource limits for the sandbox.
|
||||
/// </summary>
|
||||
public required ResourceLimits ResourceLimits { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Network policy for the sandbox.
|
||||
/// </summary>
|
||||
public required NetworkPolicy NetworkPolicy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filesystem policy for the sandbox.
|
||||
/// </summary>
|
||||
public required FilesystemPolicy FilesystemPolicy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timeouts for sandbox operations.
|
||||
/// </summary>
|
||||
public required SandboxTimeouts Timeouts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable process isolation.
|
||||
/// </summary>
|
||||
public bool ProcessIsolation { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Working directory for the sandbox. If null, a temporary directory is created.
|
||||
/// </summary>
|
||||
public string? WorkingDirectory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment variables to pass to the sandbox.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> EnvironmentVariables { get; init; } =
|
||||
new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable log streaming from the sandbox.
|
||||
/// </summary>
|
||||
public bool EnableLogStreaming { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Default configuration for untrusted plugins.
|
||||
/// </summary>
|
||||
public static SandboxConfiguration Default => new()
|
||||
{
|
||||
ResourceLimits = new ResourceLimits
|
||||
{
|
||||
MaxMemoryMb = 512,
|
||||
MaxCpuPercent = 25,
|
||||
MaxDiskMb = 100,
|
||||
MaxNetworkBandwidthMbps = 10
|
||||
},
|
||||
NetworkPolicy = new NetworkPolicy
|
||||
{
|
||||
AllowedHosts = new HashSet<string>(),
|
||||
BlockedPorts = new HashSet<int> { 22, 3389, 5432, 27017, 6379 }
|
||||
},
|
||||
FilesystemPolicy = new FilesystemPolicy
|
||||
{
|
||||
ReadOnlyPaths = new List<string>(),
|
||||
WritablePaths = new List<string>(),
|
||||
BlockedPaths = new List<string> { "/etc", "/var", "/root", "C:\\Windows" }
|
||||
},
|
||||
Timeouts = new SandboxTimeouts
|
||||
{
|
||||
StartupTimeout = TimeSpan.FromSeconds(30),
|
||||
OperationTimeout = TimeSpan.FromSeconds(60),
|
||||
ShutdownTimeout = TimeSpan.FromSeconds(10),
|
||||
HealthCheckTimeout = TimeSpan.FromSeconds(5)
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for trusted plugins with relaxed limits.
|
||||
/// </summary>
|
||||
public static SandboxConfiguration Trusted => new()
|
||||
{
|
||||
ResourceLimits = new ResourceLimits
|
||||
{
|
||||
MaxMemoryMb = 2048,
|
||||
MaxCpuPercent = 50,
|
||||
MaxDiskMb = 1024,
|
||||
MaxNetworkBandwidthMbps = 100
|
||||
},
|
||||
NetworkPolicy = new NetworkPolicy
|
||||
{
|
||||
AllowedHosts = new HashSet<string>(),
|
||||
BlockedPorts = new HashSet<int>(),
|
||||
AllowAllHosts = true
|
||||
},
|
||||
FilesystemPolicy = new FilesystemPolicy
|
||||
{
|
||||
ReadOnlyPaths = new List<string>(),
|
||||
WritablePaths = new List<string>(),
|
||||
BlockedPaths = new List<string>()
|
||||
},
|
||||
Timeouts = new SandboxTimeouts
|
||||
{
|
||||
StartupTimeout = TimeSpan.FromSeconds(60),
|
||||
OperationTimeout = TimeSpan.FromMinutes(5),
|
||||
ShutdownTimeout = TimeSpan.FromSeconds(30),
|
||||
HealthCheckTimeout = TimeSpan.FromSeconds(10)
|
||||
},
|
||||
ProcessIsolation = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resource limits for sandbox execution.
|
||||
/// </summary>
|
||||
public sealed record ResourceLimits
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum memory in megabytes.
|
||||
/// </summary>
|
||||
public int MaxMemoryMb { get; init; } = 512;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum CPU usage as a percentage.
|
||||
/// </summary>
|
||||
public int MaxCpuPercent { get; init; } = 25;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum disk usage in megabytes.
|
||||
/// </summary>
|
||||
public int MaxDiskMb { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum network bandwidth in Mbps.
|
||||
/// </summary>
|
||||
public int MaxNetworkBandwidthMbps { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of open files.
|
||||
/// </summary>
|
||||
public int MaxOpenFiles { get; init; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of processes/threads.
|
||||
/// </summary>
|
||||
public int MaxProcesses { get; init; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Network policy for sandbox execution.
|
||||
/// </summary>
|
||||
public sealed record NetworkPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Hosts that are explicitly allowed.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> AllowedHosts { get; init; } = new HashSet<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Hosts that are explicitly blocked.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> BlockedHosts { get; init; } = new HashSet<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Ports that are explicitly allowed.
|
||||
/// </summary>
|
||||
public IReadOnlySet<int> AllowedPorts { get; init; } = new HashSet<int> { 80, 443 };
|
||||
|
||||
/// <summary>
|
||||
/// Ports that are explicitly blocked.
|
||||
/// </summary>
|
||||
public IReadOnlySet<int> BlockedPorts { get; init; } = new HashSet<int>();
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow DNS resolution.
|
||||
/// </summary>
|
||||
public bool AllowDns { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow all hosts (ignores AllowedHosts).
|
||||
/// </summary>
|
||||
public bool AllowAllHosts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum connections per host.
|
||||
/// </summary>
|
||||
public int MaxConnectionsPerHost { get; init; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filesystem policy for sandbox execution.
|
||||
/// </summary>
|
||||
public sealed record FilesystemPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Paths that can be read but not written.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ReadOnlyPaths { get; init; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Paths that can be written to.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> WritablePaths { get; init; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Paths that cannot be accessed at all.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> BlockedPaths { get; init; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Maximum total bytes that can be written.
|
||||
/// </summary>
|
||||
public long MaxWriteBytes { get; init; } = 100 * 1024 * 1024; // 100 MB
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Timeout configuration for sandbox operations.
|
||||
/// </summary>
|
||||
public sealed record SandboxTimeouts
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum time to wait for sandbox startup.
|
||||
/// </summary>
|
||||
public TimeSpan StartupTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Default timeout for operations.
|
||||
/// </summary>
|
||||
public TimeSpan OperationTimeout { get; init; } = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum time to wait for graceful shutdown.
|
||||
/// </summary>
|
||||
public TimeSpan ShutdownTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for health checks.
|
||||
/// </summary>
|
||||
public TimeSpan HealthCheckTimeout { get; init; } = TimeSpan.FromSeconds(5);
|
||||
}
|
||||
167
src/Plugin/StellaOps.Plugin.Sandbox/SandboxFactory.cs
Normal file
167
src/Plugin/StellaOps.Plugin.Sandbox/SandboxFactory.cs
Normal file
@@ -0,0 +1,167 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Sandbox.Communication;
|
||||
using StellaOps.Plugin.Sandbox.Network;
|
||||
using StellaOps.Plugin.Sandbox.Process;
|
||||
using StellaOps.Plugin.Sandbox.Resources;
|
||||
|
||||
namespace StellaOps.Plugin.Sandbox;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating sandbox instances.
|
||||
/// </summary>
|
||||
public sealed class SandboxFactory : ISandboxFactory
|
||||
{
|
||||
private readonly IPluginProcessManager _processManager;
|
||||
private readonly INetworkPolicyEnforcer _networkEnforcer;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private int _sandboxCounter;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new sandbox factory.
|
||||
/// </summary>
|
||||
public SandboxFactory(
|
||||
IPluginProcessManager processManager,
|
||||
INetworkPolicyEnforcer networkEnforcer,
|
||||
ILoggerFactory loggerFactory,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_processManager = processManager;
|
||||
_networkEnforcer = networkEnforcer;
|
||||
_loggerFactory = loggerFactory;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISandbox Create(SandboxConfiguration configuration)
|
||||
{
|
||||
var id = GenerateSandboxId();
|
||||
return CreateInternal(id, configuration);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISandbox CreateDefault()
|
||||
{
|
||||
var id = GenerateSandboxId();
|
||||
return CreateInternal(id, SandboxConfiguration.Default);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISandbox CreateForTrustLevel(PluginTrustLevel trustLevel)
|
||||
{
|
||||
var id = GenerateSandboxId();
|
||||
var config = GetConfigurationForTrustLevel(trustLevel);
|
||||
return CreateInternal(id, config);
|
||||
}
|
||||
|
||||
private ISandbox CreateInternal(string id, SandboxConfiguration config)
|
||||
{
|
||||
// Create platform-specific resource limiter
|
||||
var resourceLimiter = CreateResourceLimiter();
|
||||
|
||||
// Create gRPC bridge
|
||||
var bridge = new GrpcPluginBridge(
|
||||
_loggerFactory.CreateLogger<GrpcPluginBridge>());
|
||||
|
||||
return new ProcessSandbox(
|
||||
id,
|
||||
config,
|
||||
_processManager,
|
||||
bridge,
|
||||
resourceLimiter,
|
||||
_networkEnforcer,
|
||||
_loggerFactory.CreateLogger<ProcessSandbox>(),
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
private IResourceLimiter CreateResourceLimiter()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return new WindowsResourceLimiter(
|
||||
_loggerFactory.CreateLogger<WindowsResourceLimiter>());
|
||||
}
|
||||
else
|
||||
{
|
||||
return new LinuxResourceLimiter(
|
||||
_loggerFactory.CreateLogger<LinuxResourceLimiter>());
|
||||
}
|
||||
}
|
||||
|
||||
private string GenerateSandboxId()
|
||||
{
|
||||
var counter = Interlocked.Increment(ref _sandboxCounter);
|
||||
return $"sandbox-{_timeProvider.GetUtcNow():yyyyMMdd-HHmmss}-{counter:D4}";
|
||||
}
|
||||
|
||||
private static SandboxConfiguration GetConfigurationForTrustLevel(PluginTrustLevel trustLevel)
|
||||
{
|
||||
return trustLevel switch
|
||||
{
|
||||
PluginTrustLevel.Untrusted => new SandboxConfiguration
|
||||
{
|
||||
ResourceLimits = new ResourceLimits
|
||||
{
|
||||
MaxMemoryMb = 256,
|
||||
MaxCpuPercent = 25,
|
||||
MaxProcesses = 20,
|
||||
MaxOpenFiles = 50,
|
||||
MaxDiskMb = 50
|
||||
},
|
||||
NetworkPolicy = new NetworkPolicy
|
||||
{
|
||||
AllowAllHosts = false,
|
||||
AllowedHosts = new HashSet<string>(),
|
||||
BlockedPorts = new HashSet<int> { 22, 23, 25, 53, 135, 139, 445, 1433, 3306, 5432 }
|
||||
},
|
||||
FilesystemPolicy = new FilesystemPolicy
|
||||
{
|
||||
MaxWriteBytes = 10 * 1024 * 1024 // 10 MB
|
||||
},
|
||||
Timeouts = new SandboxTimeouts
|
||||
{
|
||||
StartupTimeout = TimeSpan.FromSeconds(10),
|
||||
ShutdownTimeout = TimeSpan.FromSeconds(5),
|
||||
OperationTimeout = TimeSpan.FromSeconds(15),
|
||||
HealthCheckTimeout = TimeSpan.FromSeconds(3)
|
||||
}
|
||||
},
|
||||
|
||||
PluginTrustLevel.Trusted => SandboxConfiguration.Trusted,
|
||||
|
||||
PluginTrustLevel.BuiltIn => new SandboxConfiguration
|
||||
{
|
||||
// BuiltIn plugins get generous limits (though they typically run in-process)
|
||||
ResourceLimits = new ResourceLimits
|
||||
{
|
||||
MaxMemoryMb = 4096,
|
||||
MaxCpuPercent = 100,
|
||||
MaxProcesses = 500,
|
||||
MaxOpenFiles = 1000,
|
||||
MaxDiskMb = 2048
|
||||
},
|
||||
NetworkPolicy = new NetworkPolicy
|
||||
{
|
||||
AllowAllHosts = true,
|
||||
BlockedPorts = new HashSet<int>()
|
||||
},
|
||||
FilesystemPolicy = new FilesystemPolicy
|
||||
{
|
||||
MaxWriteBytes = 1024 * 1024 * 1024 // 1 GB
|
||||
},
|
||||
Timeouts = new SandboxTimeouts
|
||||
{
|
||||
StartupTimeout = TimeSpan.FromMinutes(5),
|
||||
ShutdownTimeout = TimeSpan.FromMinutes(2),
|
||||
OperationTimeout = TimeSpan.FromMinutes(10),
|
||||
HealthCheckTimeout = TimeSpan.FromSeconds(15)
|
||||
},
|
||||
ProcessIsolation = false
|
||||
},
|
||||
|
||||
_ => SandboxConfiguration.Default
|
||||
};
|
||||
}
|
||||
}
|
||||
110
src/Plugin/StellaOps.Plugin.Sandbox/SandboxState.cs
Normal file
110
src/Plugin/StellaOps.Plugin.Sandbox/SandboxState.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
namespace StellaOps.Plugin.Sandbox;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the current state of a plugin sandbox.
|
||||
/// </summary>
|
||||
public enum SandboxState
|
||||
{
|
||||
/// <summary>
|
||||
/// Sandbox has been created but not started.
|
||||
/// </summary>
|
||||
Created,
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox is in the process of starting.
|
||||
/// </summary>
|
||||
Starting,
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox is running and ready for operations.
|
||||
/// </summary>
|
||||
Running,
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox is in the process of stopping gracefully.
|
||||
/// </summary>
|
||||
Stopping,
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox has stopped gracefully.
|
||||
/// </summary>
|
||||
Stopped,
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox has failed due to an error.
|
||||
/// </summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox was forcefully killed.
|
||||
/// </summary>
|
||||
Killed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event arguments for sandbox state changes.
|
||||
/// </summary>
|
||||
public sealed class SandboxStateChangedEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// Previous state before the change.
|
||||
/// </summary>
|
||||
public required SandboxState OldState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// New state after the change.
|
||||
/// </summary>
|
||||
public required SandboxState NewState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional reason for the state change.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event arguments for resource usage warnings.
|
||||
/// </summary>
|
||||
public sealed class ResourceWarningEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of resource approaching its limit.
|
||||
/// </summary>
|
||||
public required ResourceType Resource { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current usage as a percentage of the limit.
|
||||
/// </summary>
|
||||
public required double CurrentUsagePercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Threshold percentage that triggered the warning.
|
||||
/// </summary>
|
||||
public required double ThresholdPercent { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of resources that can be monitored and limited.
|
||||
/// </summary>
|
||||
public enum ResourceType
|
||||
{
|
||||
/// <summary>
|
||||
/// Memory usage.
|
||||
/// </summary>
|
||||
Memory,
|
||||
|
||||
/// <summary>
|
||||
/// CPU usage.
|
||||
/// </summary>
|
||||
Cpu,
|
||||
|
||||
/// <summary>
|
||||
/// Disk usage.
|
||||
/// </summary>
|
||||
Disk,
|
||||
|
||||
/// <summary>
|
||||
/// Network bandwidth usage.
|
||||
/// </summary>
|
||||
Network
|
||||
}
|
||||
95
src/Plugin/StellaOps.Plugin.Sandbox/Secrets/ISecretProxy.cs
Normal file
95
src/Plugin/StellaOps.Plugin.Sandbox/Secrets/ISecretProxy.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
namespace StellaOps.Plugin.Sandbox.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for proxying secret access to sandboxed plugins.
|
||||
/// </summary>
|
||||
public interface ISecretProxy
|
||||
{
|
||||
/// <summary>
|
||||
/// Get a secret value by key.
|
||||
/// </summary>
|
||||
/// <param name="key">Secret key.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Secret value, or null if not found or not allowed.</returns>
|
||||
Task<string?> GetSecretAsync(string key, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Check if access to a secret is allowed.
|
||||
/// </summary>
|
||||
/// <param name="key">Secret key.</param>
|
||||
/// <returns>True if access is allowed.</returns>
|
||||
bool IsAllowed(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Get the list of allowed secret prefixes.
|
||||
/// </summary>
|
||||
/// <returns>Allowed secret prefixes.</returns>
|
||||
IReadOnlyList<string> GetAllowedPrefixes();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scoped secret proxy that limits access to specific prefixes.
|
||||
/// </summary>
|
||||
public sealed class ScopedSecretProxy : ISecretProxy
|
||||
{
|
||||
private readonly ISecretProvider _provider;
|
||||
private readonly IReadOnlyList<string> _allowedPrefixes;
|
||||
private readonly string _sandboxId;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new scoped secret proxy.
|
||||
/// </summary>
|
||||
/// <param name="provider">Underlying secret provider.</param>
|
||||
/// <param name="allowedPrefixes">Allowed secret key prefixes.</param>
|
||||
/// <param name="sandboxId">Sandbox identifier for auditing.</param>
|
||||
public ScopedSecretProxy(
|
||||
ISecretProvider provider,
|
||||
IReadOnlyList<string> allowedPrefixes,
|
||||
string sandboxId)
|
||||
{
|
||||
_provider = provider;
|
||||
_allowedPrefixes = allowedPrefixes;
|
||||
_sandboxId = sandboxId;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string?> GetSecretAsync(string key, CancellationToken ct)
|
||||
{
|
||||
if (!IsAllowed(key))
|
||||
return null;
|
||||
|
||||
return await _provider.GetSecretAsync(key, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAllowed(string key)
|
||||
{
|
||||
if (_allowedPrefixes.Count == 0)
|
||||
return false;
|
||||
|
||||
foreach (var prefix in _allowedPrefixes)
|
||||
{
|
||||
if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> GetAllowedPrefixes() => _allowedPrefixes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for providing secrets.
|
||||
/// </summary>
|
||||
public interface ISecretProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Get a secret value by key.
|
||||
/// </summary>
|
||||
/// <param name="key">Secret key.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Secret value, or null if not found.</returns>
|
||||
Task<string?> GetSecretAsync(string key, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Description>Plugin sandbox infrastructure for process isolation, resource limits, and security boundaries</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Grpc.Net.Client" />
|
||||
<PackageReference Include="Grpc.Tools">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="Google.Protobuf" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Plugin.Abstractions\StellaOps.Plugin.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Protobuf Include="Communication\Proto\plugin_bridge.proto" GrpcServices="Client" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user