Add restricted provisioning preflight helper
This commit is contained in:
parent
a15b0dd7b3
commit
4d08b93d23
21 changed files with 504 additions and 12 deletions
12
.github/workflows/linux-control-agent.yml
vendored
12
.github/workflows/linux-control-agent.yml
vendored
|
|
@ -57,6 +57,12 @@ jobs:
|
||||||
- name: Publish agent arm64
|
- 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
|
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
|
- name: Build systemd smoke release
|
||||||
run: |
|
run: |
|
||||||
dotnet publish src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj \
|
dotnet publish src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj \
|
||||||
|
|
@ -65,14 +71,18 @@ jobs:
|
||||||
dotnet publish src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj \
|
dotnet publish src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj \
|
||||||
--configuration Release --runtime linux-x64 --self-contained true \
|
--configuration Release --runtime linux-x64 --self-contained true \
|
||||||
-p:PublishSingleFile=true -p:PublishTrimmed=true -o smoke/control
|
-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 -d smoke/deploy smoke/bootstrap
|
||||||
install -m 0644 deploy/ochenstarik-smm-control.service smoke/deploy/
|
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-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 0644 deploy/ochenstarik-smm-firewall.service smoke/deploy/
|
||||||
install -m 0755 deploy/ochenstarik-smm-policy-apply 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-smm-emergency smoke/deploy/
|
||||||
install -m 0755 deploy/ochenstarik-server-monitor-manager.sh smoke/bootstrap/
|
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
|
sha256sum smoke-release.tar.gz > smoke-release.tar.gz.sha256
|
||||||
|
|
||||||
- name: Verify repeated systemd installation
|
- name: Verify repeated systemd installation
|
||||||
|
|
|
||||||
6
.github/workflows/linux-platform-matrix.yml
vendored
6
.github/workflows/linux-platform-matrix.yml
vendored
|
|
@ -52,15 +52,19 @@ jobs:
|
||||||
dotnet publish src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj \
|
dotnet publish src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj \
|
||||||
--configuration Release --runtime '${{ matrix.runtime }}' --self-contained true \
|
--configuration Release --runtime '${{ matrix.runtime }}' --self-contained true \
|
||||||
-p:PublishSingleFile=true -p:PublishTrimmed=true -o out/control
|
-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 -d out/deploy out/bootstrap
|
||||||
install -m 0644 deploy/ochenstarik-smm-control.service out/deploy/
|
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-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 0644 deploy/ochenstarik-smm-firewall.service out/deploy/
|
||||||
install -m 0755 deploy/ochenstarik-smm-policy-apply 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-smm-emergency out/deploy/
|
||||||
install -m 0755 deploy/ochenstarik-server-monitor-manager.sh out/bootstrap/
|
install -m 0755 deploy/ochenstarik-server-monitor-manager.sh out/bootstrap/
|
||||||
archive='server-monitor-manager-${{ matrix.runtime }}.tar.gz'
|
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"
|
sha256sum "$archive" >"$archive.sha256"
|
||||||
|
|
||||||
- name: Upload release archive
|
- name: Upload release archive
|
||||||
|
|
|
||||||
6
.github/workflows/linux-release.yml
vendored
6
.github/workflows/linux-release.yml
vendored
|
|
@ -84,6 +84,9 @@ jobs:
|
||||||
- name: Publish control
|
- 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
|
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
|
- name: Package
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -91,12 +94,13 @@ jobs:
|
||||||
install -d out/deploy out/bootstrap
|
install -d out/deploy out/bootstrap
|
||||||
install -m 0644 deploy/ochenstarik-smm-control.service out/deploy/
|
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-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 0644 deploy/ochenstarik-smm-firewall.service out/deploy/
|
||||||
install -m 0755 deploy/ochenstarik-smm-policy-apply 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-smm-emergency out/deploy/
|
||||||
install -m 0755 deploy/ochenstarik-server-monitor-manager.sh out/bootstrap/
|
install -m 0755 deploy/ochenstarik-server-monitor-manager.sh out/bootstrap/
|
||||||
archive="server-monitor-manager-${{ matrix.runtime }}.tar.gz"
|
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"
|
sha256sum "$archive" > "$archive.sha256"
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
## License and project policy
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,6 @@
|
||||||
<Project Path="src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj" />
|
<Project Path="src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj" />
|
||||||
<Project Path="src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj" />
|
<Project Path="src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj" />
|
||||||
<Project Path="src/ServerMonitorManager.Core/ServerMonitorManager.Core.csproj" />
|
<Project Path="src/ServerMonitorManager.Core/ServerMonitorManager.Core.csproj" />
|
||||||
|
<Project Path="src/ServerMonitorManager.Provisioning.Helper/ServerMonitorManager.Provisioning.Helper.csproj" />
|
||||||
<Project Path="tests/ServerMonitorManager.Control.Tests/ServerMonitorManager.Control.Tests.csproj" />
|
<Project Path="tests/ServerMonitorManager.Control.Tests/ServerMonitorManager.Control.Tests.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ readonly CONTROL_USER="ochenstarik-smm-control"
|
||||||
readonly AGENT_USER="ochenstarik-smm-agent"
|
readonly AGENT_USER="ochenstarik-smm-agent"
|
||||||
readonly CONTROL_UNIT="ochenstarik-smm-control.service"
|
readonly CONTROL_UNIT="ochenstarik-smm-control.service"
|
||||||
readonly AGENT_UNIT="ochenstarik-smm-agent.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 POLICY_HELPER="/usr/local/libexec/ochenstarik-smm-policy-apply"
|
||||||
readonly EMERGENCY_COMMAND="/usr/local/sbin/ochenstarik-smm-emergency"
|
readonly EMERGENCY_COMMAND="/usr/local/sbin/ochenstarik-smm-emergency"
|
||||||
readonly SUDOERS_FILE="/etc/sudoers.d/ochenstarik-smm-control"
|
readonly SUDOERS_FILE="/etc/sudoers.d/ochenstarik-smm-control"
|
||||||
|
|
@ -138,7 +139,7 @@ verify_archive() {
|
||||||
[[ -n "$entry" ]] || continue
|
[[ -n "$entry" ]] || continue
|
||||||
[[ "$entry" != /* && "$entry" != *".."* ]] || fail "Unsafe archive entry: $entry"
|
[[ "$entry" != /* && "$entry" != *".."* ]] || fail "Unsafe archive entry: $entry"
|
||||||
case "$entry" in
|
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" ;;
|
*) fail "Unexpected archive entry: $entry" ;;
|
||||||
esac
|
esac
|
||||||
done < <(tar -tzf "$archive")
|
done < <(tar -tzf "$archive")
|
||||||
|
|
@ -152,6 +153,7 @@ extract_archive() {
|
||||||
tar -xzf "$archive" -C "$TEMP_DIR" --no-same-owner --no-same-permissions
|
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/$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/$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."
|
[[ -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-policy-apply" ]] || fail "Policy helper is missing from archive."
|
||||||
[[ -x "$TEMP_DIR/deploy/ochenstarik-smm-emergency" ]] || fail "Emergency command 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"
|
extract_archive "$archive"
|
||||||
[[ -x "$TEMP_DIR/control/ochenstarik-smm-control" ]] || fail "Control binary is missing."
|
[[ -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/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-policy-apply" ]] || fail "Policy helper is missing."
|
||||||
[[ -x "$TEMP_DIR/deploy/ochenstarik-smm-emergency" ]] || fail "Emergency recovery command 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."
|
[[ -f "$TEMP_DIR/deploy/$FIREWALL_UNIT" ]] || fail "Mesh firewall unit is missing."
|
||||||
|
|
@ -229,9 +232,11 @@ create_backup() {
|
||||||
agent)
|
agent)
|
||||||
list=(
|
list=(
|
||||||
"usr/local/lib/ochenstarik-server-monitor-manager/agent"
|
"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/agent.env"
|
||||||
"etc/ochenstarik-server-monitor-manager/control-ca.crt"
|
"etc/ochenstarik-server-monitor-manager/control-ca.crt"
|
||||||
"etc/systemd/system/$AGENT_UNIT"
|
"etc/systemd/system/$AGENT_UNIT"
|
||||||
|
"etc/systemd/system/$PROVISIONING_HELPER_UNIT"
|
||||||
)
|
)
|
||||||
;;
|
;;
|
||||||
*) fail "Unknown backup role: $role" ;;
|
*) 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."
|
openssl x509 -in "$ca_cert" -noout >/dev/null 2>&1 || fail "Invalid Control CA certificate."
|
||||||
extract_archive "$archive"
|
extract_archive "$archive"
|
||||||
[[ -x "$TEMP_DIR/agent/ochenstarik-smm-agent" ]] || fail "Agent 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."
|
||||||
backup_id="$(create_backup agent)"
|
backup_id="$(create_backup agent)"
|
||||||
ensure_system_user "$AGENT_USER"
|
ensure_system_user "$AGENT_USER"
|
||||||
install -d -m 0750 -o root -g "$AGENT_USER" "$ETC_DIR"
|
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 -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/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 -d -m 0755 "$(dirname "$EMERGENCY_COMMAND")"
|
||||||
install -m 0755 "$TEMP_DIR/deploy/ochenstarik-smm-emergency" "$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
|
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"
|
chown root:"$AGENT_USER" "$ETC_DIR/control-ca.crt"
|
||||||
chmod 0640 "$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/$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 enable --now "$AGENT_UNIT"
|
||||||
systemctl is-active --quiet "$AGENT_UNIT" || {
|
systemctl is-active --quiet "$AGENT_UNIT" || {
|
||||||
systemctl status --no-pager "$AGENT_UNIT" >&2 || true
|
systemctl status --no-pager "$AGENT_UNIT" >&2 || true
|
||||||
|
|
@ -661,8 +670,19 @@ update_role() {
|
||||||
esac
|
esac
|
||||||
[[ -x "$TEMP_DIR/$role/$binary" ]] || fail "$role binary is missing."
|
[[ -x "$TEMP_DIR/$role/$binary" ]] || fail "$role binary is missing."
|
||||||
systemctl stop "$unit"
|
systemctl stop "$unit"
|
||||||
|
if [[ "$role" == "agent" ]]; then
|
||||||
|
systemctl stop "$PROVISIONING_HELPER_UNIT" 2>/dev/null || true
|
||||||
|
fi
|
||||||
backup_id="$(create_backup "$role")"
|
backup_id="$(create_backup "$role")"
|
||||||
install_tree_atomic "$TEMP_DIR/$role" "$LIB_DIR/$role" "$user"
|
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"
|
systemctl start "$unit"
|
||||||
if ! systemctl is-active --quiet "$unit"; then
|
if ! systemctl is-active --quiet "$unit"; then
|
||||||
log "Update failed; restoring backup $backup_id"
|
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
|
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"
|
[[ -f "$archive" ]] || fail "Backup archive not found: $backup_id"
|
||||||
systemctl stop "$unit" || true
|
systemctl stop "$unit" || true
|
||||||
|
if [[ "$role" == "agent" ]]; then
|
||||||
|
systemctl stop "$PROVISIONING_HELPER_UNIT" || true
|
||||||
|
fi
|
||||||
tar -C / -xzf "$archive"
|
tar -C / -xzf "$archive"
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
|
if [[ "$role" == "agent" ]]; then
|
||||||
|
systemctl start "$PROVISIONING_HELPER_UNIT"
|
||||||
|
fi
|
||||||
systemctl start "$unit"
|
systemctl start "$unit"
|
||||||
systemctl is-active --quiet "$unit" || fail "Rollback restored files but service is not active."
|
systemctl is-active --quiet "$unit" || fail "Rollback restored files but service is not active."
|
||||||
log "$role restored from $backup_id"
|
log "$role restored from $backup_id"
|
||||||
|
|
@ -825,8 +851,9 @@ uninstall_agent() {
|
||||||
local purge="${1:-}"
|
local purge="${1:-}"
|
||||||
require_root
|
require_root
|
||||||
systemctl disable --now "$AGENT_UNIT" 2>/dev/null || true
|
systemctl disable --now "$AGENT_UNIT" 2>/dev/null || true
|
||||||
rm -f -- "/etc/systemd/system/$AGENT_UNIT" "$ETC_DIR/agent.env"
|
systemctl disable --now "$PROVISIONING_HELPER_UNIT" 2>/dev/null || true
|
||||||
rm -rf -- "$LIB_DIR/agent"
|
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"
|
[[ "$purge" == "--purge" ]] && rm -rf -- "$STATE_DIR/agent" "$ETC_DIR/control-ca.crt"
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
log "Agent removed${purge:+ ($purge)}."
|
log "Agent removed${purge:+ ($purge)}."
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Ochenstarik Server Monitor Manager Agent
|
Description=Ochenstarik Server Monitor Manager Agent
|
||||||
After=network-online.target
|
After=network-online.target ochenstarik-smm-provisioning-helper.service
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
Requires=ochenstarik-smm-provisioning-helper.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
|
|
||||||
31
deploy/ochenstarik-smm-provisioning-helper.service
Normal file
31
deploy/ochenstarik-smm-provisioning-helper.service
Normal file
|
|
@ -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
|
||||||
|
|
@ -116,7 +116,7 @@
|
||||||
- [x] атомарный Agent job channel только для собственного `node_id`;
|
- [x] атомарный Agent job channel только для собственного `node_id`;
|
||||||
- [x] начальные строгие JSON schemas v1 для `preflight` и `system.base-install`;
|
- [x] начальные строгие JSON schemas v1 для `preflight` и `system.base-install`;
|
||||||
- [ ] versioned JSON schemas для остальных action type;
|
- [ ] 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] structured redacted events, bounded Operator history и progress;
|
||||||
- [x] `NeedsReconciliation` после истечения execution TTL и неопределённого результата;
|
- [x] `NeedsReconciliation` после истечения execution TTL и неопределённого результата;
|
||||||
- [ ] desired/factual configuration и drift;
|
- [ ] desired/factual configuration и drift;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using System.Text;
|
||||||
using ServerMonitorManager.Core;
|
using ServerMonitorManager.Core;
|
||||||
|
|
||||||
namespace ServerMonitorManager.Agent;
|
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 elapsed = DateTimeOffset.UtcNow - iterationStartedAt;
|
||||||
var remaining = collectionDelay - elapsed;
|
var remaining = collectionDelay - elapsed;
|
||||||
await Task.Delay(remaining > TimeSpan.Zero ? remaining : TimeSpan.FromSeconds(1), cancellationToken);
|
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<AgentHeartbeatResponse> SendHeartbeatAsync(
|
private static async Task<AgentHeartbeatResponse> SendHeartbeatAsync(
|
||||||
HttpClient client,
|
HttpClient client,
|
||||||
AgentHeartbeat heartbeat,
|
AgentHeartbeat heartbeat,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ public sealed class AgentOptions
|
||||||
public Uri ControlUrl { get; init; } = new("https://127.0.0.1:7443");
|
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 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 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 HeartbeatSeconds { get; init; } = 30;
|
||||||
public int BufferMaxSamples { get; init; } = 720;
|
public int BufferMaxSamples { get; init; } = 720;
|
||||||
public int BufferRecentSamples { get; init; } = 120;
|
public int BufferRecentSamples { get; init; } = 120;
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ if (options.HeartbeatSeconds is < 10 or > 300
|
||||||
|| options.BufferRecentSamples >= options.BufferMaxSamples
|
|| options.BufferRecentSamples >= options.BufferMaxSamples
|
||||||
|| options.BufferDownsampleFactor is < 2 or > 100
|
|| options.BufferDownsampleFactor is < 2 or > 100
|
||||||
|| options.UploadBatchSize is < 1 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(
|
Console.Error.WriteLine(
|
||||||
"Invalid buffer settings: heartbeat 10-300s, max samples 10-10000, recent samples below max, "
|
"Invalid buffer settings: heartbeat 10-300s, max samples 10-10000, recent samples below max, "
|
||||||
|
|
|
||||||
53
src/ServerMonitorManager.Agent/ProvisioningHelperClient.cs
Normal file
53
src/ServerMonitorManager.Agent/ProvisioningHelperClient.cs
Normal file
|
|
@ -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<ProvisioningPreflightResult> 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<byte[]> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace ServerMonitorManager.Core;
|
namespace ServerMonitorManager.Core;
|
||||||
|
|
||||||
|
|
@ -163,6 +164,31 @@ public sealed record ProvisioningJobProgressRequest(
|
||||||
string Message,
|
string Message,
|
||||||
string IdempotencyKey);
|
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(
|
public sealed record ProvisioningJob(
|
||||||
string Id,
|
string Id,
|
||||||
string NodeId,
|
string NodeId,
|
||||||
|
|
@ -208,3 +234,9 @@ public static class ProvisioningJobStates
|
||||||
public const string RollbackFailed = "RollbackFailed";
|
public const string RollbackFailed = "RollbackFailed";
|
||||||
public const string Cancelled = "Cancelled";
|
public const string Cancelled = "Cancelled";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class ProvisioningActionCatalog
|
||||||
|
{
|
||||||
|
public const string PreflightModuleHash =
|
||||||
|
"2dc48fb4528a291221954fc2dd3478d431b66fe34228f29684ce1648dbe2f32b";
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ namespace ServerMonitorManager.Core;
|
||||||
[JsonSerializable(typeof(ProvisioningJobCreateRequest))]
|
[JsonSerializable(typeof(ProvisioningJobCreateRequest))]
|
||||||
[JsonSerializable(typeof(ProvisioningJobCommandRequest))]
|
[JsonSerializable(typeof(ProvisioningJobCommandRequest))]
|
||||||
[JsonSerializable(typeof(ProvisioningJobProgressRequest))]
|
[JsonSerializable(typeof(ProvisioningJobProgressRequest))]
|
||||||
|
[JsonSerializable(typeof(ProvisioningHelperRequest))]
|
||||||
|
[JsonSerializable(typeof(ProvisioningHelperResponse))]
|
||||||
|
[JsonSerializable(typeof(ProvisioningPreflightResult))]
|
||||||
[JsonSerializable(typeof(ProvisioningJob))]
|
[JsonSerializable(typeof(ProvisioningJob))]
|
||||||
[JsonSerializable(typeof(ProvisioningJob[]))]
|
[JsonSerializable(typeof(ProvisioningJob[]))]
|
||||||
[JsonSerializable(typeof(ProvisioningEvent))]
|
[JsonSerializable(typeof(ProvisioningEvent))]
|
||||||
|
|
|
||||||
24
src/ServerMonitorManager.Provisioning.Helper/Program.cs
Normal file
24
src/ServerMonitorManager.Provisioning.Helper/Program.cs
Normal file
|
|
@ -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;
|
||||||
|
|
@ -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<byte[]> 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<string, string> ReadOperatingSystemRelease()
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, string>(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);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
|
<Version>0.1.0</Version>
|
||||||
|
<AssemblyName>ochenstarik-smm-provisioning-helper</AssemblyName>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ServerMonitorManager.Core\ServerMonitorManager.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
|
|
@ -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<JsonException>(() =>
|
||||||
|
JsonSerializer.Deserialize(json, SmmJsonContext.Default.ProvisioningHelperRequest));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\ServerMonitorManager.Agent\ServerMonitorManager.Agent.csproj" />
|
<ProjectReference Include="..\..\src\ServerMonitorManager.Agent\ServerMonitorManager.Agent.csproj" />
|
||||||
<ProjectReference Include="..\..\src\ServerMonitorManager.Control\ServerMonitorManager.Control.csproj" Aliases="global,controlapp" />
|
<ProjectReference Include="..\..\src\ServerMonitorManager.Control\ServerMonitorManager.Control.csproj" Aliases="global,controlapp" />
|
||||||
|
<ProjectReference Include="..\..\src\ServerMonitorManager.Provisioning.Helper\ServerMonitorManager.Provisioning.Helper.csproj" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||||
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||||
|
|
|
||||||
|
|
@ -53,16 +53,18 @@ rm -f -- "$policy_state"
|
||||||
|
|
||||||
fixture="$(mktemp -d -t smm-bootstrap-test.XXXXXXXX)"
|
fixture="$(mktemp -d -t smm-bootstrap-test.XXXXXXXX)"
|
||||||
trap 'rm -rf -- "$fixture"' EXIT
|
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/agent/ochenstarik-smm-agent"
|
||||||
install -m 0755 /bin/true "$fixture/payload/control/ochenstarik-smm-control"
|
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 "$helper" "$fixture/payload/deploy/ochenstarik-smm-policy-apply"
|
||||||
install -m 0755 "$emergency" "$fixture/payload/deploy/ochenstarik-smm-emergency"
|
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-control.service" "$fixture/payload/deploy/"
|
||||||
install -m 0644 "$root/deploy/ochenstarik-smm-agent.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 0644 "$root/deploy/ochenstarik-smm-firewall.service" "$fixture/payload/deploy/"
|
||||||
install -m 0755 "$bootstrap" "$fixture/payload/bootstrap/ochenstarik-server-monitor-manager.sh"
|
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"
|
sha256sum "$fixture/release.tar.gz" >"$fixture/release.tar.gz.sha256"
|
||||||
bash "$bootstrap" verify-release "$fixture/release.tar.gz" >/dev/null
|
bash "$bootstrap" verify-release "$fixture/release.tar.gz" >/dev/null
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue