Merge branch 'main' into antigravity/desktop-update-proof
This commit is contained in:
commit
3f5f733ebe
12 changed files with 735 additions and 10 deletions
|
|
@ -3,7 +3,7 @@ set -Eeuo pipefail
|
||||||
IFS=$'\n\t'
|
IFS=$'\n\t'
|
||||||
|
|
||||||
readonly PROGRAM_NAME="smm-setup"
|
readonly PROGRAM_NAME="smm-setup"
|
||||||
readonly DEFAULT_RELEASE_TAG="v0.1.0-alpha.16"
|
readonly DEFAULT_RELEASE_TAG="v0.1.0-alpha.18"
|
||||||
readonly DEFAULT_REPOSITORY="ochenstarik-ui/server-monitor-manager"
|
readonly DEFAULT_REPOSITORY="ochenstarik-ui/server-monitor-manager"
|
||||||
readonly INNER_ASSET="ochenstarik-server-monitor-manager.sh"
|
readonly INNER_ASSET="ochenstarik-server-monitor-manager.sh"
|
||||||
|
|
||||||
|
|
@ -27,7 +27,7 @@ force pass-through. Common bootstrap commands:
|
||||||
backup-create | backup-restore | version
|
backup-create | backup-restore | version
|
||||||
|
|
||||||
Environment overrides:
|
Environment overrides:
|
||||||
SMM_TAG Release tag (default: v0.1.0-alpha.16)
|
SMM_TAG Release tag (default: v0.1.0-alpha.18)
|
||||||
SMM_REPOSITORY GitHub repository (default: ochenstarik-ui/server-monitor-manager)
|
SMM_REPOSITORY GitHub repository (default: ochenstarik-ui/server-monitor-manager)
|
||||||
SMM_CACHE_DIR Verified-download cache directory
|
SMM_CACHE_DIR Verified-download cache directory
|
||||||
USAGE
|
USAGE
|
||||||
|
|
@ -114,7 +114,7 @@ curl -fsSL "$release_base/$INNER_ASSET.sha256" -o "$temporary_directory/$INNER_A
|
||||||
|
|
||||||
(
|
(
|
||||||
cd "$temporary_directory"
|
cd "$temporary_directory"
|
||||||
sha256sum -c "$INNER_ASSET.sha256"
|
sha256sum -c "$INNER_ASSET.sha256" >/dev/null
|
||||||
) || die "checksum verification failed for $RELEASE_TAG/$INNER_ASSET"
|
) || die "checksum verification failed for $RELEASE_TAG/$INNER_ASSET"
|
||||||
|
|
||||||
install -m 0755 "$temporary_directory/$INNER_ASSET" "$cached_script"
|
install -m 0755 "$temporary_directory/$INNER_ASSET" "$cached_script"
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,11 @@ Server Monitor Manager устанавливает Control (Hub) и Agent (Node)
|
||||||
|
|
||||||
## Быстрая установка
|
## Быстрая установка
|
||||||
|
|
||||||
Скачайте и проверьте convenience installer из `v0.1.0-alpha.16`:
|
Скачайте и проверьте convenience installer из `v0.1.0-alpha.18`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSLO https://github.com/ochenstarik-ui/server-monitor-manager/releases/download/v0.1.0-alpha.16/smm-setup.sh
|
curl -fsSLO https://github.com/ochenstarik-ui/server-monitor-manager/releases/download/v0.1.0-alpha.18/smm-setup.sh
|
||||||
curl -fsSLO https://github.com/ochenstarik-ui/server-monitor-manager/releases/download/v0.1.0-alpha.16/smm-setup.sh.sha256
|
curl -fsSLO https://github.com/ochenstarik-ui/server-monitor-manager/releases/download/v0.1.0-alpha.18/smm-setup.sh.sha256
|
||||||
sha256sum -c smm-setup.sh.sha256
|
sha256sum -c smm-setup.sh.sha256
|
||||||
chmod 700 smm-setup.sh
|
chmod 700 smm-setup.sh
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ Known release history:
|
||||||
- `v0.1.0-alpha.13` corrected the checksum portability defect, but it was also published with a keyless manifest signature and without the Fulcio signing certificate required by production consumers. The immutable release remains published as historical evidence; the producer/consumer certificate contract is corrected under a higher version.
|
- `v0.1.0-alpha.13` corrected the checksum portability defect, but it was also published with a keyless manifest signature and without the Fulcio signing certificate required by production consumers. The immutable release remains published as historical evidence; the producer/consumer certificate contract is corrected under a higher version.
|
||||||
- `v0.1.0-alpha.14` is the first release with the complete manifest, keyless signature, and Fulcio certificate set, so its published assets can be verified. A clean host cannot install it because the release does not provision cosign. Preserve it for verification and historical evidence; do not use it for installation.
|
- `v0.1.0-alpha.14` is the first release with the complete manifest, keyless signature, and Fulcio certificate set, so its published assets can be verified. A clean host cannot install it because the release does not provision cosign. Preserve it for verification and historical evidence; do not use it for installation.
|
||||||
- `v0.1.0-alpha.15` is the first release that provisions a pinned, checksum-verified cosign binary. Its automatically triggered Release Verification proved the clean-host Hub installation and manifest verification, then stopped before the clean-host Node installation because the acceptance script retained the deliberately removed cosign path in Bash's command hash. The immutable release and failed verification remain as evidence; the acceptance harness is corrected in the next version.
|
- `v0.1.0-alpha.15` is the first release that provisions a pinned, checksum-verified cosign binary. Its automatically triggered Release Verification proved the clean-host Hub installation and manifest verification, then stopped before the clean-host Node installation because the acceptance script retained the deliberately removed cosign path in Bash's command hash. The immutable release and failed verification remain as evidence; the acceptance harness is corrected in the next version.
|
||||||
- `v0.1.0-alpha.16` clears the acceptance shell's command hash after removing its test-provisioned cosign, so both `install-hub` and `install-node` are exercised from a clean host. It is the first release required to complete both automatic `workflow_run` verification and manual `workflow_dispatch` re-verification.
|
- `v0.1.0-alpha.16` clears the acceptance shell's command hash after removing its test-provisioned cosign. Its automatic verification proved clean-host Hub installation, but the convenience installer wrote the bootstrap checksum success line to stdout before the machine-readable `SMMNODE2` enrollment code. Node installation therefore rejected the contaminated value. The immutable release and failed verification remain as evidence; stdout isolation is corrected in the next version.
|
||||||
|
- `v0.1.0-alpha.17` keeps checksum verification fail-closed while suppressing its success line, so pass-through commands such as `node-code` return only their machine-readable bootstrap output. Its automatic verification completed the clean-host Hub and Node installation, then the negative-test harness stopped while creating a wrong-identity signature because cosign v3 requires an explicit bundle or legacy detached-output mode. The immutable release and failed harness run remain as evidence; the negative test is corrected in the next version.
|
||||||
|
- `v0.1.0-alpha.18` makes the wrong-identity negative test select cosign v3's explicit detached-output mode, matching the production consumer's detached signature and certificate contract. It is the first release required to complete both automatic `workflow_run` verification and manual `workflow_dispatch` re-verification.
|
||||||
|
|
||||||
Every release candidate must pass a branch `workflow_dispatch` run of the Release pipeline before its immutable version tag is created. The release owner has sole write ownership of version sources, `deploy/**`, `tests/bootstrap/**`, release workflows, the root README release status, and translated README release statuses. Other contributors request changes to those paths in their report; they do not edit or bump them directly. One pull request covers one release topic and may merge only after required CI is green.
|
Every release candidate must pass a branch `workflow_dispatch` run of the Release pipeline before its immutable version tag is created. The release owner has sole write ownership of version sources, `deploy/**`, `tests/bootstrap/**`, release workflows, the root README release status, and translated README release statuses. Other contributors request changes to those paths in their report; they do not edit or bump them directly. One pull request covers one release topic and may merge only after required CI is green.
|
||||||
|
|
|
||||||
|
|
@ -46,4 +46,20 @@ public sealed class ControlOptions
|
||||||
public string HubHelperPath { get; set; } = "/usr/local/libexec/ochenstarik-smm-policy-apply";
|
public string HubHelperPath { get; set; } = "/usr/local/libexec/ochenstarik-smm-policy-apply";
|
||||||
|
|
||||||
public string PrivilegeEscalationPath { get; set; } = "/usr/bin/sudo";
|
public string PrivilegeEscalationPath { get; set; } = "/usr/bin/sudo";
|
||||||
|
|
||||||
|
public string? PublicUrl { get; set; }
|
||||||
|
|
||||||
|
public string PublicUrlPath { get; set; } = "/etc/ochenstarik-server-monitor-manager/control-public-url";
|
||||||
|
|
||||||
|
public string MeshEnvironmentPath { get; set; } = "/etc/ochenstarik-server-monitor-manager/mesh.env";
|
||||||
|
|
||||||
|
public string MeshNodesPath { get; set; } = "/var/lib/ochenstarik-server-monitor-manager/mesh/nodes.tsv";
|
||||||
|
|
||||||
|
public string HubPublicKeyPath { get; set; } = "/etc/ochenstarik-server-monitor-manager/wireguard/hub.pub";
|
||||||
|
|
||||||
|
public string? HubEndpoint { get; set; }
|
||||||
|
|
||||||
|
public string? HubPublicKey { get; set; }
|
||||||
|
|
||||||
|
public string MeshNetwork { get; set; } = "10.77.0.0/24";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -444,6 +444,32 @@ public sealed partial class ControlStore(IOptions<ControlOptions> options)
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task RecordEnrollmentCodeIssuedAsync(
|
||||||
|
string nodeId,
|
||||||
|
string actor,
|
||||||
|
string nodeAddress,
|
||||||
|
DateTimeOffset expiresAt,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await OpenAsync(cancellationToken);
|
||||||
|
await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
|
||||||
|
await WriteAuditAsync(
|
||||||
|
connection,
|
||||||
|
transaction,
|
||||||
|
actor,
|
||||||
|
"agent.enrollment_code.issued",
|
||||||
|
nodeId,
|
||||||
|
JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
node_id = nodeId,
|
||||||
|
actor,
|
||||||
|
node_address = nodeAddress,
|
||||||
|
expires_at = expiresAt
|
||||||
|
}),
|
||||||
|
cancellationToken);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<string> CreateDeviceEnrollmentTokenAsync(
|
public async Task<string> CreateDeviceEnrollmentTokenAsync(
|
||||||
string deviceId,
|
string deviceId,
|
||||||
TimeSpan lifetime,
|
TimeSpan lifetime,
|
||||||
|
|
|
||||||
224
src/ServerMonitorManager.Control/NodeEnrollmentService.cs
Normal file
224
src/ServerMonitorManager.Control/NodeEnrollmentService.cs
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ServerMonitorManager.Core;
|
||||||
|
|
||||||
|
namespace ServerMonitorManager.Control;
|
||||||
|
|
||||||
|
public sealed class NodeEnrollmentService : IDisposable
|
||||||
|
{
|
||||||
|
private readonly IOptions<ControlOptions> _options;
|
||||||
|
private readonly ControlStore _store;
|
||||||
|
private readonly CertificateAuthority _authority;
|
||||||
|
private readonly ControlEventBroker _broker;
|
||||||
|
private readonly SemaphoreSlim _meshLock = new(1, 1);
|
||||||
|
|
||||||
|
public NodeEnrollmentService(
|
||||||
|
IOptions<ControlOptions> options,
|
||||||
|
ControlStore store,
|
||||||
|
CertificateAuthority authority,
|
||||||
|
ControlEventBroker broker)
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
_store = store;
|
||||||
|
_authority = authority;
|
||||||
|
_broker = broker;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<NodeEnrollmentCodeResponse> CreateEnrollmentCodeAsync(
|
||||||
|
string nodeId,
|
||||||
|
string actor,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!NodeIdValidator.IsValid(nodeId))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Node id must contain 1-63 lowercase letters, digits, or hyphens.", nameof(nodeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
var controlUrl = ResolveControlPublicUrl();
|
||||||
|
var (caPem, caFingerprint) = ResolveCertificateAuthorityInfo();
|
||||||
|
var (hubEndpoint, hubPublicKey, meshNetwork) = ResolveMeshConfiguration();
|
||||||
|
|
||||||
|
string nodeAddress;
|
||||||
|
await _meshLock.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
nodeAddress = await ReserveNodeAddressAsync(nodeId, cancellationToken);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_meshLock.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
var lifetime = TimeSpan.FromMinutes(10);
|
||||||
|
var token = await _store.CreateEnrollmentTokenAsync(nodeId, lifetime, cancellationToken);
|
||||||
|
var expiresAt = DateTimeOffset.UtcNow.Add(lifetime);
|
||||||
|
|
||||||
|
await _store.RecordEnrollmentCodeIssuedAsync(nodeId, actor, nodeAddress, expiresAt, cancellationToken);
|
||||||
|
_broker.Publish(
|
||||||
|
"agent.enrollment_code.issued",
|
||||||
|
nodeId,
|
||||||
|
JsonSerializer.Serialize(
|
||||||
|
new NodeEnrollmentCodeIssuedDetails(nodeId, actor, nodeAddress, expiresAt),
|
||||||
|
SmmJsonContext.Default.NodeEnrollmentCodeIssuedDetails));
|
||||||
|
|
||||||
|
var code = string.Join(".",
|
||||||
|
"SMMNODE2",
|
||||||
|
Base64UrlEncode(controlUrl),
|
||||||
|
Base64UrlEncode(caPem),
|
||||||
|
Base64UrlEncode(nodeId),
|
||||||
|
Base64UrlEncode(token),
|
||||||
|
Base64UrlEncode(hubEndpoint),
|
||||||
|
Base64UrlEncode(hubPublicKey),
|
||||||
|
Base64UrlEncode(nodeAddress),
|
||||||
|
Base64UrlEncode(meshNetwork));
|
||||||
|
|
||||||
|
return new NodeEnrollmentCodeResponse(nodeId, code, caFingerprint, expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolveControlPublicUrl()
|
||||||
|
{
|
||||||
|
var options = _options.Value;
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.PublicUrl))
|
||||||
|
{
|
||||||
|
return options.PublicUrl.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File.Exists(options.PublicUrlPath))
|
||||||
|
{
|
||||||
|
var content = File.ReadAllText(options.PublicUrlPath).Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException("Control public URL is missing.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private (string Pem, string Fingerprint) ResolveCertificateAuthorityInfo()
|
||||||
|
{
|
||||||
|
var cert = _authority.PublicCertificate;
|
||||||
|
var pem = cert.ExportCertificatePem();
|
||||||
|
var hash = cert.GetCertHash(HashAlgorithmName.SHA256);
|
||||||
|
var fingerprint = string.Join(":", hash.Select(b => b.ToString("X2")));
|
||||||
|
return (pem, fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (string HubEndpoint, string HubPublicKey, string MeshNetwork) ResolveMeshConfiguration()
|
||||||
|
{
|
||||||
|
var options = _options.Value;
|
||||||
|
var hubEndpoint = options.HubEndpoint;
|
||||||
|
var hubPublicKey = options.HubPublicKey;
|
||||||
|
var meshNetwork = options.MeshNetwork ?? "10.77.0.0/24";
|
||||||
|
|
||||||
|
if (File.Exists(options.MeshEnvironmentPath))
|
||||||
|
{
|
||||||
|
foreach (var line in File.ReadLines(options.MeshEnvironmentPath))
|
||||||
|
{
|
||||||
|
var trimmed = line.Trim();
|
||||||
|
if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#'))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var separatorIndex = trimmed.IndexOf('=');
|
||||||
|
if (separatorIndex <= 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = trimmed[..separatorIndex].Trim();
|
||||||
|
var value = trimmed[(separatorIndex + 1)..].Trim();
|
||||||
|
if (string.Equals(key, "HUB_ENDPOINT", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(hubEndpoint))
|
||||||
|
{
|
||||||
|
hubEndpoint = value;
|
||||||
|
}
|
||||||
|
else if (string.Equals(key, "HUB_PUBLIC_KEY", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(hubPublicKey))
|
||||||
|
{
|
||||||
|
hubPublicKey = value;
|
||||||
|
}
|
||||||
|
else if (string.Equals(key, "MESH_NETWORK", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
meshNetwork = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(hubPublicKey) && File.Exists(options.HubPublicKeyPath))
|
||||||
|
{
|
||||||
|
hubPublicKey = File.ReadAllText(options.HubPublicKeyPath).Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(hubEndpoint) || string.IsNullOrWhiteSpace(hubPublicKey))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Mesh Hub is not initialized.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (hubEndpoint, hubPublicKey, meshNetwork);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> ReserveNodeAddressAsync(string nodeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var path = _options.Value.MeshNodesPath;
|
||||||
|
var directory = Path.GetDirectoryName(path);
|
||||||
|
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = File.Exists(path)
|
||||||
|
? (await File.ReadAllLinesAsync(path, cancellationToken)).ToList()
|
||||||
|
: new List<string>();
|
||||||
|
|
||||||
|
var usedAddresses = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(line))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = line.Split('\t');
|
||||||
|
if (parts.Length >= 2)
|
||||||
|
{
|
||||||
|
var currentId = parts[0].Trim();
|
||||||
|
var currentAddr = parts[1].Trim();
|
||||||
|
if (string.Equals(currentId, nodeId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return currentAddr;
|
||||||
|
}
|
||||||
|
usedAddresses.Add(currentAddr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var host = 2; host <= 254; host++)
|
||||||
|
{
|
||||||
|
var candidate = $"10.77.0.{host}";
|
||||||
|
if (!usedAddresses.Contains(candidate))
|
||||||
|
{
|
||||||
|
var record = $"{nodeId}\t{candidate}\t-\treserved";
|
||||||
|
lines.Add(record);
|
||||||
|
await File.WriteAllLinesAsync(path, lines, cancellationToken);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException("Mesh address pool is exhausted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Base64UrlEncode(string value)
|
||||||
|
{
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(value);
|
||||||
|
return Convert.ToBase64String(bytes)
|
||||||
|
.TrimEnd('=')
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_meshLock.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -60,6 +60,7 @@ builder.Services.AddSingleton<ControlEventBroker>();
|
||||||
builder.Services.AddSingleton<ILinkPolicyApplier, LinkPolicyApplier>();
|
builder.Services.AddSingleton<ILinkPolicyApplier, LinkPolicyApplier>();
|
||||||
builder.Services.AddSingleton<LinkService>();
|
builder.Services.AddSingleton<LinkService>();
|
||||||
builder.Services.AddSingleton<CertificateLifecycleService>();
|
builder.Services.AddSingleton<CertificateLifecycleService>();
|
||||||
|
builder.Services.AddSingleton<NodeEnrollmentService>();
|
||||||
builder.Services.AddSingleton<ControlBackupService>();
|
builder.Services.AddSingleton<ControlBackupService>();
|
||||||
builder.Services.AddHostedService<LinkExpirationBackgroundService>();
|
builder.Services.AddHostedService<LinkExpirationBackgroundService>();
|
||||||
builder.Services.AddHostedService<LinkReconciliationBackgroundService>();
|
builder.Services.AddHostedService<LinkReconciliationBackgroundService>();
|
||||||
|
|
@ -602,6 +603,32 @@ control.MapPost("/certificates/renew", async (
|
||||||
return Results.Conflict(new ProblemDetails { Title = exception.Message });
|
return Results.Conflict(new ProblemDetails { Title = exception.Message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
control.MapPost("/agents/{nodeId}/enrollment-code", async (
|
||||||
|
string nodeId,
|
||||||
|
HttpContext context,
|
||||||
|
NodeEnrollmentService enrollmentService,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
if (!NodeIdValidator.IsValid(nodeId))
|
||||||
|
{
|
||||||
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
["nodeId"] = ["Node id must contain 1-63 lowercase letters, digits, or hyphens."]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await enrollmentService.CreateEnrollmentCodeAsync(
|
||||||
|
nodeId, actor, cancellationToken);
|
||||||
|
return Results.Ok(response);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException exception)
|
||||||
|
{
|
||||||
|
return Results.Conflict(new ProblemDetails { Title = exception.Message });
|
||||||
|
}
|
||||||
|
}).RequireRateLimiting("enrollment");
|
||||||
control.MapGet("/agents", async (ControlStore controlStore, CancellationToken cancellationToken) =>
|
control.MapGet("/agents", async (ControlStore controlStore, CancellationToken cancellationToken) =>
|
||||||
Results.Ok((await controlStore.ListAgentsAsync(cancellationToken)).ToArray()));
|
Results.Ok((await controlStore.ListAgentsAsync(cancellationToken)).ToArray()));
|
||||||
control.MapGet("/provisioning/catalogs/system-base-install/1", () =>
|
control.MapGet("/provisioning/catalogs/system-base-install/1", () =>
|
||||||
|
|
|
||||||
13
src/ServerMonitorManager.Core/NodeEnrollmentModels.cs
Normal file
13
src/ServerMonitorManager.Core/NodeEnrollmentModels.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
namespace ServerMonitorManager.Core;
|
||||||
|
|
||||||
|
public sealed record NodeEnrollmentCodeResponse(
|
||||||
|
string NodeId,
|
||||||
|
string Code,
|
||||||
|
string CaFingerprint,
|
||||||
|
DateTimeOffset ExpiresAt);
|
||||||
|
|
||||||
|
public sealed record NodeEnrollmentCodeIssuedDetails(
|
||||||
|
string NodeId,
|
||||||
|
string Actor,
|
||||||
|
string NodeAddress,
|
||||||
|
DateTimeOffset ExpiresAt);
|
||||||
|
|
@ -57,4 +57,6 @@ namespace ServerMonitorManager.Core;
|
||||||
[JsonSerializable(typeof(ProvisioningJob[]))]
|
[JsonSerializable(typeof(ProvisioningJob[]))]
|
||||||
[JsonSerializable(typeof(ProvisioningEvent))]
|
[JsonSerializable(typeof(ProvisioningEvent))]
|
||||||
[JsonSerializable(typeof(ProvisioningEvent[]))]
|
[JsonSerializable(typeof(ProvisioningEvent[]))]
|
||||||
|
[JsonSerializable(typeof(NodeEnrollmentCodeResponse))]
|
||||||
|
[JsonSerializable(typeof(NodeEnrollmentCodeIssuedDetails))]
|
||||||
public sealed partial class SmmJsonContext : JsonSerializerContext;
|
public sealed partial class SmmJsonContext : JsonSerializerContext;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,400 @@
|
||||||
|
extern alias controlapp;
|
||||||
|
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.AspNetCore.TestHost;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ServerMonitorManager.Core;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ServerMonitorManager.Control.Tests;
|
||||||
|
|
||||||
|
public sealed class NodeEnrollmentCodeTests : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly NodeEnrollmentTestFactory _factory = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnonymousRequestIsRejectedWithUnauthorized()
|
||||||
|
{
|
||||||
|
using var anonymous = _factory.CreateClient();
|
||||||
|
var response = await anonymous.PostAsync(
|
||||||
|
"/api/v1/control/agents/test-node/enrollment-code",
|
||||||
|
null,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AgentRoleIsRejectedWithForbidden()
|
||||||
|
{
|
||||||
|
using var client = _factory.CreateClient();
|
||||||
|
client.DefaultRequestHeaders.Add("X-Test-Identity", "agent-node");
|
||||||
|
client.DefaultRequestHeaders.Add("X-Test-Role", "Agent");
|
||||||
|
|
||||||
|
var response = await client.PostAsync(
|
||||||
|
"/api/v1/control/agents/test-node/enrollment-code",
|
||||||
|
null,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AutomationRoleIsRejectedWithForbidden()
|
||||||
|
{
|
||||||
|
using var client = _factory.CreateClient();
|
||||||
|
client.DefaultRequestHeaders.Add("X-Test-Identity", "automation-worker");
|
||||||
|
client.DefaultRequestHeaders.Add("X-Test-Role", "Automation");
|
||||||
|
|
||||||
|
var response = await client.PostAsync(
|
||||||
|
"/api/v1/control/agents/test-node/enrollment-code",
|
||||||
|
null,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(" ")]
|
||||||
|
[InlineData("NODE_UPPERCASE")]
|
||||||
|
[InlineData("node_with_underscore")]
|
||||||
|
[InlineData("node with spaces")]
|
||||||
|
[InlineData("this-node-name-is-way-too-long-and-exceeds-the-maximum-sixty-three-characters-limit")]
|
||||||
|
public async Task InvalidNodeIdIsRejectedWithBadRequestBeforeTokenCreation(string invalidNodeId)
|
||||||
|
{
|
||||||
|
using var client = _factory.CreateClient();
|
||||||
|
client.DefaultRequestHeaders.Add("X-Test-Identity", "operator-1");
|
||||||
|
client.DefaultRequestHeaders.Add("X-Test-Role", "Operator");
|
||||||
|
|
||||||
|
var response = await client.PostAsync(
|
||||||
|
$"/api/v1/control/agents/{Uri.EscapeDataString(invalidNodeId)}/enrollment-code",
|
||||||
|
null,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
|
||||||
|
// Verify that no token was generated in database
|
||||||
|
var store = _factory.Services.GetRequiredService<ControlStore>();
|
||||||
|
var dbPath = _factory.DatabasePath;
|
||||||
|
await using var conn = new SqliteConnection($"Data Source={dbPath}");
|
||||||
|
await conn.OpenAsync(TestContext.Current.CancellationToken);
|
||||||
|
var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT COUNT(*) FROM enrollment_tokens;";
|
||||||
|
var count = Convert.ToInt32(await cmd.ExecuteScalarAsync(TestContext.Current.CancellationToken), System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
Assert.Equal(0, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EmptyNodeIdThrowsArgumentExceptionInService()
|
||||||
|
{
|
||||||
|
var service = _factory.Services.GetRequiredService<NodeEnrollmentService>();
|
||||||
|
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||||
|
service.CreateEnrollmentCodeAsync(string.Empty, "operator-1", TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OperatorCanRequestNodeEnrollmentCodeWithCorrectStructure()
|
||||||
|
{
|
||||||
|
using var client = _factory.CreateClient();
|
||||||
|
client.DefaultRequestHeaders.Add("X-Test-Identity", "operator-admin");
|
||||||
|
client.DefaultRequestHeaders.Add("X-Test-Role", "Operator");
|
||||||
|
|
||||||
|
var response = await client.PostAsync(
|
||||||
|
"/api/v1/control/agents/node-alpha/enrollment-code",
|
||||||
|
null,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<NodeEnrollmentCodeResponse>(
|
||||||
|
SmmJsonContext.Default.NodeEnrollmentCodeResponse,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("node-alpha", result.NodeId);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(result.CaFingerprint));
|
||||||
|
Assert.True(result.ExpiresAt > DateTimeOffset.UtcNow.AddMinutes(9));
|
||||||
|
Assert.True(result.ExpiresAt <= DateTimeOffset.UtcNow.AddMinutes(11));
|
||||||
|
|
||||||
|
// Format check: SMMNODE2.<control_url>.<ca_pem>.<node_id>.<token>.<hub_endpoint>.<hub_public_key>.<node_address>.<mesh_network>
|
||||||
|
var segments = result.Code.Split('.');
|
||||||
|
Assert.Equal(9, segments.Length);
|
||||||
|
Assert.Equal("SMMNODE2", segments[0]);
|
||||||
|
|
||||||
|
var controlUrl = DecodeBase64UrlString(segments[1]);
|
||||||
|
var caPem = DecodeBase64UrlString(segments[2]);
|
||||||
|
var nodeId = DecodeBase64UrlString(segments[3]);
|
||||||
|
var token = DecodeBase64UrlString(segments[4]);
|
||||||
|
var hubEndpoint = DecodeBase64UrlString(segments[5]);
|
||||||
|
var hubPublicKey = DecodeBase64UrlString(segments[6]);
|
||||||
|
var nodeAddress = DecodeBase64UrlString(segments[7]);
|
||||||
|
var meshNetwork = DecodeBase64UrlString(segments[8]);
|
||||||
|
|
||||||
|
Assert.Equal("https://hub.example.com:7443", controlUrl);
|
||||||
|
Assert.Contains("-----BEGIN CERTIFICATE-----", caPem);
|
||||||
|
Assert.Equal("node-alpha", nodeId);
|
||||||
|
Assert.Equal(43, token.Length); // 32 bytes base64url unpadded is 43 chars
|
||||||
|
Assert.Equal("hub.example.com:51820", hubEndpoint);
|
||||||
|
Assert.Equal("mQZ/Y4yQpQhX6j0rL8vU2w==", hubPublicKey);
|
||||||
|
Assert.Equal("10.77.0.2", nodeAddress);
|
||||||
|
Assert.Equal("10.77.0.0/24", meshNetwork);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TwoDifferentNodeIdsGetDistinctMeshAddresses()
|
||||||
|
{
|
||||||
|
using var client = _factory.CreateClient();
|
||||||
|
client.DefaultRequestHeaders.Add("X-Test-Identity", "operator-admin");
|
||||||
|
client.DefaultRequestHeaders.Add("X-Test-Role", "Operator");
|
||||||
|
|
||||||
|
var response1 = await client.PostAsync(
|
||||||
|
"/api/v1/control/agents/node-first/enrollment-code",
|
||||||
|
null,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response1.StatusCode);
|
||||||
|
var result1 = await response1.Content.ReadFromJsonAsync<NodeEnrollmentCodeResponse>(
|
||||||
|
SmmJsonContext.Default.NodeEnrollmentCodeResponse,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var response2 = await client.PostAsync(
|
||||||
|
"/api/v1/control/agents/node-second/enrollment-code",
|
||||||
|
null,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response2.StatusCode);
|
||||||
|
var result2 = await response2.Content.ReadFromJsonAsync<NodeEnrollmentCodeResponse>(
|
||||||
|
SmmJsonContext.Default.NodeEnrollmentCodeResponse,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var address1 = DecodeBase64UrlString(result1!.Code.Split('.')[7]);
|
||||||
|
var address2 = DecodeBase64UrlString(result2!.Code.Split('.')[7]);
|
||||||
|
|
||||||
|
Assert.Equal("10.77.0.2", address1);
|
||||||
|
Assert.Equal("10.77.0.3", address2);
|
||||||
|
Assert.NotEqual(address1, address2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RepeatedRequestForSameNodeIdReusesReservedAddress()
|
||||||
|
{
|
||||||
|
using var client = _factory.CreateClient();
|
||||||
|
client.DefaultRequestHeaders.Add("X-Test-Identity", "operator-admin");
|
||||||
|
client.DefaultRequestHeaders.Add("X-Test-Role", "Operator");
|
||||||
|
|
||||||
|
var response1 = await client.PostAsync(
|
||||||
|
"/api/v1/control/agents/node-repeat/enrollment-code",
|
||||||
|
null,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
var result1 = await response1.Content.ReadFromJsonAsync<NodeEnrollmentCodeResponse>(
|
||||||
|
SmmJsonContext.Default.NodeEnrollmentCodeResponse,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var response2 = await client.PostAsync(
|
||||||
|
"/api/v1/control/agents/node-repeat/enrollment-code",
|
||||||
|
null,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
var result2 = await response2.Content.ReadFromJsonAsync<NodeEnrollmentCodeResponse>(
|
||||||
|
SmmJsonContext.Default.NodeEnrollmentCodeResponse,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var address1 = DecodeBase64UrlString(result1!.Code.Split('.')[7]);
|
||||||
|
var address2 = DecodeBase64UrlString(result2!.Code.Split('.')[7]);
|
||||||
|
|
||||||
|
Assert.Equal("10.77.0.2", address1);
|
||||||
|
Assert.Equal("10.77.0.2", address2);
|
||||||
|
|
||||||
|
// Tokens should be distinct per request
|
||||||
|
var token1 = DecodeBase64UrlString(result1.Code.Split('.')[4]);
|
||||||
|
var token2 = DecodeBase64UrlString(result2.Code.Split('.')[4]);
|
||||||
|
Assert.NotEqual(token1, token2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuditLogRecordsEnrollmentCodeIssuance()
|
||||||
|
{
|
||||||
|
using var client = _factory.CreateClient();
|
||||||
|
client.DefaultRequestHeaders.Add("X-Test-Identity", "operator-carol");
|
||||||
|
client.DefaultRequestHeaders.Add("X-Test-Role", "Operator");
|
||||||
|
|
||||||
|
var response = await client.PostAsync(
|
||||||
|
"/api/v1/control/agents/audited-node/enrollment-code",
|
||||||
|
null,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
|
||||||
|
await using var conn = new SqliteConnection($"Data Source={_factory.DatabasePath}");
|
||||||
|
await conn.OpenAsync(TestContext.Current.CancellationToken);
|
||||||
|
var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT actor, action, subject, details_json FROM audit WHERE action = 'agent.enrollment_code.issued';";
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(TestContext.Current.CancellationToken);
|
||||||
|
Assert.True(await reader.ReadAsync(TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
var actor = reader.GetString(0);
|
||||||
|
var action = reader.GetString(1);
|
||||||
|
var subject = reader.GetString(2);
|
||||||
|
var detailsJson = reader.GetString(3);
|
||||||
|
|
||||||
|
Assert.Equal("operator-carol", actor);
|
||||||
|
Assert.Equal("agent.enrollment_code.issued", action);
|
||||||
|
Assert.Equal("audited-node", subject);
|
||||||
|
Assert.Contains("audited-node", detailsJson);
|
||||||
|
Assert.Contains("10.77.0.2", detailsJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CodeStructureMatchesBashReferenceFormatFixture()
|
||||||
|
{
|
||||||
|
// Fixture matching the reference implementation in create_node_code:
|
||||||
|
// SMMNODE2.<control_url>.<ca_pem>.<node_id>.<token>.<hub_endpoint>.<hub_public_key>.<node_address>.<mesh_network>
|
||||||
|
using var client = _factory.CreateClient();
|
||||||
|
client.DefaultRequestHeaders.Add("X-Test-Identity", "operator-admin");
|
||||||
|
client.DefaultRequestHeaders.Add("X-Test-Role", "Operator");
|
||||||
|
|
||||||
|
var response = await client.PostAsync(
|
||||||
|
"/api/v1/control/agents/reference-node/enrollment-code",
|
||||||
|
null,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<NodeEnrollmentCodeResponse>(
|
||||||
|
SmmJsonContext.Default.NodeEnrollmentCodeResponse,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
var parts = result.Code.Split('.');
|
||||||
|
Assert.Equal(9, parts.Length);
|
||||||
|
Assert.Equal("SMMNODE2", parts[0]);
|
||||||
|
|
||||||
|
// Validate each segment character set (base64url characters only, no =)
|
||||||
|
for (int i = 1; i < parts.Length; i++)
|
||||||
|
{
|
||||||
|
Assert.Matches("^[A-Za-z0-9_-]+$", parts[i]);
|
||||||
|
Assert.DoesNotContain("=", parts[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync() => await _factory.DisposeAsync();
|
||||||
|
|
||||||
|
private static string DecodeBase64UrlString(string base64Url)
|
||||||
|
{
|
||||||
|
var padded = base64Url.Replace('-', '+').Replace('_', '/');
|
||||||
|
padded += (padded.Length % 4) switch
|
||||||
|
{
|
||||||
|
2 => "==",
|
||||||
|
3 => "=",
|
||||||
|
0 => string.Empty,
|
||||||
|
_ => throw new FormatException("Invalid base64url length.")
|
||||||
|
};
|
||||||
|
return Encoding.UTF8.GetString(Convert.FromBase64String(padded));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NodeEnrollmentTestFactory : WebApplicationFactory<controlapp::Program>
|
||||||
|
{
|
||||||
|
private readonly string _directory = Path.Combine(
|
||||||
|
Path.GetTempPath(), $"smm-node-enrollment-tests-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
|
public string DatabasePath => Path.Combine(_directory, "control.db");
|
||||||
|
|
||||||
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_directory);
|
||||||
|
var authorityPath = Path.Combine(_directory, "control-ca.pfx");
|
||||||
|
if (!File.Exists(authorityPath))
|
||||||
|
{
|
||||||
|
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||||
|
var request = new CertificateRequest("CN=SMM Node Code Test CA", key, HashAlgorithmName.SHA256);
|
||||||
|
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
||||||
|
request.CertificateExtensions.Add(new X509KeyUsageExtension(
|
||||||
|
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true));
|
||||||
|
using var certificate = request.CreateSelfSigned(
|
||||||
|
DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddDays(1));
|
||||||
|
File.WriteAllBytes(authorityPath, certificate.Export(X509ContentType.Pfx));
|
||||||
|
}
|
||||||
|
|
||||||
|
var publicUrlPath = Path.Combine(_directory, "control-public-url");
|
||||||
|
File.WriteAllText(publicUrlPath, "https://hub.example.com:7443\n");
|
||||||
|
|
||||||
|
var meshEnvPath = Path.Combine(_directory, "mesh.env");
|
||||||
|
File.WriteAllText(meshEnvPath, "HUB_ENDPOINT=hub.example.com:51820\nHUB_PUBLIC_KEY=mQZ/Y4yQpQhX6j0rL8vU2w==\nMESH_NETWORK=10.77.0.0/24\n");
|
||||||
|
|
||||||
|
var meshNodesPath = Path.Combine(_directory, "mesh", "nodes.tsv");
|
||||||
|
|
||||||
|
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||||
|
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Control:DatabasePath"] = DatabasePath,
|
||||||
|
["Control:CertificateAuthorityPath"] = authorityPath,
|
||||||
|
["Control:BackupDirectory"] = Path.Combine(_directory, "backups"),
|
||||||
|
["Control:PublicUrlPath"] = publicUrlPath,
|
||||||
|
["Control:MeshEnvironmentPath"] = meshEnvPath,
|
||||||
|
["Control:MeshNodesPath"] = meshNodesPath
|
||||||
|
}));
|
||||||
|
|
||||||
|
builder.ConfigureTestServices(services =>
|
||||||
|
{
|
||||||
|
services.RemoveAll<IHostedService>();
|
||||||
|
services.AddAuthentication(options =>
|
||||||
|
{
|
||||||
|
options.DefaultAuthenticateScheme = "Test";
|
||||||
|
options.DefaultChallengeScheme = "Test";
|
||||||
|
})
|
||||||
|
.AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>("Test", _ => { });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await base.DisposeAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_directory))
|
||||||
|
{
|
||||||
|
Directory.Delete(_directory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best effort cleanup in tests
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestAuthenticationHandler(
|
||||||
|
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||||
|
ILoggerFactory logger,
|
||||||
|
UrlEncoder encoder)
|
||||||
|
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||||
|
{
|
||||||
|
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
|
{
|
||||||
|
if (!Request.Headers.TryGetValue("X-Test-Identity", out var identity)
|
||||||
|
|| !Request.Headers.TryGetValue("X-Test-Role", out var role))
|
||||||
|
{
|
||||||
|
return Task.FromResult(AuthenticateResult.NoResult());
|
||||||
|
}
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(ClaimTypes.NameIdentifier, identity.ToString()),
|
||||||
|
new(ClaimTypes.Role, role.ToString())
|
||||||
|
};
|
||||||
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, Scheme.Name));
|
||||||
|
return Task.FromResult(AuthenticateResult.Success(
|
||||||
|
new AuthenticationTicket(principal, Scheme.Name)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,7 @@ v1_fixture="$root/tests/fixtures/alpha8-v1-release"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
bash -n "$setup"
|
bash -n "$setup"
|
||||||
grep -Fq 'readonly DEFAULT_RELEASE_TAG="v0.1.0-alpha.16"' "$setup"
|
grep -Fq 'readonly DEFAULT_RELEASE_TAG="v0.1.0-alpha.18"' "$setup"
|
||||||
grep -Fq 'install-hub PUBLIC_HOST [HTTPS_PORT] [WG_PORT]' "$setup"
|
grep -Fq 'install-hub PUBLIC_HOST [HTTPS_PORT] [WG_PORT]' "$setup"
|
||||||
grep -Fxq ' install-node' "$setup"
|
grep -Fxq ' install-node' "$setup"
|
||||||
if grep -Fq 'validate_control_url' "$setup" || grep -Fq '${CONTROL_URL%/}/control' "$setup"; then
|
if grep -Fq 'validate_control_url' "$setup" || grep -Fq '${CONTROL_URL%/}/control' "$setup"; then
|
||||||
|
|
@ -61,7 +61,11 @@ grep -Fq 'v0.1.0-alpha.13' "$policy"
|
||||||
grep -Fq 'v0.1.0-alpha.14' "$policy"
|
grep -Fq 'v0.1.0-alpha.14' "$policy"
|
||||||
grep -Fq 'v0.1.0-alpha.15' "$policy"
|
grep -Fq 'v0.1.0-alpha.15' "$policy"
|
||||||
grep -Fq 'v0.1.0-alpha.16' "$policy"
|
grep -Fq 'v0.1.0-alpha.16' "$policy"
|
||||||
|
grep -Fq 'v0.1.0-alpha.17' "$policy"
|
||||||
|
grep -Fq 'v0.1.0-alpha.18' "$policy"
|
||||||
grep -Fq 'hash -r' "$root/tests/release-verification/run-positive-installation.sh"
|
grep -Fq 'hash -r' "$root/tests/release-verification/run-positive-installation.sh"
|
||||||
|
grep -Fq -- '--use-signing-config=false --new-bundle-format=false' \
|
||||||
|
"$root/tests/release-verification/run-negative-tests.sh"
|
||||||
grep -Fq 'readonly COSIGN_VERSION="v3.1.3"' "$bootstrap"
|
grep -Fq 'readonly COSIGN_VERSION="v3.1.3"' "$bootstrap"
|
||||||
grep -Fq 'readonly COSIGN_SHA256_AMD64="4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71"' "$bootstrap"
|
grep -Fq 'readonly COSIGN_SHA256_AMD64="4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71"' "$bootstrap"
|
||||||
grep -Fq 'readonly COSIGN_SHA256_ARM64="c5d324e091826b0d7a78eb16fef316450b4eb9aaec045611c08ba06f5e73220a"' "$bootstrap"
|
grep -Fq 'readonly COSIGN_SHA256_ARM64="c5d324e091826b0d7a78eb16fef316450b4eb9aaec045611c08ba06f5e73220a"' "$bootstrap"
|
||||||
|
|
@ -109,6 +113,10 @@ mkdir -p "$work/bin" "$work/home"
|
||||||
cat >"$work/inner.sh" <<'INNER'
|
cat >"$work/inner.sh" <<'INNER'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
if [[ "${1:-}" == "node-code" ]]; then
|
||||||
|
printf '%s\n' 'SMMNODE1.fixture'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
printf 'INNER_ARGS='
|
printf 'INNER_ARGS='
|
||||||
printf '%s ' "$@"
|
printf '%s ' "$@"
|
||||||
printf '\n'
|
printf '\n'
|
||||||
|
|
@ -185,8 +193,14 @@ chmod +x "$work/bin/uname"
|
||||||
|
|
||||||
HOME="$work/home" PATH="$work/bin:$PATH" bash "$setup" version >"$work/output"
|
HOME="$work/home" PATH="$work/bin:$PATH" bash "$setup" version >"$work/output"
|
||||||
grep -Fq 'INNER_ARGS=version ' "$work/output"
|
grep -Fq 'INNER_ARGS=version ' "$work/output"
|
||||||
grep -Fq '/releases/download/v0.1.0-alpha.16/ochenstarik-server-monitor-manager.sh' "$work/urls"
|
grep -Fq '/releases/download/v0.1.0-alpha.18/ochenstarik-server-monitor-manager.sh' "$work/urls"
|
||||||
grep -Fq '/releases/download/v0.1.0-alpha.16/ochenstarik-server-monitor-manager.sh.sha256' "$work/urls"
|
grep -Fq '/releases/download/v0.1.0-alpha.18/ochenstarik-server-monitor-manager.sh.sha256' "$work/urls"
|
||||||
|
|
||||||
|
node_code="$(HOME="$work/home" PATH="$work/bin:$PATH" bash "$setup" node-code fixture-node)"
|
||||||
|
[[ "$node_code" == 'SMMNODE1.fixture' ]] || {
|
||||||
|
printf 'machine-readable node-code output was contaminated: %q\n' "$node_code" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
if HOME="$work/home" PATH="$work/bin:$PATH" bash "$setup" install-hub >"$work/invalid.out" 2>&1; then
|
if HOME="$work/home" PATH="$work/bin:$PATH" bash "$setup" install-hub >"$work/invalid.out" 2>&1; then
|
||||||
printf '%s\n' 'install-hub accepted a missing PUBLIC_HOST' >&2
|
printf '%s\n' 'install-hub accepted a missing PUBLIC_HOST' >&2
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ echo "Test 4: Signature made by another identity"
|
||||||
export COSIGN_PASSWORD=""
|
export COSIGN_PASSWORD=""
|
||||||
cosign generate-key-pair >/dev/null
|
cosign generate-key-pair >/dev/null
|
||||||
cosign sign-blob --yes --key cosign.key \
|
cosign sign-blob --yes --key cosign.key \
|
||||||
|
--use-signing-config=false --new-bundle-format=false \
|
||||||
--output-signature fake.sig server-monitor-manager-manifest.json >/dev/null
|
--output-signature fake.sig server-monitor-manager-manifest.json >/dev/null
|
||||||
if ./ochenstarik-server-monitor-manager.sh verify-manifest server-monitor-manager-manifest.json \
|
if ./ochenstarik-server-monitor-manager.sh verify-manifest server-monitor-manager-manifest.json \
|
||||||
fake.sig server-monitor-manager-manifest.pem >/dev/null 2>&1; then
|
fake.sig server-monitor-manager-manifest.pem >/dev/null 2>&1; then
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue