diff --git a/.github/workflows/linux-control-agent.yml b/.github/workflows/linux-control-agent.yml
index 425d55e..e442118 100644
--- a/.github/workflows/linux-control-agent.yml
+++ b/.github/workflows/linux-control-agent.yml
@@ -57,6 +57,12 @@ jobs:
- name: Publish agent arm64
run: dotnet publish src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj --configuration Release --runtime linux-arm64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true
+ - name: Publish provisioning helper amd64
+ run: dotnet publish src/ServerMonitorManager.Provisioning.Helper/ServerMonitorManager.Provisioning.Helper.csproj --configuration Release --runtime linux-x64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true
+
+ - name: Publish provisioning helper arm64
+ run: dotnet publish src/ServerMonitorManager.Provisioning.Helper/ServerMonitorManager.Provisioning.Helper.csproj --configuration Release --runtime linux-arm64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true
+
- name: Build systemd smoke release
run: |
dotnet publish src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj \
@@ -65,14 +71,18 @@ jobs:
dotnet publish src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj \
--configuration Release --runtime linux-x64 --self-contained true \
-p:PublishSingleFile=true -p:PublishTrimmed=true -o smoke/control
+ dotnet publish src/ServerMonitorManager.Provisioning.Helper/ServerMonitorManager.Provisioning.Helper.csproj \
+ --configuration Release --runtime linux-x64 --self-contained true \
+ -p:PublishSingleFile=true -p:PublishTrimmed=true -o smoke/provisioning-helper
install -d smoke/deploy smoke/bootstrap
install -m 0644 deploy/ochenstarik-smm-control.service smoke/deploy/
install -m 0644 deploy/ochenstarik-smm-agent.service smoke/deploy/
+ install -m 0644 deploy/ochenstarik-smm-provisioning-helper.service smoke/deploy/
install -m 0644 deploy/ochenstarik-smm-firewall.service smoke/deploy/
install -m 0755 deploy/ochenstarik-smm-policy-apply smoke/deploy/
install -m 0755 deploy/ochenstarik-smm-emergency smoke/deploy/
install -m 0755 deploy/ochenstarik-server-monitor-manager.sh smoke/bootstrap/
- tar -C smoke -czf smoke-release.tar.gz agent control deploy bootstrap
+ tar -C smoke -czf smoke-release.tar.gz agent control provisioning-helper deploy bootstrap
sha256sum smoke-release.tar.gz > smoke-release.tar.gz.sha256
- name: Verify repeated systemd installation
diff --git a/.github/workflows/linux-platform-matrix.yml b/.github/workflows/linux-platform-matrix.yml
index 502224a..b018276 100644
--- a/.github/workflows/linux-platform-matrix.yml
+++ b/.github/workflows/linux-platform-matrix.yml
@@ -52,15 +52,19 @@ jobs:
dotnet publish src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj \
--configuration Release --runtime '${{ matrix.runtime }}' --self-contained true \
-p:PublishSingleFile=true -p:PublishTrimmed=true -o out/control
+ dotnet publish src/ServerMonitorManager.Provisioning.Helper/ServerMonitorManager.Provisioning.Helper.csproj \
+ --configuration Release --runtime '${{ matrix.runtime }}' --self-contained true \
+ -p:PublishSingleFile=true -p:PublishTrimmed=true -o out/provisioning-helper
install -d out/deploy out/bootstrap
install -m 0644 deploy/ochenstarik-smm-control.service out/deploy/
install -m 0644 deploy/ochenstarik-smm-agent.service out/deploy/
+ install -m 0644 deploy/ochenstarik-smm-provisioning-helper.service out/deploy/
install -m 0644 deploy/ochenstarik-smm-firewall.service out/deploy/
install -m 0755 deploy/ochenstarik-smm-policy-apply out/deploy/
install -m 0755 deploy/ochenstarik-smm-emergency out/deploy/
install -m 0755 deploy/ochenstarik-server-monitor-manager.sh out/bootstrap/
archive='server-monitor-manager-${{ matrix.runtime }}.tar.gz'
- tar -C out -czf "$archive" agent control deploy bootstrap
+ tar -C out -czf "$archive" agent control provisioning-helper deploy bootstrap
sha256sum "$archive" >"$archive.sha256"
- name: Upload release archive
diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml
index 8df7022..e8b263a 100644
--- a/.github/workflows/linux-release.yml
+++ b/.github/workflows/linux-release.yml
@@ -84,6 +84,9 @@ jobs:
- name: Publish control
run: dotnet publish src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj --configuration Release --runtime ${{ matrix.runtime }} --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -o out/control
+ - name: Publish provisioning helper
+ run: dotnet publish src/ServerMonitorManager.Provisioning.Helper/ServerMonitorManager.Provisioning.Helper.csproj --configuration Release --runtime ${{ matrix.runtime }} --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -o out/provisioning-helper
+
- name: Package
shell: bash
run: |
@@ -91,12 +94,13 @@ jobs:
install -d out/deploy out/bootstrap
install -m 0644 deploy/ochenstarik-smm-control.service out/deploy/
install -m 0644 deploy/ochenstarik-smm-agent.service out/deploy/
+ install -m 0644 deploy/ochenstarik-smm-provisioning-helper.service out/deploy/
install -m 0644 deploy/ochenstarik-smm-firewall.service out/deploy/
install -m 0755 deploy/ochenstarik-smm-policy-apply out/deploy/
install -m 0755 deploy/ochenstarik-smm-emergency out/deploy/
install -m 0755 deploy/ochenstarik-server-monitor-manager.sh out/bootstrap/
archive="server-monitor-manager-${{ matrix.runtime }}.tar.gz"
- tar -C out -czf "$archive" agent control deploy bootstrap
+ tar -C out -czf "$archive" agent control provisioning-helper deploy bootstrap
sha256sum "$archive" > "$archive.sha256"
- name: Upload artifact
diff --git a/README.md b/README.md
index 6e85745..75cf6e8 100644
--- a/README.md
+++ b/README.md
@@ -100,7 +100,7 @@ In the application, generate or copy the monitoring SSH key, add the Hub profile
The current development branch implements dedicated Windows pages for Servers, Links, Sessions, and Settings; SSH monitoring; directional Links; one-time enrollment; separate mTLS Agent, Operator, and source-scoped Automation identities; certificate revocation/re-enrollment; SQLite control state; audit; authenticated event streaming; Windows Control API integration; and a bounded durable Agent buffer with downsampling.
-Reconnect reconciliation is implemented with a durable SQLite marker: after a Node returns, the Hub reapplies the latest effective disabled policies and clears the marker only after the firewall confirms success. Control also expires TTL Links through the firewall helper, prunes bounded operational data, versions its SQLite schema, and creates verified backups of SQLite state and the Control CA. The first Provisioning control-plane slice now persists versioned jobs, enforces TTL/audit/idempotency and one active job per Node, and exposes Operator-only create/read/confirm/cancel endpoints. CI exercises the Control-to-helper process boundary, HTTP authorization, Agent parsing, Desktop contracts, and a 100-Node concurrent heartbeat/replay scenario. Still required are Provisioning execution/retry/rollback and the restricted helper channel, physical WireGuard/nftables/reboot acceptance, trusted public code signing, Xray, and clients for additional platforms.
+Reconnect reconciliation is implemented with a durable SQLite marker: after a Node returns, the Hub reapplies the latest effective disabled policies and clears the marker only after the firewall confirms success. Control also expires TTL Links through the firewall helper, prunes bounded operational data, versions its SQLite schema, and creates verified backups of SQLite state and the Control CA. The Provisioning control plane persists versioned jobs, enforces TTL/audit/idempotency and one active job per Node, and supports confirmation, progress, reconciliation, retry, and rollback states. A restricted root helper now accepts only versioned, module-hashed allowlisted requests through a local Unix socket; the first executable action is read-only Linux `preflight`. CI exercises process boundaries, authorization, Agent parsing, Desktop contracts, and concurrent heartbeat/replay. Still required are mutating Provisioning actions with factual-state verification, physical WireGuard/nftables/reboot acceptance, trusted public code signing, Xray, and clients for additional platforms.
## License and project policy
diff --git a/ServerMonitorManager.slnx b/ServerMonitorManager.slnx
index 25c13cb..c25a2d1 100644
--- a/ServerMonitorManager.slnx
+++ b/ServerMonitorManager.slnx
@@ -2,5 +2,6 @@
+
diff --git a/deploy/ochenstarik-server-monitor-manager.sh b/deploy/ochenstarik-server-monitor-manager.sh
index 336acc7..34825df 100755
--- a/deploy/ochenstarik-server-monitor-manager.sh
+++ b/deploy/ochenstarik-server-monitor-manager.sh
@@ -12,6 +12,7 @@ readonly CONTROL_USER="ochenstarik-smm-control"
readonly AGENT_USER="ochenstarik-smm-agent"
readonly CONTROL_UNIT="ochenstarik-smm-control.service"
readonly AGENT_UNIT="ochenstarik-smm-agent.service"
+readonly PROVISIONING_HELPER_UNIT="ochenstarik-smm-provisioning-helper.service"
readonly POLICY_HELPER="/usr/local/libexec/ochenstarik-smm-policy-apply"
readonly EMERGENCY_COMMAND="/usr/local/sbin/ochenstarik-smm-emergency"
readonly SUDOERS_FILE="/etc/sudoers.d/ochenstarik-smm-control"
@@ -138,7 +139,7 @@ verify_archive() {
[[ -n "$entry" ]] || continue
[[ "$entry" != /* && "$entry" != *".."* ]] || fail "Unsafe archive entry: $entry"
case "$entry" in
- agent|agent/*|control|control/*|deploy|deploy/*|bootstrap|bootstrap/*) ;;
+ agent|agent/*|control|control/*|provisioning-helper|provisioning-helper/*|deploy|deploy/*|bootstrap|bootstrap/*) ;;
*) fail "Unexpected archive entry: $entry" ;;
esac
done < <(tar -tzf "$archive")
@@ -152,6 +153,7 @@ extract_archive() {
tar -xzf "$archive" -C "$TEMP_DIR" --no-same-owner --no-same-permissions
[[ -f "$TEMP_DIR/deploy/$CONTROL_UNIT" ]] || fail "Control systemd unit is missing from archive."
[[ -f "$TEMP_DIR/deploy/$AGENT_UNIT" ]] || fail "Agent systemd unit is missing from archive."
+ [[ -f "$TEMP_DIR/deploy/$PROVISIONING_HELPER_UNIT" ]] || fail "Provisioning helper systemd unit is missing from archive."
[[ -f "$TEMP_DIR/deploy/$FIREWALL_UNIT" ]] || fail "Mesh firewall systemd unit is missing from archive."
[[ -x "$TEMP_DIR/deploy/ochenstarik-smm-policy-apply" ]] || fail "Policy helper is missing from archive."
[[ -x "$TEMP_DIR/deploy/ochenstarik-smm-emergency" ]] || fail "Emergency command is missing from archive."
@@ -164,6 +166,7 @@ verify_release_payload() {
extract_archive "$archive"
[[ -x "$TEMP_DIR/control/ochenstarik-smm-control" ]] || fail "Control binary is missing."
[[ -x "$TEMP_DIR/agent/ochenstarik-smm-agent" ]] || fail "Agent binary is missing."
+ [[ -x "$TEMP_DIR/provisioning-helper/ochenstarik-smm-provisioning-helper" ]] || fail "Provisioning helper binary is missing."
[[ -x "$TEMP_DIR/deploy/ochenstarik-smm-policy-apply" ]] || fail "Policy helper is missing."
[[ -x "$TEMP_DIR/deploy/ochenstarik-smm-emergency" ]] || fail "Emergency recovery command is missing."
[[ -f "$TEMP_DIR/deploy/$FIREWALL_UNIT" ]] || fail "Mesh firewall unit is missing."
@@ -229,9 +232,11 @@ create_backup() {
agent)
list=(
"usr/local/lib/ochenstarik-server-monitor-manager/agent"
+ "usr/local/lib/ochenstarik-server-monitor-manager/provisioning-helper"
"etc/ochenstarik-server-monitor-manager/agent.env"
"etc/ochenstarik-server-monitor-manager/control-ca.crt"
"etc/systemd/system/$AGENT_UNIT"
+ "etc/systemd/system/$PROVISIONING_HELPER_UNIT"
)
;;
*) fail "Unknown backup role: $role" ;;
@@ -531,11 +536,13 @@ install_agent() {
openssl x509 -in "$ca_cert" -noout >/dev/null 2>&1 || fail "Invalid Control CA certificate."
extract_archive "$archive"
[[ -x "$TEMP_DIR/agent/ochenstarik-smm-agent" ]] || fail "Agent binary is missing."
+ [[ -x "$TEMP_DIR/provisioning-helper/ochenstarik-smm-provisioning-helper" ]] || fail "Provisioning helper binary is missing."
backup_id="$(create_backup agent)"
ensure_system_user "$AGENT_USER"
install -d -m 0750 -o root -g "$AGENT_USER" "$ETC_DIR"
install -d -m 0700 -o "$AGENT_USER" -g "$AGENT_USER" "$STATE_DIR/agent"
install_tree_atomic "$TEMP_DIR/agent" "$LIB_DIR/agent" "root:root"
+ install_tree_atomic "$TEMP_DIR/provisioning-helper" "$LIB_DIR/provisioning-helper" "root:root"
install -d -m 0755 "$(dirname "$EMERGENCY_COMMAND")"
install -m 0755 "$TEMP_DIR/deploy/ochenstarik-smm-emergency" "$EMERGENCY_COMMAND"
if [[ "$(realpath "$ca_cert")" != "$(realpath -m "$ETC_DIR/control-ca.crt")" ]]; then
@@ -564,6 +571,8 @@ EOF
chown root:"$AGENT_USER" "$ETC_DIR/control-ca.crt"
chmod 0640 "$ETC_DIR/control-ca.crt"
install_unit "$TEMP_DIR/deploy/$AGENT_UNIT" "$AGENT_UNIT"
+ install_unit "$TEMP_DIR/deploy/$PROVISIONING_HELPER_UNIT" "$PROVISIONING_HELPER_UNIT"
+ systemctl enable --now "$PROVISIONING_HELPER_UNIT"
systemctl enable --now "$AGENT_UNIT"
systemctl is-active --quiet "$AGENT_UNIT" || {
systemctl status --no-pager "$AGENT_UNIT" >&2 || true
@@ -661,8 +670,19 @@ update_role() {
esac
[[ -x "$TEMP_DIR/$role/$binary" ]] || fail "$role binary is missing."
systemctl stop "$unit"
+ if [[ "$role" == "agent" ]]; then
+ systemctl stop "$PROVISIONING_HELPER_UNIT" 2>/dev/null || true
+ fi
backup_id="$(create_backup "$role")"
install_tree_atomic "$TEMP_DIR/$role" "$LIB_DIR/$role" "$user"
+ if [[ "$role" == "agent" ]]; then
+ [[ -x "$TEMP_DIR/provisioning-helper/ochenstarik-smm-provisioning-helper" ]] \
+ || fail "Provisioning helper binary is missing."
+ install_tree_atomic "$TEMP_DIR/provisioning-helper" "$LIB_DIR/provisioning-helper" "root:root"
+ install_unit "$TEMP_DIR/deploy/$AGENT_UNIT" "$AGENT_UNIT"
+ install_unit "$TEMP_DIR/deploy/$PROVISIONING_HELPER_UNIT" "$PROVISIONING_HELPER_UNIT"
+ systemctl enable --now "$PROVISIONING_HELPER_UNIT"
+ fi
systemctl start "$unit"
if ! systemctl is-active --quiet "$unit"; then
log "Update failed; restoring backup $backup_id"
@@ -684,8 +704,14 @@ restore_backup() {
case "$role" in control) unit="$CONTROL_UNIT" ;; agent) unit="$AGENT_UNIT" ;; *) fail "Unknown role: $role" ;; esac
[[ -f "$archive" ]] || fail "Backup archive not found: $backup_id"
systemctl stop "$unit" || true
+ if [[ "$role" == "agent" ]]; then
+ systemctl stop "$PROVISIONING_HELPER_UNIT" || true
+ fi
tar -C / -xzf "$archive"
systemctl daemon-reload
+ if [[ "$role" == "agent" ]]; then
+ systemctl start "$PROVISIONING_HELPER_UNIT"
+ fi
systemctl start "$unit"
systemctl is-active --quiet "$unit" || fail "Rollback restored files but service is not active."
log "$role restored from $backup_id"
@@ -825,8 +851,9 @@ uninstall_agent() {
local purge="${1:-}"
require_root
systemctl disable --now "$AGENT_UNIT" 2>/dev/null || true
- rm -f -- "/etc/systemd/system/$AGENT_UNIT" "$ETC_DIR/agent.env"
- rm -rf -- "$LIB_DIR/agent"
+ systemctl disable --now "$PROVISIONING_HELPER_UNIT" 2>/dev/null || true
+ rm -f -- "/etc/systemd/system/$AGENT_UNIT" "/etc/systemd/system/$PROVISIONING_HELPER_UNIT" "$ETC_DIR/agent.env"
+ rm -rf -- "$LIB_DIR/agent" "$LIB_DIR/provisioning-helper"
[[ "$purge" == "--purge" ]] && rm -rf -- "$STATE_DIR/agent" "$ETC_DIR/control-ca.crt"
systemctl daemon-reload
log "Agent removed${purge:+ ($purge)}."
diff --git a/deploy/ochenstarik-smm-agent.service b/deploy/ochenstarik-smm-agent.service
index a8eec71..f82bbbf 100644
--- a/deploy/ochenstarik-smm-agent.service
+++ b/deploy/ochenstarik-smm-agent.service
@@ -1,7 +1,8 @@
[Unit]
Description=Ochenstarik Server Monitor Manager Agent
-After=network-online.target
+After=network-online.target ochenstarik-smm-provisioning-helper.service
Wants=network-online.target
+Requires=ochenstarik-smm-provisioning-helper.service
[Service]
Type=simple
diff --git a/deploy/ochenstarik-smm-provisioning-helper.service b/deploy/ochenstarik-smm-provisioning-helper.service
new file mode 100644
index 0000000..ecf3697
--- /dev/null
+++ b/deploy/ochenstarik-smm-provisioning-helper.service
@@ -0,0 +1,31 @@
+[Unit]
+Description=Ochenstarik Server Monitor Manager restricted provisioning helper
+After=local-fs.target
+
+[Service]
+Type=simple
+User=root
+Group=ochenstarik-smm-agent
+UMask=0007
+RuntimeDirectory=ochenstarik-server-monitor-manager
+RuntimeDirectoryMode=0750
+WorkingDirectory=/usr/local/lib/ochenstarik-server-monitor-manager/provisioning-helper
+ExecStart=/usr/local/lib/ochenstarik-server-monitor-manager/provisioning-helper/ochenstarik-smm-provisioning-helper
+Restart=on-failure
+RestartSec=5s
+NoNewPrivileges=true
+PrivateTmp=true
+PrivateDevices=true
+ProtectSystem=strict
+ProtectHome=true
+ProtectKernelTunables=true
+ProtectKernelModules=true
+ProtectControlGroups=true
+LockPersonality=true
+RestrictSUIDSGID=true
+RestrictAddressFamilies=AF_UNIX
+CapabilityBoundingSet=
+AmbientCapabilities=
+
+[Install]
+WantedBy=multi-user.target
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 11853bc..2decf5d 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -116,7 +116,7 @@
- [x] атомарный Agent job channel только для собственного `node_id`;
- [x] начальные строгие JSON schemas v1 для `preflight` и `system.base-install`;
- [ ] versioned JSON schemas для остальных action type;
-- [ ] restricted root helper через Unix socket;
+- [x] restricted root helper через Unix socket (первый allowlisted action `preflight`);
- [x] structured redacted events, bounded Operator history и progress;
- [x] `NeedsReconciliation` после истечения execution TTL и неопределённого результата;
- [ ] desired/factual configuration и drift;
diff --git a/src/ServerMonitorManager.Agent/AgentClient.cs b/src/ServerMonitorManager.Agent/AgentClient.cs
index 013ddf6..032d73c 100644
--- a/src/ServerMonitorManager.Agent/AgentClient.cs
+++ b/src/ServerMonitorManager.Agent/AgentClient.cs
@@ -2,6 +2,7 @@ using System.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
+using System.Text;
using ServerMonitorManager.Core;
namespace ServerMonitorManager.Agent;
@@ -105,12 +106,99 @@ internal sealed class AgentClient(AgentOptions options)
}
}
+ try
+ {
+ await ExecuteNextProvisioningJobAsync(client, cancellationToken);
+ }
+ catch (Exception exception) when (exception is not OperationCanceledException)
+ {
+ Console.Error.WriteLine($"Provisioning poll failed: {exception.Message}");
+ }
+
var elapsed = DateTimeOffset.UtcNow - iterationStartedAt;
var remaining = collectionDelay - elapsed;
await Task.Delay(remaining > TimeSpan.Zero ? remaining : TimeSpan.FromSeconds(1), cancellationToken);
}
}
+ private async Task ExecuteNextProvisioningJobAsync(
+ HttpClient client,
+ CancellationToken cancellationToken)
+ {
+ using var response = await client.GetAsync("api/v1/agents/provisioning/jobs/next", cancellationToken);
+ if (response.StatusCode == HttpStatusCode.NoContent)
+ {
+ return;
+ }
+ response.EnsureSuccessStatusCode();
+ var job = await response.Content.ReadFromJsonAsync(
+ SmmJsonContext.Default.ProvisioningJob,
+ cancellationToken)
+ ?? throw new InvalidOperationException("Control service returned an empty provisioning job.");
+
+ if (job.ActionType != "preflight" || job.SchemaVersion != 1)
+ {
+ await ReportProvisioningAsync(
+ client, job, ProvisioningJobStates.Failed, job.ProgressPercent,
+ "dispatch", "action.unsupported", "Unsupported provisioning action.", cancellationToken);
+ return;
+ }
+
+ var currentProgress = job.ProgressPercent;
+ try
+ {
+ var helper = new ProvisioningHelperClient(options.ProvisioningSocketPath);
+ var result = await helper.RunPreflightAsync(job, cancellationToken);
+ await ReportProvisioningAsync(
+ client, job, ProvisioningJobStates.Running, 40,
+ "inspect-host", "preflight.inspected", "Host inspection completed.", cancellationToken);
+ currentProgress = 40;
+ await ReportProvisioningAsync(
+ client, job, ProvisioningJobStates.Verifying, 80,
+ "verify-host", "preflight.verifying", "Verifying preflight result.", cancellationToken);
+ currentProgress = 80;
+ await ReportProvisioningAsync(
+ client, job, ProvisioningJobStates.Completed, 100,
+ "completed", "preflight.completed", "Preflight completed.", cancellationToken);
+ Console.WriteLine(
+ $"Preflight {job.Id} completed: {result.OperatingSystem} "
+ + $"{result.OperatingSystemVersion} {result.Architecture}.");
+ }
+ catch (Exception exception) when (exception is not OperationCanceledException)
+ {
+ await ReportProvisioningAsync(
+ client, job, ProvisioningJobStates.Failed, currentProgress,
+ "preflight", "preflight.failed", "Preflight helper failed.", cancellationToken);
+ Console.Error.WriteLine($"Preflight {job.Id} failed: {exception.Message}");
+ }
+ }
+
+ private static async Task ReportProvisioningAsync(
+ HttpClient client,
+ ProvisioningJob job,
+ string state,
+ int progress,
+ string step,
+ string eventCode,
+ string message,
+ CancellationToken cancellationToken)
+ {
+ var request = new ProvisioningJobProgressRequest(
+ state, progress, step, eventCode, message, CreateOperationId(job.Id, state));
+ using var response = await client.PostAsJsonAsync(
+ $"api/v1/agents/provisioning/jobs/{job.Id}/progress",
+ request,
+ SmmJsonContext.Default.ProvisioningJobProgressRequest,
+ cancellationToken);
+ response.EnsureSuccessStatusCode();
+ }
+
+ private static string CreateOperationId(string jobId, string state)
+ {
+ var digest = SHA256.HashData(Encoding.UTF8.GetBytes($"{jobId}:{state}"));
+ return new Guid(digest.AsSpan(0, 16)).ToString();
+ }
+
private static async Task SendHeartbeatAsync(
HttpClient client,
AgentHeartbeat heartbeat,
diff --git a/src/ServerMonitorManager.Agent/AgentOptions.cs b/src/ServerMonitorManager.Agent/AgentOptions.cs
index 68795da..b5603d5 100644
--- a/src/ServerMonitorManager.Agent/AgentOptions.cs
+++ b/src/ServerMonitorManager.Agent/AgentOptions.cs
@@ -6,6 +6,7 @@ public sealed class AgentOptions
public Uri ControlUrl { get; init; } = new("https://127.0.0.1:7443");
public string StateDirectory { get; init; } = "/var/lib/ochenstarik-server-monitor-manager/agent";
public string CertificateAuthorityPath { get; init; } = "/etc/ochenstarik-server-monitor-manager/control-ca.crt";
+ public string ProvisioningSocketPath { get; init; } = "/run/ochenstarik-server-monitor-manager/provisioning.sock";
public int HeartbeatSeconds { get; init; } = 30;
public int BufferMaxSamples { get; init; } = 720;
public int BufferRecentSamples { get; init; } = 120;
diff --git a/src/ServerMonitorManager.Agent/Program.cs b/src/ServerMonitorManager.Agent/Program.cs
index 7e536fa..c9693a7 100644
--- a/src/ServerMonitorManager.Agent/Program.cs
+++ b/src/ServerMonitorManager.Agent/Program.cs
@@ -19,7 +19,8 @@ if (options.HeartbeatSeconds is < 10 or > 300
|| options.BufferRecentSamples >= options.BufferMaxSamples
|| options.BufferDownsampleFactor is < 2 or > 100
|| options.UploadBatchSize is < 1 or > 100
- || options.MaxRetrySeconds is < 10 or > 3600)
+ || options.MaxRetrySeconds is < 10 or > 3600
+ || !Path.IsPathFullyQualified(options.ProvisioningSocketPath))
{
Console.Error.WriteLine(
"Invalid buffer settings: heartbeat 10-300s, max samples 10-10000, recent samples below max, "
diff --git a/src/ServerMonitorManager.Agent/ProvisioningHelperClient.cs b/src/ServerMonitorManager.Agent/ProvisioningHelperClient.cs
new file mode 100644
index 0000000..48b13a8
--- /dev/null
+++ b/src/ServerMonitorManager.Agent/ProvisioningHelperClient.cs
@@ -0,0 +1,53 @@
+using System.Net.Sockets;
+using System.Text;
+using System.Text.Json;
+using ServerMonitorManager.Core;
+
+namespace ServerMonitorManager.Agent;
+
+public sealed class ProvisioningHelperClient(string socketPath)
+{
+ private const int MaximumResponseBytes = 16 * 1024;
+
+ public async Task RunPreflightAsync(
+ ProvisioningJob job,
+ CancellationToken cancellationToken)
+ {
+ var request = new ProvisioningHelperRequest(
+ "1", job.Id, job.ActionType, job.SchemaVersion,
+ ProvisioningActionCatalog.PreflightModuleHash, job.Parameters);
+ using var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
+ await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath), cancellationToken);
+ await using var stream = new NetworkStream(socket, ownsSocket: false);
+ var json = JsonSerializer.Serialize(request, SmmJsonContext.Default.ProvisioningHelperRequest) + "\n";
+ await stream.WriteAsync(Encoding.UTF8.GetBytes(json), cancellationToken);
+ var payload = await ReadResponseAsync(stream, cancellationToken);
+ var response = JsonSerializer.Deserialize(payload, SmmJsonContext.Default.ProvisioningHelperResponse)
+ ?? throw new InvalidDataException("Provisioning helper returned an empty response.");
+ if (!response.Success || response.Preflight is null)
+ {
+ throw new InvalidOperationException($"Provisioning helper rejected the request: {response.Code}");
+ }
+ return response.Preflight;
+ }
+
+ private static async Task ReadResponseAsync(Stream stream, CancellationToken cancellationToken)
+ {
+ using var buffer = new MemoryStream();
+ var singleByte = new byte[1];
+ while (buffer.Length <= MaximumResponseBytes)
+ {
+ var count = await stream.ReadAsync(singleByte, cancellationToken);
+ if (count == 0 || singleByte[0] == (byte)'\n')
+ {
+ break;
+ }
+ buffer.WriteByte(singleByte[0]);
+ }
+ if (buffer.Length == 0 || buffer.Length > MaximumResponseBytes)
+ {
+ throw new InvalidDataException("Provisioning helper response size is invalid.");
+ }
+ return buffer.ToArray();
+ }
+}
diff --git a/src/ServerMonitorManager.Core/Contracts.cs b/src/ServerMonitorManager.Core/Contracts.cs
index 689a60b..307f6d2 100644
--- a/src/ServerMonitorManager.Core/Contracts.cs
+++ b/src/ServerMonitorManager.Core/Contracts.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using System.Text.Json.Serialization;
namespace ServerMonitorManager.Core;
@@ -163,6 +164,31 @@ public sealed record ProvisioningJobProgressRequest(
string Message,
string IdempotencyKey);
+[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
+public sealed record ProvisioningHelperRequest(
+ string ProtocolVersion,
+ string JobId,
+ string ActionType,
+ int SchemaVersion,
+ string ModuleHash,
+ JsonElement Parameters);
+
+public sealed record ProvisioningHelperResponse(
+ bool Success,
+ string Code,
+ string Message,
+ ProvisioningPreflightResult? Preflight);
+
+public sealed record ProvisioningPreflightResult(
+ string OperatingSystem,
+ string OperatingSystemVersion,
+ string Architecture,
+ bool HasSystemd,
+ bool HasSshd,
+ bool HasNftables,
+ bool HasWireGuard,
+ bool HasApt);
+
public sealed record ProvisioningJob(
string Id,
string NodeId,
@@ -208,3 +234,9 @@ public static class ProvisioningJobStates
public const string RollbackFailed = "RollbackFailed";
public const string Cancelled = "Cancelled";
}
+
+public static class ProvisioningActionCatalog
+{
+ public const string PreflightModuleHash =
+ "2dc48fb4528a291221954fc2dd3478d431b66fe34228f29684ce1648dbe2f32b";
+}
diff --git a/src/ServerMonitorManager.Core/SmmJsonContext.cs b/src/ServerMonitorManager.Core/SmmJsonContext.cs
index 04dc7fe..d65308d 100644
--- a/src/ServerMonitorManager.Core/SmmJsonContext.cs
+++ b/src/ServerMonitorManager.Core/SmmJsonContext.cs
@@ -28,6 +28,9 @@ namespace ServerMonitorManager.Core;
[JsonSerializable(typeof(ProvisioningJobCreateRequest))]
[JsonSerializable(typeof(ProvisioningJobCommandRequest))]
[JsonSerializable(typeof(ProvisioningJobProgressRequest))]
+[JsonSerializable(typeof(ProvisioningHelperRequest))]
+[JsonSerializable(typeof(ProvisioningHelperResponse))]
+[JsonSerializable(typeof(ProvisioningPreflightResult))]
[JsonSerializable(typeof(ProvisioningJob))]
[JsonSerializable(typeof(ProvisioningJob[]))]
[JsonSerializable(typeof(ProvisioningEvent))]
diff --git a/src/ServerMonitorManager.Provisioning.Helper/Program.cs b/src/ServerMonitorManager.Provisioning.Helper/Program.cs
new file mode 100644
index 0000000..0407ecc
--- /dev/null
+++ b/src/ServerMonitorManager.Provisioning.Helper/Program.cs
@@ -0,0 +1,24 @@
+using ServerMonitorManager.Provisioning.Helper;
+
+if (!OperatingSystem.IsLinux())
+{
+ Console.Error.WriteLine("The provisioning helper is supported only on Linux.");
+ return 2;
+}
+
+if (args.Length != 0)
+{
+ Console.Error.WriteLine("The provisioning helper does not accept command-line arguments.");
+ return 2;
+}
+const string socketPath = "/run/ochenstarik-server-monitor-manager/provisioning.sock";
+
+using var shutdown = new CancellationTokenSource();
+Console.CancelKeyPress += (_, eventArgs) =>
+{
+ eventArgs.Cancel = true;
+ shutdown.Cancel();
+};
+
+await new ProvisioningHelperServer(socketPath).RunAsync(shutdown.Token);
+return 0;
diff --git a/src/ServerMonitorManager.Provisioning.Helper/ProvisioningHelperServer.cs b/src/ServerMonitorManager.Provisioning.Helper/ProvisioningHelperServer.cs
new file mode 100644
index 0000000..55b89b5
--- /dev/null
+++ b/src/ServerMonitorManager.Provisioning.Helper/ProvisioningHelperServer.cs
@@ -0,0 +1,139 @@
+using System.Net.Sockets;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+using System.Text;
+using System.Text.Json;
+using ServerMonitorManager.Core;
+
+namespace ServerMonitorManager.Provisioning.Helper;
+
+public sealed class ProvisioningHelperServer(string socketPath)
+{
+ private const int MaximumRequestBytes = 16 * 1024;
+
+ [SupportedOSPlatform("linux")]
+ public async Task RunAsync(CancellationToken cancellationToken)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(socketPath)!);
+ File.Delete(socketPath);
+ using var listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
+ listener.Bind(new UnixDomainSocketEndPoint(socketPath));
+ File.SetUnixFileMode(socketPath,
+ UnixFileMode.UserRead | UnixFileMode.UserWrite
+ | UnixFileMode.GroupRead | UnixFileMode.GroupWrite);
+ listener.Listen(8);
+ try
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ var connection = await listener.AcceptAsync(cancellationToken);
+ await HandleAsync(connection, cancellationToken);
+ }
+ }
+ finally
+ {
+ File.Delete(socketPath);
+ }
+ }
+
+ public static ProvisioningHelperResponse Execute(ProvisioningHelperRequest request)
+ {
+ if (request.ProtocolVersion != "1")
+ {
+ return Failure("protocol.unsupported", "Unsupported helper protocol version.");
+ }
+ if (request.JobId.Length != 32 || !request.JobId.All(Uri.IsHexDigit))
+ {
+ return Failure("request.invalid-job", "Invalid provisioning job identifier.");
+ }
+ if (request.ActionType != "preflight" || request.SchemaVersion != 1
+ || request.ModuleHash != ProvisioningActionCatalog.PreflightModuleHash
+ || request.Parameters.ValueKind != JsonValueKind.Object
+ || request.Parameters.EnumerateObject().Any())
+ {
+ return Failure("action.denied", "The requested action is not allowed.");
+ }
+
+ var release = ReadOperatingSystemRelease();
+ var result = new ProvisioningPreflightResult(
+ release.GetValueOrDefault("ID", "linux"),
+ release.GetValueOrDefault("VERSION_ID", "unknown"),
+ RuntimeInformation.OSArchitecture.ToString().ToLowerInvariant(),
+ Directory.Exists("/run/systemd/system"),
+ Exists("/usr/sbin/sshd", "/usr/bin/sshd", "/sbin/sshd"),
+ Exists("/usr/sbin/nft", "/usr/bin/nft", "/sbin/nft"),
+ Exists("/usr/bin/wg", "/usr/sbin/wg", "/bin/wg"),
+ Exists("/usr/bin/apt-get", "/bin/apt-get"));
+ return new ProvisioningHelperResponse(true, "preflight.completed", "Preflight completed.", result);
+ }
+
+ private static async Task HandleAsync(Socket socket, CancellationToken cancellationToken)
+ {
+ using (socket)
+ await using (var stream = new NetworkStream(socket, ownsSocket: false))
+ {
+ ProvisioningHelperResponse response;
+ try
+ {
+ var payload = await ReadRequestAsync(stream, cancellationToken);
+ var request = JsonSerializer.Deserialize(payload, SmmJsonContext.Default.ProvisioningHelperRequest)
+ ?? throw new JsonException("Empty request.");
+ response = Execute(request);
+ }
+ catch (Exception exception) when (exception is JsonException or InvalidDataException)
+ {
+ response = Failure("request.invalid", "Invalid helper request.");
+ }
+ var json = JsonSerializer.Serialize(response, SmmJsonContext.Default.ProvisioningHelperResponse) + "\n";
+ await stream.WriteAsync(Encoding.UTF8.GetBytes(json), cancellationToken);
+ }
+ }
+
+ private static async Task ReadRequestAsync(Stream stream, CancellationToken cancellationToken)
+ {
+ using var buffer = new MemoryStream();
+ var singleByte = new byte[1];
+ while (buffer.Length <= MaximumRequestBytes)
+ {
+ var count = await stream.ReadAsync(singleByte, cancellationToken);
+ if (count == 0 || singleByte[0] == (byte)'\n')
+ {
+ break;
+ }
+ buffer.WriteByte(singleByte[0]);
+ }
+ if (buffer.Length == 0 || buffer.Length > MaximumRequestBytes)
+ {
+ throw new InvalidDataException("Request size is invalid.");
+ }
+ return buffer.ToArray();
+ }
+
+ private static Dictionary ReadOperatingSystemRelease()
+ {
+ var result = new Dictionary(StringComparer.Ordinal);
+ if (!File.Exists("/etc/os-release"))
+ {
+ return result;
+ }
+ foreach (var line in File.ReadLines("/etc/os-release"))
+ {
+ var separator = line.IndexOf('=');
+ if (separator <= 0)
+ {
+ continue;
+ }
+ var key = line[..separator];
+ if (key is "ID" or "VERSION_ID")
+ {
+ result[key] = line[(separator + 1)..].Trim().Trim('"');
+ }
+ }
+ return result;
+ }
+
+ private static bool Exists(params string[] paths) => paths.Any(File.Exists);
+
+ private static ProvisioningHelperResponse Failure(string code, string message)
+ => new(false, code, message, null);
+}
diff --git a/src/ServerMonitorManager.Provisioning.Helper/ServerMonitorManager.Provisioning.Helper.csproj b/src/ServerMonitorManager.Provisioning.Helper/ServerMonitorManager.Provisioning.Helper.csproj
new file mode 100644
index 0000000..749100f
--- /dev/null
+++ b/src/ServerMonitorManager.Provisioning.Helper/ServerMonitorManager.Provisioning.Helper.csproj
@@ -0,0 +1,14 @@
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ true
+ 0.1.0
+ ochenstarik-smm-provisioning-helper
+
+
+
+
+
diff --git a/tests/ServerMonitorManager.Control.Tests/ProvisioningHelperTests.cs b/tests/ServerMonitorManager.Control.Tests/ProvisioningHelperTests.cs
new file mode 100644
index 0000000..f5c8190
--- /dev/null
+++ b/tests/ServerMonitorManager.Control.Tests/ProvisioningHelperTests.cs
@@ -0,0 +1,56 @@
+using System.Text.Json;
+using ServerMonitorManager.Core;
+using ServerMonitorManager.Provisioning.Helper;
+using Xunit;
+
+namespace ServerMonitorManager.Control.Tests;
+
+public sealed class ProvisioningHelperTests
+{
+ [Fact]
+ public void HelperRejectsEveryActionOutsideFixedAllowlist()
+ {
+ using var document = JsonDocument.Parse("{}");
+ var response = ProvisioningHelperServer.Execute(new ProvisioningHelperRequest(
+ "1", new string('a', 32), "shell", 1,
+ ProvisioningActionCatalog.PreflightModuleHash, document.RootElement.Clone()));
+
+ Assert.False(response.Success);
+ Assert.Equal("action.denied", response.Code);
+ Assert.Null(response.Preflight);
+ }
+
+ [Fact]
+ public void HelperAcceptsOnlyEmptyPreflightSchemaOne()
+ {
+ if (!OperatingSystem.IsLinux())
+ {
+ return;
+ }
+
+ using var document = JsonDocument.Parse("{}");
+ var response = ProvisioningHelperServer.Execute(new ProvisioningHelperRequest(
+ "1", new string('b', 32), "preflight", 1,
+ ProvisioningActionCatalog.PreflightModuleHash, document.RootElement.Clone()));
+
+ Assert.True(response.Success);
+ Assert.Equal("preflight.completed", response.Code);
+ Assert.NotNull(response.Preflight);
+ Assert.NotEmpty(response.Preflight.OperatingSystem);
+ Assert.NotEmpty(response.Preflight.Architecture);
+ }
+
+ [Fact]
+ public void HelperContractRejectsUnknownJsonMembers()
+ {
+ const string json = """
+ {"protocolVersion":"1","jobId":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "actionType":"preflight","schemaVersion":1,
+ "moduleHash":"2dc48fb4528a291221954fc2dd3478d431b66fe34228f29684ce1648dbe2f32b",
+ "parameters":{},"command":"id"}
+ """;
+
+ Assert.Throws(() =>
+ JsonSerializer.Deserialize(json, SmmJsonContext.Default.ProvisioningHelperRequest));
+ }
+}
diff --git a/tests/ServerMonitorManager.Control.Tests/ServerMonitorManager.Control.Tests.csproj b/tests/ServerMonitorManager.Control.Tests/ServerMonitorManager.Control.Tests.csproj
index 9e9bb6b..5413588 100644
--- a/tests/ServerMonitorManager.Control.Tests/ServerMonitorManager.Control.Tests.csproj
+++ b/tests/ServerMonitorManager.Control.Tests/ServerMonitorManager.Control.Tests.csproj
@@ -8,6 +8,7 @@
+
diff --git a/tests/bootstrap/test-bootstrap-contract.sh b/tests/bootstrap/test-bootstrap-contract.sh
index c1aed61..135cdee 100755
--- a/tests/bootstrap/test-bootstrap-contract.sh
+++ b/tests/bootstrap/test-bootstrap-contract.sh
@@ -53,16 +53,18 @@ rm -f -- "$policy_state"
fixture="$(mktemp -d -t smm-bootstrap-test.XXXXXXXX)"
trap 'rm -rf -- "$fixture"' EXIT
-mkdir -p "$fixture/payload/agent" "$fixture/payload/control" "$fixture/payload/deploy" "$fixture/payload/bootstrap"
+mkdir -p "$fixture/payload/agent" "$fixture/payload/control" "$fixture/payload/provisioning-helper" "$fixture/payload/deploy" "$fixture/payload/bootstrap"
install -m 0755 /bin/true "$fixture/payload/agent/ochenstarik-smm-agent"
install -m 0755 /bin/true "$fixture/payload/control/ochenstarik-smm-control"
+install -m 0755 /bin/true "$fixture/payload/provisioning-helper/ochenstarik-smm-provisioning-helper"
install -m 0755 "$helper" "$fixture/payload/deploy/ochenstarik-smm-policy-apply"
install -m 0755 "$emergency" "$fixture/payload/deploy/ochenstarik-smm-emergency"
install -m 0644 "$root/deploy/ochenstarik-smm-control.service" "$fixture/payload/deploy/"
install -m 0644 "$root/deploy/ochenstarik-smm-agent.service" "$fixture/payload/deploy/"
+install -m 0644 "$root/deploy/ochenstarik-smm-provisioning-helper.service" "$fixture/payload/deploy/"
install -m 0644 "$root/deploy/ochenstarik-smm-firewall.service" "$fixture/payload/deploy/"
install -m 0755 "$bootstrap" "$fixture/payload/bootstrap/ochenstarik-server-monitor-manager.sh"
-tar -C "$fixture/payload" -czf "$fixture/release.tar.gz" agent control deploy bootstrap
+tar -C "$fixture/payload" -czf "$fixture/release.tar.gz" agent control provisioning-helper deploy bootstrap
sha256sum "$fixture/release.tar.gz" >"$fixture/release.tar.gz.sha256"
bash "$bootstrap" verify-release "$fixture/release.tar.gz" >/dev/null