diff --git a/src/Foundry.Bootstrap.Tests/Processes/ApplicationLauncherTests.cs b/src/Foundry.Bootstrap.Tests/Processes/ApplicationLauncherTests.cs index 0daa8b77c..09c9420e7 100644 --- a/src/Foundry.Bootstrap.Tests/Processes/ApplicationLauncherTests.cs +++ b/src/Foundry.Bootstrap.Tests/Processes/ApplicationLauncherTests.cs @@ -326,69 +326,4 @@ public async Task StartupFailureRecoveryWaitsForExitWithinBoundedGrace(bool exit Directory.Delete(directory, recursive: true); } } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task NegotiatedChildAcknowledgementUsesActualLaunchIdentity(bool connect) - { - string directory = Directory.CreateTempSubdirectory("FoundryStartup-").FullName; - Process? fixture = null; - try - { - string application = connect ? "Foundry.Connect" : "Foundry.Deploy"; - string executable = Path.Combine(directory, application + ".exe"); - File.WriteAllText(executable, "prepared payload"); - File.WriteAllText(Path.Combine(directory, "foundry.startup.json"), "{\"protocolVersions\":[1]}"); - using var logger = new LoggerConfiguration().CreateLogger(); - var launcher = new ApplicationLauncher(logger, Path.Combine(directory, "session"), startProcess: start => - { - string script = $$""" - $status = @{ - protocolVersion = [int]$env:FOUNDRY_STARTUP_PROTOCOL - sessionId = $env:FOUNDRY_DIAGNOSTIC_SESSION_ID - launchId = $env:FOUNDRY_STARTUP_LAUNCH_ID - application = '{{application}}' - processId = $PID - stage = 'ui_ready' - timestampUtc = [DateTime]::UtcNow.ToString('o') - } | ConvertTo-Json -Compress - [IO.File]::WriteAllText($env:FOUNDRY_STARTUP_STATUS_PATH, $status) - {{(connect ? "exit 0" : "Start-Sleep -Seconds 30")}} - """; - start.FileName = "powershell.exe"; - start.WorkingDirectory = Path.GetTempPath(); - start.CreateNoWindow = true; - start.ArgumentList.Clear(); - start.ArgumentList.Add("-NoProfile"); - start.ArgumentList.Add("-NonInteractive"); - start.ArgumentList.Add("-EncodedCommand"); - start.ArgumentList.Add(Convert.ToBase64String(System.Text.Encoding.Unicode.GetBytes(script))); - Process? child = Process.Start(start); - if (child is not null) { fixture = Process.GetProcessById(child.Id); } - return child; - }, releasePayload: File.Delete); - using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(15)); - var environment = new Dictionary { ["FOUNDRY_DIAGNOSTIC_SESSION_ID"] = "TEST" }; - ApplicationLaunchResult result = connect - ? await launcher.RunConnectAsync(executable, "missing.json", environment, deadline.Token) - : await launcher.StartDeployAsync(executable, environment, deadline.Token); - Assert.True(result.Succeeded); - Assert.True(result.ReadinessConfirmed); - Assert.Equal("ui_ready", result.LastStage); - Assert.Equal(!connect, File.Exists(executable)); - } - finally - { - if (fixture is not null) - { - using (fixture) - { - if (!fixture.HasExited) { fixture.Kill(); } - await fixture.WaitForExitAsync(CancellationToken.None); - } - } - Directory.Delete(directory, recursive: true); - } - } } diff --git a/src/Foundry.Core.Tests/BootMediaTelemetryPropertyBuilderTests.cs b/src/Foundry.Core.Tests/BootMediaTelemetryPropertyBuilderTests.cs index f85c49630..8ab8d33ae 100644 --- a/src/Foundry.Core.Tests/BootMediaTelemetryPropertyBuilderTests.cs +++ b/src/Foundry.Core.Tests/BootMediaTelemetryPropertyBuilderTests.cs @@ -735,6 +735,66 @@ public void Build_WhenAutopilotIsDisabled_ReportsDisabledProvisioningMode() Assert.Equal(TelemetryBootMediaUsbOperations.None, result["boot_media_usb_operation"]); } + [Theory] + [InlineData(false, DomainJoinMode.Automatic, "disabled", 0, 0, false, false)] + [InlineData(true, DomainJoinMode.Automatic, "zero_touch", 2, 3, true, true)] + [InlineData(true, DomainJoinMode.Interactive, "interactive", 2, 3, true, false)] + public void Build_ReportsDomainJoinUsageWithoutDirectoryNames(bool enabled, DomainJoinMode mode, string expectedMode, + int expectedDomains, int expectedOus, bool expectedDefaultOu, bool expectedSharedAccount) + { + var document = new FoundryConfigurationDocument + { + DomainJoin = new DomainJoinSettings + { + IsEnabled = enabled, + Mode = mode, + SharedAccountName = @"PRIVATE\joiner", + DefaultDomainId = "one", + Domains = + [ + new() + { + Id = "one", + DomainName = "private.example.test", + DefaultOuId = "ou-1", + OrganizationalUnits = + [ + new() { Id = "ou-1", DisplayName = "PrivateOne", DistinguishedName = "OU=PrivateOne,DC=private,DC=example,DC=test" }, + new() { Id = "ou-2", DisplayName = "PrivateTwo", DistinguishedName = "OU=PrivateTwo,DC=private,DC=example,DC=test" } + ] + }, + new() + { + Id = "two", + DomainName = "private2.example.test", + AccountName = @"PRIVATE2\joiner", + OrganizationalUnits = [new() { Id = "ou-3", DisplayName = "PrivateThree", DistinguishedName = "OU=PrivateThree,DC=private2,DC=example,DC=test" }] + } + ] + } + }; + + IReadOnlyDictionary result = BootMediaTelemetryPropertyBuilder.Build( + TelemetryBootMediaTargets.Iso, + TelemetryBootMediaUsbOperations.None, + new MediaPreflightOptions(), + document, + success: true, + failedStepName: null, + duration: TimeSpan.Zero, + connectRuntimePayloadSource: TelemetryRuntimePayloadSources.None, + deployRuntimePayloadSource: TelemetryRuntimePayloadSources.None); + + Assert.Equal(enabled, result["domain_join_enabled"]); + Assert.Equal(expectedMode, result["domain_join_mode"]); + Assert.Equal(expectedDomains, result["domain_join_domain_count"]); + Assert.Equal(expectedOus, result["domain_join_ou_count"]); + Assert.Equal(expectedDefaultOu, result["domain_join_default_ou_set"]); + + Assert.Equal(expectedSharedAccount, result["domain_join_shared_account_used"]); + Assert.DoesNotContain(result.Values, value => value is string text && text.Contains("private", StringComparison.OrdinalIgnoreCase)); + } + [Fact] public void Build_WhenInteractiveHardwareHashUploadIsEnabled_ReportsInteractiveMode() { diff --git a/src/Foundry.Core.Tests/Configuration/AutopilotProvisioningModeToggleEvaluatorTests.cs b/src/Foundry.Core.Tests/Configuration/AutopilotProvisioningModeToggleEvaluatorTests.cs deleted file mode 100644 index 68b53991a..000000000 --- a/src/Foundry.Core.Tests/Configuration/AutopilotProvisioningModeToggleEvaluatorTests.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Foundry Project contributors. -// Licensed under the MIT License. -// See the LICENSE file in the project root for more information. - -using Foundry.Core.Models.Configuration; -using Foundry.Core.Services.Configuration; - -namespace Foundry.Core.Tests.Configuration; - -public sealed class AutopilotProvisioningModeToggleEvaluatorTests -{ - [Fact] - public void Evaluate_WhenRequestedModeIsActive_DisablesItWithoutConfirmation() - { - AutopilotProvisioningModeToggleResult result = AutopilotProvisioningModeToggleEvaluator.Evaluate( - isEnabled: true, - AutopilotProvisioningMode.JsonProfile, - AutopilotProvisioningMode.JsonProfile); - - Assert.False(result.IsEnabled); - Assert.Equal(AutopilotProvisioningMode.JsonProfile, result.Mode); - Assert.False(result.RequiresConfirmation); - } - - [Fact] - public void Evaluate_WhenAutopilotIsDisabled_EnablesRequestedModeWithoutConfirmation() - { - AutopilotProvisioningModeToggleResult result = AutopilotProvisioningModeToggleEvaluator.Evaluate( - isEnabled: false, - AutopilotProvisioningMode.JsonProfile, - AutopilotProvisioningMode.HardwareHashUpload); - - Assert.True(result.IsEnabled); - Assert.Equal(AutopilotProvisioningMode.HardwareHashUpload, result.Mode); - Assert.False(result.RequiresConfirmation); - } - - [Fact] - public void Evaluate_WhenAnotherModeIsActive_RequiresConfirmationBeforeReplacement() - { - AutopilotProvisioningModeToggleResult result = AutopilotProvisioningModeToggleEvaluator.Evaluate( - isEnabled: true, - AutopilotProvisioningMode.JsonProfile, - AutopilotProvisioningMode.InteractiveHardwareHashUpload); - - Assert.True(result.IsEnabled); - Assert.Equal(AutopilotProvisioningMode.InteractiveHardwareHashUpload, result.Mode); - Assert.True(result.RequiresConfirmation); - } -} diff --git a/src/Foundry.Core.Tests/Configuration/ConfigurationOverviewEvaluatorTests.cs b/src/Foundry.Core.Tests/Configuration/ConfigurationOverviewEvaluatorTests.cs index bb0e7402e..817a390e3 100644 --- a/src/Foundry.Core.Tests/Configuration/ConfigurationOverviewEvaluatorTests.cs +++ b/src/Foundry.Core.Tests/Configuration/ConfigurationOverviewEvaluatorTests.cs @@ -9,6 +9,38 @@ namespace Foundry.Core.Tests.Configuration; public sealed class ConfigurationOverviewEvaluatorTests { + [Theory] + [InlineData(DomainJoinMode.Interactive, true, ConfigurationOverviewState.Configured)] + [InlineData(DomainJoinMode.Interactive, false, ConfigurationOverviewState.NeedsAttention)] + [InlineData(DomainJoinMode.Automatic, true, ConfigurationOverviewState.Configured)] + [InlineData(DomainJoinMode.Automatic, false, ConfigurationOverviewState.NeedsAttention)] + public void Evaluate_DomainJoinOnlyActiveModeUsesReadiness(DomainJoinMode mode, bool ready, ConfigurationOverviewState expected) + { + var configuration = new FoundryConfigurationDocument + { + DomainJoin = new DomainJoinSettings { IsEnabled = true, Mode = mode } + }; + ConfigurationOverviewEvaluation evaluation = ConfigurationOverviewEvaluator.Evaluate( + CreateContext(configuration) with { IsDomainJoinConfigurationReady = ready }); + ConfigurationOverviewItem active = mode == DomainJoinMode.Interactive ? ConfigurationOverviewItem.DomainJoinInteractive : ConfigurationOverviewItem.DomainJoinAutomatic; + ConfigurationOverviewItem inactive = mode == DomainJoinMode.Interactive ? ConfigurationOverviewItem.DomainJoinAutomatic : ConfigurationOverviewItem.DomainJoinInteractive; + Assert.Equal(expected, evaluation[active]); + Assert.Equal(ConfigurationOverviewState.NotSelected, evaluation[inactive]); + Assert.Equal(expected, ConfigurationOverviewNavigationEvaluator.EvaluateTarget(evaluation, + mode == DomainJoinMode.Interactive ? ConfigurationNavigationTarget.DomainJoinInteractive : ConfigurationNavigationTarget.DomainJoinAutomatic)); + } + + [Fact] + public void Evaluate_AutomaticDomainWithoutProtectionNeedsAttentionOnGeneralPage() + { + var configuration = new FoundryConfigurationDocument + { + DomainJoin = new DomainJoinSettings { IsEnabled = true, Mode = DomainJoinMode.Automatic } + }; + ConfigurationOverviewEvaluation evaluation = ConfigurationOverviewEvaluator.Evaluate(CreateContext(configuration)); + Assert.Equal(ConfigurationOverviewState.NeedsAttention, evaluation[ConfigurationOverviewItem.DeploymentProtection]); + } + [Theory] [InlineData(false, 0, true, ConfigurationOverviewState.Disabled)] [InlineData(true, 0, true, ConfigurationOverviewState.NeedsAttention)] diff --git a/src/Foundry.Core.Tests/Configuration/ConfigurationSchemaVersionsTests.cs b/src/Foundry.Core.Tests/Configuration/ConfigurationSchemaVersionsTests.cs index 12c298b2d..c4bee0ea9 100644 --- a/src/Foundry.Core.Tests/Configuration/ConfigurationSchemaVersionsTests.cs +++ b/src/Foundry.Core.Tests/Configuration/ConfigurationSchemaVersionsTests.cs @@ -11,8 +11,8 @@ public sealed class ConfigurationSchemaVersionsTests [Fact] public void CurrentSchemaVersions_MatchNextPublishedContractVersions() { - Assert.Equal(17, ConfigurationSchemaVersions.FoundryCurrent); - Assert.Equal(15, ConfigurationSchemaVersions.DeployCurrent); + Assert.Equal(18, ConfigurationSchemaVersions.FoundryCurrent); + Assert.Equal(16, ConfigurationSchemaVersions.DeployCurrent); Assert.Equal(5, ConfigurationSchemaVersions.ConnectCurrent); } diff --git a/src/Foundry.Core.Tests/Configuration/DeployConfigurationGeneratorTests.cs b/src/Foundry.Core.Tests/Configuration/DeployConfigurationGeneratorTests.cs index 51fa56b8a..65c4601c1 100644 --- a/src/Foundry.Core.Tests/Configuration/DeployConfigurationGeneratorTests.cs +++ b/src/Foundry.Core.Tests/Configuration/DeployConfigurationGeneratorTests.cs @@ -14,6 +14,124 @@ namespace Foundry.Core.Tests.Configuration; public sealed class DeployConfigurationGeneratorTests { + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnInvalidDomainListBlocksMediaOnlyWhileDomainJoinIsEnabled(bool enabled) + { + var document = new FoundryConfigurationDocument + { + DomainJoin = new() + { + IsEnabled = enabled, + DefaultDomainId = "a", + Domains = [new() { Id = "a", DomainName = "fabrikam.test", OrganizationalUnits = [new() { Id = "devices", DisplayName = "Devices", DistinguishedName = "OU=Devices,DC=contoso,DC=test" }] }] + } + }; + var generator = new DeployConfigurationGenerator(); + + if (enabled) + { + Assert.Throws(() => generator.Generate(document, null, null, null, null)); + return; + } + + var media = generator.Generate(document, null, null, null, null); + Assert.False(media.DomainJoin.IsEnabled); + Assert.Empty(media.DomainJoin.Domains); + } + + [Fact] + public void InteractiveProjectionOmitsAccountsAndPayloads() + { + using var secrets = new DomainJoinSecretState(); + secrets.SetPassword("CORP\\join", "password"); + var document = new FoundryConfigurationDocument { DomainJoin = TwoDomains() with { Mode = DomainJoinMode.Interactive } }; + var media = new DeployConfigurationGenerator().Generate(document, null, null, null, secrets); + Assert.True(media.DomainJoin.IsEnabled); + Assert.Equal(2, media.DomainJoin.Domains.Count); + Assert.Equal("corp", media.DomainJoin.DefaultDomainId); + + Assert.All(media.DomainJoin.Domains, domain => + { + Assert.Null(domain.AccountName); + Assert.Null(domain.EncryptedCredentials); + }); + Assert.Equal("ws", media.DomainJoin.Domains[0].DefaultOuId); + Assert.Single(media.DomainJoin.Domains[0].OrganizationalUnits); + } + + [Theory] + [InlineData(false, true, true)] + [InlineData(true, false, true)] + [InlineData(true, true, false)] + public void ZeroTouchRequiresProtectionAndEveryPassword(bool protectedMedia, bool hasKey, bool hasEveryPassword) + { + using var secrets = new DomainJoinSecretState(); + secrets.SetPassword("CORP\\join", "p1"); + if (hasEveryPassword) secrets.SetPassword("EMEA\\join", "p2"); + var document = new FoundryConfigurationDocument { DomainJoin = TwoDomains() }; + Assert.Throws(() => new DeployConfigurationGenerator().Generate(document, hasKey ? new byte[32] : null, + new() { IsEnabled = protectedMedia }, null, secrets)); + } + + [Fact] + public void ZeroTouchWritesOnePayloadPerDomainBoundToItsOwnDomain() + { + using var secrets = new DomainJoinSecretState(); + secrets.SetPassword("CORP\\join", " exact password "); + secrets.SetPassword("EMEA\\join", "p2"); + byte[] key = new byte[32]; + var generator = new DeployConfigurationGenerator(); + var media = generator.Generate(new FoundryConfigurationDocument { DomainJoin = TwoDomains() }, key, new() { IsEnabled = true }, null, secrets); + Assert.Equal("CORP\\join", media.DomainJoin.Domains[0].AccountName); + Assert.Equal("EMEA\\join", media.DomainJoin.Domains[1].AccountName); + byte[][] plaintext = media.DomainJoin.Domains + .Select(domain => MediaSecretEnvelopeProtector.DecryptBytes(domain.EncryptedCredentials!, key, MediaSecretEnvelopeProtector.DeploymentKeyId)).ToArray(); + try + { + using var corp = DomainJoinCredentialPayloadCodec.Decode(plaintext[0], new("corp.test", "CORP\\join")); + using var emea = DomainJoinCredentialPayloadCodec.Decode(plaintext[1], new("emea.test", "EMEA\\join")); + Assert.Equal(" exact password ", new string(corp.Password.Span)); + Assert.Equal("p2", new string(emea.Password.Span)); + Assert.DoesNotContain("exact password", generator.Serialize(media)); + } + finally { foreach (byte[] bytes in plaintext) System.Security.Cryptography.CryptographicOperations.ZeroMemory(bytes); } + } + + [Fact] + public void ASharedAccountPayloadCannotBeDecodedForAnotherDomain() + { + using var secrets = new DomainJoinSecretState(); + secrets.SetPassword("CORP\\join", "shared"); + byte[] key = new byte[32]; + DomainJoinSettings settings = TwoDomains(); + settings = settings with { Domains = [settings.Domains[0], settings.Domains[1] with { AccountName = null }] }; + var media = new DeployConfigurationGenerator().Generate(new FoundryConfigurationDocument { DomainJoin = settings }, key, new() { IsEnabled = true }, null, secrets); + Assert.All(media.DomainJoin.Domains, domain => Assert.Equal("CORP\\join", domain.AccountName)); + byte[] corp = MediaSecretEnvelopeProtector.DecryptBytes(media.DomainJoin.Domains[0].EncryptedCredentials!, key, MediaSecretEnvelopeProtector.DeploymentKeyId); + try + { + Assert.Throws(() => DomainJoinCredentialPayloadCodec.Decode(corp, new("emea.test", "CORP\\join"))); + using var decoded = DomainJoinCredentialPayloadCodec.Decode(corp, new("corp.test", "CORP\\join")); + Assert.Equal("shared", new string(decoded.Password.Span)); + } + finally { System.Security.Cryptography.CryptographicOperations.ZeroMemory(corp); } + } + + private static DomainJoinSettings TwoDomains() => new() + { + IsEnabled = true, + Mode = DomainJoinMode.Automatic, + SharedAccountName = "CORP\\join", + DefaultDomainId = "corp", + Domains = + [ + new() { Id = "corp", DomainName = "corp.test", DefaultOuId = "ws", OrganizationalUnits = [new() { Id = "ws", DisplayName = "Workstations", DistinguishedName = "OU=Workstations,DC=corp,DC=test" }] }, + new() { Id = "emea", DomainName = "emea.test", AccountName = "EMEA\\join" } + ] + }; + [Theory] [InlineData("26.10.3.1")] [InlineData(null)] diff --git a/src/Foundry.Core.Tests/Configuration/DistinguishedNameRulesTests.cs b/src/Foundry.Core.Tests/Configuration/DistinguishedNameRulesTests.cs new file mode 100644 index 000000000..f2df71da3 --- /dev/null +++ b/src/Foundry.Core.Tests/Configuration/DistinguishedNameRulesTests.cs @@ -0,0 +1,70 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Services.Configuration; + +namespace Foundry.Core.Tests.Configuration; + +public sealed class DistinguishedNameRulesTests +{ + [Theory] + [InlineData("OU=Workstations,DC=corp,DC=test", true)] + [InlineData("ou=Laptops,OU=Workstations,DC=corp,DC=test", true)] + [InlineData("CN=Computers,DC=corp,DC=test", false)] + [InlineData("DC=corp,DC=test", false)] + [InlineData("OU=A+CN=B,DC=corp,DC=test", false)] + [InlineData("not-a-dn", false)] + public void OnlyAnOrganizationalUnitIsAJoinTarget(string distinguishedName, bool expected) + { + Assert.Equal(expected, DistinguishedNameRules.IsOrganizationalUnit(distinguishedName)); + } + + [Theory] + [InlineData("OU=Sales\\, West,DC=contoso,DC=test", "Sales, West")] + [InlineData("OU=Sales\\2C West,DC=contoso,DC=test", "Sales, West")] + [InlineData("OU=\\C3\\89quipe,DC=contoso,DC=test", "Équipe")] + [InlineData("OU=研发,DC=contoso,DC=test", "研发")] + public void ParsesEscapedAndUnicodeValues(string dn, string value) + { + Assert.True(DistinguishedNameRules.TryParse(dn, out ParsedDistinguishedName parsed)); + Assert.Equal(value, parsed.Rdns[0].Attributes[0].Value); + Assert.Equal("DC=contoso,DC=test", parsed.Parent); + Assert.True(DistinguishedNameRules.IsWithinDomain(dn, "CONTOSO.TEST.")); + Assert.False(DistinguishedNameRules.IsWithinDomain(dn, "other.test")); + } + + [Theory] + [InlineData("OU=Bad\\")] + [InlineData("OU=Bad\\q,DC=contoso,DC=test")] + [InlineData("OU=Bad\\C3,DC=contoso,DC=test")] + [InlineData("OU=Bad,DC=contoso,,DC=test")] + [InlineData("OU=Bad\0Name,DC=contoso,DC=test")] + [InlineData("OU= unescaped,DC=contoso,DC=test")] + [InlineData("OU=unescaped ,DC=contoso,DC=test")] + [InlineData("OU=Bad;Name,DC=contoso,DC=test")] + public void RejectsMalformedNames(string dn) => Assert.False(DistinguishedNameRules.TryParse(dn, out _)); + + [Fact] + public void DomainSuffixCannotBeAnEscapedOrMultivaluedLookalike() + { + Assert.False(DistinguishedNameRules.IsWithinDomain("OU=PC,DC=contoso+OU=evil,DC=test", "contoso.test")); + Assert.False(DistinguishedNameRules.IsWithinDomain("OU=PC,DC=contoso\\,DC=test", "contoso.test")); + Assert.False(DistinguishedNameRules.IsWithinDomain("OU=PC,DC=child,DC=contoso,DC=test", "contoso.test")); + } + + [Theory] + [InlineData("OU=Sales=West,DC=contoso,DC=test")] + [InlineData("OU=Sales\\=West,DC=contoso,DC=test")] + public void ParsesEqualsWithinAnAttributeValue(string dn) + { + Assert.True(DistinguishedNameRules.TryParse(dn, out ParsedDistinguishedName parsed)); + Assert.Equal("Sales=West", parsed.Rdns[0].Attributes[0].Value); + } + + [Fact] + public void RejectsUnpairedUnicodeSurrogates() + { + Assert.False(DistinguishedNameRules.TryParse("OU=Bad\uD800,DC=contoso,DC=test", out _)); + } +} diff --git a/src/Foundry.Core.Tests/Configuration/DomainJoinConfigurationTests.cs b/src/Foundry.Core.Tests/Configuration/DomainJoinConfigurationTests.cs new file mode 100644 index 000000000..4084fc144 --- /dev/null +++ b/src/Foundry.Core.Tests/Configuration/DomainJoinConfigurationTests.cs @@ -0,0 +1,261 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using Foundry.Core.Models.PreOobe; +using Foundry.Core.Models.Configuration; +using Foundry.Core.Services.Configuration; + +namespace Foundry.Core.Tests.Configuration; + +public sealed class DomainJoinConfigurationTests +{ + [Fact] + public void PreparedReceipt_RoundTripsExplicitUnassignedSeedAndPreservesExistingNumericKinds() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + var seed = JsonSerializer.Deserialize(JsonSerializer.Serialize(new DomainJoinPhaseReceipt + { OperationId = "a", AttemptId = "b", PlanHash = "c", ActionId = "join" }, options), options)!; + Assert.Equal(DomainJoinReceiptPhase.Prepared, seed.Phase); Assert.Null(seed.OriginatingBootId); + Assert.Equal(DomainJoinPhaseState.NotStarted, seed.Join.State); Assert.Equal(DomainJoinPhaseState.NotStarted, seed.Placement.State); + Assert.Equal(PreOobeBuiltInKind.Cleanup, JsonSerializer.Deserialize("5")); + Assert.Equal(PreOobeBuiltInKind.DomainJoinAndPlacement, JsonSerializer.Deserialize("6")); + Assert.Equal(PreOobeBuiltInKind.VerifyDomainMembership, JsonSerializer.Deserialize("7")); + } + + [Fact] + public void PhaseResults_PreserveNumericErrorFamilyAndAcceptOmittedServerCode() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + var legacy = JsonSerializer.Deserialize("""{"state":2,"failureCode":4,"ldapErrorCode":49}""", options)!; + Assert.Equal(49, legacy.LdapErrorCode); Assert.Null(legacy.DirectoryResultCode); + var server = legacy with { LdapErrorCode = null, DirectoryResultCode = 50 }; + var restored = JsonSerializer.Deserialize(JsonSerializer.Serialize(server, options), options)!; + Assert.Equal(50, restored.DirectoryResultCode); Assert.Null(restored.LdapErrorCode); Assert.Null(restored.NativeErrorCode); + } + + [Fact] + public void MetadataAcceptsAnEmptyDomainList() + { + Assert.True(DomainJoinConfigurationValidator.ValidateMetadata(new()).IsValid); + } + + [Theory] + [InlineData("corp.test", "CORP.TEST")] + [InlineData("corp.test", "corp.test.")] + public void DomainsThatDifferOnlyByCaseOrTrailingDotAreDuplicates(string first, string second) + { + DomainJoinSettings settings = With(Domain("a", first), Domain("b", second)); + Assert.Contains(DomainJoinConfigurationValidator.ValidateMetadata(settings).Issues, + issue => issue.Code == DomainJoinValidationCode.DuplicateDomainName && issue.DomainId == "b"); + } + + [Fact] + public void DomainIdsMustBeUnique() + { + DomainJoinSettings settings = With(Domain("a", "corp.test"), Domain("A", "emea.test")); + Assert.Contains(DomainJoinConfigurationValidator.ValidateMetadata(settings).Issues, issue => issue.Code == DomainJoinValidationCode.DuplicateDomainId); + } + + [Fact] + public void DefaultDomainMustBeListedWhenDomainsExist() + { + DomainJoinSettings settings = With(Domain("a", "corp.test")) with { DefaultDomainId = "missing" }; + Assert.Contains(DomainJoinConfigurationValidator.ValidateMetadata(settings).Issues, issue => issue.Code == DomainJoinValidationCode.DefaultDomainMissing); + Assert.Contains(DomainJoinConfigurationValidator.ValidateMetadata(settings with { DefaultDomainId = null }).Issues, + issue => issue.Code == DomainJoinValidationCode.DefaultDomainMissing); + } + + [Fact] + public void MoreThanThirtyTwoDomainsAreRefused() + { + DomainJoinDomainSettings[] domains = Enumerable.Range(0, 33).Select(index => Domain($"d{index}", $"d{index}.test")).ToArray(); + Assert.True(DomainJoinConfigurationValidator.ValidateMetadata(With(domains.Take(32).ToArray())).IsValid); + Assert.Contains(DomainJoinConfigurationValidator.ValidateMetadata(With(domains)).Issues, issue => issue.Code == DomainJoinValidationCode.TooManyDomains); + } + + [Fact] + public void AnOuMustBelongToItsOwnDomain() + { + DomainJoinSettings settings = With(Domain("a", "corp.test", Unit("one", "OU=Sales,DC=other,DC=test"))); + Assert.Contains(DomainJoinConfigurationValidator.ValidateMetadata(settings).Issues, + issue => issue.Code == DomainJoinValidationCode.OuOutsideDomain && issue.DomainId == "a"); + } + + [Fact] + public void DefaultOuMustBelongToTheSameDomain() + { + DomainJoinSettings settings = With( + Domain("a", "corp.test", Unit("one", "OU=Sales,DC=corp,DC=test")) with { DefaultOuId = "two" }, + Domain("b", "emea.test", Unit("two", "OU=Sales,DC=emea,DC=test"))); + Assert.Contains(DomainJoinConfigurationValidator.ValidateMetadata(settings).Issues, + issue => issue.Code == DomainJoinValidationCode.DefaultOuMissing && issue.DomainId == "a"); + } + + [Fact] + public void TheSameOuIdMayExistInTwoDomainsButNotTwiceInOne() + { + Assert.True(DomainJoinConfigurationValidator.ValidateMetadata(With( + Domain("a", "corp.test", Unit("one", "OU=Sales,DC=corp,DC=test")), + Domain("b", "emea.test", Unit("one", "OU=Sales,DC=emea,DC=test")))).IsValid); + DomainJoinValidationResult result = DomainJoinConfigurationValidator.ValidateMetadata(With(Domain("a", "corp.test", + Unit("one", "OU=Sales,DC=corp,DC=test"), Unit("ONE", "OU=Field,DC=corp,DC=test"), Unit("two", "ou=sales,dc=corp,dc=test")))); + Assert.Contains(result.Issues, issue => issue.Code == DomainJoinValidationCode.DuplicateOuId); + Assert.Contains(result.Issues, issue => issue.Code == DomainJoinValidationCode.DuplicateDistinguishedName); + } + + + [Fact] + public void ZeroTouchNeedsAtLeastOneDomain() + { + var settings = new DomainJoinSettings { IsEnabled = true, Mode = DomainJoinMode.Automatic, SharedAccountName = "CORP\\join" }; + Assert.Contains(DomainJoinConfigurationValidator.EvaluateReadiness(settings, _ => true, true).Issues, + issue => issue.Code == DomainJoinValidationCode.DomainsRequired); + } + + [Fact] + public void ZeroTouchReportsEachDomainWithoutAnAccount() + { + DomainJoinSettings settings = ZeroTouch(null, Domain("a", "corp.test"), Domain("b", "emea.test") with { AccountName = "EMEA\\join" }); + IReadOnlyList issues = DomainJoinConfigurationValidator.EvaluateReadiness(settings, _ => true, true).Issues; + Assert.Contains(issues, issue => issue.Code == DomainJoinValidationCode.SharedAccountRequired && issue.DomainId == "a"); + Assert.DoesNotContain(issues, issue => issue.DomainId == "b"); + } + + [Fact] + public void ZeroTouchReportsADomainWhoseAccountHasNoPassword() + { + DomainJoinSettings settings = ZeroTouch("CORP\\join", Domain("a", "corp.test"), Domain("b", "emea.test") with { AccountName = "EMEA\\join" }); + DomainJoinValidationResult result = DomainJoinConfigurationValidator.EvaluateReadiness(settings, + account => string.Equals(account, "CORP\\join", StringComparison.OrdinalIgnoreCase), true); + DomainJoinValidationIssue issue = Assert.Single(result.Issues); + Assert.Equal(DomainJoinValidationCode.PasswordRequired, issue.Code); + Assert.Equal("b", issue.DomainId); + } + + [Theory] + [InlineData("technician")] + [InlineData("CONTOSO\\")] + [InlineData("@contoso.test")] + [InlineData("a@b@c")] + public void ZeroTouchRequiresAQualifiedAccount(string account) + { + DomainJoinSettings settings = ZeroTouch(account, Domain("a", "corp.test")); + Assert.True(DomainJoinConfigurationValidator.ValidateMetadata(settings).IsValid); + Assert.Contains(DomainJoinConfigurationValidator.EvaluateReadiness(settings, _ => true, true).Issues, + issue => issue.Code == DomainJoinValidationCode.QualifiedAccountRequired && issue.DomainId == "a"); + } + + [Theory] + [InlineData("CONTOSO\\technician")] + [InlineData("technician@contoso.test")] + public void ZeroTouchWithQualifiedAccountPasswordAndProtectedMediaIsReady(string account) + { + Assert.True(DomainJoinConfigurationValidator.EvaluateReadiness(ZeroTouch(account, Domain("a", "corp.test")), _ => true, true).IsValid); + } + + [Fact] + public void ZeroTouchNeedsProtectedMedia() + { + DomainJoinValidationIssue issue = Assert.Single(DomainJoinConfigurationValidator.EvaluateReadiness( + ZeroTouch("CORP\\join", Domain("a", "corp.test")), _ => true, false).Issues); + Assert.Equal(DomainJoinValidationCode.MediaProtectionRequired, issue.Code); + Assert.Null(issue.DomainId); + } + + [Fact] + public void InteractiveIsReadyWithoutDomains() + { + var settings = new DomainJoinSettings { IsEnabled = true, Mode = DomainJoinMode.Interactive }; + Assert.True(DomainJoinConfigurationValidator.EvaluateReadiness(settings, _ => false, false).IsValid); + } + + [Fact] + public void ContradictoryModesAreRejected() + { + Assert.Throws(() => DomainJoinConfigurationValidator.ThrowIfProvisioningModesConflict( + new AutopilotSettings { IsEnabled = true }, ZeroTouch("CORP\\join", Domain("a", "corp.test")))); + } + + [Fact] + public void MetadataRejectsMaximumPlusOneBoundsAndEmbeddedNull() + { + DomainJoinOrganizationalUnitSettings unit = Unit("one", "OU=Sales,DC=corp,DC=test"); + foreach ((DomainJoinSettings invalid, DomainJoinValidationCode code) in new[] + { + (With(Domain("a", new string('a', 254))), DomainJoinValidationCode.InvalidDomainName), + (With(Domain("a", "bad_domain.test")), DomainJoinValidationCode.InvalidDomainName), + (With(Domain(new string('i', 129), "corp.test")), DomainJoinValidationCode.InvalidDomainId), + (With(Domain("a", "corp.test")) with { SharedAccountName = new string('a', 513) }, DomainJoinValidationCode.InvalidAccountName), + (With(Domain("a", "corp.test") with { AccountName = "domain\\a\0b" }), DomainJoinValidationCode.InvalidAccountName), + (With(Domain("a", "corp.test", Enumerable.Range(0, 1025).Select(i => Unit(i.ToString(), $"OU=OU{i},DC=corp,DC=test")).ToArray())), DomainJoinValidationCode.TooManyOrganizationalUnits), + (With(Domain("a", "corp.test", unit with { Id = new string('a', 129) })), DomainJoinValidationCode.InvalidOuId), + (With(Domain("a", "corp.test", unit with { DisplayName = new string('a', 121) })), DomainJoinValidationCode.InvalidOuDisplayName), + (With(Domain("a", "corp.test", unit with { DistinguishedName = "OU=" + new string('a', 4097) })), DomainJoinValidationCode.InvalidDistinguishedName), + (With(Domain("a", "corp.test", unit with { DistinguishedName = "CN=Computers,DC=corp,DC=test" })), DomainJoinValidationCode.InvalidDistinguishedName) + }) + { + DomainJoinValidationResult result = DomainJoinConfigurationValidator.ValidateMetadata(invalid); + Assert.False(result.IsValid); + Assert.Contains(result.Issues, issue => issue.Code == code); + } + } + + [Fact] + public void MetadataAcceptsExactBounds() + { + string id = new('i', 128); + string suffix = ",DC=corp,DC=test"; + string dn = "OU=" + new string('a', 4096 - 3 - suffix.Length) + suffix; + DomainJoinSettings settings = With(Domain(id, "corp.test", new DomainJoinOrganizationalUnitSettings + { Id = id, DisplayName = new string('a', 120), DistinguishedName = dn }) with + { DefaultOuId = id, AccountName = new string('a', 512) }); + Assert.True(DomainJoinConfigurationValidator.ValidateMetadata(settings).IsValid); + settings = With(Domain("a", "corp.test", Enumerable.Range(0, 1024).Select(i => Unit(i.ToString(), $"OU=OU{i},DC=corp,DC=test")).ToArray())); + Assert.True(DomainJoinConfigurationValidator.ValidateMetadata(settings).IsValid); + string maximumDomain = string.Join('.', new string('a', 63), new string('b', 63), new string('c', 63), new string('d', 61)); + Assert.True(DomainJoinConfigurationValidator.EvaluateReadiness(ZeroTouch("CORP\\join", Domain("a", maximumDomain)), _ => true, true).IsValid); + } + + [Fact] + public void ReferencedAccountsAreDistinctResolvedAndOnlyForZeroTouch() + { + DomainJoinSettings settings = ZeroTouch("CORP\\join", Domain("a", "corp.test"), Domain("b", "emea.test"), + Domain("c", "lab.test") with { AccountName = "LAB\\Join" }); + Assert.Equal(new[] { DomainJoinCredentialContext.CanonicalizeAccountName("CORP\\join"), DomainJoinCredentialContext.CanonicalizeAccountName("LAB\\Join") }, + settings.GetReferencedAccountNames()); + // The shared account keeps its password while it is being set up, before any domain relies on it. + Assert.Equal([DomainJoinCredentialContext.CanonicalizeAccountName("CORP\\join")], (settings with { Domains = [] }).GetReferencedAccountNames()); + Assert.Empty((settings with { Mode = DomainJoinMode.Interactive }).GetReferencedAccountNames()); + Assert.Empty((settings with { IsEnabled = false }).GetReferencedAccountNames()); + // Passwords follow the accounts the configuration stores, whatever the mode; only Zero-touch media uses them. + Assert.Equal(settings.GetReferencedAccountNames(), settings.GetStoredAccountNames()); + Assert.Equal(settings.GetReferencedAccountNames(), (settings with { Mode = DomainJoinMode.Interactive }).GetStoredAccountNames()); + Assert.Equal(settings.GetReferencedAccountNames(), (settings with { IsEnabled = false }).GetStoredAccountNames()); + Assert.Equal("LAB\\Join", settings.ResolveAccountName(settings.Domains[2])); + Assert.Equal("CORP\\join", settings.ResolveAccountName(settings.Domains[0])); + Assert.Same(settings.Domains[1], settings.FindDomain("B")); + } + + [Fact] + public void CredentialOwnershipCanonicalizesDomainAndAccountButRejectsChangedOwners() + { + var context = new DomainJoinCredentialContext("Contoso.TEST.", "contoso\\Technician"); + Assert.True(context.Matches(new("contoso.test", "CONTOSO\\technician"))); + Assert.False(context.Matches(new("other.test", "CONTOSO\\technician"))); + Assert.False(context.Matches(new("contoso.test", "CONTOSO\\other"))); + } + + private static DomainJoinDomainSettings Domain(string id, string name, params DomainJoinOrganizationalUnitSettings[] units) => + new() { Id = id, DomainName = name, OrganizationalUnits = units }; + + private static DomainJoinOrganizationalUnitSettings Unit(string id, string distinguishedName) => + new() { Id = id, DisplayName = "Computers", DistinguishedName = distinguishedName }; + + private static DomainJoinSettings With(params DomainJoinDomainSettings[] domains) => + new() { Domains = domains, DefaultDomainId = domains.FirstOrDefault()?.Id }; + + private static DomainJoinSettings ZeroTouch(string? shared, params DomainJoinDomainSettings[] domains) => + With(domains) with { IsEnabled = true, Mode = DomainJoinMode.Automatic, SharedAccountName = shared }; +} diff --git a/src/Foundry.Core.Tests/Configuration/DomainJoinDomainCatalogTests.cs b/src/Foundry.Core.Tests/Configuration/DomainJoinDomainCatalogTests.cs new file mode 100644 index 000000000..08a2f7227 --- /dev/null +++ b/src/Foundry.Core.Tests/Configuration/DomainJoinDomainCatalogTests.cs @@ -0,0 +1,98 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Models.Configuration; +using Foundry.Core.Services.Configuration; + +namespace Foundry.Core.Tests.Configuration; + +public sealed class DomainJoinDomainCatalogTests +{ + [Fact] + public void FirstDomainBecomesTheDefault() + { + DomainJoinSettings added = DomainJoinDomainCatalog.Add(new(), " corp.test ", " CORP\\join "); + DomainJoinDomainSettings domain = Assert.Single(added.Domains); + Assert.Equal(domain.Id, added.DefaultDomainId); + Assert.Equal("corp.test", domain.DomainName); + Assert.Equal("CORP\\join", domain.AccountName); + DomainJoinSettings second = DomainJoinDomainCatalog.Add(added, "emea.test", " "); + Assert.Equal(domain.Id, second.DefaultDomainId); + Assert.Null(second.Domains[1].AccountName); + } + + [Theory] + [InlineData("CORP.TEST")] + [InlineData("corp.test.")] + [InlineData("bad_domain.test")] + [InlineData("")] + public void AddingADuplicateOrInvalidDomainIsRefused(string name) + { + DomainJoinSettings current = DomainJoinDomainCatalog.Add(new(), "corp.test", null); + Assert.Throws(() => DomainJoinDomainCatalog.Add(current, name, null)); + } + + [Fact] + public void RemovingTheDefaultMovesItToTheFirstRemainingDomain() + { + DomainJoinSettings current = Two(); + DomainJoinSettings removed = DomainJoinDomainCatalog.Remove(current, current.Domains[0].Id); + Assert.Equal(current.Domains[1].Id, removed.DefaultDomainId); + Assert.Equal(current.DefaultDomainId, DomainJoinDomainCatalog.Remove(current, current.Domains[1].Id).DefaultDomainId); + } + + [Fact] + public void RemovingTheLastDomainClearsTheDefault() + { + DomainJoinSettings current = DomainJoinDomainCatalog.Add(new(), "corp.test", null); + DomainJoinSettings removed = DomainJoinDomainCatalog.Remove(current, current.Domains[0].Id); + Assert.Empty(removed.Domains); + Assert.Null(removed.DefaultDomainId); + Assert.True(DomainJoinConfigurationValidator.ValidateMetadata(removed).IsValid); + } + + [Fact] + public void RenamingADomainThatHasOusIsRefused() + { + DomainJoinSettings current = Two(); + string id = current.Domains[0].Id; + current = DomainJoinOrganizationalUnitCatalog.Merge(current, id, + [new() { Id = "one", DisplayName = "Sales", DistinguishedName = "OU=Sales,DC=corp,DC=test" }]); + Assert.Throws(() => DomainJoinDomainCatalog.Update(current, id, "corp2.test", null)); + DomainJoinSettings updated = DomainJoinDomainCatalog.Update(current, id, "CORP.test", "CORP\\join"); + Assert.Equal("CORP\\join", updated.Domains[0].AccountName); + Assert.Single(updated.Domains[0].OrganizationalUnits); + } + + [Fact] + public void RenamingADomainKeepsItsDedicatedAccount() + { + DomainJoinSettings current = DomainJoinDomainCatalog.Add(new(), "corp.test", "CORP\\join"); + + DomainJoinSettings renamed = DomainJoinDomainCatalog.Rename(current, current.Domains[0].Id, " lab.test "); + + Assert.Equal("lab.test", renamed.Domains[0].DomainName); + Assert.Equal("CORP\\join", renamed.Domains[0].AccountName); + Assert.Throws(() => DomainJoinDomainCatalog.Rename(current, "missing", "lab.test")); + } + + [Fact] + public void ADomainWithoutOusCanBeRenamedButNotToAnotherListedName() + { + DomainJoinSettings current = Two(); + Assert.Equal("lab.test", DomainJoinDomainCatalog.Update(current, current.Domains[1].Id, "lab.test", null).Domains[1].DomainName); + Assert.Throws(() => DomainJoinDomainCatalog.Update(current, current.Domains[1].Id, "corp.test", null)); + } + + [Fact] + public void DefaultCanOnlyNameAListedDomain() + { + DomainJoinSettings current = Two(); + Assert.Equal(current.Domains[1].Id, DomainJoinDomainCatalog.SetDefault(current, current.Domains[1].Id).DefaultDomainId); + Assert.Throws(() => DomainJoinDomainCatalog.SetDefault(current, "missing")); + } + + private static DomainJoinSettings Two() => + DomainJoinDomainCatalog.Add(DomainJoinDomainCatalog.Add(new(), "corp.test", null), "emea.test", null); +} diff --git a/src/Foundry.Core.Tests/Configuration/DomainJoinEditionRulesTests.cs b/src/Foundry.Core.Tests/Configuration/DomainJoinEditionRulesTests.cs new file mode 100644 index 000000000..9e7fb37ca --- /dev/null +++ b/src/Foundry.Core.Tests/Configuration/DomainJoinEditionRulesTests.cs @@ -0,0 +1,40 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Models.Configuration; +using Foundry.Core.Services.Configuration; + +namespace Foundry.Core.Tests.Configuration; + +public sealed class DomainJoinEditionRulesTests +{ + [Theory] + [InlineData("Core", DomainJoinEditionSupport.Unsupported)] + [InlineData("CoreN", DomainJoinEditionSupport.Unsupported)] + [InlineData("CoreSingleLanguage", DomainJoinEditionSupport.Unsupported)] + [InlineData("CoreCountrySpecific", DomainJoinEditionSupport.Unsupported)] + [InlineData("Professional", DomainJoinEditionSupport.Supported)] + [InlineData("ProfessionalN", DomainJoinEditionSupport.Supported)] + [InlineData("Education", DomainJoinEditionSupport.Supported)] + [InlineData("EducationN", DomainJoinEditionSupport.Supported)] + [InlineData("Enterprise", DomainJoinEditionSupport.Supported)] + [InlineData("EnterpriseN", DomainJoinEditionSupport.Supported)] + [InlineData("enterprise", DomainJoinEditionSupport.Supported)] + [InlineData(null, DomainJoinEditionSupport.Unknown)] + [InlineData("", DomainJoinEditionSupport.Unknown)] + [InlineData("ServerStandard", DomainJoinEditionSupport.Unknown)] + [InlineData("Home", DomainJoinEditionSupport.Unknown)] + public void Evaluate_DefersUnknownIds(string? edition, DomainJoinEditionSupport expected) => + Assert.Equal(expected, DomainJoinEditionRules.Evaluate(edition)); + + [Theory] + [InlineData("Home", DomainJoinEditionSupport.Unsupported)] + [InlineData("Home N", DomainJoinEditionSupport.Unsupported)] + [InlineData("Home Single Language", DomainJoinEditionSupport.Unsupported)] + [InlineData("Home China", DomainJoinEditionSupport.Unsupported)] + [InlineData("Pro", DomainJoinEditionSupport.Supported)] + [InlineData("Pro N", DomainJoinEditionSupport.Supported)] + public void CatalogLabels_AreResolvedBeforeEditionPolicy(string label, DomainJoinEditionSupport expected) => + Assert.Equal(expected, DomainJoinEditionRules.Evaluate(WindowsEditionCatalog.Find(label)?.EditionId)); +} diff --git a/src/Foundry.Core.Tests/Configuration/DomainJoinOrganizationalUnitCatalogTests.cs b/src/Foundry.Core.Tests/Configuration/DomainJoinOrganizationalUnitCatalogTests.cs new file mode 100644 index 000000000..f1ced8cbd --- /dev/null +++ b/src/Foundry.Core.Tests/Configuration/DomainJoinOrganizationalUnitCatalogTests.cs @@ -0,0 +1,117 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Models.Configuration; +using Foundry.Core.Services.Configuration; + +namespace Foundry.Core.Tests.Configuration; + +public sealed class DomainJoinOrganizationalUnitCatalogTests +{ + [Fact] + public void MergeAddsOusToOneDomainOnlyAndSkipsListedOnes() + { + DomainJoinSettings current = Catalog(); + DomainJoinSettings merged = DomainJoinOrganizationalUnitCatalog.Merge(current, "corp", + [Unit("import", "Renamed", "ou=devices,dc=corp,dc=test"), Unit("new", "Servers", "OU=Servers,DC=corp,DC=test")]); + Assert.Equal(2, merged.Domains[0].OrganizationalUnits.Count); + Assert.Equal(current.Domains[0].OrganizationalUnits[0], merged.Domains[0].OrganizationalUnits[0]); + Assert.Equal("manual", merged.Domains[0].DefaultOuId); + Assert.Same(current.Domains[1], merged.Domains[1]); + Assert.Single(current.Domains[0].OrganizationalUnits); + } + + [Fact] + public void MergeRefusesAnOuOfAnotherDomainOrAnUnknownDomain() + { + Assert.Throws(() => DomainJoinOrganizationalUnitCatalog.Merge(Catalog(), "corp", + [Unit("x", "Other", "OU=Other,DC=emea,DC=test")])); + Assert.Throws(() => DomainJoinOrganizationalUnitCatalog.Merge(Catalog(), "missing", + [Unit("x", "Other", "OU=Other,DC=corp,DC=test")])); + Assert.Throws(() => DomainJoinOrganizationalUnitCatalog.Merge(Catalog(), "corp", + [Unit("x", "Container", "CN=Computers,DC=corp,DC=test")])); + } + + [Fact] + public void RemovingTheDefaultOuClearsThatDomainsDefault() + { + DomainJoinSettings removed = DomainJoinOrganizationalUnitCatalog.Remove(Catalog(), "corp", "manual"); + Assert.Empty(removed.Domains[0].OrganizationalUnits); + Assert.Null(removed.Domains[0].DefaultOuId); + } + + [Fact] + public void DefaultOuCanBeSetClearedAndMustBelongToTheDomain() + { + DomainJoinSettings current = Catalog(); + Assert.Null(DomainJoinOrganizationalUnitCatalog.SetDefault(current, "corp", null).Domains[0].DefaultOuId); + Assert.Equal("manual", DomainJoinOrganizationalUnitCatalog.SetDefault(current, "corp", "manual").Domains[0].DefaultOuId); + Assert.Throws(() => DomainJoinOrganizationalUnitCatalog.SetDefault(current, "emea", "manual")); + } + + [Fact] + public void RenamingAnOuChangesOnlyItsDisplayNameAndKeepsItTheDefault() + { + DomainJoinSettings current = Catalog(); + DomainJoinOrganizationalUnitSettings before = current.Domains[0].OrganizationalUnits[0]; + + DomainJoinSettings renamed = DomainJoinOrganizationalUnitCatalog.Rename(current, "corp", "MANUAL", " Paris workstations "); + + DomainJoinOrganizationalUnitSettings after = Assert.Single(renamed.Domains[0].OrganizationalUnits); + Assert.Equal(before with { DisplayName = "Paris workstations" }, after); + Assert.Equal("manual", renamed.Domains[0].DefaultOuId); + Assert.Same(current.Domains[1], renamed.Domains[1]); + // A later import of the same OU keeps the chosen name. + Assert.Equal(after, DomainJoinOrganizationalUnitCatalog.Merge(renamed, "corp", + [Unit("import", "Devices", before.DistinguishedName)]).Domains[0].OrganizationalUnits[0]); + } + + [Theory] + [InlineData("corp", "manual", " ")] + [InlineData("corp", "missing", "Sales")] + [InlineData("missing", "manual", "Sales")] + public void RenamingRefusesABlankNameOrAnUnknownOu(string domainId, string ouId, string displayName) + { + Assert.Throws(() => DomainJoinOrganizationalUnitCatalog.Rename(Catalog(), domainId, ouId, displayName)); + Assert.Throws(() => DomainJoinOrganizationalUnitCatalog.Rename(Catalog(), "corp", "manual", new string('a', 121))); + } + + [Fact] + public void TheOnlyOuIsTheDefaultAndSeveralOusNeedAMarkedOne() + { + DomainJoinOrganizationalUnitSettings devices = Unit("manual", "Devices", "OU=Devices,DC=corp,DC=test"); + DomainJoinOrganizationalUnitSettings servers = Unit("servers", "Servers", "OU=Servers,DC=corp,DC=test"); + + Assert.Null(DomainJoinOrganizationalUnitCatalog.ResolveDefault([], "manual")); + Assert.Null(DomainJoinOrganizationalUnitCatalog.ResolveDefault(null, null)); + Assert.Same(devices, DomainJoinOrganizationalUnitCatalog.ResolveDefault([devices], null)); + Assert.Null(DomainJoinOrganizationalUnitCatalog.ResolveDefault([devices, servers], null)); + Assert.Same(servers, DomainJoinOrganizationalUnitCatalog.ResolveDefault([devices, servers], "SERVERS")); + Assert.Null(DomainJoinOrganizationalUnitCatalog.ResolveDefault([devices, servers], "missing")); + } + + [Fact] + public void ExcludeListedKeepsOnlyOusAnImportWouldAdd() + { + DomainJoinOrganizationalUnitSettings added = Unit("new", "Servers", "OU=Servers,DC=corp,DC=test"); + + IReadOnlyList remaining = DomainJoinOrganizationalUnitCatalog.ExcludeListed(Catalog().Domains[0], + [Unit("import", "Renamed", "ou=devices,dc=corp,dc=test"), added]); + + Assert.Equal([added], remaining); + } + + private static DomainJoinSettings Catalog() => new() + { + DefaultDomainId = "corp", + Domains = + [ + new() { Id = "corp", DomainName = "corp.test", DefaultOuId = "manual", OrganizationalUnits = [Unit("manual", "Devices", "OU=Devices,DC=corp,DC=test")] }, + new() { Id = "emea", DomainName = "emea.test" } + ] + }; + + private static DomainJoinOrganizationalUnitSettings Unit(string id, string name, string distinguishedName) => + new() { Id = id, DisplayName = name, DistinguishedName = distinguishedName }; +} diff --git a/src/Foundry.Core.Tests/Configuration/DomainJoinSecretStateTests.cs b/src/Foundry.Core.Tests/Configuration/DomainJoinSecretStateTests.cs new file mode 100644 index 000000000..52096396b --- /dev/null +++ b/src/Foundry.Core.Tests/Configuration/DomainJoinSecretStateTests.cs @@ -0,0 +1,181 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reflection; +using Foundry.Core.Models.Configuration; +using Foundry.Core.Services.Configuration; + +namespace Foundry.Core.Tests.Configuration; + +public sealed class DomainJoinSecretStateTests +{ + private static readonly DomainJoinCredentialContext Context = new("example.com", "EXAMPLE\\joiner"); + + [Fact] + public void CodecPreservesPasswordAndClearsDecodedBuffer() + { + byte[] encoded = DomainJoinCredentialPayloadCodec.Encode(Context, " exact password "); + using DomainJoinCredentialPayload decoded = DomainJoinCredentialPayloadCodec.Decode(encoded, new("EXAMPLE.COM.", "example\\JOINER")); + ReadOnlyMemory owned = decoded.Password; + Assert.Equal(" exact password ", new string(owned.Span)); + decoded.Dispose(); + Assert.All(owned.ToArray(), value => Assert.Equal('\0', value)); + } + + [Fact] + public void CodecRejectsContextSubstitutionAndMalformedPayload() + { + byte[] encoded = DomainJoinCredentialPayloadCodec.Encode(Context, "password"); + Assert.Throws(() => DomainJoinCredentialPayloadCodec.Decode(encoded, new("other.com", Context.AccountName))); + Assert.Throws(() => DomainJoinCredentialPayloadCodec.Decode(encoded, new("example.com", "EXAMPLE\\other"))); + Assert.Throws(() => DomainJoinCredentialPayloadCodec.Decode(encoded.AsSpan(0, encoded.Length - 1))); + Assert.Throws(() => DomainJoinCredentialPayloadCodec.Decode(new byte[32769])); + } + + [Theory] + [InlineData("\0")] + public void CodecRejectsUnsafePassword(string password) + { + Assert.Throws(() => DomainJoinCredentialPayloadCodec.Encode(Context, password)); + } + + [Fact] + public void CodecRejectsUnpairedSurrogate() + { + string password = new('\ud800', 1); + Assert.Throws(() => DomainJoinCredentialPayloadCodec.Encode(Context, password)); + } + + [Fact] + public void CodecRejectsTrailingBytesAndInvalidUtf8() + { + byte[] encoded = DomainJoinCredentialPayloadCodec.Encode(Context, "password"); + Assert.Throws(() => DomainJoinCredentialPayloadCodec.Decode([.. encoded, 0])); + encoded[^1] = 0xff; + Assert.Throws(() => DomainJoinCredentialPayloadCodec.Decode(encoded)); + } + + [Fact] + public void CodecEnforcesPasswordUtf8Bound() + { + Assert.Throws(() => DomainJoinCredentialPayloadCodec.Encode(Context, new string('é', 1281))); + using var state = new DomainJoinSecretState(); + Assert.Throws(() => state.SetPassword(Account, new string('a', 2561))); + } + + [Fact] + public void AnAccountIsOneEntryWhateverItsSpelling() + { + using var state = new DomainJoinSecretState(); + state.SetPassword("CORP\\join", " exact password "); + Assert.True(state.HasPassword("corp\\JOIN")); + Assert.Equal(" exact password ", new string(state.GetPasswordCopy("corp\\JOIN")!)); + // Both spellings name one entry: writing through the other replaces the password instead of adding one. + state.SetPassword("corp\\JOIN", "replaced"); + Assert.Equal("replaced", new string(state.GetPasswordCopy("CORP\\join")!)); + } + + [Fact] + public void AnAccountStillUsedByAnotherDomainKeepsItsPassword() + { + using var state = new DomainJoinSecretState(); + state.SetPassword("LAB\\join", "dedicated"); + var first = new DomainJoinDomainSettings { Id = "a", DomainName = "corp.test", AccountName = "LAB\\join" }; + var second = new DomainJoinDomainSettings { Id = "b", DomainName = "emea.test", AccountName = "LAB\\join" }; + + Assert.False(state.Update(ZeroTouch("CORP\\join", first with { AccountName = "EMEA\\join" }, second))); + Assert.True(state.HasPassword("LAB\\join")); + + Assert.True(state.Update(ZeroTouch("CORP\\join", first with { AccountName = "EMEA\\join" }, second with { AccountName = null }))); + Assert.False(state.HasPassword("LAB\\join")); + } + + [Fact] + public void AnAccountNoLongerStoredLosesItsPassword() + { + using var state = new DomainJoinSecretState(); + state.SetPassword("CORP\\join", "shared"); + state.SetPassword("LAB\\join", "dedicated"); + Assert.True(state.Update(ZeroTouch("CORP\\join", new DomainJoinDomainSettings { Id = "a", DomainName = "corp.test" }))); + Assert.True(state.HasPassword("CORP\\join")); + Assert.False(state.HasPassword("LAB\\join")); + Assert.Null(state.GetPasswordCopy("LAB\\join")); + } + + [Theory] + [InlineData(false, DomainJoinMode.Automatic)] + [InlineData(true, DomainJoinMode.Interactive)] + public void InteractiveOrDisabledSettingsKeepThePasswordsOfStoredAccounts(bool enabled, DomainJoinMode mode) + { + using var state = new DomainJoinSecretState(); + state.SetPassword(Account, "password"); + state.SetPassword("LAB\\join", "dedicated"); + var domain = new DomainJoinDomainSettings { Id = "a", DomainName = "example.com", AccountName = "LAB\\join" }; + DomainJoinSettings settings = ZeroTouch(Account, domain) with { IsEnabled = enabled, Mode = mode }; + + Assert.False(state.Update(settings)); + Assert.True(state.HasPassword(Account)); + Assert.True(state.HasPassword("LAB\\join")); + + // Removing an account still erases its password, whatever the mode. + Assert.True(state.Update(settings with { Domains = [settings.Domains[0] with { AccountName = null }] })); + Assert.True(state.HasPassword(Account)); + Assert.False(state.HasPassword("LAB\\join")); + } + + [Theory] + [InlineData("join")] + [InlineData("")] + [InlineData("CORP\\")] + public void AnUnqualifiedAccountCannotOwnAPassword(string account) + { + using var state = new DomainJoinSecretState(); + Assert.Throws(() => state.SetPassword(account, "password")); + Assert.False(state.HasPassword(account)); + } + + [Fact] + public void AnEmptyValueRemovesTheAccountsPassword() + { + using var state = new DomainJoinSecretState(); + state.SetPassword(Account, "password"); + state.SetPassword(Account, string.Empty); + Assert.False(state.HasPassword(Account)); + } + + [Fact] + public void ReturnedCopiesAreIndependent() + { + using var state = new DomainJoinSecretState(); + state.SetPassword(Account, "password"); + char[] first = state.GetPasswordCopy(Account)!; + Array.Clear(first); + Assert.Equal("password", new string(state.GetPasswordCopy(Account)!)); + } + + [Fact] + public void OwnedCopiesAreZeroedOnDispose() + { + var state = new DomainJoinSecretState(); + char[] input = "password".ToCharArray(); + state.SetPassword(Account, input); + var owned = (Dictionary)typeof(DomainJoinSecretState).GetField("passwords", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(state)!; + char[] buffer = owned.Values.Single(); + state.Dispose(); + Assert.All(buffer, value => Assert.Equal('\0', value)); + Assert.Equal("password", new string(input)); + Assert.Throws(() => state.GetPasswordCopy(Account)); + } + + private const string Account = "EXAMPLE\\joiner"; + + private static DomainJoinSettings ZeroTouch(string? shared, params DomainJoinDomainSettings[] domains) => new() + { + IsEnabled = true, + Mode = DomainJoinMode.Automatic, + SharedAccountName = shared, + Domains = domains, + DefaultDomainId = domains[0].Id + }; +} diff --git a/src/Foundry.Core.Tests/Configuration/FoundryConfigurationServiceTests.cs b/src/Foundry.Core.Tests/Configuration/FoundryConfigurationServiceTests.cs index 7dd0d45c4..524d174cc 100644 --- a/src/Foundry.Core.Tests/Configuration/FoundryConfigurationServiceTests.cs +++ b/src/Foundry.Core.Tests/Configuration/FoundryConfigurationServiceTests.cs @@ -10,6 +10,170 @@ namespace Foundry.Core.Tests.Configuration; public sealed class FoundryConfigurationServiceTests { + [Fact] + public void DomainJoinRoundTripsDomainsWithTheirOusAndLeavesOtherSectionsAlone() + { + var service = new FoundryConfigurationService(); + + FoundryConfigurationDocument loaded = service.Deserialize(service.Serialize(TwoDomainDraft())); + + Assert.Equal(["corp.test", "emea.test"], loaded.DomainJoin.Domains.Select(domain => domain.DomainName)); + Assert.Equal("emea", loaded.DomainJoin.DefaultDomainId); + Assert.Equal("CORP\\join", loaded.DomainJoin.SharedAccountName); + Assert.Equal("EMEA\\join", loaded.DomainJoin.Domains[1].AccountName); + + Assert.Equal(["devices", "servers"], loaded.DomainJoin.Domains[0].OrganizationalUnits.Select(unit => unit.Id)); + Assert.Equal("servers", loaded.DomainJoin.Domains[0].DefaultOuId); + Assert.Empty(loaded.DomainJoin.Domains[1].OrganizationalUnits); + Assert.Equal(@"E:\Media\retained.iso", loaded.General.IsoOutputPath); + Assert.Equal("retained-wifi", loaded.Network.Wifi.Ssid); + Assert.Equal("retained-machine", loaded.Customization.MachineNaming.ManualInitialValue); + } + + [Fact] + public void EarlierUnreleasedDomainJoinShapeLoadsAsAnEmptyDomainList() + { + const string json = "{\"schemaVersion\":18,\"domainJoin\":{\"isEnabled\":false,\"mode\":1,\"domainName\":\"corp.test\"," + + "\"accountName\":\"CORP\\\\join\",\"ouCatalogDomain\":\"corp.test\",\"organizationalUnits\":[{\"id\":\"a\",\"displayName\":\"A\"," + + "\"distinguishedName\":\"OU=A,DC=corp,DC=test\"}],\"defaultOuId\":\"a\",\"allowOuSelectionDuringDeployment\":true}}"; + + FoundryConfigurationDocument document = new FoundryConfigurationService().Deserialize(json); + + Assert.Empty(document.DomainJoin.Domains); + Assert.Null(document.DomainJoin.DefaultDomainId); + Assert.Null(document.DomainJoin.SharedAccountName); + Assert.True(DomainJoinConfigurationValidator.ValidateMetadata(document.DomainJoin).IsValid); + } + + [Fact] + public void NullDomainAndOuListsOfAHandEditedFileLoadAsEmptyLists() + { + var service = new FoundryConfigurationService(); + + FoundryConfigurationDocument withoutDomains = service.Deserialize("{\"schemaVersion\":18,\"domainJoin\":{\"isEnabled\":true,\"mode\":1,\"domains\":null}}"); + FoundryConfigurationDocument withoutUnits = service.Deserialize("{\"schemaVersion\":18,\"domainJoin\":{\"isEnabled\":true,\"mode\":1," + + "\"sharedAccountName\":\"CORP\\\\join\",\"defaultDomainId\":\"a\",\"domains\":[{\"id\":\"a\",\"domainName\":\"corp.test\",\"organizationalUnits\":null}]}}"); + + Assert.Empty(withoutDomains.DomainJoin.Domains); + Assert.Empty(withoutDomains.DomainJoin.GetStoredAccountNames()); + DomainJoinDomainSettings domain = Assert.Single(withoutUnits.DomainJoin.Domains); + Assert.Empty(domain.OrganizationalUnits); + // The loaded list is usable by the catalogs, which is where a null list used to fail. + Assert.Single(DomainJoinOrganizationalUnitCatalog.Merge(withoutUnits.DomainJoin, "a", + [new() { Id = "ou", DisplayName = "Sales", DistinguishedName = "OU=Sales,DC=corp,DC=test" }]).Domains[0].OrganizationalUnits); + } + + [Theory] + [InlineData("malformed-dn")] + [InlineData("foreign-dn")] + [InlineData("duplicate-id")] + [InlineData("duplicate-dn")] + [InlineData("missing-default-ou")] + [InlineData("missing-default-domain")] + [InlineData("duplicate-domain")] + [InlineData("oversized-label")] + [InlineData("overflow")] + [InlineData("provisioning-conflict")] + public void Deserialize_RejectsMalformedDomainListAndProvisioningConflict(string corruption) + { + FoundryConfigurationDocument draft = TwoDomainDraft(); + DomainJoinSettings settings = draft.DomainJoin; + DomainJoinDomainSettings corp = settings.Domains[0]; + DomainJoinOrganizationalUnitSettings first = corp.OrganizationalUnits[0]; + DomainJoinOrganizationalUnitSettings second = corp.OrganizationalUnits[1]; + DomainJoinSettings WithCorp(DomainJoinDomainSettings changed) => settings with { Domains = [changed, settings.Domains[1]] }; + settings = corruption switch + { + "malformed-dn" => WithCorp(corp with { OrganizationalUnits = [first with { DistinguishedName = "invalid" }, second] }), + "foreign-dn" => WithCorp(corp with { OrganizationalUnits = [first with { DistinguishedName = "OU=Devices,DC=emea,DC=test" }, second] }), + "duplicate-id" => WithCorp(corp with { DefaultOuId = null, OrganizationalUnits = [first, second with { Id = "devices" }] }), + "duplicate-dn" => WithCorp(corp with { OrganizationalUnits = [first, second with { DistinguishedName = "OU=Devices,DC=corp,DC=test" }] }), + "missing-default-ou" => WithCorp(corp with { DefaultOuId = "missing" }), + "missing-default-domain" => settings with { DefaultDomainId = "missing" }, + "duplicate-domain" => settings with { Domains = [corp, settings.Domains[1] with { DomainName = "CORP.test" }] }, + "oversized-label" => WithCorp(corp with { OrganizationalUnits = [first with { DisplayName = new string('x', 121) }, second] }), + "overflow" => WithCorp(corp with + { + DefaultOuId = null, + OrganizationalUnits = Enumerable.Range(0, 1025).Select(index => first with + { Id = index.ToString(), DistinguishedName = $"OU=Devices{index},DC=corp,DC=test" }).ToArray() + }), + _ => settings + }; + draft = draft with { DomainJoin = settings, Autopilot = draft.Autopilot with { IsEnabled = corruption == "provisioning-conflict" } }; + var service = new FoundryConfigurationService(); + string json = service.Serialize(draft); + Assert.Throws(() => service.Deserialize(json)); + } + + [Fact] + public void DisabledDomainJoin_RemainsSavableAndPortable() + { + var service = new FoundryConfigurationService(); + FoundryConfigurationDocument draft = TwoDomainDraft(); + draft = draft with { DomainJoin = draft.DomainJoin with { IsEnabled = false } }; + + FoundryConfigurationDocument loaded = service.Deserialize(service.Serialize(draft)); + FoundryConfigurationDocument portable = Foundry.Core.Services.Profiles.DeploymentProfileProjection.CreatePortable(loaded); + + Assert.False(portable.DomainJoin.IsEnabled); + Assert.Equal(["devices", "servers"], portable.DomainJoin.Domains[0].OrganizationalUnits.Select(unit => unit.Id)); + } + + private static FoundryConfigurationDocument TwoDomainDraft() => new() + { + General = new() { IsoOutputPath = @"E:\Media\retained.iso", WinPeLanguage = "fr-FR", IncludeDellDrivers = true }, + Network = new() { Wifi = new() { Ssid = "retained-wifi" } }, + Customization = new() { MachineNaming = new() { IsEnabled = true, Mode = MachineNamingMode.Manual, ManualInitialValue = "retained-machine" } }, + DomainJoin = new() + { + IsEnabled = true, + Mode = DomainJoinMode.Automatic, + SharedAccountName = "CORP\\join", + DefaultDomainId = "emea", + Domains = + [ + new() + { + Id = "corp", + DomainName = "corp.test", + DefaultOuId = "servers", + OrganizationalUnits = + [ + new() { Id = "devices", DisplayName = "Devices", DistinguishedName = "OU=Devices,DC=corp,DC=test" }, + new() { Id = "servers", DisplayName = "Servers", DistinguishedName = "OU=Servers,DC=corp,DC=test" } + ] + }, + new() { Id = "emea", DomainName = "emea.test", AccountName = "EMEA\\join" } + ] + } + }; + + [Fact] + public void AConfigurationWithoutDomainJoinLoadsItDisabledAtTheCurrentSchema() + { + FoundryConfigurationDocument document = new FoundryConfigurationService().Deserialize("{\"schemaVersion\":17}"); + Assert.Equal(18, document.SchemaVersion); + Assert.False(document.DomainJoin.IsEnabled); + } + + [Fact] + public void Deserialize_RejectsContradictoryProvisioningModesWithoutChoosingWinner() + { + Assert.Throws(() => new FoundryConfigurationService().Deserialize( + "{\"schemaVersion\":18,\"autopilot\":{\"isEnabled\":true},\"domainJoin\":{\"isEnabled\":true}}")); + } + + [Fact] + public void Deserialize_AutomaticSecretFreeDraftIsImportable() + { + FoundryConfigurationDocument document = new FoundryConfigurationService().Deserialize( + "{\"schemaVersion\":18,\"domainJoin\":{\"isEnabled\":true,\"mode\":1}}"); + Assert.True(document.DomainJoin.IsEnabled); + Assert.True(DomainJoinConfigurationValidator.ValidateMetadata(document.DomainJoin).IsValid); + Assert.False(DomainJoinConfigurationValidator.EvaluateReadiness(document.DomainJoin, _ => false, false).IsValid); + } + [Theory] [InlineData("[\"99H1\"]", null)] [InlineData("[]", null)] @@ -39,7 +203,7 @@ public void Deserialize_PublishedConfiguration_MigratesUnavailableReleasePolicy( FoundryConfigurationDocument document = service.Deserialize(json); var generated = new DeployConfigurationGenerator().Generate(document); - Assert.Equal(17, document.SchemaVersion); + Assert.Equal(18, document.SchemaVersion); Assert.Equal(ConfigurationSchemaVersions.DeployCurrent, generated.SchemaVersion); Assert.Equal(expectedDefault is null ? [] : new[] { expectedDefault }, document.OperatingSystemSelection.AllowedReleaseIds); Assert.Equal(expectedDefault, document.OperatingSystemSelection.DefaultReleaseId); diff --git a/src/Foundry.Core.Tests/Configuration/ProvisioningModeSelectionTests.cs b/src/Foundry.Core.Tests/Configuration/ProvisioningModeSelectionTests.cs new file mode 100644 index 000000000..20f0c03bf --- /dev/null +++ b/src/Foundry.Core.Tests/Configuration/ProvisioningModeSelectionTests.cs @@ -0,0 +1,78 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Models.Configuration; +using Foundry.Core.Services.Configuration; + +namespace Foundry.Core.Tests.Configuration; + +public sealed class ProvisioningModeSelectionTests +{ + [Theory] + [InlineData(AutopilotProvisioningMode.JsonProfile, ProvisioningSelection.AutopilotJsonProfile)] + [InlineData(AutopilotProvisioningMode.HardwareHashUpload, ProvisioningSelection.AutopilotHardwareHashUpload)] + [InlineData(AutopilotProvisioningMode.InteractiveHardwareHashUpload, ProvisioningSelection.AutopilotInteractiveHardwareHashUpload)] + public void ActiveAutopilotSettingsToggleTheirOwnSelectionOff(AutopilotProvisioningMode mode, ProvisioningSelection requested) + { + var autopilot = new AutopilotSettings { IsEnabled = true, ProvisioningMode = mode }; + var domainJoin = new DomainJoinSettings { Mode = DomainJoinMode.Automatic }; + ProvisioningSelectionDecision decision = ProvisioningModeSelectionEvaluator.Evaluate(autopilot, domainJoin, requested); + Assert.Equal(ProvisioningSelection.None, decision.Next); + Assert.False(decision.RequiresReplacementConfirmation); + } + + [Theory] + [InlineData(DomainJoinMode.Interactive, ProvisioningSelection.DomainJoinInteractive)] + [InlineData(DomainJoinMode.Automatic, ProvisioningSelection.DomainJoinAutomatic)] + public void ActiveDomainSettingsToggleTheirOwnSelectionOff(DomainJoinMode mode, ProvisioningSelection requested) + { + var domainJoin = new DomainJoinSettings { IsEnabled = true, Mode = mode }; + ProvisioningSelectionDecision decision = ProvisioningModeSelectionEvaluator.Evaluate(new(), domainJoin, requested); + Assert.Equal(ProvisioningSelection.None, decision.Next); + Assert.False(decision.RequiresReplacementConfirmation); + } + + [Fact] + public void ReplacingAutopilotWithDomainRequiresConfirmation() + { + ProvisioningSelectionDecision decision = ProvisioningModeSelectionEvaluator.Evaluate( + new() { IsEnabled = true, ProvisioningMode = AutopilotProvisioningMode.HardwareHashUpload }, + new(), ProvisioningSelection.DomainJoinInteractive); + Assert.Equal(ProvisioningSelection.DomainJoinInteractive, decision.Next); + Assert.True(decision.RequiresReplacementConfirmation); + } + + [Fact] + public void ReplacingDomainWithAutopilotRequiresConfirmation() + { + ProvisioningSelectionDecision decision = ProvisioningModeSelectionEvaluator.Evaluate( + new(), new() { IsEnabled = true, Mode = DomainJoinMode.Automatic }, ProvisioningSelection.AutopilotInteractiveHardwareHashUpload); + Assert.Equal(ProvisioningSelection.AutopilotInteractiveHardwareHashUpload, decision.Next); + Assert.True(decision.RequiresReplacementConfirmation); + } + + [Fact] + public void InactiveDraftModesDoNotRequireReplacementConfirmation() + { + ProvisioningSelectionDecision decision = ProvisioningModeSelectionEvaluator.Evaluate( + new() { ProvisioningMode = AutopilotProvisioningMode.HardwareHashUpload }, + new() { Mode = DomainJoinMode.Automatic }, ProvisioningSelection.DomainJoinInteractive); + Assert.Equal(ProvisioningSelection.DomainJoinInteractive, decision.Next); + Assert.False(decision.RequiresReplacementConfirmation); + } + + [Fact] + public void ContradictorySettingsAreRejectedBeforeSelection() + { + Assert.Throws(() => ProvisioningModeSelectionEvaluator.Evaluate( + new() { IsEnabled = true }, new() { IsEnabled = true }, ProvisioningSelection.DomainJoinAutomatic)); + } + + [Fact] + public void NoEnabledModeResolvesToNoSelection() + { + Assert.Equal(ProvisioningSelection.None, ProvisioningModeSelectionEvaluator.GetCurrent( + new() { ProvisioningMode = AutopilotProvisioningMode.HardwareHashUpload }, new() { Mode = DomainJoinMode.Automatic })); + } +} diff --git a/src/Foundry.Core.Tests/Configuration/UnattendFileServiceTests.cs b/src/Foundry.Core.Tests/Configuration/UnattendFileServiceTests.cs index 655905207..3345bf967 100644 --- a/src/Foundry.Core.Tests/Configuration/UnattendFileServiceTests.cs +++ b/src/Foundry.Core.Tests/Configuration/UnattendFileServiceTests.cs @@ -11,6 +11,54 @@ namespace Foundry.Core.Tests.Configuration; public sealed class UnattendFileServiceTests { + [Fact] + public void Inspect_DomainIntentAcceptsExactlyOneApplicableConcreteNameUnchanged() + { + byte[] content = Xml(Settings("Custom-PC", "amd64") + + Settings("Other-PC", "arm64")); + byte[] original = content.ToArray(); + UnattendInspection result = UnattendFileService.Inspect(content, "x64", requiresDomainJoin: true); + Assert.Equal("Custom-PC", result.ConcreteComputerName); + Assert.False(result.HasUnattendedJoinComponent); + Assert.Equal(original, content); + } + + [Theory] + [InlineData("")] + [InlineData("*")] + [InlineData("bad name")] + [InlineData(" PC ")] + [InlineData("1234")] + [InlineData("FIRSTSECOND")] + [InlineData("Owner")] + public void Inspect_DomainIntentRejectsMissingInvalidOrConflictingNames(string settings) + { + byte[] content = Xml(Settings(settings)); + Assert.Throws(() => UnattendFileService.Inspect(content, "x64", requiresDomainJoin: true)); + UnattendFileService.Inspect(content, "x64"); + } + + [Theory] + [InlineData("")] + [InlineData("WORKGROUP")] + [InlineData("contoso.test")] + public void Inspect_DomainIntentRejectsAnyUnattendedJoinComponent(string joinSettings) + { + byte[] content = Xml(Settings("PC") + + $"{joinSettings}"); + Assert.True(UnattendFileService.Inspect(content, "x64").HasUnattendedJoinComponent); + Assert.Throws(() => UnattendFileService.Inspect(content, "x64", requiresDomainJoin: true)); + } + + [Fact] + public void Inspect_DomainIntentRejectsDuplicateSameNamesAndWrongPassName() + { + Assert.Throws(() => UnattendFileService.Inspect( + Xml(Settings("PC") + Settings("PC")), "x64", requiresDomainJoin: true)); + Assert.Throws(() => UnattendFileService.Inspect( + Xml(Settings("PC", pass: "oobeSystem")), "x64", requiresDomainJoin: true)); + } + [Theory] [InlineData("windowsPE")] [InlineData("offlineServicing")] diff --git a/src/Foundry.Core.Tests/Profiles/DeploymentBuildSnapshotTests.cs b/src/Foundry.Core.Tests/Profiles/DeploymentBuildSnapshotTests.cs index 557d7cdf6..2be508306 100644 --- a/src/Foundry.Core.Tests/Profiles/DeploymentBuildSnapshotTests.cs +++ b/src/Foundry.Core.Tests/Profiles/DeploymentBuildSnapshotTests.cs @@ -17,6 +17,49 @@ public sealed class DeploymentBuildSnapshotTests : IDisposable { private readonly string root = Path.Combine(Path.GetTempPath(), "FoundrySnapshotTests", Guid.NewGuid().ToString("N")); + [Fact] + public async Task SnapshotCopiesThePasswordOfEveryReferencedAccount() + { + using var accounts = new OobeAccountSecretState(); + using var domain = new DomainJoinSecretState(); + var context = new DomainJoinCredentialContext("example.com", "EXAMPLE\\joiner"); + var dedicated = new DomainJoinCredentialContext("emea.example.com", "EMEA\\joiner"); + domain.SetPassword(context.AccountName, " frozen password "); + domain.SetPassword(dedicated.AccountName, "second"); + var document = new FoundryConfigurationDocument + { + General = new() { DeploymentProtection = new() { IsEnabled = true } }, + DomainJoin = new() + { + IsEnabled = true, + Mode = DomainJoinMode.Automatic, + SharedAccountName = context.AccountName, + DefaultDomainId = "a", + Domains = [new() { Id = "a", DomainName = context.DomainName }, new() { Id = "b", DomainName = dedicated.DomainName, AccountName = dedicated.AccountName }] + } + }; + Task capturing = DeploymentBuildSnapshot.CaptureAsync(document, accounts, domain, "MediaPassword123!", root, null, TestContext.Current.CancellationToken); + domain.Clear(); + using DeploymentBuildSnapshot snapshot = await capturing; + using var protection = snapshot.CreateDeploymentProtectionMaterial(); + string json = snapshot.GenerateDeployConfigurationJson(deploymentSecretsKey: protection.DeploymentKey, protectionSettings: protection.Settings); + var deploy = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + byte[] payload = MediaSecretEnvelopeProtector.DecryptBytes(deploy.DomainJoin.Domains[0].EncryptedCredentials!, protection.DeploymentKey, MediaSecretEnvelopeProtector.DeploymentKeyId); + byte[] second = MediaSecretEnvelopeProtector.DecryptBytes(deploy.DomainJoin.Domains[1].EncryptedCredentials!, protection.DeploymentKey, MediaSecretEnvelopeProtector.DeploymentKeyId); + try + { + using var decoded = DomainJoinCredentialPayloadCodec.Decode(payload, context); + using var decodedSecond = DomainJoinCredentialPayloadCodec.Decode(second, dedicated); + Assert.Equal(" frozen password ", new string(decoded.Password.Span)); + Assert.Equal("second", new string(decodedSecond.Password.Span)); + } + finally + { + CryptographicOperations.ZeroMemory(payload); + CryptographicOperations.ZeroMemory(second); + } + } + [Fact] public async Task CaptureAsync_PreservesOneAuthoringVersionAcrossBothRuntimeOutputs() { diff --git a/src/Foundry.Core.Tests/Profiles/DeploymentProfileMergeTests.cs b/src/Foundry.Core.Tests/Profiles/DeploymentProfileMergeTests.cs index b6bc8d254..6909b5f3b 100644 --- a/src/Foundry.Core.Tests/Profiles/DeploymentProfileMergeTests.cs +++ b/src/Foundry.Core.Tests/Profiles/DeploymentProfileMergeTests.cs @@ -10,6 +10,123 @@ namespace Foundry.Core.Tests.Profiles; public sealed class DeploymentProfileMergeTests { + [Theory] + [InlineData(true)] + [InlineData(false)] + public void MergeKeepsAnOmittedSecretForEachAccountStillStored(bool includeSecrets) + { + DeploymentProfileDocument baseline = TwoAccountProfile(); + baseline = baseline with { Secrets = new() { Entries = [Secret(baseline, "CORP\\join", ProfileValueState.Omitted), Secret(baseline, "EMEA\\join", ProfileValueState.Omitted)] } }; + var captured = baseline with { Secrets = new() { Entries = [baseline.Secrets.Entries[0] with { State = ProfileValueState.Unavailable }] } }; + + var result = DeploymentProfileMerge.PreserveOmittedSourceMetadata(captured, baseline, baseline.Configuration, includeSecrets); + + Assert.Equal(2, result.Secrets.Entries.Count); + Assert.All(result.Secrets.Entries, secret => + { + Assert.Equal(ProfileValueState.Omitted, secret.State); + Assert.Null(secret.Value); + }); + Assert.Equal(baseline.Secrets.Entries.Select(secret => secret.Identity).Order(), result.Secrets.Entries.Select(secret => secret.Identity).Order()); + } + + [Fact] + public void MergeDropsTheSecretOfAnAccountNoLongerStored() + { + DeploymentProfileDocument baseline = TwoAccountProfile(); + baseline = baseline with { Secrets = new() { Entries = [Secret(baseline, "CORP\\join", ProfileValueState.Omitted), Secret(baseline, "EMEA\\join", ProfileValueState.Omitted)] } }; + DomainJoinSettings settings = baseline.Configuration.DomainJoin; + // The second domain falls back to the shared account, so EMEA\join is referenced by nothing. + var captured = baseline with + { + Secrets = new(), + Configuration = baseline.Configuration with { DomainJoin = settings with { Domains = [settings.Domains[0], settings.Domains[1] with { AccountName = null }] } } + }; + + var result = DeploymentProfileMerge.PreserveOmittedSourceMetadata(captured, baseline, baseline.Configuration, includeSecrets: false); + + DeploymentProfileSecret kept = Assert.Single(result.Secrets.Entries); + Assert.Equal(baseline.Secrets.Entries[0].Identity, kept.Identity); + } + + [Fact] + public void MergeKeepsOmittedSecretsWhileTheAccountsAreStoredInInteractiveMode() + { + DeploymentProfileDocument baseline = TwoAccountProfile(); + baseline = baseline with + { + Configuration = baseline.Configuration with { DomainJoin = baseline.Configuration.DomainJoin with { Mode = DomainJoinMode.Interactive } } + }; + baseline = baseline with { Secrets = new() { Entries = [Secret(baseline, "CORP\\join", ProfileValueState.Omitted), Secret(baseline, "EMEA\\join", ProfileValueState.Omitted)] } }; + + var result = DeploymentProfileMerge.PreserveOmittedSourceMetadata(baseline with { Secrets = new() }, baseline, baseline.Configuration, includeSecrets: false); + + Assert.Equal(2, result.Secrets.Entries.Count); + } + + [Fact] + public void LocalValueIsPreservedOnlyForTheSameAccount() + { + DeploymentProfileDocument local = TwoAccountProfile(); + local = local with + { + Secrets = new() + { + Entries = [Secret(local, "CORP\\join", ProfileValueState.Present, [112, 97, 115, 115]), Secret(local, "EMEA\\join", ProfileValueState.Present, [101, 109, 101, 97])] + } + }; + DeploymentProfileDocument incoming = local with { Secrets = new() { Entries = [local.Secrets.Entries[0] with { State = ProfileValueState.Omitted, Value = null }] } }; + + var retained = DeploymentProfileMerge.PreserveOmittedLocalValues(incoming, local); + + DeploymentProfileSecret secret = Assert.Single(retained.Secrets.Entries); + Assert.Equal(new byte[] { 112, 97, 115, 115 }, secret.Value); + Assert.NotSame(local.Secrets.Entries[0].Value, secret.Value); + // Switching Domain Join off or to Interactive keeps the account, so its local password stays with it. + var unused = incoming with { Configuration = incoming.Configuration with { DomainJoin = incoming.Configuration.DomainJoin with { IsEnabled = false, Mode = DomainJoinMode.Interactive } } }; + Assert.Equal(new byte[] { 112, 97, 115, 115 }, DeploymentProfileMerge.PreserveOmittedLocalValues(unused, local).Secrets.Entries[0].Value); + // Even a stale imported identity must not carry a password to an account the incoming profile no longer uses. + var changed = incoming with { Configuration = incoming.Configuration with { DomainJoin = incoming.Configuration.DomainJoin with { SharedAccountName = "CORP\\other" } } }; + Assert.Null(DeploymentProfileMerge.PreserveOmittedLocalValues(changed, local).Secrets.Entries[0].Value); + } + + [Fact] + public void RenamingADomainKeepsItsAccountSecret() + { + DeploymentProfileDocument profile = TwoAccountProfile(); + DomainJoinSettings settings = profile.Configuration.DomainJoin; + DeploymentProfileDocument renamed = profile with + { + Configuration = profile.Configuration with { DomainJoin = settings with { Domains = [settings.Domains[0] with { DomainName = "renamed.test" }, settings.Domains[1]] } } + }; + Assert.Equal(DeploymentProfileSecretBinding.Identity(ProfileSecretPurpose.DomainJoinPassword, profile, "CORP\\join"), + DeploymentProfileSecretBinding.Identity(ProfileSecretPurpose.DomainJoinPassword, renamed, "corp\\JOIN")); + } + + private static DeploymentProfileDocument TwoAccountProfile() => new() + { + ProfileId = Guid.NewGuid(), + Configuration = new() + { + DomainJoin = new() + { + IsEnabled = true, + Mode = DomainJoinMode.Automatic, + SharedAccountName = "CORP\\join", + DefaultDomainId = "corp", + Domains = [new() { Id = "corp", DomainName = "corp.test" }, new() { Id = "emea", DomainName = "emea.test", AccountName = "EMEA\\join" }] + } + } + }; + + private static DeploymentProfileSecret Secret(DeploymentProfileDocument profile, string account, ProfileValueState state, byte[]? value = null) => new() + { + Purpose = ProfileSecretPurpose.DomainJoinPassword, + Identity = DeploymentProfileSecretBinding.Identity(ProfileSecretPurpose.DomainJoinPassword, profile, account), + State = state, + Value = value + }; + [Fact] public void Merge_SettingsOnlyRoundTripRetainsAnotherPcsCertificate() { diff --git a/src/Foundry.Core.Tests/Profiles/DeploymentProfilePackageServiceTests.cs b/src/Foundry.Core.Tests/Profiles/DeploymentProfilePackageServiceTests.cs index 704850996..fe7e5566f 100644 --- a/src/Foundry.Core.Tests/Profiles/DeploymentProfilePackageServiceTests.cs +++ b/src/Foundry.Core.Tests/Profiles/DeploymentProfilePackageServiceTests.cs @@ -4,6 +4,7 @@ using System.Security.Cryptography; using System.Text; +using System.Text.Json.Nodes; using Foundry.Core.Models.Configuration; using Foundry.Core.Models.Profiles; using Foundry.Core.Services.Profiles; @@ -15,6 +16,119 @@ public sealed class DeploymentProfilePackageServiceTests { private readonly DeploymentProfilePackageService _service = new(); + [Fact] + public void SharedRevisionRejectsEmbeddedNulDomainPassword() + { + var profile = CreateProfile() with { Secrets = new() { Entries = [new() { Purpose = ProfileSecretPurpose.DomainJoinPassword, Identity = "domain", State = ProfileValueState.Present, Value = [112, 0, 119] }] } }; + Assert.Throws(() => _service.Encrypt(profile, new byte[32], ProfilePackagePurpose.SharedRevision)); + } + + [Fact] + public void ProfileSavedWithTheEarlierSingleDomainShapeLoadsWithAnEmptyDomainList() + { + DeploymentProfileDocument profile = CreateProfile() with + { + Secrets = new() { Entries = [new() { Purpose = ProfileSecretPurpose.DomainJoinPassword, Identity = new string('A', 64), State = ProfileValueState.Present, Value = Encoding.UTF8.GetBytes("domain-password") }] } + }; + JsonNode root = JsonNode.Parse(DeploymentProfilePayload.Serialize(profile, portable: false))!; + JsonObject domainJoin = root["configuration"]!["domainJoin"]!.AsObject(); + foreach (string added in new[] { "sharedAccountName", "domains", "defaultDomainId" }) domainJoin.Remove(added); + domainJoin["isEnabled"] = true; + domainJoin["domainName"] = "corp.test"; + domainJoin["accountName"] = "CORP\\join"; + domainJoin["ouCatalogDomain"] = "corp.test"; + domainJoin["organizationalUnits"] = new JsonArray(new JsonObject { ["id"] = "a", ["displayName"] = "A", ["distinguishedName"] = "OU=A,DC=corp,DC=test" }); + domainJoin["defaultOuId"] = "a"; + + DeploymentProfileDocument loaded = DeploymentProfilePayload.Deserialize(Encoding.UTF8.GetBytes(root.ToJsonString()), portable: false); + + Assert.True(loaded.Configuration.DomainJoin.IsEnabled); + Assert.Empty(loaded.Configuration.DomainJoin.Domains); + Assert.Null(loaded.Configuration.DomainJoin.DefaultDomainId); + Assert.Single(loaded.Secrets.Entries); + } + + [Fact] + public void ProfileSavedWithTheRetiredChoiceSettingsKeepsItsDomains() + { + DeploymentProfileDocument profile = CreateProfile() with + { + Configuration = new() { DomainJoin = new() { IsEnabled = true, DefaultDomainId = "a", Domains = [new() { Id = "a", DomainName = "example.com" }] } } + }; + JsonNode root = JsonNode.Parse(DeploymentProfilePayload.Serialize(profile, portable: false))!; + root["configuration"]!["domainJoin"]!["allowDomainSelectionDuringDeployment"] = true; + root["configuration"]!["domainJoin"]!["allowOuSelectionDuringDeployment"] = false; + + DeploymentProfileDocument loaded = DeploymentProfilePayload.Deserialize(Encoding.UTF8.GetBytes(root.ToJsonString()), portable: false); + + Assert.Equal("example.com", Assert.Single(loaded.Configuration.DomainJoin.Domains).DomainName); + Assert.Equal("a", loaded.Configuration.DomainJoin.DefaultDomainId); + } + + [Fact] + public void AnUnknownDomainJoinMemberStillInvalidatesTheProfile() + { + JsonNode root = JsonNode.Parse(DeploymentProfilePayload.Serialize(CreateProfile(), portable: false))!; + root["configuration"]!["domainJoin"]!["domainName"] = "corp.test"; + root["configuration"]!["domainJoin"]!["unknownMember"] = true; + + Assert.Throws(() => DeploymentProfilePayload.Deserialize(Encoding.UTF8.GetBytes(root.ToJsonString()), portable: false)); + } + + [Fact] + public void PortableExportOmitsEveryDomainPasswordWithoutMutatingSource() + { + byte[] password = Encoding.UTF8.GetBytes("domain-password"); + DeploymentProfileDocument profile = CreateProfile() with + { + Configuration = new() + { + DomainJoin = new() + { + IsEnabled = true, + Mode = DomainJoinMode.Automatic, + SharedAccountName = "EXAMPLE\\joiner", + DefaultDomainId = "a", + Domains = [new() { Id = "a", DomainName = "example.com" }, new() { Id = "b", DomainName = "emea.example.com", AccountName = "EMEA\\joiner" }] + } + }, + Secrets = new() + { + Entries = [new() { Purpose = ProfileSecretPurpose.DomainJoinPassword, Identity = "domain", State = ProfileValueState.Present, Value = password }, + new() { Purpose = ProfileSecretPurpose.DomainJoinPassword, Identity = "domain2", State = ProfileValueState.Present, Value = Encoding.UTF8.GetBytes("second-password") }, + new() { Purpose = ProfileSecretPurpose.WifiPassphrase, Identity = "wifi", State = ProfileValueState.Present, Value = Encoding.UTF8.GetBytes("wifi-password") }] + } + }; + DeploymentProfileDocument imported = _service.Import(_service.Export(profile, "password"), "password"); + Assert.All(imported.Secrets.Entries.Take(2), secret => + { + Assert.Equal(ProfileValueState.Omitted, secret.State); + Assert.Null(secret.Value); + }); + Assert.Equal("wifi-password", Encoding.UTF8.GetString(imported.Secrets.Entries[2].Value!)); + Assert.Equal("domain-password", Encoding.UTF8.GetString(password)); + Assert.Equal(ProfileValueState.Present, profile.Secrets.Entries[0].State); + Assert.Equal(["example.com", "emea.example.com"], imported.Configuration.DomainJoin.Domains.Select(domain => domain.DomainName)); + } + + [Fact] + public void SharedRevisionRetainsDomainPassword() + { + DeploymentProfileDocument profile = CreateProfile() with + { + Secrets = new() + { + Entries = [new() { Purpose = ProfileSecretPurpose.DomainJoinPassword, Identity = "domain", + State = ProfileValueState.Present, Value = Encoding.UTF8.GetBytes("domain-password") }] + } + }; + byte[] key = new byte[32]; + DeploymentProfileDocument revision = _service.Decrypt(_service.Encrypt(profile, key, ProfilePackagePurpose.SharedRevision), key, ProfilePackagePurpose.SharedRevision); + var secret = Assert.Single(revision.Secrets.Entries); + Assert.Equal(ProfileValueState.Present, secret.State); + Assert.Equal("domain-password", Encoding.UTF8.GetString(secret.Value!)); + } + [Fact] public void ExportImport_PreservesSelectedSecretsAndExactAssetBytes() { diff --git a/src/Foundry.Core.Tests/Profiles/DeploymentProfileProjectionTests.cs b/src/Foundry.Core.Tests/Profiles/DeploymentProfileProjectionTests.cs index 8f71e7be9..dd9da6ae9 100644 --- a/src/Foundry.Core.Tests/Profiles/DeploymentProfileProjectionTests.cs +++ b/src/Foundry.Core.Tests/Profiles/DeploymentProfileProjectionTests.cs @@ -10,6 +10,17 @@ namespace Foundry.Core.Tests.Profiles; public sealed class DeploymentProfileProjectionTests { + [Fact] + public void DomainSecretIdentityIsTheCanonicalAccountAlone() + { + var profile = new DeploymentProfileDocument(); + Assert.Equal(DeploymentProfileSecretBinding.Identity(ProfileSecretPurpose.DomainJoinPassword, profile, "EXAMPLE\\joiner"), + DeploymentProfileSecretBinding.Identity(ProfileSecretPurpose.DomainJoinPassword, profile, " example\\JOINER ")); + Assert.NotEqual(DeploymentProfileSecretBinding.Identity(ProfileSecretPurpose.DomainJoinPassword, profile, "EXAMPLE\\joiner"), + DeploymentProfileSecretBinding.Identity(ProfileSecretPurpose.DomainJoinPassword, profile, "EXAMPLE\\other")); + Assert.Throws(() => DeploymentProfileSecretBinding.Identity(ProfileSecretPurpose.DomainJoinPassword, profile)); + } + [Fact] public void PortableContent_IgnoresHostPathsTelemetryAndRecordOrdering() { diff --git a/src/Foundry.Core.Tests/Storage/AuthoringArtifactCacheTests.cs b/src/Foundry.Core.Tests/Storage/AuthoringArtifactCacheTests.cs index ebc23b2f1..098d18c13 100644 --- a/src/Foundry.Core.Tests/Storage/AuthoringArtifactCacheTests.cs +++ b/src/Foundry.Core.Tests/Storage/AuthoringArtifactCacheTests.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. // See the LICENSE file in the project root for more information. -using System.Diagnostics; using System.Security.Cryptography; using System.Text; using Foundry.Core.Services.Storage; @@ -143,41 +142,6 @@ public async Task AcquireAsync_CanceledWaitDoesNotAffectActiveLease() Assert.Equal(1, downloads); } - [Fact] - public async Task AcquireAsync_AnotherProcessLeasePreventsAcquisition() - { - await using (var first = await Acquire()) { } - string leasePath = Assert.Single(Directory.GetFiles(root, ".lease", SearchOption.AllDirectories)); - string ready = Path.Combine(root, "ready"); - string release = Path.Combine(root, "release"); - string script = $"$lease = [IO.File]::Open('{leasePath.Replace("'", "''")}', 'Open', 'ReadWrite', 'None'); try {{ [IO.File]::WriteAllText('{ready.Replace("'", "''")}', 'ready'); $deadline = [DateTime]::UtcNow.AddSeconds(15); while (-not [IO.File]::Exists('{release.Replace("'", "''")}') -and [DateTime]::UtcNow -lt $deadline) {{ Start-Sleep -Milliseconds 20 }} }} finally {{ $lease.Dispose() }}"; - var start = new ProcessStartInfo("powershell.exe") { UseShellExecute = false, CreateNoWindow = true, WindowStyle = ProcessWindowStyle.Hidden }; - start.ArgumentList.Add("-NoProfile"); - start.ArgumentList.Add("-NonInteractive"); - start.ArgumentList.Add("-EncodedCommand"); - start.ArgumentList.Add(Convert.ToBase64String(Encoding.Unicode.GetBytes(script))); - using Process process = Process.Start(start)!; - try - { - using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - timeout.CancelAfter(TimeSpan.FromSeconds(10)); - while (!File.Exists(ready)) await Task.Delay(20, timeout.Token); - using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - Task pending = new AuthoringArtifactCache(root).AcquireAsync(Request, Download, cancellation.Token); - Assert.False(pending.IsCompleted); - cancellation.Cancel(); - await Assert.ThrowsAnyAsync(() => pending); - Assert.Equal(1, downloads); - } - finally - { - File.WriteAllText(release, "release"); - await process.WaitForExitAsync(TestContext.Current.CancellationToken); - } - await using var afterRelease = await Acquire(); - Assert.True(afterRelease.CacheHit); - } - [Fact] public async Task AcquireAsync_FailedRefreshPreservesPublishedGenerationAndRemovesOnlyOwnTemporary() { diff --git a/src/Foundry.Core.Tests/WinPe/WinPeRuntimePayloadProvisioningOptionsTests.cs b/src/Foundry.Core.Tests/WinPe/WinPeRuntimePayloadProvisioningOptionsTests.cs index 213e4947e..4308c303d 100644 --- a/src/Foundry.Core.Tests/WinPe/WinPeRuntimePayloadProvisioningOptionsTests.cs +++ b/src/Foundry.Core.Tests/WinPe/WinPeRuntimePayloadProvisioningOptionsTests.cs @@ -8,6 +8,20 @@ namespace Foundry.Core.Tests.WinPe; public sealed class WinPeRuntimePayloadProvisioningOptionsTests { + [Theory] + [InlineData(true)] + [InlineData(false)] + public void DomainMediaRetainsExplicitPostInstallSourceWithoutDeployDebug(bool archive) + { + var options = WinPeRuntimePayloadProvisioningOptions.CreateDeveloperOptions(WinPeArchitecture.X64, "work", "mount", "usb", false, + key => key == (archive ? WinPeRuntimePayloadEnvironmentVariables.DebugPostInstallArchive : WinPeRuntimePayloadEnvironmentVariables.DebugPostInstallProject) + ? archive ? "selected.zip" : "selected.csproj" : null).WithDomainJoinRuntime(true); + Assert.True(options.PostInstall.IsEnabled); + Assert.Equal(archive ? "selected.zip" : "", options.PostInstall.ArchivePath); + Assert.Equal(archive ? "" : "selected.csproj", options.PostInstall.ProjectPath); + Assert.Equal(WinPeProvisioningSource.Debug, options.PostInstall.ProvisioningSource); + } + [Fact] public void CreateDeveloperOptions_WhenDebuggerAttachedAndProjectsExist_EnablesDebugRuntimes() { diff --git a/src/Foundry.Core.Tests/WinPe/WinPeRuntimePayloadProvisioningServiceTests.cs b/src/Foundry.Core.Tests/WinPe/WinPeRuntimePayloadProvisioningServiceTests.cs index 5bfdea105..5d1eb3050 100644 --- a/src/Foundry.Core.Tests/WinPe/WinPeRuntimePayloadProvisioningServiceTests.cs +++ b/src/Foundry.Core.Tests/WinPe/WinPeRuntimePayloadProvisioningServiceTests.cs @@ -13,6 +13,60 @@ namespace Foundry.Core.Tests.WinPe; public sealed class WinPeRuntimePayloadProvisioningServiceTests { + [Theory] + [InlineData("missing", false)] + [InlineData("duplicate", false)] + [InlineData("oversize", false)] + public async Task PostInstallManifestMustBePresentUniqueAndBounded(string shape, bool accepted) + { + using var workspace = TempRuntimeWorkspace.Create(); + string path = workspace.CreateArchive("postinstall.zip", "Foundry.PostInstall.exe"); + using (var archive = ZipFile.Open(path, ZipArchiveMode.Update)) + { + if (shape != "duplicate") archive.GetEntry("foundry.postinstall.json")!.Delete(); + if (shape != "missing") + { + using var writer = new StreamWriter(archive.CreateEntry(shape == "duplicate" ? "FOUNDRY.POSTINSTALL.JSON" : "foundry.postinstall.json").Open()); + writer.Write(shape == "oversize" ? new string('x', 65537) : "{\"schemaVersion\":1,\"runtimeIdentifier\":\"win-x64\"}"); + } + } + var result = await new WinPeRuntimePayloadProvisioningService(new FakeRuntimeProcessRunner()).PrepareAsync(new() + { + WorkingDirectoryPath = workspace.WorkingDirectoryPath, + PostInstall = new() { IsEnabled = true, ArchivePath = path } + }, TestContext.Current.CancellationToken); + Assert.Equal(accepted, result.IsSuccess); + Assert.False(Directory.Exists(Path.Combine(workspace.MountedImagePath, "Foundry"))); + } + + [Theory] + [InlineData(1, true)] + [InlineData(2, false)] + public async Task PreparedArchiveRequiresSupportedPostInstallContract(int contract, bool accepted) + { + using var workspace = TempRuntimeWorkspace.Create(); + string archive = workspace.CreateArchive("postinstall.zip", "Foundry.PostInstall.exe", contract: contract); + var service = new WinPeRuntimePayloadProvisioningService(new FakeRuntimeProcessRunner()); + var result = await service.PrepareAsync(new() + { + WorkingDirectoryPath = workspace.WorkingDirectoryPath, + PostInstall = new() { IsEnabled = true, ArchivePath = archive } + }, TestContext.Current.CancellationToken); + Assert.Equal(accepted, result.IsSuccess); + } + + [Fact] + public void DomainMediaEnablesPairedReleaseAndPreservesExplicitLocalSelection() + { + var original = new WinPeRuntimePayloadProvisioningOptions(); + Assert.Same(original, original.WithDomainJoinRuntime(false)); + var release = original.WithDomainJoinRuntime(true); + Assert.True(release.PostInstall.IsEnabled); + Assert.Equal(WinPeProvisioningSource.Release, release.PostInstall.ProvisioningSource); + var local = original with { PostInstall = new() { ArchivePath = "selected.zip" } }; + Assert.Equal(local.PostInstall with { IsEnabled = true }, local.WithDomainJoinRuntime(true).PostInstall); + } + [Fact] public async Task PrepareAsync_WhenCancelledDuringLocalPublish_WaitsForPublisherAndSkipsArchive() { @@ -49,7 +103,7 @@ public async Task ProvisionAsync_WhenImagePayloadsAreExcluded_PreservesTrustForU { using TempRuntimeWorkspace workspace = TempRuntimeWorkspace.Create(); string application = postInstall ? "Foundry.PostInstall" : "Foundry.Connect"; - string archive = workspace.CreateArchive("runtime.zip", application + ".exe"); + string archive = workspace.CreateArchive("runtime.zip", application + ".exe", runtimeIdentifier: runtime); string hash = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(archive))).ToLowerInvariant(); var service = new WinPeRuntimePayloadProvisioningService(new FakeRuntimeProcessRunner()); var prepared = await service.PrepareAsync(new WinPeRuntimePayloadProvisioningOptions @@ -580,7 +634,8 @@ public async Task PrepareAsync_WhenLatestChanges_ReusesOneSnapshotAcrossImageAnd var payloads = new Dictionary { [$"Foundry.Bootstrap-{runtime}.zip"] = File.ReadAllBytes(workspace.CreateArchive("bootstrap-release.zip", "Foundry.Bootstrap.exe")), - [$"Foundry.Connect-{runtime}.zip"] = File.ReadAllBytes(workspace.CreateArchive("connect-release.zip", "Foundry.Connect.exe")) + [$"Foundry.Connect-{runtime}.zip"] = File.ReadAllBytes(workspace.CreateArchive("connect-release.zip", "Foundry.Connect.exe")), + [$"Foundry.PostInstall-{runtime}.zip"] = File.ReadAllBytes(workspace.CreateArchive("postinstall-release.zip", "Foundry.PostInstall.exe", runtimeIdentifier: runtime)) }; var handler = new MovingReleaseHttpMessageHandler(payloads); var service = new WinPeRuntimePayloadProvisioningService(new FakeRuntimeProcessRunner(), new HttpClient(handler)); @@ -590,22 +645,23 @@ public async Task PrepareAsync_WhenLatestChanges_ReusesOneSnapshotAcrossImageAnd WorkingDirectoryPath = workspace.WorkingDirectoryPath, Bootstrap = new() { IsEnabled = true, ProvisioningSource = WinPeProvisioningSource.Release }, Connect = new() { IsEnabled = true, ProvisioningSource = WinPeProvisioningSource.Release } - }; + }.WithDomainJoinRuntime(true); var prepared = await service.PrepareAsync(original, TestContext.Current.CancellationToken); Assert.True(prepared.IsSuccess, prepared.Error?.Details); Assert.Equal("v1", prepared.Value!.ReleaseSnapshot!.TagName); - Assert.Equal(3, handler.RequestUris.Count); + Assert.Equal(4, handler.RequestUris.Count); var image = await service.ProvisionAsync(prepared.Value with { MountedImagePath = workspace.MountedImagePath }, cancellationToken: TestContext.Current.CancellationToken); var usb = await service.ProvisionAsync(prepared.Value with { UsbCacheRootPath = workspace.UsbCacheRootPath }, cancellationToken: TestContext.Current.CancellationToken); Assert.True(image.IsSuccess, image.Error?.Details); Assert.True(usb.IsSuccess, usb.Error?.Details); Assert.Equal(1, handler.MetadataRequests); - Assert.Equal(3, handler.RequestUris.Count); + Assert.Equal(4, handler.RequestUris.Count); Assert.All(handler.RequestUris.Where(uri => uri != FakeReleaseHttpMessageHandler.LatestReleaseUri), uri => Assert.Contains("/v1/", uri)); Assert.True(File.Exists(Path.Combine(workspace.MountedImagePath, "Foundry", "Bootstrap", "Foundry.Bootstrap.exe"))); Assert.False(Directory.Exists(Path.Combine(workspace.UsbCacheRootPath, "Runtime", "Foundry.Bootstrap"))); Assert.False(Directory.Exists(Path.Combine(workspace.UsbCacheRootPath, "Bootstrap"))); Assert.True(File.Exists(Path.Combine(workspace.UsbCacheRootPath, "Runtime", "Foundry.Connect", runtime, "original.zip"))); + Assert.True(File.Exists(Path.Combine(workspace.UsbCacheRootPath, "Runtime", "Foundry.PostInstall", runtime, "original.zip"))); var nextBuild = await service.PrepareAsync(original, TestContext.Current.CancellationToken); Assert.True(nextBuild.IsSuccess, nextBuild.Error?.Details); Assert.Equal("v2", nextBuild.Value!.ReleaseSnapshot!.TagName); @@ -854,12 +910,22 @@ public static TempRuntimeWorkspace Create() return new TempRuntimeWorkspace(Path.Combine(Path.GetTempPath(), $"foundry-runtime-{Guid.NewGuid():N}")); } - public string CreateArchive(string archiveName, string executableName, string? capability = "{\"runtimeTrustVersion\":1,\"postInstallVersion\":1}") + public string CreateArchive(string archiveName, string executableName, string? capability = "{\"runtimeTrustVersion\":1,\"postInstallVersion\":1}", string runtimeIdentifier = "win-x64", int contract = 1) { string payloadPath = Path.Combine(RootPath, "payloads", Path.GetFileNameWithoutExtension(archiveName)); Directory.CreateDirectory(payloadPath); File.WriteAllText(Path.Combine(payloadPath, executableName), executableName); File.WriteAllText(Path.Combine(payloadPath, "dependency.dll"), "archive dependency"); + if (executableName == "Foundry.PostInstall.exe") + { + File.WriteAllText(Path.Combine(payloadPath, "Launch.cmd"), "launch"); + File.WriteAllText(Path.Combine(payloadPath, "foundry.postinstall.json"), System.Text.Json.JsonSerializer.Serialize(new + { + schemaVersion = 1, + contractVersion = contract, + runtimeIdentifier + })); + } if (executableName == "Foundry.Bootstrap.exe" && capability is not null) { File.WriteAllText(Path.Combine(payloadPath, "foundry.runtime-trust-capability.json"), capability); diff --git a/src/Foundry.Core/Models/Configuration/ConfigurationSchemaVersions.cs b/src/Foundry.Core/Models/Configuration/ConfigurationSchemaVersions.cs index 8bde00a5f..e913d38e2 100644 --- a/src/Foundry.Core/Models/Configuration/ConfigurationSchemaVersions.cs +++ b/src/Foundry.Core/Models/Configuration/ConfigurationSchemaVersions.cs @@ -6,10 +6,10 @@ namespace Foundry.Core.Models.Configuration; public static class ConfigurationSchemaVersions { - public const int FoundryCurrent = 17; + public const int FoundryCurrent = 18; public const int ConnectCurrent = 5; - public const int DeployCurrent = 15; + public const int DeployCurrent = 16; } diff --git a/src/Foundry.Core/Models/Configuration/Deploy/DeployDomainJoinDomainSettings.cs b/src/Foundry.Core/Models/Configuration/Deploy/DeployDomainJoinDomainSettings.cs new file mode 100644 index 000000000..29437d737 --- /dev/null +++ b/src/Foundry.Core/Models/Configuration/Deploy/DeployDomainJoinDomainSettings.cs @@ -0,0 +1,18 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +namespace Foundry.Core.Models.Configuration.Deploy; + +/// Carries one joinable domain with its resolved account and the credentials bound to that pair. +public sealed record DeployDomainJoinDomainSettings +{ + public string Id { get; init; } = string.Empty; + public string DomainName { get; init; } = string.Empty; + /// Gets the account this domain joins with, dedicated or shared. Zero-touch only. + public string? AccountName { get; init; } + public IReadOnlyList OrganizationalUnits { get; init; } = []; + public string? DefaultOuId { get; init; } + /// Gets the payload that only decodes for this domain and this account. Zero-touch only. + public SecretEnvelope? EncryptedCredentials { get; init; } +} diff --git a/src/Foundry.Core/Models/Configuration/Deploy/DeployDomainJoinSettings.cs b/src/Foundry.Core/Models/Configuration/Deploy/DeployDomainJoinSettings.cs new file mode 100644 index 000000000..1cd914c91 --- /dev/null +++ b/src/Foundry.Core/Models/Configuration/Deploy/DeployDomainJoinSettings.cs @@ -0,0 +1,15 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +namespace Foundry.Core.Models.Configuration.Deploy; + +/// Carries the joinable domains to Foundry.Deploy; interactive projections omit accounts and credential envelopes. +public sealed record DeployDomainJoinSettings +{ + public bool IsEnabled { get; init; } + public DomainJoinMode Mode { get; init; } = DomainJoinMode.Interactive; + public IReadOnlyList Domains { get; init; } = []; + /// Gets the domain preselected when several are listed; the technician then chooses among them. + public string? DefaultDomainId { get; init; } +} diff --git a/src/Foundry.Core/Models/Configuration/Deploy/FoundryDeployConfigurationDocument.cs b/src/Foundry.Core/Models/Configuration/Deploy/FoundryDeployConfigurationDocument.cs index 28ac67ac0..81eeb04c3 100644 --- a/src/Foundry.Core/Models/Configuration/Deploy/FoundryDeployConfigurationDocument.cs +++ b/src/Foundry.Core/Models/Configuration/Deploy/FoundryDeployConfigurationDocument.cs @@ -71,6 +71,9 @@ public sealed record FoundryDeployConfigurationDocument /// public DeployAutopilotSettings Autopilot { get; init; } = new(); + /// Gets domain metadata and any protected automatic credentials consumed during deployment. + public DeployDomainJoinSettings DomainJoin { get; init; } = new(); + /// /// Gets telemetry policy and runtime settings consumed by Foundry.Deploy. /// diff --git a/src/Foundry.Core/Models/Configuration/DomainJoinCredentialContext.cs b/src/Foundry.Core/Models/Configuration/DomainJoinCredentialContext.cs new file mode 100644 index 000000000..710da05c9 --- /dev/null +++ b/src/Foundry.Core/Models/Configuration/DomainJoinCredentialContext.cs @@ -0,0 +1,35 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +namespace Foundry.Core.Models.Configuration; + +/// Binds a secret to its target domain and qualified account; passwords are never normalized. +public sealed record DomainJoinCredentialContext(string DomainName, string AccountName) +{ + /// Returns the canonical DNS spelling used for comparison and secret ownership. + public static string CanonicalizeDomainName(string? value) => (value ?? string.Empty).Trim().TrimEnd('.').ToLowerInvariant(); + + /// Returns the case-insensitive account spelling used for secret ownership. + public static string CanonicalizeAccountName(string? value) => (value ?? string.Empty).Trim().ToUpperInvariant(); + + /// Compares ownership without comparing or exposing a password. + public bool Matches(DomainJoinCredentialContext other) + { + ArgumentNullException.ThrowIfNull(other); + return string.Equals(CanonicalizeDomainName(DomainName), CanonicalizeDomainName(other.DomainName), StringComparison.Ordinal) && + string.Equals(CanonicalizeAccountName(AccountName), CanonicalizeAccountName(other.AccountName), StringComparison.Ordinal); + } + + /// Validates a DNS domain suitable for structural OU suffix checks. + public static bool IsValidDomainName(string? value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > 253 || value.Contains('\0')) return false; + string domain = value.Trim(); + if (domain.EndsWith('.')) domain = domain[..^1]; + string[] labels = domain.Split('.'); + return labels.Length >= 2 && labels.All(label => label.Length is >= 1 and <= 63 && + char.IsAsciiLetterOrDigit(label[0]) && char.IsAsciiLetterOrDigit(label[^1]) && + label.All(character => char.IsAsciiLetterOrDigit(character) || character == '-')); + } +} diff --git a/src/Foundry.Core/Models/Configuration/DomainJoinDomainSettings.cs b/src/Foundry.Core/Models/Configuration/DomainJoinDomainSettings.cs new file mode 100644 index 000000000..9a0fce37a --- /dev/null +++ b/src/Foundry.Core/Models/Configuration/DomainJoinDomainSettings.cs @@ -0,0 +1,18 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +namespace Foundry.Core.Models.Configuration; + +/// Describes one domain a deployment media can join, with the OUs that belong to it. +public sealed record DomainJoinDomainSettings +{ + /// Gets the stable identifier other settings refer to; it is never shown. + public string Id { get; init; } = string.Empty; + public string DomainName { get; init; } = string.Empty; + /// Gets the dedicated join account; means the shared account. Zero-touch only. + public string? AccountName { get; init; } + public IReadOnlyList OrganizationalUnits { get; init; } = []; + /// Gets the default OU of this domain; means the domain's default location. + public string? DefaultOuId { get; init; } +} diff --git a/src/Foundry.Core/Models/Configuration/DomainJoinMode.cs b/src/Foundry.Core/Models/Configuration/DomainJoinMode.cs new file mode 100644 index 000000000..aa6321c51 --- /dev/null +++ b/src/Foundry.Core/Models/Configuration/DomainJoinMode.cs @@ -0,0 +1,12 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +namespace Foundry.Core.Models.Configuration; + +/// Determines whether technicians supply credentials or media supplies protected credentials. +public enum DomainJoinMode +{ + Interactive, + Automatic +} diff --git a/src/Foundry.Core/Models/Configuration/DomainJoinOrganizationalUnitSettings.cs b/src/Foundry.Core/Models/Configuration/DomainJoinOrganizationalUnitSettings.cs new file mode 100644 index 000000000..f3b44c962 --- /dev/null +++ b/src/Foundry.Core/Models/Configuration/DomainJoinOrganizationalUnitSettings.cs @@ -0,0 +1,16 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +namespace Foundry.Core.Models.Configuration; + +/// +/// Describes a saved OU. The id is the directory object GUID for a discovered OU and a generated value for a +/// manually added one; the runtime resolves the live object from the distinguished name. +/// +public sealed record DomainJoinOrganizationalUnitSettings +{ + public string Id { get; init; } = string.Empty; + public string DisplayName { get; init; } = string.Empty; + public string DistinguishedName { get; init; } = string.Empty; +} diff --git a/src/Foundry.Core/Models/Configuration/DomainJoinSettings.cs b/src/Foundry.Core/Models/Configuration/DomainJoinSettings.cs new file mode 100644 index 000000000..2465d5863 --- /dev/null +++ b/src/Foundry.Core/Models/Configuration/DomainJoinSettings.cs @@ -0,0 +1,46 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +namespace Foundry.Core.Models.Configuration; + +/// Stores the portable list of joinable domains without persisting credentials. +public sealed record DomainJoinSettings +{ + public bool IsEnabled { get; init; } + public DomainJoinMode Mode { get; init; } = DomainJoinMode.Interactive; + /// Gets the account used by every domain that has no account of its own. Zero-touch only. + public string? SharedAccountName { get; init; } + public IReadOnlyList Domains { get; init; } = []; + /// + /// Gets the domain joined when a single domain is listed, and the one preselected when the technician chooses + /// among several. + /// + public string? DefaultDomainId { get; init; } + + /// Returns the account a domain joins with: its own, or the shared one. + public string? ResolveAccountName(DomainJoinDomainSettings domain) + { + ArgumentNullException.ThrowIfNull(domain); + return string.IsNullOrWhiteSpace(domain.AccountName) ? SharedAccountName : domain.AccountName; + } + + public DomainJoinDomainSettings? FindDomain(string? id) => id is null + ? null + : Domains.FirstOrDefault(domain => string.Equals(domain.Id, id, StringComparison.OrdinalIgnoreCase)); + + /// + /// Returns the distinct canonical accounts the configuration stores, whatever the mode: the shared account, + /// even before a domain uses it, and every dedicated account. A password is kept for as long as its account is + /// listed here, so switching to Interactive or switching Domain Join off does not erase it. + /// + public IReadOnlyList GetStoredAccountNames() => Domains.Select(ResolveAccountName).Prepend(SharedAccountName) + .Where(account => !string.IsNullOrWhiteSpace(account)) + .Select(account => DomainJoinCredentialContext.CanonicalizeAccountName(account)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + /// Returns the accounts generated media joins with: the stored accounts in Zero-touch, none otherwise. + public IReadOnlyList GetReferencedAccountNames() => + IsEnabled && Mode == DomainJoinMode.Automatic ? GetStoredAccountNames() : []; +} diff --git a/src/Foundry.Core/Models/Configuration/FoundryConfigurationDocument.cs b/src/Foundry.Core/Models/Configuration/FoundryConfigurationDocument.cs index 6d3e4e6e0..8fde7a5f1 100644 --- a/src/Foundry.Core/Models/Configuration/FoundryConfigurationDocument.cs +++ b/src/Foundry.Core/Models/Configuration/FoundryConfigurationDocument.cs @@ -62,6 +62,9 @@ public sealed record FoundryConfigurationDocument /// public AutopilotSettings Autopilot { get; init; } = new(); + /// Gets optional online domain joining: the joinable domains with their OUs and account names, without passwords. + public DomainJoinSettings DomainJoin { get; init; } = new(); + /// /// Gets telemetry policy and runtime settings propagated into generated media. /// diff --git a/src/Foundry.Core/Models/PreOobe/DomainJoinActionParameters.cs b/src/Foundry.Core/Models/PreOobe/DomainJoinActionParameters.cs new file mode 100644 index 000000000..808c99144 --- /dev/null +++ b/src/Foundry.Core/Models/PreOobe/DomainJoinActionParameters.cs @@ -0,0 +1,21 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +namespace Foundry.Core.Models.PreOobe; + +/// Identifies an online join and its operation-owned sensitive payload without embedding credentials in the plan. +public sealed record DomainJoinActionParameters(string DomainName, string ComputerName, string? TargetOuDn, string CredentialPayloadPath); + +/// Names the owned state files Deploy stages for the join besides the plan and its seeds. +public static class DomainJoinStateFiles +{ + /// + /// Empty marker asking the runtime to skip the Windows account creation page once membership is verified. + /// Deploy stages it only with the answer file Foundry generates; an imported file keeps its own OOBE choices. + /// + public const string SkipAccountCreationRequest = "skip-account-creation.request"; +} + +/// Verifies membership on a later boot without retaining credentials. +public sealed record DomainMembershipVerificationParameters(string DomainName, string ComputerName, string JoinActionId); diff --git a/src/Foundry.Core/Models/PreOobe/DomainJoinPhaseReceipt.cs b/src/Foundry.Core/Models/PreOobe/DomainJoinPhaseReceipt.cs new file mode 100644 index 000000000..efbc59f07 --- /dev/null +++ b/src/Foundry.Core/Models/PreOobe/DomainJoinPhaseReceipt.cs @@ -0,0 +1,50 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace Foundry.Core.Models.PreOobe; + +/// Started phases durably identify in-flight mutation, independently of terminal phase outcomes. +public enum DomainJoinReceiptPhase { Prepared, JoinStarted, JoinReturned, PlacementStarted, PlacementReturned, Finished } + +/// Worker-owned receipt. Only an untouched Prepared seed may have an unassigned installed boot. +public sealed record DomainJoinPhaseReceipt +{ + [JsonRequired] + public int SchemaVersion { get; init; } = 1; + [JsonRequired] + public string OperationId { get; init; } = string.Empty; + [JsonRequired] + public string AttemptId { get; init; } = string.Empty; + [JsonRequired] + public string PlanHash { get; init; } = string.Empty; + [JsonRequired] + public string ActionId { get; init; } = string.Empty; + [JsonRequired] + public string? OriginatingBootId { get; init; } + [JsonRequired] + public long Generation { get; init; } + [JsonRequired] + public DomainJoinReceiptPhase Phase { get; init; } + [JsonRequired] + public DomainJoinPhaseResult Join { get; init; } = new(); + [JsonRequired] + public DomainJoinPhaseResult Placement { get; init; } = new(); + [JsonRequired] + public Guid? ComputerObjectGuid { get; init; } + [JsonRequired] + public Guid? DestinationObjectGuid { get; init; } + [JsonRequired] + public bool RestartRequired { get; init; } + + /// Creates the untouched receipt Deploy stages; the worker binds the installed boot before any mutation. + public static DomainJoinPhaseReceipt CreateSeed(string operationId, string attemptId, string planHash, string actionId) => new() + { + OperationId = operationId, + AttemptId = attemptId, + PlanHash = planHash, + ActionId = actionId + }; +} diff --git a/src/Foundry.Core/Models/PreOobe/DomainJoinResult.cs b/src/Foundry.Core/Models/PreOobe/DomainJoinResult.cs new file mode 100644 index 000000000..b62760a69 --- /dev/null +++ b/src/Foundry.Core/Models/PreOobe/DomainJoinResult.cs @@ -0,0 +1,83 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace Foundry.Core.Models.PreOobe; + +/// Distinguishes observed success from failure, interrupted mutation and unverified membership. +public enum DomainJoinPhaseState { NotStarted, Succeeded, Failed, Skipped, Unknown, Unverified } + +/// Tracks restart independently from OU placement success. +public enum DomainJoinRestartState { NotRequired, Required, Requested, Completed } + +/// Tracks sensitive payload disposal independently from domain mutation outcomes. +public enum DomainJoinCleanupState { Pending, Disposed } + +/// Allowlisted domain failures; diagnostics never persist account names or exception text. +public enum DomainJoinFailureCode +{ + CredentialUnavailable, ContextMismatch, ComputerNameMismatch, DomainUnavailable, + ReadinessTimeout, JoinFailed, PlacementFailed, MembershipMismatch, MembershipUnverified, + Interrupted, WorkerTimeout, OrganizationalUnitNotFound +} + +/// Reports one independent phase and an optional native or LDAP numeric diagnostic. +public sealed record DomainJoinPhaseResult +{ + [JsonRequired] + public DomainJoinPhaseState State { get; init; } = DomainJoinPhaseState.NotStarted; + public DomainJoinFailureCode? FailureCode { get; init; } + public int? NativeErrorCode { get; init; } + public int? LdapErrorCode { get; init; } + /// Preserves an LDAP server result separately from transport/client and native error families. + public int? DirectoryResultCode { get; init; } +} + +/// Binds password-free results to one deployment attempt, immutable plan and originating boot. +public sealed record DomainJoinResult +{ + public const int CurrentSchemaVersion = 1; + [JsonRequired] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + [JsonRequired] + public string OperationId { get; init; } = string.Empty; + [JsonRequired] + public string AttemptId { get; init; } = string.Empty; + [JsonRequired] + public string PlanHash { get; init; } = string.Empty; + [JsonRequired] + public string OriginatingBootId { get; init; } = string.Empty; + [JsonRequired] + public string ExpectedComputerName { get; init; } = string.Empty; + [JsonRequired] + public string ExpectedDomainName { get; init; } = string.Empty; + [JsonRequired] + public string? TargetOuDn { get; init; } + [JsonRequired] + public DomainJoinPhaseResult Join { get; init; } = new(); + [JsonRequired] + public DomainJoinPhaseResult Placement { get; init; } = new(); + [JsonRequired] + public DomainJoinPhaseResult Membership { get; init; } = new(); + [JsonRequired] + public DomainJoinRestartState Restart { get; init; } + [JsonRequired] + public DomainJoinCleanupState Cleanup { get; init; } + [JsonRequired] + public Guid? ComputerObjectGuid { get; init; } + + /// Creates the not-started result Deploy stages and PostInstall validates every later result against. + public static DomainJoinResult CreateSeed(string operationId, string attemptId, string planHash, + string computerName, string domainName, string? targetOuDn) => new() + { + OperationId = operationId, + AttemptId = attemptId, + PlanHash = planHash, + ExpectedComputerName = computerName, + ExpectedDomainName = domainName, + TargetOuDn = targetOuDn, + Cleanup = DomainJoinCleanupState.Pending + }; +} diff --git a/src/Foundry.Core/Models/PreOobe/PreOobeExecutionPlan.cs b/src/Foundry.Core/Models/PreOobe/PreOobeExecutionPlan.cs index c2038098c..d6bc4e175 100644 --- a/src/Foundry.Core/Models/PreOobe/PreOobeExecutionPlan.cs +++ b/src/Foundry.Core/Models/PreOobe/PreOobeExecutionPlan.cs @@ -7,7 +7,7 @@ namespace Foundry.Core.Models.PreOobe; -public enum PreOobeBuiltInKind { Driver, Network, Appx, AiRemoval, Activation, Cleanup } +public enum PreOobeBuiltInKind { Driver, Network, Appx, AiRemoval, Activation, Cleanup, DomainJoinAndPlacement, VerifyDomainMembership } /// Immutable installed-Windows input; its serialized bytes are bound to the seeded execution journal. public sealed record PreOobeExecutionPlan diff --git a/src/Foundry.Core/Models/Profiles/DeploymentProfileDocument.cs b/src/Foundry.Core/Models/Profiles/DeploymentProfileDocument.cs index f5382cc0f..0e9e5ae65 100644 --- a/src/Foundry.Core/Models/Profiles/DeploymentProfileDocument.cs +++ b/src/Foundry.Core/Models/Profiles/DeploymentProfileDocument.cs @@ -53,7 +53,7 @@ public sealed record DeploymentProfileAsset public enum ProfileValueState { Omitted, Unavailable, Deleted, Blank, Present } /// Allowlisted password families, independent of operating-system credential storage names. -public enum ProfileSecretPurpose { DeploymentPassword, WifiPassphrase, AdministratorPassword, AdditionalAccountPassword, WiredCertificatePassword, WifiCertificatePassword, AutopilotCertificatePassword, SharedProfileKey } +public enum ProfileSecretPurpose { DeploymentPassword, WifiPassphrase, AdministratorPassword, AdditionalAccountPassword, WiredCertificatePassword, WifiCertificatePassword, AutopilotCertificatePassword, SharedProfileKey, DomainJoinPassword } /// Allowlisted portable inputs. Opaque files are included only when the caller explicitly supplies their bytes. public enum ProfileAssetKind { Unattend, WiredProfile, WifiProfile, WiredCertificate, WifiCertificate, AutopilotProfile, AutopilotCertificate } diff --git a/src/Foundry.Core/Services/Configuration/AutopilotProvisioningModeToggleEvaluator.cs b/src/Foundry.Core/Services/Configuration/AutopilotProvisioningModeToggleEvaluator.cs deleted file mode 100644 index 8d687dcfe..000000000 --- a/src/Foundry.Core/Services/Configuration/AutopilotProvisioningModeToggleEvaluator.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Foundry Project contributors. -// Licensed under the MIT License. -// See the LICENSE file in the project root for more information. - -using Foundry.Core.Models.Configuration; - -namespace Foundry.Core.Services.Configuration; - -public sealed record AutopilotProvisioningModeToggleResult( - bool IsEnabled, - AutopilotProvisioningMode Mode, - bool RequiresConfirmation); - -public static class AutopilotProvisioningModeToggleEvaluator -{ - public static AutopilotProvisioningModeToggleResult Evaluate( - bool isEnabled, - AutopilotProvisioningMode currentMode, - AutopilotProvisioningMode requestedMode) - { - bool isRequestedModeActive = isEnabled && currentMode == requestedMode; - return new AutopilotProvisioningModeToggleResult( - !isRequestedModeActive, - requestedMode, - isEnabled && !isRequestedModeActive); - } -} diff --git a/src/Foundry.Core/Services/Configuration/ConfigurationNavigationTarget.cs b/src/Foundry.Core/Services/Configuration/ConfigurationNavigationTarget.cs index 1d161316f..35b0fafd3 100644 --- a/src/Foundry.Core/Services/Configuration/ConfigurationNavigationTarget.cs +++ b/src/Foundry.Core/Services/Configuration/ConfigurationNavigationTarget.cs @@ -22,5 +22,7 @@ public enum ConfigurationNavigationTarget WindowsOptionalFeatures, AppxRemoval, AiComponentRemoval, - PostInstallation + PostInstallation, + DomainJoinInteractive, + DomainJoinAutomatic } diff --git a/src/Foundry.Core/Services/Configuration/ConfigurationOverviewEvaluator.cs b/src/Foundry.Core/Services/Configuration/ConfigurationOverviewEvaluator.cs index 700da8f16..f2f63d4ef 100644 --- a/src/Foundry.Core/Services/Configuration/ConfigurationOverviewEvaluator.cs +++ b/src/Foundry.Core/Services/Configuration/ConfigurationOverviewEvaluator.cs @@ -32,7 +32,9 @@ public enum ConfigurationOverviewItem OptionalFeatures, AppxRemoval, AiComponents, - PostInstallation + PostInstallation, + DomainJoinInteractive, + DomainJoinAutomatic } /// @@ -88,6 +90,9 @@ public sealed record ConfigurationOverviewContext /// public bool IsAutopilotConfigurationReady { get; init; } + /// Gets whether the active domain mode has valid metadata and required session inputs. + public bool IsDomainJoinConfigurationReady { get; init; } + /// /// Gets whether enabled answer-file sources, references, and media protection are ready. /// @@ -179,7 +184,8 @@ public static ConfigurationOverviewEvaluation Evaluate(ConfigurationOverviewCont [ConfigurationOverviewItem.DeploymentProtection] = EvaluateDeploymentProtection( general.DeploymentProtection.IsEnabled, context.IsDeploymentProtectionSecretReady, - OobeAccountConfigurationValidator.RequiresProtectedMedia(customization.Oobe)), + OobeAccountConfigurationValidator.RequiresProtectedMedia(customization.Oobe) || + (configuration.DomainJoin.IsEnabled && configuration.DomainJoin.Mode == DomainJoinMode.Automatic)), [ConfigurationOverviewItem.DriverOptions] = EvaluateDrivers(general, context.IsCustomDriverConfigurationReady), [ConfigurationOverviewItem.EthernetDot1x] = EvaluateNetworkTransport( configuration.Network.Dot1x.IsEnabled, @@ -206,9 +212,25 @@ public static ConfigurationOverviewEvaluation Evaluate(ConfigurationOverviewCont }; AddAutopilotStates(states, configuration.Autopilot, context.IsAutopilotConfigurationReady); + AddDomainJoinStates(states, configuration.DomainJoin, context.IsDomainJoinConfigurationReady); return new ConfigurationOverviewEvaluation(states); } + private static void AddDomainJoinStates( + IDictionary states, + DomainJoinSettings settings, + bool isReady) + { + states[ConfigurationOverviewItem.DomainJoinInteractive] = EvaluateDomainJoinMode(DomainJoinMode.Interactive); + states[ConfigurationOverviewItem.DomainJoinAutomatic] = EvaluateDomainJoinMode(DomainJoinMode.Automatic); + + ConfigurationOverviewState EvaluateDomainJoinMode(DomainJoinMode mode) => !settings.IsEnabled || settings.Mode != mode + ? ConfigurationOverviewState.NotSelected + : isReady + ? ConfigurationOverviewState.Configured + : ConfigurationOverviewState.NeedsAttention; + } + private static bool IsDefaultCompletion(GeneralSettings settings) => settings.AutomaticRebootEnabled && DeploymentRebootDelay.NormalizeRuntime(settings.AutomaticRebootDelaySeconds) == DeploymentRebootDelay.DefaultSeconds; diff --git a/src/Foundry.Core/Services/Configuration/ConfigurationOverviewNavigationEvaluator.cs b/src/Foundry.Core/Services/Configuration/ConfigurationOverviewNavigationEvaluator.cs index 06c3258bf..48690c3ef 100644 --- a/src/Foundry.Core/Services/Configuration/ConfigurationOverviewNavigationEvaluator.cs +++ b/src/Foundry.Core/Services/Configuration/ConfigurationOverviewNavigationEvaluator.cs @@ -40,6 +40,8 @@ public static ConfigurationOverviewState EvaluateTarget( ConfigurationNavigationTarget.AutopilotJsonProfile => evaluation[ConfigurationOverviewItem.AutopilotJsonProfile], ConfigurationNavigationTarget.AutopilotHardwareHashUpload => evaluation[ConfigurationOverviewItem.AutopilotZeroTouch], ConfigurationNavigationTarget.AutopilotInteractiveHardwareHashUpload => evaluation[ConfigurationOverviewItem.AutopilotInteractive], + ConfigurationNavigationTarget.DomainJoinInteractive => evaluation[ConfigurationOverviewItem.DomainJoinInteractive], + ConfigurationNavigationTarget.DomainJoinAutomatic => evaluation[ConfigurationOverviewItem.DomainJoinAutomatic], ConfigurationNavigationTarget.OperatingSystemSelection => evaluation[ConfigurationOverviewItem.OperatingSystemSelection], ConfigurationNavigationTarget.CustomImages => evaluation[ConfigurationOverviewItem.CustomImages], ConfigurationNavigationTarget.PostInstallation => evaluation[ConfigurationOverviewItem.PostInstallation], diff --git a/src/Foundry.Core/Services/Configuration/DeployConfigurationGenerator.cs b/src/Foundry.Core/Services/Configuration/DeployConfigurationGenerator.cs index c31982478..546e726ba 100644 --- a/src/Foundry.Core/Services/Configuration/DeployConfigurationGenerator.cs +++ b/src/Foundry.Core/Services/Configuration/DeployConfigurationGenerator.cs @@ -57,9 +57,24 @@ public FoundryDeployConfigurationDocument Generate( byte[]? deploymentSecretsKey, DeployProtectionSettings? protectionSettings, OobeAccountSecretState? oobeAccountSecretState, + string? authoringVersion = null) => + Generate(document, deploymentSecretsKey, protectionSettings, oobeAccountSecretState, null, authoringVersion); + + /// + public FoundryDeployConfigurationDocument Generate( + FoundryConfigurationDocument document, + byte[]? deploymentSecretsKey, + DeployProtectionSettings? protectionSettings, + OobeAccountSecretState? oobeAccountSecretState, + DomainJoinSecretState? domainJoinSecretState, string? authoringVersion = null) { ArgumentNullException.ThrowIfNull(document); + DomainJoinConfigurationValidator.ThrowIfProvisioningModesConflict(document.Autopilot, document.DomainJoin); + if (document.DomainJoin.IsEnabled && !DomainJoinConfigurationValidator.EvaluateReadiness(document.DomainJoin, + account => domainJoinSecretState?.HasPassword(account) == true, + protectionSettings?.IsEnabled == true && deploymentSecretsKey is { Length: MediaSecretEnvelopeProtector.KeySizeBytes }).IsValid) + throw new InvalidOperationException("Domain joining configuration is not ready for deployment media."); CustomImageSettingsValidator.ThrowIfInvalid(document.CustomImages); PreOobeConfigurationValidator.ThrowIfInvalid(document.PreOobe); UnattendFileService.ValidateSettings(document.Unattend, protectionSettings?.IsEnabled == true); @@ -72,6 +87,7 @@ public FoundryDeployConfigurationDocument Generate( return new FoundryDeployConfigurationDocument { AuthoringVersion = authoringVersion, + DomainJoin = MapDomainJoinSettings(document.DomainJoin, deploymentSecretsKey, domainJoinSecretState), Protection = protectionSettings ?? new DeployProtectionSettings(), PreOobe = new DeployPreOobeSettings { @@ -184,6 +200,52 @@ public FoundryDeployConfigurationDocument Generate( }; } + private static DeployDomainJoinSettings MapDomainJoinSettings(DomainJoinSettings settings, byte[]? key, DomainJoinSecretState? secrets) + { + if (!settings.IsEnabled) return new(); + bool automatic = settings.Mode == DomainJoinMode.Automatic; + return new() + { + IsEnabled = true, + Mode = settings.Mode, + Domains = settings.Domains.Select(domain => + { + string? account = automatic ? settings.ResolveAccountName(domain) : null; + return new DeployDomainJoinDomainSettings + { + Id = domain.Id, + DomainName = domain.DomainName, + AccountName = account, + OrganizationalUnits = domain.OrganizationalUnits.Select(unit => unit with { }).ToArray(), + DefaultOuId = domain.DefaultOuId, + EncryptedCredentials = automatic ? ProtectDomainCredentials(domain.DomainName, account!, key!, secrets!) : null + }; + }).ToArray(), + DefaultDomainId = settings.DefaultDomainId + }; + } + + /// + /// Binds the account's password to one domain. A shared account therefore yields one payload per domain, and + /// none of them decodes for another domain. + /// + private static SecretEnvelope ProtectDomainCredentials(string domainName, string accountName, byte[] key, DomainJoinSecretState secrets) + { + var context = new DomainJoinCredentialContext(domainName, accountName); + char[] password = secrets.GetPasswordCopy(accountName)!; + byte[]? payload = null; + try + { + payload = DomainJoinCredentialPayloadCodec.Encode(context, password); + return MediaSecretEnvelopeProtector.EncryptBytes(payload, key, MediaSecretEnvelopeProtector.DeploymentKeyId); + } + finally + { + CryptographicOperations.ZeroMemory(MemoryMarshal.AsBytes(password.AsSpan())); + if (payload is not null) CryptographicOperations.ZeroMemory(payload); + } + } + private static PreOobeActionSettings ClonePreOobeAction(PreOobeActionSettings action) => action with { Package = action.Package is { } package ? package with { } : null, diff --git a/src/Foundry.Core/Services/Configuration/DistinguishedNameRules.cs b/src/Foundry.Core/Services/Configuration/DistinguishedNameRules.cs new file mode 100644 index 000000000..579914056 --- /dev/null +++ b/src/Foundry.Core/Services/Configuration/DistinguishedNameRules.cs @@ -0,0 +1,162 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Text; +using Foundry.Core.Models.Configuration; + +namespace Foundry.Core.Services.Configuration; + +/// Parses directory names structurally without rewriting their source representation. +public static class DistinguishedNameRules +{ + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + + /// Accepts escaped LDAP DNs, including UTF-8 hex escapes; LDAP filter escaping is a separate operation. + public static bool TryParse(string value, out ParsedDistinguishedName parsed) + { + parsed = new([], null); + if (string.IsNullOrWhiteSpace(value) || value.Length > 4096 || value.Contains('\0')) return false; + if (!TrySplit(value, ',', out List rawRdns)) return false; + var rdns = new List(); + foreach (string rawRdn in rawRdns) + { + if (!TrySplit(rawRdn, '+', out List rawAttributes)) return false; + var attributes = new List(); + foreach (string rawAttribute in rawAttributes) + { + int equals = rawAttribute.IndexOf('='); + if (equals <= 0) return false; + string type = rawAttribute[..equals].Trim(); + if (!IsAttributeType(type) || !TryDecode(rawAttribute[(equals + 1)..], out string decoded)) return false; + attributes.Add(new(type, decoded)); + } + + rdns.Add(new(attributes)); + } + + parsed = new(rdns, rawRdns.Count > 1 ? value[(rawRdns[0].Length + 1)..] : null); + return true; + } + + /// Matches single-valued DC suffixes structurally; escaped delimiters cannot introduce a domain suffix. + public static bool IsWithinDomain(string distinguishedName, string dnsDomain) + { + if (!DomainJoinCredentialContext.IsValidDomainName(dnsDomain) || !TryParse(distinguishedName, out ParsedDistinguishedName parsed)) return false; + string[] labels = DomainJoinCredentialContext.CanonicalizeDomainName(dnsDomain).Split('.'); + if (parsed.Rdns.Count < labels.Length) return false; + int domainStart = parsed.Rdns.Count - labels.Length; + if (domainStart > 0 && parsed.Rdns[domainStart - 1].Attributes.Any(attribute => + string.Equals(attribute.Type, "DC", StringComparison.OrdinalIgnoreCase))) return false; + for (int index = 0; index < labels.Length; index++) + { + DistinguishedNameRdn rdn = parsed.Rdns[domainStart + index]; + if (rdn.Attributes.Count != 1 || !string.Equals(rdn.Attributes[0].Type, "DC", StringComparison.OrdinalIgnoreCase) || + !string.Equals(rdn.Attributes[0].Value, labels[index], StringComparison.OrdinalIgnoreCase)) return false; + } + + return true; + } + + /// + /// Determines whether the name designates an organizational unit, which is the only container the join can + /// place a computer in. A container such as CN=Computers is rejected here instead of failing at join time. + /// + public static bool IsOrganizationalUnit(string distinguishedName) => + TryParse(distinguishedName, out ParsedDistinguishedName parsed) && IsOrganizationalUnit(parsed); + + internal static bool IsOrganizationalUnit(ParsedDistinguishedName parsed) => + parsed.Rdns.Count > 0 && parsed.Rdns[0].Attributes.Count == 1 && + string.Equals(parsed.Rdns[0].Attributes[0].Type, "OU", StringComparison.OrdinalIgnoreCase); + + /// Detects duplicate saved OUs only; live directory GUIDs remain authoritative. + internal static string GetComparisonKey(ParsedDistinguishedName parsed) => string.Join("|", parsed.Rdns.Select(rdn => + string.Join("+", rdn.Attributes.Select(attribute => $"{attribute.Type.ToUpperInvariant()}:{Convert.ToHexString(Encoding.UTF8.GetBytes(attribute.Value.ToUpperInvariant()))}").Order(StringComparer.Ordinal)))); + + private static bool TrySplit(string value, char separator, out List parts) + { + parts = []; + int start = 0; + for (int index = 0; index < value.Length; index++) + { + if (value[index] == '\\') + { + if (++index >= value.Length) return false; + continue; + } + + if (value[index] != separator) continue; + if (index == start) return false; + parts.Add(value[start..index]); + start = index + 1; + } + + if (start == value.Length) return false; + parts.Add(value[start..]); + return true; + } + + private static bool IsAttributeType(string type) => type.Length > 0 && + (char.IsAsciiLetter(type[0]) && type.All(character => char.IsAsciiLetterOrDigit(character) || character == '-') || + char.IsAsciiDigit(type[0]) && type.All(character => char.IsAsciiDigit(character) || character == '.') && + type.Split('.').All(part => part.Length > 0)); + + private static bool TryDecode(string raw, out string decoded) + { + decoded = string.Empty; + if (raw.Length == 0 || raw[0] is ' ' or '#' || raw[^1] == ' ' && !HasEscapedLastSpace(raw)) return false; + var builder = new StringBuilder(); + for (int index = 0; index < raw.Length; index++) + { + char character = raw[index]; + if (character != '\\') + { + if (character is '"' or '+' or ',' or ';' or '<' or '>' || char.IsControl(character)) return false; + builder.Append(character); + continue; + } + + if (++index >= raw.Length) return false; + if (index + 1 < raw.Length && Uri.IsHexDigit(raw[index]) && Uri.IsHexDigit(raw[index + 1])) + { + var bytes = new List(); + while (true) + { + bytes.Add(Convert.ToByte(raw.Substring(index, 2), 16)); + index++; + if (index + 3 >= raw.Length || raw[index + 1] != '\\' || !Uri.IsHexDigit(raw[index + 2]) || !Uri.IsHexDigit(raw[index + 3])) break; + index += 2; + } + + try { builder.Append(StrictUtf8.GetString(bytes.ToArray())); } + catch (DecoderFallbackException) { return false; } + } + else + { + if (raw[index] is not (' ' or '#' or '"' or '+' or ',' or ';' or '<' or '>' or '=' or '\\')) return false; + builder.Append(raw[index]); + } + } + + decoded = builder.ToString(); + try { StrictUtf8.GetByteCount(decoded); } + catch (EncoderFallbackException) { return false; } + return decoded.Length > 0 && !decoded.Contains('\0'); + } + + private static bool HasEscapedLastSpace(string raw) + { + int slashes = 0; + for (int index = raw.Length - 2; index >= 0 && raw[index] == '\\'; index--) slashes++; + return slashes % 2 == 1; + } +} + +/// Contains leaf-first RDNs and the original escaped parent name. +public sealed record ParsedDistinguishedName(IReadOnlyList Rdns, string? Parent); + +/// Represents one possibly multivalued relative distinguished name. +public sealed record DistinguishedNameRdn(IReadOnlyList Attributes); + +/// Contains an attribute type and decoded value for structural comparison only. +public sealed record DistinguishedNameAttribute(string Type, string Value); diff --git a/src/Foundry.Core/Services/Configuration/DomainJoinConfigurationValidator.cs b/src/Foundry.Core/Services/Configuration/DomainJoinConfigurationValidator.cs new file mode 100644 index 000000000..f3890ccd1 --- /dev/null +++ b/src/Foundry.Core/Services/Configuration/DomainJoinConfigurationValidator.cs @@ -0,0 +1,149 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Models.Configuration; + +namespace Foundry.Core.Services.Configuration; + +/// Validates portable metadata separately from inputs required to build deployment media. +public static class DomainJoinConfigurationValidator +{ + public const int MaximumDomains = 32; + public const int MaximumOrganizationalUnits = 1024; + public const int MaximumPasswordUtf8Bytes = 2560; + public const int MaximumCredentialPayloadBytes = 32 * 1024; + public const int MaximumResultBytes = 64 * 1024; + + /// Rejects unsafe bounds and inconsistent domain or OU lists; credentials are not examined here. + public static DomainJoinValidationResult ValidateMetadata(DomainJoinSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + var issues = new List(); + if (!Enum.IsDefined(settings.Mode)) Add(DomainJoinValidationCode.UnsupportedMode); + if (!IsBounded(settings.SharedAccountName, 512)) Add(DomainJoinValidationCode.InvalidAccountName); + if (!IsBounded(settings.DefaultDomainId, 128)) Add(DomainJoinValidationCode.InvalidDomainId); + IReadOnlyList domains = settings.Domains ?? []; + if (domains.Count > MaximumDomains) Add(DomainJoinValidationCode.TooManyDomains); + var domainIds = new HashSet(StringComparer.OrdinalIgnoreCase); + var domainNames = new HashSet(StringComparer.Ordinal); + foreach (DomainJoinDomainSettings domain in domains.Take(MaximumDomains)) + { + if (domain is null) { Add(DomainJoinValidationCode.InvalidDomainId); continue; } + string? id = IsRequiredBounded(domain.Id, 128) ? domain.Id : null; + if (id is null) Add(DomainJoinValidationCode.InvalidDomainId); + else if (!domainIds.Add(id)) Add(DomainJoinValidationCode.DuplicateDomainId, id); + ValidateDomain(domain, id, domainNames, issues); + } + + // A listed domain always has a default; without domains there is nothing a default could name. + if (domains.Count > 0 ? settings.DefaultDomainId is null || !domainIds.Contains(settings.DefaultDomainId) : settings.DefaultDomainId is not null) + Add(DomainJoinValidationCode.DefaultDomainMissing); + return new(issues.Count == 0, issues); + + void Add(DomainJoinValidationCode code, string? domainId = null) => issues.Add(new(code, domainId)); + } + + private static void ValidateDomain(DomainJoinDomainSettings domain, string? id, HashSet domainNames, List issues) + { + if (!IsBounded(domain.DomainName, 253) || !DomainJoinCredentialContext.IsValidDomainName(domain.DomainName)) + Add(DomainJoinValidationCode.InvalidDomainName); + // Compared in canonical form, so case and a trailing dot do not make two domains out of one. + string canonicalName = IsBounded(domain.DomainName, 253) ? DomainJoinCredentialContext.CanonicalizeDomainName(domain.DomainName) : string.Empty; + if (canonicalName.Length > 0 && !domainNames.Add(canonicalName)) Add(DomainJoinValidationCode.DuplicateDomainName); + if (!IsBounded(domain.AccountName, 512)) Add(DomainJoinValidationCode.InvalidAccountName); + if (!IsBounded(domain.DefaultOuId, 128)) Add(DomainJoinValidationCode.InvalidOuId); + IReadOnlyList units = domain.OrganizationalUnits ?? []; + if (units.Count > MaximumOrganizationalUnits) Add(DomainJoinValidationCode.TooManyOrganizationalUnits); + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (DomainJoinOrganizationalUnitSettings unit in units.Take(MaximumOrganizationalUnits)) + { + if (unit is null) { Add(DomainJoinValidationCode.InvalidOuId); continue; } + if (!IsRequiredBounded(unit.Id, 128)) Add(DomainJoinValidationCode.InvalidOuId); + else if (!ids.Add(unit.Id)) Add(DomainJoinValidationCode.DuplicateOuId); + if (!IsRequiredBounded(unit.DisplayName, 120)) Add(DomainJoinValidationCode.InvalidOuDisplayName); + if (!DistinguishedNameRules.TryParse(unit.DistinguishedName, out ParsedDistinguishedName parsed) || + !DistinguishedNameRules.IsOrganizationalUnit(parsed)) + Add(DomainJoinValidationCode.InvalidDistinguishedName); + else + { + if (!names.Add(DistinguishedNameRules.GetComparisonKey(parsed))) Add(DomainJoinValidationCode.DuplicateDistinguishedName); + if (!DistinguishedNameRules.IsWithinDomain(unit.DistinguishedName, domain.DomainName ?? string.Empty)) + Add(DomainJoinValidationCode.OuOutsideDomain); + } + } + + if (domain.DefaultOuId is not null && !ids.Contains(domain.DefaultOuId)) Add(DomainJoinValidationCode.DefaultOuMissing); + + void Add(DomainJoinValidationCode code) => issues.Add(new(code, id)); + } + + /// + /// Adds what media creation needs on top of valid metadata. Zero-touch needs a domain, and for each domain a + /// qualified account that owns a password; Interactive needs nothing more because the technician supplies it. + /// + /// Tells whether the given resolved account owns a password in this session. + public static DomainJoinValidationResult EvaluateReadiness(DomainJoinSettings settings, Func hasPassword, bool isMediaProtected) + { + ArgumentNullException.ThrowIfNull(hasPassword); + DomainJoinValidationResult metadata = ValidateMetadata(settings); + if (!settings.IsEnabled || settings.Mode != DomainJoinMode.Automatic) return metadata; + var issues = metadata.Issues.ToList(); + IReadOnlyList domains = settings.Domains ?? []; + if (domains.Count == 0) issues.Add(new(DomainJoinValidationCode.DomainsRequired)); + foreach (DomainJoinDomainSettings domain in domains.Take(MaximumDomains)) + { + if (domain is null) continue; + string? account = settings.ResolveAccountName(domain); + if (string.IsNullOrWhiteSpace(account)) issues.Add(new(DomainJoinValidationCode.SharedAccountRequired, domain.Id)); + else if (!IsQualifiedAccount(account)) issues.Add(new(DomainJoinValidationCode.QualifiedAccountRequired, domain.Id)); + else if (!hasPassword(account)) issues.Add(new(DomainJoinValidationCode.PasswordRequired, domain.Id)); + } + + if (!isMediaProtected) issues.Add(new(DomainJoinValidationCode.MediaProtectionRequired)); + return new(issues.Count == 0, issues); + } + + /// Rejects contradictory saved documents rather than choosing a silent import winner. + public static void ThrowIfProvisioningModesConflict(AutopilotSettings autopilot, DomainJoinSettings domainJoin) + { + ArgumentNullException.ThrowIfNull(autopilot); + ArgumentNullException.ThrowIfNull(domainJoin); + if (autopilot.IsEnabled && domainJoin.IsEnabled) + throw new InvalidOperationException("Autopilot and domain joining cannot both be enabled."); + } + + /// Accepts explicit UPN or down-level logon names supported by online joining. + public static bool IsQualifiedAccount(string? value) + { + if (!IsRequiredBounded(value, 512) || value!.Any(char.IsControl)) return false; + string account = value!.Trim(); + if (account.Any(char.IsWhiteSpace)) return false; + string[] downLevel = account.Split('\\'); + if (downLevel.Length == 2) + return downLevel.All(part => part.Length > 0 && !part.Contains('@') && !part.Contains('/')); + if (downLevel.Length != 1) return false; + string[] upn = account.Split('@'); + return upn.Length == 2 && upn[0].Length > 0 && !upn[0].Contains('/') && DomainJoinCredentialContext.IsValidDomainName(upn[1]); + } + + private static bool IsBounded(string? value, int maximum) => value is null || (value.Length <= maximum && !value.Contains('\0')); + private static bool IsRequiredBounded(string? value, int maximum) => !string.IsNullOrWhiteSpace(value) && IsBounded(value, maximum); +} + +/// Contains non-secret validation outcomes. +public sealed record DomainJoinValidationResult(bool IsValid, IReadOnlyList Issues); + +/// Identifies the rule that needs attention, and the domain it concerns when there is one, without echoing input. +public sealed record DomainJoinValidationIssue(DomainJoinValidationCode Code, string? DomainId = null); + +/// Stable validation codes for authoring and runtime presentation. +public enum DomainJoinValidationCode +{ + UnsupportedMode, InvalidDomainName, InvalidAccountName, TooManyOrganizationalUnits, + InvalidOuId, InvalidOuDisplayName, InvalidDistinguishedName, DuplicateOuId, DuplicateDistinguishedName, + OuOutsideDomain, DefaultOuMissing, DomainsRequired, TooManyDomains, InvalidDomainId, DuplicateDomainId, + DuplicateDomainName, DefaultDomainMissing, SharedAccountRequired, QualifiedAccountRequired, PasswordRequired, + MediaProtectionRequired +} diff --git a/src/Foundry.Core/Services/Configuration/DomainJoinCredentialPayloadCodec.cs b/src/Foundry.Core/Services/Configuration/DomainJoinCredentialPayloadCodec.cs new file mode 100644 index 000000000..49f62812d --- /dev/null +++ b/src/Foundry.Core/Services/Configuration/DomainJoinCredentialPayloadCodec.cs @@ -0,0 +1,138 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Buffers.Binary; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using Foundry.Core.Models.Configuration; + +namespace Foundry.Core.Services.Configuration; + +/// Encodes bounded context-bound credentials for media and operation-owned target payloads. +public static class DomainJoinCredentialPayloadCodec +{ + private static readonly UTF8Encoding Encoding = new(false, true); + private static ReadOnlySpan Magic => "FNDRYDJ1"u8; + + /// Returns an owned version-1 binary payload. The caller must clear the returned plaintext bytes. + public static byte[] Encode(DomainJoinCredentialContext context, ReadOnlySpan password) + { + ValidateContext(context); + ValidatePassword(password); + string domain = DomainJoinCredentialContext.CanonicalizeDomainName(context.DomainName); + string account = context.AccountName.Trim(); + int domainLength = Encoding.GetByteCount(domain); + int accountLength = Encoding.GetByteCount(account); + int passwordLength = Encoding.GetByteCount(password); + byte[] bytes = new byte[20 + domainLength + accountLength + passwordLength]; + try + { + Magic.CopyTo(bytes); + BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(8), domainLength); + BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(12), accountLength); + BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(16), passwordLength); + Encoding.GetBytes(domain, bytes.AsSpan(20, domainLength)); + Encoding.GetBytes(account, bytes.AsSpan(20 + domainLength, accountLength)); + Encoding.GetBytes(password, bytes.AsSpan(20 + domainLength + accountLength)); + return bytes; + } + catch + { + CryptographicOperations.ZeroMemory(bytes); + throw; + } + } + + /// Validates structure and authenticated inner context before returning any password. Media readers must supply the outer expected context. + public static DomainJoinCredentialPayload Decode(ReadOnlySpan bytes, DomainJoinCredentialContext? expected = null) + { + if (bytes.Length < 20 || bytes.Length > DomainJoinConfigurationValidator.MaximumCredentialPayloadBytes || !bytes[..8].SequenceEqual(Magic)) + throw new InvalidDataException("The domain credential payload is invalid."); + int domainLength = BinaryPrimitives.ReadInt32LittleEndian(bytes[8..]); + int accountLength = BinaryPrimitives.ReadInt32LittleEndian(bytes[12..]); + int passwordLength = BinaryPrimitives.ReadInt32LittleEndian(bytes[16..]); + if (domainLength is <= 0 or > 1012 || accountLength is <= 0 or > 2048 || + passwordLength is <= 0 or > DomainJoinConfigurationValidator.MaximumPasswordUtf8Bytes || + 20L + domainLength + accountLength + passwordLength != bytes.Length) + throw new InvalidDataException("The domain credential payload is invalid."); + char[]? password = null; + try + { + var context = new DomainJoinCredentialContext(Encoding.GetString(bytes.Slice(20, domainLength)), + Encoding.GetString(bytes.Slice(20 + domainLength, accountLength))); + ValidateContext(context); + if (expected is not null && !context.Matches(expected)) + throw new CryptographicException("The domain credential context does not match."); + password = new char[Encoding.GetCharCount(bytes.Slice(20 + domainLength + accountLength, passwordLength))]; + Encoding.GetChars(bytes.Slice(20 + domainLength + accountLength, passwordLength), password); + ValidatePassword(password); + return new(context, password); + } + catch (DecoderFallbackException) + { + Clear(password); + throw new InvalidDataException("The domain credential payload is invalid."); + } + catch + { + Clear(password); + throw; + } + } + + internal static void ValidateContext(DomainJoinCredentialContext context) + { + ArgumentNullException.ThrowIfNull(context); + if (!DomainJoinCredentialContext.IsValidDomainName(context.DomainName) || + !DomainJoinConfigurationValidator.IsQualifiedAccount(context.AccountName)) + throw new InvalidDataException("The domain credential context is invalid."); + } + + public static void ValidatePassword(ReadOnlySpan password) + { + try + { + if (password.IsEmpty || password.Contains('\0') || Encoding.GetByteCount(password) > DomainJoinConfigurationValidator.MaximumPasswordUtf8Bytes) + throw new InvalidDataException("The domain credential password is invalid."); + } + catch (EncoderFallbackException) + { + throw new InvalidDataException("The domain credential password is invalid."); + } + } + + private static void Clear(char[]? password) + { + if (password is not null) CryptographicOperations.ZeroMemory(MemoryMarshal.AsBytes(password.AsSpan())); + } +} + +/// Owns decoded plaintext; disposal clears its password memory. The context contains no password. +public sealed class DomainJoinCredentialPayload : IDisposable +{ + private readonly char[] password; + private bool isDisposed; + internal DomainJoinCredentialPayload(DomainJoinCredentialContext context, char[] password) + { + Context = context; + this.password = password; + } + public DomainJoinCredentialContext Context { get; } + /// Exposes owned memory valid only until disposal; consumers must not retain plaintext copies. + public ReadOnlyMemory Password + { + get + { + ObjectDisposedException.ThrowIf(isDisposed, this); + return password; + } + } + public void Dispose() + { + if (isDisposed) return; + CryptographicOperations.ZeroMemory(MemoryMarshal.AsBytes(password.AsSpan())); + isDisposed = true; + } +} diff --git a/src/Foundry.Core/Services/Configuration/DomainJoinDomainCatalog.cs b/src/Foundry.Core/Services/Configuration/DomainJoinDomainCatalog.cs new file mode 100644 index 000000000..c554f0547 --- /dev/null +++ b/src/Foundry.Core/Services/Configuration/DomainJoinDomainCatalog.cs @@ -0,0 +1,85 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Models.Configuration; + +namespace Foundry.Core.Services.Configuration; + +/// Maintains the list of joinable domains and its default, returning validated copies. +public static class DomainJoinDomainCatalog +{ + /// Adds a domain. The first domain listed becomes the default. + public static DomainJoinSettings Add(DomainJoinSettings current, string domainName, string? accountName) + { + ArgumentNullException.ThrowIfNull(current); + var domain = new DomainJoinDomainSettings + { + Id = Guid.NewGuid().ToString("D"), + DomainName = (domainName ?? string.Empty).Trim(), + AccountName = NormalizeAccount(accountName) + }; + return Validated(current with + { + Domains = [.. current.Domains, domain], + DefaultDomainId = current.Domains.Count == 0 ? domain.Id : current.DefaultDomainId + }, nameof(domainName)); + } + + /// + /// Changes a domain's name or dedicated account. The name cannot change while the domain lists OUs, because + /// they would silently fall outside their domain. + /// + public static DomainJoinSettings Update(DomainJoinSettings current, string domainId, string domainName, string? accountName) + { + DomainJoinDomainSettings domain = Require(current, domainId); + string name = (domainName ?? string.Empty).Trim(); + bool renamed = !string.Equals(DomainJoinCredentialContext.CanonicalizeDomainName(domain.DomainName), + DomainJoinCredentialContext.CanonicalizeDomainName(name), StringComparison.Ordinal); + if (renamed && domain.OrganizationalUnits.Count > 0) + throw new ArgumentException("A domain that lists OUs cannot be renamed.", nameof(domainName)); + return Validated(Replace(current, domain with { DomainName = name, AccountName = NormalizeAccount(accountName) }), nameof(domainName)); + } + + /// + /// Changes only a domain's name and keeps its dedicated account. Interactive authoring edits a domain without + /// showing the account that Zero-touch stored on it. + /// + public static DomainJoinSettings Rename(DomainJoinSettings current, string domainId, string domainName) => + Update(current, domainId, domainName, Require(current, domainId).AccountName); + + /// Removes a domain with its OUs. A removed default moves to the first remaining domain. + public static DomainJoinSettings Remove(DomainJoinSettings current, string domainId) + { + ArgumentNullException.ThrowIfNull(current); + DomainJoinDomainSettings[] domains = current.Domains + .Where(domain => !string.Equals(domain.Id, domainId, StringComparison.OrdinalIgnoreCase)).ToArray(); + bool removedDefault = string.Equals(current.DefaultDomainId, domainId, StringComparison.OrdinalIgnoreCase); + return current with + { + Domains = domains, + DefaultDomainId = domains.Length == 0 ? null : removedDefault ? domains[0].Id : current.DefaultDomainId + }; + } + + public static DomainJoinSettings SetDefault(DomainJoinSettings current, string domainId) => + Validated(current with { DefaultDomainId = Require(current, domainId).Id }, nameof(domainId)); + + internal static DomainJoinDomainSettings Require(DomainJoinSettings current, string domainId) + { + ArgumentNullException.ThrowIfNull(current); + return current.FindDomain(domainId) ?? throw new ArgumentException("The domain is not listed.", nameof(domainId)); + } + + internal static DomainJoinSettings Replace(DomainJoinSettings current, DomainJoinDomainSettings updated) => current with + { + Domains = current.Domains.Select(domain => string.Equals(domain.Id, updated.Id, StringComparison.OrdinalIgnoreCase) ? updated : domain).ToArray() + }; + + internal static DomainJoinSettings Validated(DomainJoinSettings result, string parameterName) => + DomainJoinConfigurationValidator.ValidateMetadata(result).IsValid + ? result + : throw new ArgumentException("The domain list would not be valid.", parameterName); + + private static string? NormalizeAccount(string? accountName) => string.IsNullOrWhiteSpace(accountName) ? null : accountName.Trim(); +} diff --git a/src/Foundry.Core/Services/Configuration/DomainJoinEditionRules.cs b/src/Foundry.Core/Services/Configuration/DomainJoinEditionRules.cs new file mode 100644 index 000000000..5d2f873a9 --- /dev/null +++ b/src/Foundry.Core/Services/Configuration/DomainJoinEditionRules.cs @@ -0,0 +1,20 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +namespace Foundry.Core.Services.Configuration; + +/// Classifies raw Windows edition IDs without expanding image support or blocking unknown editions. +public static class DomainJoinEditionRules +{ + /// Defers missing or unfamiliar IDs to image inspection and native capability reporting. + public static DomainJoinEditionSupport Evaluate(string? editionId) => editionId?.Trim().ToUpperInvariant() switch + { + "CORE" or "COREN" or "CORESINGLELANGUAGE" or "CORECOUNTRYSPECIFIC" => DomainJoinEditionSupport.Unsupported, + "PROFESSIONAL" or "PROFESSIONALN" or "EDUCATION" or "EDUCATIONN" or "ENTERPRISE" or "ENTERPRISEN" => DomainJoinEditionSupport.Supported, + _ => DomainJoinEditionSupport.Unknown + }; +} + +/// Separates positively identified client capabilities from deferred inspection. +public enum DomainJoinEditionSupport { Supported, Unsupported, Unknown } diff --git a/src/Foundry.Core/Services/Configuration/DomainJoinOrganizationalUnitCatalog.cs b/src/Foundry.Core/Services/Configuration/DomainJoinOrganizationalUnitCatalog.cs new file mode 100644 index 000000000..032b57e6d --- /dev/null +++ b/src/Foundry.Core/Services/Configuration/DomainJoinOrganizationalUnitCatalog.cs @@ -0,0 +1,98 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Models.Configuration; + +namespace Foundry.Core.Services.Configuration; + +/// Maintains the bounded OU list of one domain without changing the identity of OUs already listed. +public static class DomainJoinOrganizationalUnitCatalog +{ + /// Adds manually entered or discovered OUs to one domain, skipping OUs it already lists. + public static DomainJoinSettings Merge(DomainJoinSettings current, string domainId, + IReadOnlyList selected) + { + ArgumentNullException.ThrowIfNull(selected); + DomainJoinDomainSettings domain = DomainJoinDomainCatalog.Require(current, domainId); + var units = domain.OrganizationalUnits.ToList(); + var names = new HashSet(units.Select(unit => Key(unit.DistinguishedName)), StringComparer.OrdinalIgnoreCase); + foreach (DomainJoinOrganizationalUnitSettings unit in selected) + { + if (unit is null) throw new ArgumentException("An OU is required.", nameof(selected)); + if (names.Add(Key(unit.DistinguishedName))) units.Add(unit); + } + + return DomainJoinDomainCatalog.Validated( + DomainJoinDomainCatalog.Replace(current, domain with { OrganizationalUnits = units.ToArray() }), nameof(selected)); + } + + /// + /// Changes the display name of one listed OU. Its identity and distinguished name stay, so it remains the + /// default when it was, and a later import of the same OU keeps the chosen name. + /// + public static DomainJoinSettings Rename(DomainJoinSettings current, string domainId, string ouId, string displayName) + { + DomainJoinDomainSettings domain = DomainJoinDomainCatalog.Require(current, domainId); + if (!domain.OrganizationalUnits.Any(IsRenamed)) throw new ArgumentException("The OU is not listed.", nameof(ouId)); + string name = (displayName ?? string.Empty).Trim(); + return DomainJoinDomainCatalog.Validated(DomainJoinDomainCatalog.Replace(current, domain with + { + OrganizationalUnits = domain.OrganizationalUnits.Select(unit => IsRenamed(unit) ? unit with { DisplayName = name } : unit).ToArray() + }), nameof(displayName)); + + bool IsRenamed(DomainJoinOrganizationalUnitSettings unit) => string.Equals(unit.Id, ouId, StringComparison.OrdinalIgnoreCase); + } + + /// Removes an OU from one domain and clears it as that domain's default. Removing from a valid list cannot make it invalid. + public static DomainJoinSettings Remove(DomainJoinSettings current, string domainId, string ouId) + { + DomainJoinDomainSettings domain = DomainJoinDomainCatalog.Require(current, domainId); + return DomainJoinDomainCatalog.Replace(current, domain with + { + OrganizationalUnits = domain.OrganizationalUnits + .Where(unit => !string.Equals(unit.Id, ouId, StringComparison.OrdinalIgnoreCase)).ToArray(), + DefaultOuId = string.Equals(domain.DefaultOuId, ouId, StringComparison.OrdinalIgnoreCase) ? null : domain.DefaultOuId + }); + } + + /// Sets or clears the default OU of one domain; means the domain's default location. + public static DomainJoinSettings SetDefault(DomainJoinSettings current, string domainId, string? ouId) => + DomainJoinDomainCatalog.Validated( + DomainJoinDomainCatalog.Replace(current, DomainJoinDomainCatalog.Require(current, domainId) with { DefaultOuId = ouId }), nameof(ouId)); + + /// + /// Returns the OU a domain uses when nobody chooses: its only OU, or the one marked as default among several. + /// means the domain's default location when no OU is listed, and that the technician + /// must choose when several are listed without a default. + /// + public static DomainJoinOrganizationalUnitSettings? ResolveDefault(IReadOnlyList? units, string? defaultOuId) => + units is null || units.Count == 0 ? null + : units.Count == 1 ? units[0] + : defaultOuId is null ? null + : units.FirstOrDefault(unit => string.Equals(unit.Id, defaultOuId, StringComparison.OrdinalIgnoreCase)); + + /// + /// Returns the candidates a domain does not list yet, compared as compares them, so an + /// import offers only what it would add. A candidate with an unreadable name is kept for the merge to reject. + /// + public static IReadOnlyList ExcludeListed(DomainJoinDomainSettings domain, + IReadOnlyList candidates) + { + ArgumentNullException.ThrowIfNull(domain); + ArgumentNullException.ThrowIfNull(candidates); + var listed = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (DomainJoinOrganizationalUnitSettings unit in domain.OrganizationalUnits) + { + if (DistinguishedNameRules.TryParse(unit.DistinguishedName, out ParsedDistinguishedName parsed)) + listed.Add(DistinguishedNameRules.GetComparisonKey(parsed)); + } + + return candidates.Where(candidate => !DistinguishedNameRules.TryParse(candidate.DistinguishedName, out ParsedDistinguishedName parsed) || + !listed.Contains(DistinguishedNameRules.GetComparisonKey(parsed))).ToArray(); + } + + private static string Key(string dn) => DistinguishedNameRules.TryParse(dn, out ParsedDistinguishedName parsed) + ? DistinguishedNameRules.GetComparisonKey(parsed) + : throw new ArgumentException("The OU distinguished name is invalid.", nameof(dn)); +} diff --git a/src/Foundry.Core/Services/Configuration/DomainJoinSecretState.cs b/src/Foundry.Core/Services/Configuration/DomainJoinSecretState.cs new file mode 100644 index 000000000..c272c2ae3 --- /dev/null +++ b/src/Foundry.Core/Services/Configuration/DomainJoinSecretState.cs @@ -0,0 +1,84 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using Foundry.Core.Models.Configuration; + +namespace Foundry.Core.Services.Configuration; + +/// +/// Owns the session passwords of the join accounts, one per account. Two domains that name the same account share +/// one password; the domain is bound later, when a payload is written for the media. +/// +public sealed class DomainJoinSecretState : IDisposable +{ + private readonly Dictionary passwords = new(StringComparer.Ordinal); + private bool isDisposed; + + /// Validates and copies a password without normalization; an empty value removes the account's password. + public void SetPassword(string accountName, ReadOnlySpan value) + { + ObjectDisposedException.ThrowIf(isDisposed, this); + if (!DomainJoinConfigurationValidator.IsQualifiedAccount(accountName)) + throw new ArgumentException("Only a qualified account can own a password.", nameof(accountName)); + if (!value.IsEmpty) DomainJoinCredentialPayloadCodec.ValidatePassword(value); + string key = DomainJoinCredentialContext.CanonicalizeAccountName(accountName); + Erase(key); + if (!value.IsEmpty) passwords[key] = value.ToArray(); + } + + /// Returns an independent owned copy for the account; callers must clear it. + public char[]? GetPasswordCopy(string accountName) + { + ObjectDisposedException.ThrowIf(isDisposed, this); + return Find(accountName)?.ToArray(); + } + + /// Reports whether the account, compared in canonical form, owns a password. + public bool HasPassword(string accountName) + { + ObjectDisposedException.ThrowIf(isDisposed, this); + return Find(accountName) is not null; + } + + /// + /// Erases the passwords of accounts the configuration no longer stores; returns whether any was erased. The + /// mode does not matter: passwords survive a switch to Interactive so that switching back needs no retyping. + /// + public bool Update(DomainJoinSettings settings) + { + ObjectDisposedException.ThrowIf(isDisposed, this); + ArgumentNullException.ThrowIfNull(settings); + var stored = new HashSet(settings.GetStoredAccountNames(), StringComparer.Ordinal); + string[] orphaned = passwords.Keys.Where(account => !stored.Contains(account)).ToArray(); + foreach (string account in orphaned) Erase(account); + return orphaned.Length > 0; + } + + /// Erases every owned password; independent caller copies remain caller-owned. + public bool Clear() + { + bool changed = passwords.Count > 0; + foreach (string account in passwords.Keys.ToArray()) Erase(account); + return changed; + } + + public void Dispose() + { + if (isDisposed) return; + Clear(); + isDisposed = true; + } + + private char[]? Find(string accountName) => + !string.IsNullOrWhiteSpace(accountName) && + passwords.TryGetValue(DomainJoinCredentialContext.CanonicalizeAccountName(accountName), out char[]? password) ? password : null; + + private void Erase(string key) + { + if (!passwords.Remove(key, out char[]? password)) return; + CryptographicOperations.ZeroMemory(MemoryMarshal.AsBytes(password.AsSpan())); + } +} diff --git a/src/Foundry.Core/Services/Configuration/FoundryConfigurationMigration.cs b/src/Foundry.Core/Services/Configuration/FoundryConfigurationMigration.cs index 3a9aada3f..5b22cc9e9 100644 --- a/src/Foundry.Core/Services/Configuration/FoundryConfigurationMigration.cs +++ b/src/Foundry.Core/Services/Configuration/FoundryConfigurationMigration.cs @@ -30,6 +30,16 @@ public static FoundryConfigurationDocument MigrateDefaultIsoOutput(FoundryConfig private const int SupportedOperatingSystemReleasesSchemaVersion = 17; private const int LegacyRandomLength = 6; + /// + /// A file edited by hand may carry null where a list is expected. It means "none", so the lists become + /// empty instead of failing later. A null domain entry is kept for validation to reject. + /// + private static DomainJoinSettings WithoutNullLists(DomainJoinSettings settings) => settings with + { + Domains = (settings.Domains ?? []) + .Select(domain => domain is { OrganizationalUnits: null } ? domain with { OrganizationalUnits = [] } : domain).ToArray() + }; + public static FoundryConfigurationDocument ApplySchemaMigrations(FoundryConfigurationDocument document) { ArgumentNullException.ThrowIfNull(document); @@ -40,6 +50,12 @@ public static FoundryConfigurationDocument ApplySchemaMigrations(FoundryConfigur migrated = migrated with { CustomImages = migrated.CustomImages ?? new CustomImagesSettings() }; migrated = migrated with { PreOobe = migrated.PreOobe ?? new PreOobeSettings() }; + migrated = migrated with { DomainJoin = WithoutNullLists(migrated.DomainJoin ?? new DomainJoinSettings()) }; + DomainJoinConfigurationValidator.ThrowIfProvisioningModesConflict(migrated.Autopilot, migrated.DomainJoin); + if (!DomainJoinConfigurationValidator.ValidateMetadata(migrated.DomainJoin).IsValid) + { + throw new InvalidOperationException("Domain join metadata is invalid."); + } if (migrated.SchemaVersion < SupportedOperatingSystemReleasesSchemaVersion) { diff --git a/src/Foundry.Core/Services/Configuration/IDeployConfigurationGenerator.cs b/src/Foundry.Core/Services/Configuration/IDeployConfigurationGenerator.cs index 743c104da..8f555cced 100644 --- a/src/Foundry.Core/Services/Configuration/IDeployConfigurationGenerator.cs +++ b/src/Foundry.Core/Services/Configuration/IDeployConfigurationGenerator.cs @@ -55,6 +55,15 @@ FoundryDeployConfigurationDocument Generate( OobeAccountSecretState? oobeAccountSecretState, string? authoringVersion = null); + /// Generates active domain metadata and context-bound credentials using the existing media protection. + FoundryDeployConfigurationDocument Generate( + FoundryConfigurationDocument document, + byte[]? deploymentSecretsKey, + DeployProtectionSettings? protectionSettings, + OobeAccountSecretState? oobeAccountSecretState, + DomainJoinSecretState? domainJoinSecretState, + string? authoringVersion = null); + /// /// Serializes a deployment runtime configuration document to JSON. /// diff --git a/src/Foundry.Core/Services/Configuration/ProvisioningModeSelectionEvaluator.cs b/src/Foundry.Core/Services/Configuration/ProvisioningModeSelectionEvaluator.cs new file mode 100644 index 000000000..107c6bb91 --- /dev/null +++ b/src/Foundry.Core/Services/Configuration/ProvisioningModeSelectionEvaluator.cs @@ -0,0 +1,62 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Models.Configuration; + +namespace Foundry.Core.Services.Configuration; + +/// Models a single mutually exclusive provisioning choice. +public enum ProvisioningSelection +{ + None, AutopilotJsonProfile, AutopilotHardwareHashUpload, AutopilotInteractiveHardwareHashUpload, + DomainJoinInteractive, DomainJoinAutomatic +} + +/// Describes the proposed choice; callers confirm replacement before applying it. +public sealed record ProvisioningSelectionDecision(ProvisioningSelection Next, bool RequiresReplacementConfirmation); + +/// Evaluates the provisioning mode an author asks for without mutating saved settings. +public static class ProvisioningModeSelectionEvaluator +{ + /// Maps the active configuration to the shared policy while ignoring inactive nonsecret drafts. + public static ProvisioningSelectionDecision Evaluate(AutopilotSettings autopilot, DomainJoinSettings domainJoin, ProvisioningSelection requested) => + Evaluate(GetCurrent(autopilot, domainJoin), requested); + + /// Resolves the single active choice, rejecting documents that enable both families. + public static ProvisioningSelection GetCurrent(AutopilotSettings autopilot, DomainJoinSettings domainJoin) + { + DomainJoinConfigurationValidator.ThrowIfProvisioningModesConflict(autopilot, domainJoin); + if (autopilot.IsEnabled) + { + return autopilot.ProvisioningMode switch + { + AutopilotProvisioningMode.JsonProfile => ProvisioningSelection.AutopilotJsonProfile, + AutopilotProvisioningMode.HardwareHashUpload => ProvisioningSelection.AutopilotHardwareHashUpload, + AutopilotProvisioningMode.InteractiveHardwareHashUpload => ProvisioningSelection.AutopilotInteractiveHardwareHashUpload, + _ => throw new ArgumentOutOfRangeException(nameof(autopilot)) + }; + } + + if (domainJoin.IsEnabled) + { + return domainJoin.Mode switch + { + DomainJoinMode.Interactive => ProvisioningSelection.DomainJoinInteractive, + DomainJoinMode.Automatic => ProvisioningSelection.DomainJoinAutomatic, + _ => throw new ArgumentOutOfRangeException(nameof(domainJoin)) + }; + } + + return ProvisioningSelection.None; + } + + /// Toggling the active choice disables it; replacing an active choice requires confirmation. + public static ProvisioningSelectionDecision Evaluate(ProvisioningSelection current, ProvisioningSelection requested) + { + if (!Enum.IsDefined(current)) throw new ArgumentOutOfRangeException(nameof(current)); + if (!Enum.IsDefined(requested)) throw new ArgumentOutOfRangeException(nameof(requested)); + return new(current == requested ? ProvisioningSelection.None : requested, + current != ProvisioningSelection.None && requested != ProvisioningSelection.None && current != requested); + } +} diff --git a/src/Foundry.Core/Services/Configuration/UnattendFileService.cs b/src/Foundry.Core/Services/Configuration/UnattendFileService.cs index 708b9236d..1b88407c0 100644 --- a/src/Foundry.Core/Services/Configuration/UnattendFileService.cs +++ b/src/Foundry.Core/Services/Configuration/UnattendFileService.cs @@ -88,7 +88,7 @@ public static byte[] ReadValidated(UnattendFileSettings file) /// Checks supported passes and applicable component settings without certifying Windows SIM compatibility. /// Auxiliary architectures remain unchanged; only applicable components contribute conflict signals. /// - public static UnattendInspection Inspect(byte[] content, string? targetArchitecture = null) + public static UnattendInspection Inspect(byte[] content, string? targetArchitecture = null, bool requiresDomainJoin = false) { XDocument document = Parse(content); XElement root = document.Root!; @@ -107,6 +107,8 @@ public static UnattendInspection Inspect(byte[] content, string? targetArchitect bool hasApplicableSettings = false; bool hasCommands = false; bool conflictsWithAutopilot = false; + bool hasUnattendedJoinComponent = false; + var computerNames = new List(); foreach (XElement settings in root.Elements(UnattendNamespace + "settings")) { string? pass = (string?)settings.Attribute("pass"); @@ -123,6 +125,7 @@ public static UnattendInspection Inspect(byte[] content, string? targetArchitect foreach (XElement component in settings.Elements(UnattendNamespace + "component")) { + hasUnattendedJoinComponent |= (string?)component.Attribute("name") == "Microsoft-Windows-UnattendedJoin"; if (string.IsNullOrWhiteSpace((string?)component.Attribute("name")) || !component.Elements().Any(element => element.Name.Namespace == UnattendNamespace)) { continue; @@ -144,6 +147,11 @@ public static UnattendInspection Inspect(byte[] content, string? targetArchitect hasApplicableSettings = true; + if (name == "Microsoft-Windows-Shell-Setup" && pass == "specialize") + { + computerNames.AddRange(Children(component, "ComputerName").Select(element => element.Value)); + } + hasCommands |= HasCommands(component, name, pass); conflictsWithAutopilot |= ConflictsWithAutopilot(component, name, pass); } @@ -154,11 +162,20 @@ public static UnattendInspection Inspect(byte[] content, string? targetArchitect throw new InvalidDataException("The answer file has no supported component settings applicable to the selected architecture."); } + string? concreteComputerName = computerNames.Count == 1 && ComputerNameRules.IsValid(computerNames[0]) + ? computerNames[0] : null; + if (requiresDomainJoin && (hasUnattendedJoinComponent || concreteComputerName is null)) + { + throw new InvalidDataException("Domain joining requires exactly one valid specialize computer name and no UnattendedJoin component."); + } + return new UnattendInspection { Architectures = architectures.Order(StringComparer.Ordinal).ToArray(), HasCommands = hasCommands, - ConflictsWithAutopilot = conflictsWithAutopilot + ConflictsWithAutopilot = conflictsWithAutopilot, + HasUnattendedJoinComponent = hasUnattendedJoinComponent, + ConcreteComputerName = concreteComputerName }; } diff --git a/src/Foundry.Core/Services/Configuration/UnattendInspection.cs b/src/Foundry.Core/Services/Configuration/UnattendInspection.cs index 691dc954c..d72eebca9 100644 --- a/src/Foundry.Core/Services/Configuration/UnattendInspection.cs +++ b/src/Foundry.Core/Services/Configuration/UnattendInspection.cs @@ -23,4 +23,10 @@ public sealed record UnattendInspection /// Gets whether applicable known settings take ownership of enrollment-sensitive OOBE or accounts. /// public bool ConflictsWithAutopilot { get; init; } + + /// Gets whether a join component exists, including empty and workgroup-only components. + public bool HasUnattendedJoinComponent { get; init; } + + /// Gets exactly one valid applicable specialize name, preserving its original spelling. + public string? ConcreteComputerName { get; init; } } diff --git a/src/Foundry.Core/Services/Profiles/DeploymentBuildSnapshot.cs b/src/Foundry.Core/Services/Profiles/DeploymentBuildSnapshot.cs index 9f1a0b64f..7877d8617 100644 --- a/src/Foundry.Core/Services/Profiles/DeploymentBuildSnapshot.cs +++ b/src/Foundry.Core/Services/Profiles/DeploymentBuildSnapshot.cs @@ -24,6 +24,7 @@ public sealed class DeploymentBuildSnapshot : IDisposable private const int MaximumDriverEntries = 10_000; private const string LeaseFileName = ".lease"; private readonly OobeAccountSecretState accountSecrets = new(); + private readonly DomainJoinSecretState domainSecrets = new(); private readonly char[] deploymentPassword; private readonly char[]? wifiPassphrase; private readonly char[]? wiredCertificatePassword; @@ -66,6 +67,17 @@ public static Task CaptureAsync( ReadOnlySpan deploymentPassword, string privateRootDirectory, string? authoringVersion, + CancellationToken cancellationToken = default) => + CaptureAsync(configuration, accountSecrets, null, deploymentPassword, privateRootDirectory, authoringVersion, cancellationToken); + + /// Copies active domain credentials synchronously before source preparation can await or authoring can change. + public static Task CaptureAsync( + FoundryConfigurationDocument configuration, + OobeAccountSecretState accountSecrets, + DomainJoinSecretState? domainSecrets, + ReadOnlySpan deploymentPassword, + string privateRootDirectory, + string? authoringVersion, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(configuration); @@ -75,6 +87,14 @@ public static Task CaptureAsync( try { snapshot.CopyAccountSecrets(accountSecrets); + if (domainSecrets is not null) + { + foreach (string account in configuration.DomainJoin.GetReferencedAccountNames()) + { + char[]? password = domainSecrets.GetPasswordCopy(account); + if (password is not null) Copy(password, value => snapshot.domainSecrets.SetPassword(account, value)); + } + } return snapshot.PrepareAsync(cancellationToken); } catch @@ -100,7 +120,7 @@ public string GenerateDeployConfigurationJson(TelemetrySettings? telemetryOverri throw new InvalidOperationException("OOBE local account password confirmation is invalid."); } var generator = new DeployConfigurationGenerator(); - return generator.Serialize(generator.Generate(CreateGenerationDocument(telemetryOverride), deploymentSecretsKey, protectionSettings, accountSecrets, authoringVersion)); + return generator.Serialize(generator.Generate(CreateGenerationDocument(telemetryOverride), deploymentSecretsKey, protectionSettings, accountSecrets, domainSecrets, authoringVersion)); } /// Creates fresh per-media protection using the confirmed password captured before preparation began. @@ -119,6 +139,7 @@ public void Dispose() { isDisposed = true; accountSecrets.Dispose(); + domainSecrets.Dispose(); Clear(deploymentPassword); Clear(wifiPassphrase); Clear(wiredCertificatePassword); diff --git a/src/Foundry.Core/Services/Profiles/DeploymentProfileMerge.cs b/src/Foundry.Core/Services/Profiles/DeploymentProfileMerge.cs index 430affcf2..ebf2b7e30 100644 --- a/src/Foundry.Core/Services/Profiles/DeploymentProfileMerge.cs +++ b/src/Foundry.Core/Services/Profiles/DeploymentProfileMerge.cs @@ -54,6 +54,18 @@ public static DeploymentProfileDocument PreserveOmittedSourceMetadata(Deployment }); } } + // One secret per join account still stored; an account that left the configuration takes its secret with it. + foreach (string account in captured.Configuration.DomainJoin.GetStoredAccountNames()) + { + string identity = DeploymentProfileSecretBinding.Identity(ProfileSecretPurpose.DomainJoinPassword, captured, account); + DeploymentProfileSecret? omitted = baseline.Secrets.Entries.SingleOrDefault(secret => secret.Purpose == ProfileSecretPurpose.DomainJoinPassword && + secret.Identity == identity && secret.State == ProfileValueState.Omitted); + if (omitted is null) continue; + int index = secrets.FindIndex(secret => secret.Purpose == ProfileSecretPurpose.DomainJoinPassword && secret.Identity == identity); + if (index < 0) secrets.Add(omitted with { Value = null }); + else if (secrets[index].State == ProfileValueState.Unavailable) + secrets[index] = secrets[index] with { State = ProfileValueState.Omitted, Value = null }; + } return result with { Secrets = new() { Entries = secrets } }; } @@ -99,6 +111,11 @@ public static DeploymentProfileDocument PreserveOmittedLocalValues(DeploymentPro { if (incoming.ProfileId != local.ProfileId) throw new ArgumentException("Local values belong to a different profile.", nameof(local)); + // A local domain password is reused only for an account both sides still store. + HashSet sharedDomainIdentities = incoming.Configuration.DomainJoin.GetStoredAccountNames() + .Intersect(local.Configuration.DomainJoin.GetStoredAccountNames(), StringComparer.Ordinal) + .Select(account => DeploymentProfileSecretBinding.Identity(ProfileSecretPurpose.DomainJoinPassword, incoming, account)) + .ToHashSet(StringComparer.Ordinal); return incoming with { Secrets = new DeploymentProfileSecrets @@ -106,7 +123,8 @@ public static DeploymentProfileDocument PreserveOmittedLocalValues(DeploymentPro Entries = incoming.Secrets.Entries.Select(secret => { DeploymentProfileSecret selected = secret; - if (secret.State == ProfileValueState.Omitted) + bool matchingDomain = secret.Purpose != ProfileSecretPurpose.DomainJoinPassword || sharedDomainIdentities.Contains(secret.Identity); + if (secret.State == ProfileValueState.Omitted && matchingDomain) { selected = local.Secrets.Entries.SingleOrDefault(candidate => candidate.Purpose == secret.Purpose && candidate.Identity == secret.Identity && candidate.State is ProfileValueState.Present or ProfileValueState.Blank) ?? secret; diff --git a/src/Foundry.Core/Services/Profiles/DeploymentProfilePackageService.cs b/src/Foundry.Core/Services/Profiles/DeploymentProfilePackageService.cs index 6334c9db6..cf5378729 100644 --- a/src/Foundry.Core/Services/Profiles/DeploymentProfilePackageService.cs +++ b/src/Foundry.Core/Services/Profiles/DeploymentProfilePackageService.cs @@ -29,7 +29,7 @@ public sealed class DeploymentProfilePackageService : IDeploymentProfilePackageS private const int Iterations = 600_000; private static ReadOnlySpan Magic => "FNDRYPRF"u8; - /// Creates an encrypted portable export. Only explicitly supplied logical secrets and assets are included. + /// Creates an encrypted portable export. Domain passwords are always omitted; other explicitly supplied logical secrets and assets are included. public byte[] Export(DeploymentProfileDocument profile, ReadOnlySpan passphrase) { ValidatePassphrase(passphrase); @@ -37,7 +37,15 @@ public byte[] Export(DeploymentProfileDocument profile, ReadOnlySpan passp byte[] key = PasswordKeyDerivation.DeriveKey(passphrase, header.AsSpan(14, 16), Iterations, 32); try { - return EncryptPayload(profile, key, header, [], true); + DeploymentProfileDocument exported = profile with + { + Secrets = new() + { + Entries = profile.Secrets.Entries.Select(secret => secret.Purpose == ProfileSecretPurpose.DomainJoinPassword + ? secret with { State = ProfileValueState.Omitted, Value = null } : secret).ToArray() + } + }; + return EncryptPayload(exported, key, header, [], true); } finally { diff --git a/src/Foundry.Core/Services/Profiles/DeploymentProfilePayload.cs b/src/Foundry.Core/Services/Profiles/DeploymentProfilePayload.cs index 35f9dc27d..ef91170b0 100644 --- a/src/Foundry.Core/Services/Profiles/DeploymentProfilePayload.cs +++ b/src/Foundry.Core/Services/Profiles/DeploymentProfilePayload.cs @@ -18,6 +18,14 @@ internal static class DeploymentProfilePayload private const int MaximumAssetBytes = 4 * 1024 * 1024; private const int MaximumTotalAssetBytes = 8 * 1024 * 1024; private static readonly UTF8Encoding SecretEncoding = new(false, true); + + // Written by earlier builds: the single-domain shape, then the two technician-choice settings that the number of + // listed domains and OUs now replaces. The strict reader would otherwise reject the whole profile. + private static readonly string[] RetiredDomainJoinMembers = + [ + "domainName", "accountName", "ouCatalogDomain", "organizationalUnits", "defaultOuId", + "allowDomainSelectionDuringDeployment", "allowOuSelectionDuringDeployment" + ]; private static readonly JsonSerializerOptions Options = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -43,6 +51,7 @@ internal static byte[] Serialize(DeploymentProfileDocument profile, bool portabl internal static DeploymentProfileDocument Deserialize(byte[] bytes, bool portable) { DeploymentProfileDocument? profile = null; + byte[]? current = null; try { using JsonDocument json = JsonDocument.Parse(bytes, new() { MaxDepth = 32 }); @@ -71,7 +80,8 @@ internal static DeploymentProfileDocument Deserialize(byte[] bytes, bool portabl { RequireProperties(asset, "id", "kind", "relativePath", "state"); } - profile = JsonSerializer.Deserialize(bytes, Options) + current = WithoutRetiredDomainJoinMembers(json.RootElement); + profile = JsonSerializer.Deserialize(current ?? bytes, Options) ?? throw new InvalidDataException("The profile payload is missing."); Validate(profile); return profile with { Configuration = ProjectConfiguration(profile.Configuration, portable) }; @@ -86,6 +96,63 @@ internal static DeploymentProfileDocument Deserialize(byte[] bytes, bool portabl ClearBuffers(profile); throw; } + finally + { + if (current is not null) CryptographicOperations.ZeroMemory(current); + } + } + + /// + /// Rewrites a payload saved by an earlier build without its retired Domain Join members, so the profile loads + /// instead of being rejected: a single-domain profile yields an empty domain list, and a profile that only + /// carried the technician-choice settings keeps its domains. Returns when the payload + /// carries none of them. The copy holds the profile secrets; the caller clears it. + /// + private static byte[]? WithoutRetiredDomainJoinMembers(JsonElement root) + { + if (!root.GetProperty("configuration").TryGetProperty("domainJoin", out JsonElement domainJoin) + || domainJoin.ValueKind != JsonValueKind.Object || !RetiredDomainJoinMembers.Any(name => domainJoin.TryGetProperty(name, out _))) + { + return null; + } + using var buffer = new BoundedPayloadStream(); + try + { + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartObject(); + foreach (JsonProperty property in root.EnumerateObject()) + { + if (!property.NameEquals("configuration")) + { + property.WriteTo(writer); + continue; + } + writer.WriteStartObject(property.Name); + foreach (JsonProperty section in property.Value.EnumerateObject()) + { + if (!section.NameEquals("domainJoin")) + { + section.WriteTo(writer); + continue; + } + writer.WriteStartObject(section.Name); + foreach (JsonProperty member in section.Value.EnumerateObject().Where(member => !RetiredDomainJoinMembers.Contains(member.Name, StringComparer.Ordinal))) + { + member.WriteTo(writer); + } + writer.WriteEndObject(); + } + writer.WriteEndObject(); + } + writer.WriteEndObject(); + } + return buffer.ToArray(); + } + finally + { + CryptographicOperations.ZeroMemory(buffer.GetBuffer()); + } } private static FoundryConfigurationDocument ProjectConfiguration(FoundryConfigurationDocument configuration, bool portable) @@ -120,7 +187,8 @@ private static void Validate(DeploymentProfileDocument profile) { if (secret is null || !Enum.IsDefined(secret.Purpose) || !Enum.IsDefined(secret.State) || !IsIdentity(secret.Identity) || !identities.Add($"{(int)secret.Purpose}:{secret.Identity}") - || (secret.State == ProfileValueState.Present ? !IsValidSecret(secret.Value) : secret.Value is not null)) + || (secret.State == ProfileValueState.Present ? !IsValidSecret(secret.Value) : secret.Value is not null) + || (secret.Purpose == ProfileSecretPurpose.DomainJoinPassword && secret.Value?.Contains((byte)0) == true)) { throw new InvalidDataException("The profile secret record is invalid."); } @@ -199,6 +267,8 @@ private static void ValidateConfigurationCollections(FoundryConfigurationDocumen ValidateList(configuration.OperatingSystemSelection.AllowedEditions); ValidateList(configuration.Autopilot.HardwareHashUpload.KnownGroupTags); ValidateList(configuration.Autopilot.Profiles); + ValidateList(configuration.DomainJoin.Domains); + foreach (DomainJoinDomainSettings domain in configuration.DomainJoin.Domains) ValidateList(domain.OrganizationalUnits); ValidateList(configuration.Unattend.Files); var ids = new HashSet(StringComparer.OrdinalIgnoreCase); var folders = new HashSet(StringComparer.OrdinalIgnoreCase); diff --git a/src/Foundry.Core/Services/Profiles/DeploymentProfileSecretBinding.cs b/src/Foundry.Core/Services/Profiles/DeploymentProfileSecretBinding.cs index 771307cbc..aefb9bcaf 100644 --- a/src/Foundry.Core/Services/Profiles/DeploymentProfileSecretBinding.cs +++ b/src/Foundry.Core/Services/Profiles/DeploymentProfileSecretBinding.cs @@ -19,6 +19,10 @@ public static string Identity(ProfileSecretPurpose purpose, DeploymentProfileDoc FoundryConfigurationDocument configuration = profile.Configuration; string[] context = purpose switch { + // Bound to the account alone: a shared account serves several domains, and renaming a domain must not orphan it. + ProfileSecretPurpose.DomainJoinPassword => [string.IsNullOrWhiteSpace(accountId) + ? throw new ArgumentException("A domain join secret is identified by its account.", nameof(accountId)) + : DomainJoinCredentialContext.CanonicalizeAccountName(accountId)], ProfileSecretPurpose.DeploymentPassword => ["media-protection"], ProfileSecretPurpose.WifiPassphrase => [configuration.Network.Wifi.Ssid ?? string.Empty, configuration.Network.Wifi.SecurityType ?? string.Empty], ProfileSecretPurpose.AdministratorPassword => ["builtin-administrator"], diff --git a/src/Foundry.Core/Services/Telemetry/BootMediaTelemetryPropertyBuilder.cs b/src/Foundry.Core/Services/Telemetry/BootMediaTelemetryPropertyBuilder.cs index fb98b2452..e190b6f12 100644 --- a/src/Foundry.Core/Services/Telemetry/BootMediaTelemetryPropertyBuilder.cs +++ b/src/Foundry.Core/Services/Telemetry/BootMediaTelemetryPropertyBuilder.cs @@ -101,6 +101,7 @@ public static class BootMediaTelemetryPropertyBuilder AddCustomizationTelemetryProperties(properties, document.Customization); AddPostInstallationTelemetryProperties(properties, postInstallation); + AddDomainJoinTelemetryProperties(properties, document.DomainJoin ?? new DomainJoinSettings()); AddOperatingSystemSelectionTelemetryProperties(properties, document.OperatingSystemSelection); properties["customization_any_enabled"] = (bool)properties["customization_any_enabled"]! || document.OperatingSystemSelection.IsEnabled || unattend.IsEnabled || customImages.IsEnabled || postInstallation.IsEnabled; @@ -110,6 +111,27 @@ public static class BootMediaTelemetryPropertyBuilder return properties; } + /// + /// Reports how Domain Join is configured without the domain, account or any OU name. A disabled mode reports + /// neutral values so a saved but inactive draft is not counted as usage. + /// + private static void AddDomainJoinTelemetryProperties(IDictionary properties, DomainJoinSettings settings) + { + bool enabled = settings.IsEnabled; + IReadOnlyList domains = settings.Domains ?? []; + int organizationalUnitCount = domains.Sum(domain => domain.OrganizationalUnits?.Count ?? 0); + properties["domain_join_enabled"] = enabled; + properties["domain_join_mode"] = !enabled ? "disabled" : settings.Mode == DomainJoinMode.Automatic ? "zero_touch" : "interactive"; + properties["domain_join_domain_count"] = enabled ? Math.Clamp(domains.Count, 0, DomainJoinConfigurationValidator.MaximumDomains) : 0; + + properties["domain_join_shared_account_used"] = enabled && settings.Mode == DomainJoinMode.Automatic && + domains.Any(domain => string.IsNullOrWhiteSpace(domain.AccountName)); + properties["domain_join_ou_count"] = enabled + ? Math.Clamp(organizationalUnitCount, 0, DomainJoinConfigurationValidator.MaximumDomains * DomainJoinConfigurationValidator.MaximumOrganizationalUnits) + : 0; + properties["domain_join_default_ou_set"] = enabled && domains.Any(domain => !string.IsNullOrWhiteSpace(domain.DefaultOuId)); + } + private static void AddPostInstallationTelemetryProperties( IDictionary properties, PreOobeSettings settings) diff --git a/src/Foundry.Core/Services/WinPe/WinPeRuntimePayloadProvisioningOptions.cs b/src/Foundry.Core/Services/WinPe/WinPeRuntimePayloadProvisioningOptions.cs index 2b67a97b9..e1252cc8f 100644 --- a/src/Foundry.Core/Services/WinPe/WinPeRuntimePayloadProvisioningOptions.cs +++ b/src/Foundry.Core/Services/WinPe/WinPeRuntimePayloadProvisioningOptions.cs @@ -57,6 +57,14 @@ public sealed record WinPeRuntimePayloadProvisioningOptions /// Gets Foundry.PostInstall payload options, following the Deploy debug selection. public WinPeRuntimePayloadApplicationOptions PostInstall { get; init; } = new(); + /// Enables the selected local runtime or paired release source when domain actions require it. + public WinPeRuntimePayloadProvisioningOptions WithDomainJoinRuntime(bool enabled) => !enabled ? this : this with + { + PostInstall = PostInstall.IsEnabled || !string.IsNullOrWhiteSpace(PostInstall.ArchivePath) || !string.IsNullOrWhiteSpace(PostInstall.ProjectPath) + ? PostInstall with { IsEnabled = true } + : new() { IsEnabled = true, ProvisioningSource = WinPeProvisioningSource.Release } + }; + /// /// Creates development-time payload options from debugger state and environment variables. /// @@ -111,7 +119,19 @@ public static WinPeRuntimePayloadProvisioningOptions CreateDeveloperOptions( getEnvironmentVariable, projectDiscoveryStartPath) }; - if (!options.Deploy.IsEnabled) return options; + if (!options.Deploy.IsEnabled) + { + string archive = (getEnvironmentVariable(WinPeRuntimePayloadEnvironmentVariables.DebugPostInstallArchive) ?? string.Empty).Trim(); + string project = (getEnvironmentVariable(WinPeRuntimePayloadEnvironmentVariables.DebugPostInstallProject) ?? string.Empty).Trim(); + return string.IsNullOrWhiteSpace(archive) && string.IsNullOrWhiteSpace(project) ? options : options with + { + PostInstall = new() + { + ArchivePath = archive, + ProjectPath = string.IsNullOrWhiteSpace(archive) ? project : string.Empty + } + }; + } string? discoveryPath = string.IsNullOrWhiteSpace(options.Deploy.ProjectPath) ? projectDiscoveryStartPath : Path.GetDirectoryName(Path.GetFullPath(options.Deploy.ProjectPath)); return options with diff --git a/src/Foundry.Core/Services/WinPe/WinPeRuntimePayloadProvisioningService.cs b/src/Foundry.Core/Services/WinPe/WinPeRuntimePayloadProvisioningService.cs index 5bd668a8b..3c8947de0 100644 --- a/src/Foundry.Core/Services/WinPe/WinPeRuntimePayloadProvisioningService.cs +++ b/src/Foundry.Core/Services/WinPe/WinPeRuntimePayloadProvisioningService.cs @@ -170,10 +170,32 @@ private async Task PrepareApplicationAsyn { ValidateBootstrapCapability(archive); } + if (applicationName == "Foundry.PostInstall") + await ValidatePostInstallManifestAsync(archive, runtimeIdentifier, cancellationToken).ConfigureAwait(false); return applicationOptions with { ArchivePath = archivePath, ArchiveSha256 = hash }; } + private static async Task ValidatePostInstallManifestAsync(ZipArchive archive, string runtimeIdentifier, CancellationToken cancellationToken) + { + var entries = archive.Entries.Where(entry => entry.FullName.Equals(Models.PreOobe.PostInstallRuntimeManifest.FileName, StringComparison.OrdinalIgnoreCase)).ToArray(); + if (entries.Length != 1 || entries[0].Length is <= 0 or > 65536) + throw new InvalidDataException("PostInstall runtime metadata is missing, ambiguous or exceeds its size limit."); + await using var input = entries[0].Open(); + using var bounded = new MemoryStream(); + byte[] buffer = new byte[8192]; + int count; + while ((count = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) != 0) + { + if (bounded.Length + count > 65536) throw new InvalidDataException("PostInstall runtime metadata exceeds its size limit."); + bounded.Write(buffer, 0, count); + } + var manifest = JsonSerializer.Deserialize(bounded.ToArray(), + Configuration.ConfigurationJsonDefaults.SerializerOptions); + if (manifest is null || manifest.SchemaVersion != 1 || manifest.ContractVersion != 1 || manifest.RuntimeIdentifier != runtimeIdentifier) + throw new InvalidDataException("PostInstall runtime metadata is incompatible with the selected Windows image or deployment contract."); + } + public async Task ProvisionAsync( WinPeRuntimePayloadProvisioningOptions options, IProgress? downloadProgress = null, diff --git a/src/Foundry.Deploy.Tests/DeployConfigurationServiceTests.cs b/src/Foundry.Deploy.Tests/DeployConfigurationServiceTests.cs index 7e7bfbaf8..f5c52908a 100644 --- a/src/Foundry.Deploy.Tests/DeployConfigurationServiceTests.cs +++ b/src/Foundry.Deploy.Tests/DeployConfigurationServiceTests.cs @@ -12,6 +12,77 @@ namespace Foundry.Deploy.Tests; public sealed class DeployConfigurationServiceTests { + [Theory] + [InlineData("""{"isEnabled":true,"mode":1,"defaultDomainId":"a","domains":[{"id":"a","domainName":"corp.test","accountName":"CORP\\join","encryptedCredentials":{}}]}""")] + [InlineData("""{"isEnabled":true,"mode":1,"defaultDomainId":"a","domains":[{"id":"a","domainName":"corp.test","accountName":"CORP\\join"}]}""")] + [InlineData("""{"isEnabled":true,"mode":0,"defaultDomainId":"a","domains":[{"id":"a","domainName":"corp.test","encryptedCredentials":{}}]}""")] + [InlineData("""{"isEnabled":true,"mode":0,"defaultDomainId":"a","domains":[{"id":"a","domainName":"corp.test","accountName":"CORP\\join"}]}""")] + [InlineData("""{"isEnabled":true,"mode":0,"defaultDomainId":"a","domains":[{"id":"a","domainName":"invalid domain"}]}""")] + [InlineData("""{"isEnabled":true,"mode":0,"defaultDomainId":"missing","domains":[{"id":"a","domainName":"corp.test"}]}""")] + [InlineData("""{"isEnabled":true,"mode":0,"defaultDomainId":"a","domains":[{"id":"a","domainName":"corp.test","organizationalUnits":null}]}""")] + [InlineData("""{"isEnabled":true,"domains":null}""")] + [InlineData("""{"isEnabled":true,"domains":[null]}""")] + public void LoadOptional_RejectsUnprotectedAutomaticOrMalformedDomainMetadata(string domain) + { + using var directory = new TemporaryDirectory(); + string path = CreateJsonFile(directory.Path, "config.json", "{\"domainJoin\":" + domain + "}"); + DeployConfigurationLoadResult result = new DeployConfigurationService( + NullLogger.Instance, path).LoadOptional(); + Assert.Null(result.Document); + Assert.NotEmpty(result.FailureMessage!); + Assert.DoesNotContain("CORP", result.FailureMessage); + } + + [Theory] + [InlineData("""{"id":"b","domainName":"emea.test","accountName":"EMEA\\join","encryptedCredentials":{}}""", true)] + [InlineData("""{"id":"b","domainName":"emea.test","accountName":"EMEA\\join"}""", false)] + [InlineData("""{"id":"b","domainName":"emea.test","encryptedCredentials":{}}""", false)] + // The account already has a payload on the first domain, so only the per-domain rule can refuse this one. + [InlineData("""{"id":"b","domainName":"emea.test","accountName":"CORP\\join"}""", false)] + public void LoadOptional_RequiresCredentialsForEveryZeroTouchDomainOnProtectedMedia(string second, bool loads) + { + using var directory = new TemporaryDirectory(); + string path = CreateJsonFile(directory.Path, "config.json", + """{"protection":{"isEnabled":true},"domainJoin":{"isEnabled":true,"mode":1,"defaultDomainId":"a","domains":[""" + + """{"id":"a","domainName":"corp.test","accountName":"CORP\\join","encryptedCredentials":{}},""" + second + "]}}"); + + DeployConfigurationLoadResult result = new DeployConfigurationService( + NullLogger.Instance, path).LoadOptional(); + + Assert.Equal(loads, result.Document is not null); + } + + [Fact] + public void LoadOptional_PreservesInteractiveSettingsOnUnprotectedMedia() + { + using var directory = new TemporaryDirectory(); + string path = CreateJsonFile(directory.Path, "config.json", + """{"domainJoin":{"isEnabled":true,"mode":0,"defaultDomainId":"a","domains":[{"id":"a","domainName":"corp.test"}]}}"""); + DeployConfigurationLoadResult result = new DeployConfigurationService( + NullLogger.Instance, path).LoadOptional(); + Assert.NotNull(result.Document); + Assert.True(result.Document.DomainJoin.IsEnabled); + var domain = Assert.Single(result.Document.DomainJoin.Domains); + Assert.Equal("corp.test", domain.DomainName); + Assert.Null(domain.AccountName); + Assert.Null(domain.EncryptedCredentials); + Assert.False(result.Document.Protection.IsEnabled); + } + + [Fact] + public void LoadOptional_RejectsConflictingDomainAndAutopilotBeforeLaunch() + { + using var directory = new TemporaryDirectory(); + string path = CreateJsonFile(directory.Path, "config.json", + """{"domainJoin":{"isEnabled":true},"autopilot":{"isEnabled":true}}"""); + var service = new DeployConfigurationService(NullLogger.Instance, path); + + DeployConfigurationLoadResult result = service.LoadOptional(); + + Assert.Null(result.Document); + Assert.NotEmpty(result.FailureMessage!); + } + private static readonly BootMediaRuntimeContext ProductionRuntime = new("26.10.3.2", true, false, false); [Fact] diff --git a/src/Foundry.Deploy.Tests/DeploymentExecutionServiceTests.cs b/src/Foundry.Deploy.Tests/DeploymentExecutionServiceTests.cs index d3ed3b53e..cfbec5a44 100644 --- a/src/Foundry.Deploy.Tests/DeploymentExecutionServiceTests.cs +++ b/src/Foundry.Deploy.Tests/DeploymentExecutionServiceTests.cs @@ -8,11 +8,45 @@ using Foundry.Deploy.Services.Deployment; using Foundry.Deploy.Services.Security; using Microsoft.Extensions.Logging.Abstractions; +using Foundry.Deploy.Services.DomainJoin; namespace Foundry.Deploy.Tests; public sealed class DeploymentExecutionServiceTests { + [Theory] + [InlineData("denied")] + [InlineData("canceled")] + [InlineData("failed")] + [InlineData("success")] + public async Task ExecuteAsync_DisposesDomainInputOnEveryExit(string exit) + { + using var cancellation = new CancellationTokenSource(); + if (exit == "canceled") cancellation.Cancel(); + using var keys = new DeploymentSecretKeySession(); + var orchestrator = new RecordingOrchestrator + { + Run = _ => exit == "failed" ? Task.FromException(new InvalidOperationException("failure")) : + Task.FromResult(new DeploymentResult { IsSuccess = true, Message = "Completed" }) + }; + var service = new DeploymentExecutionService(orchestrator, new FakeConfigurationService(exit == "denied"), keys, + NullLogger.Instance); + var input = new DomainJoinPreparedInput(new("corp.test", "CORP\\join"), "TEST-PC", null, "secret".AsSpan()); + ReadOnlyMemory password = input.Password; + DeploymentContext request = CreateContext() with + { + DomainJoinRequest = new(DomainJoinDeploymentDisposition.Ready), + DomainJoinIntent = new("corp.test", "TEST-PC", null) + }; + + DeploymentExecutionRunResult result = await service.ExecuteAsync(request, input, cancellation.Token); + + Assert.Equal(exit == "success", result.IsSuccess); + Assert.All(password.ToArray(), value => Assert.Equal('\0', value)); + Assert.DoesNotContain("secret", result.Message ?? ""); + Assert.DoesNotContain("CORP", result.Message ?? ""); + } + [Fact] public async Task ExecuteAsync_ForwardsCallerTokenAndCancelledOutcome() { @@ -182,5 +216,8 @@ public Task RunAsync(DeploymentContext context, CancellationTo ReceivedToken = cancellationToken; return Run?.Invoke(cancellationToken) ?? Task.FromResult(Result); } + + public Task RunAsync(DeploymentContext context, DomainJoinPreparedInput? input, CancellationToken cancellationToken = default) => + RunAsync(context, cancellationToken); } } diff --git a/src/Foundry.Deploy.Tests/DeploymentLaunchPreparationServiceTests.cs b/src/Foundry.Deploy.Tests/DeploymentLaunchPreparationServiceTests.cs index 2f0104447..da4d12271 100644 --- a/src/Foundry.Deploy.Tests/DeploymentLaunchPreparationServiceTests.cs +++ b/src/Foundry.Deploy.Tests/DeploymentLaunchPreparationServiceTests.cs @@ -9,11 +9,203 @@ using System.Globalization; using System.Text.Json; using Foundry.Utilities.Storage; +using Foundry.Deploy.Services.DomainJoin; +using Foundry.Deploy.Services.Security; +using DeployDomainJoinSettings = Foundry.Core.Models.Configuration.Deploy.DeployDomainJoinSettings; namespace Foundry.Deploy.Tests; public sealed class DeploymentLaunchPreparationServiceTests { + [Theory] + [InlineData(false)] + [InlineData(true)] + public void MissingOuSelectionStopsBeforeDestructiveConfirmation(bool automatic) + { + using var keys = new DeploymentSecretKeySession(); + byte[] key = new byte[32]; + keys.SetKey(key); + var shell = new FakeApplicationShellService(); + // Two listed OUs make the technician choose; a single one would be used without a selection. + DeployDomainJoinSettings settings = DomainJoinPreparationServiceTests.WithTwoOus(DomainJoinPreparationServiceTests.WithOneOu(automatic + ? DomainJoinPreparationServiceTests.Automatic(new("corp.test", "CORP\\join"), key) : new() { IsEnabled = true })); + using DomainJoinSubmission submission = Submission(); + DeploymentLaunchPreparationService service = CreateService(shell, keys); + + using DeploymentLaunchPreparationResult result = service.Prepare(CreateRequest(CreateDisk()), settings, submission); + + Assert.False(result.IsReadyToStart); + Assert.Null(result.Context); + Assert.Null(result.TakeDomainJoinInput()); + Assert.Equal(0, shell.ConfirmationCallCount); + } + + [Theory] + [InlineData("OU=Field,DC=corp,DC=test", true)] + [InlineData("OU=Field,DC=other,DC=test", false)] + public void TypedOuIsValidatedBeforeDestructiveConfirmation(string typedOu, bool valid) + { + using var keys = new DeploymentSecretKeySession(); + var shell = new FakeApplicationShellService(); + using DomainJoinSubmission submission = Submission(typedOu: typedOu); + DeploymentLaunchPreparationService service = CreateService(shell, keys); + + using DeploymentLaunchPreparationResult result = service.Prepare(CreateRequest(CreateDisk()), new() { IsEnabled = true }, submission); + + Assert.Equal(valid, result.IsReadyToStart); + Assert.Equal(valid ? 1 : 0, shell.ConfirmationCallCount); + if (valid) + { + Assert.Equal(typedOu, result.Context!.DomainJoinIntent!.TargetOuDn); + Assert.DoesNotContain("CORP", JsonSerializer.Serialize(result.Context)); + Assert.DoesNotContain("secret", JsonSerializer.Serialize(result.Context)); + } + else + { + Assert.Null(result.Context); + Assert.Null(result.TakeDomainJoinInput()); + } + } + + [Fact] + public void InteractiveInputReachesTheContextWithoutCredentials() + { + using var keys = new DeploymentSecretKeySession(); + var shell = new FakeApplicationShellService(); + using DomainJoinSubmission submission = Submission(); + DeploymentLaunchPreparationService service = CreateService(shell, keys); + using DeploymentLaunchPreparationResult result = service.Prepare(CreateRequest(CreateDisk()), new() { IsEnabled = true }, submission); + Assert.True(result.IsReadyToStart); + Assert.Equal(1, shell.ConfirmationCallCount); + Assert.Equal(new DomainJoinDeploymentIntent("corp.test", "LAB-01", null), result.Context!.DomainJoinIntent); + Assert.Equal(DomainJoinDeploymentDisposition.Ready, result.Context.DomainJoinRequest!.Disposition); + // The erase confirmation is about the disk and the image; the join is reviewed on the summary page. + Assert.DoesNotContain("corp.test", shell.LastConfirmationMessage); + string json = JsonSerializer.Serialize(result.Context); + Assert.DoesNotContain("CORP", json); + Assert.DoesNotContain("secret", json); + Assert.DoesNotContain("encryptedCredentials", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("CORP", result.Context.ToString()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void MissingWizardInputNeverErasesDisk(bool automatic) + { + using var keys = new DeploymentSecretKeySession(); + byte[] key = new byte[32]; + keys.SetKey(key); + var shell = new FakeApplicationShellService(); + DeploymentLaunchPreparationService service = CreateService(shell, keys); + // Zero-touch only needs wizard input when the technician has to choose, here between two OUs. + DeployDomainJoinSettings settings = automatic + ? DomainJoinPreparationServiceTests.WithTwoOus(DomainJoinPreparationServiceTests.WithOneOu(DomainJoinPreparationServiceTests.Automatic(new("corp.test", "CORP\\join"), key))) + : new() { IsEnabled = true }; + using DeploymentLaunchPreparationResult result = service.Prepare(CreateRequest(CreateDisk()), settings, domainJoinSubmission: null); + Assert.False(result.IsReadyToStart); + Assert.Null(result.Context); + Assert.Equal(0, shell.ConfirmationCallCount); + Assert.Null(result.TakeDomainJoinInput()); + } + + [Fact] + public void DeclinedConfirmationDisposesInput() + { + var input = new DomainJoinPreparedInput(new("corp.test", "CORP\\join"), "LAB-01", null, "secret".AsSpan()); + ReadOnlyMemory password = input.Password; + var shell = new FakeApplicationShellService { ConfirmationResult = false }; + var service = new DeploymentLaunchPreparationService(shell, + domainJoinPreparationService: new OwnedInputPreparation(input)); + using DeploymentLaunchPreparationResult result = service.Prepare(CreateRequest(CreateDisk()), new() { IsEnabled = true }); + Assert.False(result.IsReadyToStart); + Assert.Null(result.TakeDomainJoinInput()); + Assert.All(password.ToArray(), value => Assert.Equal('\0', value)); + } + + private sealed class OwnedInputPreparation(DomainJoinPreparedInput input) : IDomainJoinPreparationService + { + public DomainJoinPreparationResult Prepare(DeployDomainJoinSettings settings, string computerName, DomainJoinSubmission? submission) => + DomainJoinPreparationResult.Ready(input); + } + + /// Fails the test when credentials are prepared on a path that must not touch them. + private sealed class ForbiddenPreparation : IDomainJoinPreparationService + { + public DomainJoinPreparationResult Prepare(DeployDomainJoinSettings settings, string computerName, DomainJoinSubmission? submission) => + throw new InvalidOperationException("Credentials must not be prepared."); + } + + private static DomainJoinSubmission Submission(string? typedOu = null) => + new(null, "corp.test", "CORP\\join", null, "secret".AsSpan(), typedOu); + + private static DeploymentLaunchPreparationService CreateService(FakeApplicationShellService shell, DeploymentSecretKeySession keys) => + new(shell, domainJoinPreparationService: new DomainJoinPreparationService(keys)); + + [Fact] + public void DisposeClearsUntransferredInput() + { + var input = new DomainJoinPreparedInput(new("corp.test", "CORP\\join"), "LAB-01", null, "secret".AsSpan()); + ReadOnlyMemory password = input.Password; + var service = new DeploymentLaunchPreparationService(new FakeApplicationShellService(), + domainJoinPreparationService: new OwnedInputPreparation(input)); + DeploymentLaunchPreparationResult result = service.Prepare(CreateRequest(CreateDisk()), new() { IsEnabled = true }); + Assert.True(result.IsReadyToStart); + result.Dispose(); + Assert.Null(result.TakeDomainJoinInput()); + Assert.All(password.ToArray(), value => Assert.Equal('\0', value)); + } + + [Theory] + [InlineData("Home")] + [InlineData("Core")] + [InlineData("Home N")] + [InlineData("CoreSingleLanguage")] + public void KnownUnsupportedEditionNeverPreparesCredentials(string edition) + { + var shell = new FakeApplicationShellService(); + var service = new DeploymentLaunchPreparationService(shell, domainJoinPreparationService: new ForbiddenPreparation()); + DeploymentLaunchRequest request = CreateRequest(CreateDisk()); + request = request with { SelectedOperatingSystem = (OperatingSystemCatalogItem)request.SelectedOperatingSystem! with { Edition = edition } }; + using DomainJoinSubmission submission = Submission(); + using DeploymentLaunchPreparationResult result = service.Prepare(request, new() { IsEnabled = true }, submission); + Assert.True(result.IsReadyToStart); + Assert.Null(result.TakeDomainJoinInput()); + Assert.Null(result.Context!.DomainJoinIntent); + Assert.Equal(DomainJoinDeploymentDisposition.UnsupportedEdition, result.Context.DomainJoinRequest!.Disposition); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DryRunSimulatesTheJoinTargetWithoutPreparingCredentials(bool hasWizardInput) + { + var service = new DeploymentLaunchPreparationService(new FakeApplicationShellService(), domainJoinPreparationService: new ForbiddenPreparation()); + using DomainJoinSubmission? submission = hasWizardInput ? Submission(typedOu: "OU=Field,DC=corp,DC=test") : null; + using DeploymentLaunchPreparationResult result = service.Prepare(CreateRequest(null, isDryRun: true), new() { IsEnabled = true }, submission); + Assert.True(result.IsReadyToStart); + Assert.Null(result.TakeDomainJoinInput()); + Assert.Equal(hasWizardInput ? new DomainJoinDeploymentIntent("corp.test", "LAB-01", "OU=Field,DC=corp,DC=test") : null, + result.Context!.DomainJoinIntent); + Assert.Equal(DomainJoinDeploymentDisposition.DryRun, result.Context.DomainJoinRequest!.Disposition); + } + + [Fact] + public void TransferredInputSurvivesResultDisposal() + { + using var keys = new DeploymentSecretKeySession(); + using DomainJoinSubmission submission = Submission(); + DeploymentLaunchPreparationService service = CreateService(new FakeApplicationShellService(), keys); + var result = service.Prepare(CreateRequest(CreateDisk()), new() { IsEnabled = true }, submission); + using DomainJoinPreparedInput? input = result.TakeDomainJoinInput(); + ReadOnlyMemory password = input!.Password; + result.Dispose(); + Assert.Null(result.TakeDomainJoinInput()); + Assert.Equal("secret", new string(password.Span)); + input.Dispose(); + Assert.All(password.ToArray(), value => Assert.Equal('\0', value)); + } + [Theory] [InlineData(true)] [InlineData(false)] diff --git a/src/Foundry.Deploy.Tests/DeploymentOrchestratorTests.cs b/src/Foundry.Deploy.Tests/DeploymentOrchestratorTests.cs index 65e6a9491..a30f830e0 100644 --- a/src/Foundry.Deploy.Tests/DeploymentOrchestratorTests.cs +++ b/src/Foundry.Deploy.Tests/DeploymentOrchestratorTests.cs @@ -610,6 +610,11 @@ await orchestrator.RunAsync(new DeploymentContext Assert.Equal("hardware_hash_upload", telemetryEvent.Properties["deploy_autopilot_provisioning_mode"]); Assert.Equal("planned", telemetryEvent.Properties["deploy_autopilot_hash_upload_state"]); Assert.True((bool)telemetryEvent.Properties["deploy_autopilot_hash_group_tag_selected"]!); + Assert.False((bool)telemetryEvent.Properties["deploy_domain_join_enabled"]!); + Assert.Equal("disabled", telemetryEvent.Properties["deploy_domain_join_mode"]); + Assert.Equal("none", telemetryEvent.Properties["deploy_domain_join_ou_source"]); + Assert.Equal("none", telemetryEvent.Properties["deploy_domain_join_domain_source"]); + Assert.Equal("disabled", telemetryEvent.Properties["deploy_domain_join_status"]); Assert.True((bool)telemetryEvent.Properties["deploy_oobe_enabled"]!); Assert.True((bool)telemetryEvent.Properties["deploy_oobe_administrator_enabled"]!); Assert.Equal(1, telemetryEvent.Properties["deploy_oobe_additional_account_count"]); diff --git a/src/Foundry.Deploy.Tests/DeploymentPlanTests.cs b/src/Foundry.Deploy.Tests/DeploymentPlanTests.cs index 85b54c7f1..42302856d 100644 --- a/src/Foundry.Deploy.Tests/DeploymentPlanTests.cs +++ b/src/Foundry.Deploy.Tests/DeploymentPlanTests.cs @@ -10,6 +10,18 @@ namespace Foundry.Deploy.Tests; public sealed class DeploymentPlanTests { + [Fact] + public void DomainOnlyDeploymentRequiresPostInstall() + { + DeploymentContext request = Request() with + { + DomainJoinRequest = new(Services.DomainJoin.DomainJoinDeploymentDisposition.Ready), + DomainJoinIntent = new("example.com", "LAB01", null) + }; + Assert.Contains(DeploymentPlan.Build(request), step => step.Name == DeploymentStepNames.StagePreOobeCustomization); + Assert.True(Services.Deployment.PreOobe.PreOobeContentResolver.IsRequired(request)); + } + [Theory] [InlineData(false)] [InlineData(true)] diff --git a/src/Foundry.Deploy.Tests/DeploymentPreflightTests.cs b/src/Foundry.Deploy.Tests/DeploymentPreflightTests.cs index fa7cabbdd..692e07d3c 100644 --- a/src/Foundry.Deploy.Tests/DeploymentPreflightTests.cs +++ b/src/Foundry.Deploy.Tests/DeploymentPreflightTests.cs @@ -25,6 +25,65 @@ namespace Foundry.Deploy.Tests; public sealed class DeploymentPreflightTests { + [Fact] + public async Task InitialOrchestratedPlanIncludesDomainOnlyStaging() + { + using var fixture = new PipelineFixture { Failure = "dead_url" }; + fixture.DomainJoinRequest = new(Services.DomainJoin.DomainJoinDeploymentDisposition.Ready); + fixture.DomainJoinIntent = new("corp.test", "LAB01", null); + using var input = new Services.DomainJoin.DomainJoinPreparedInput(new("corp.test", "CORP\\joiner"), "LAB01", null, "secret"); + await fixture.RunOrchestratedAsync(input); + Assert.Contains(fixture.Progress.First().Plan!, entry => entry.Name == DeploymentStepNames.StagePreOobeCustomization); + } + + [Theory] + [InlineData(false, "Core", DomainJoinExecutionStatus.SkippedUnsupportedEdition)] + [InlineData(true, "Professional", DomainJoinExecutionStatus.SkippedImageComposition)] + [InlineData(false, null, DomainJoinExecutionStatus.Pending)] + public async Task AppliedImageDomainEligibilityWarnsAndContinues(bool embeddedJoin, string? appliedEdition, DomainJoinExecutionStatus status) + { + using var fixture = new PipelineFixture + { + AppliedEdition = appliedEdition, + EmbeddedAnswerFile = embeddedJoin + ? "" + : "LAB01" + }; + fixture.DomainJoinRequest = new(Services.DomainJoin.DomainJoinDeploymentDisposition.Ready); + fixture.DomainJoinIntent = new("corp.test", "LAB01", null); + var result = await fixture.RunAsync(); + Assert.Equal(DeploymentStepState.Succeeded, result.State); + Assert.Equal(status, fixture.Context!.RuntimeState.DomainJoinStatus); + Assert.Contains("apply:1", fixture.Events); + Assert.Equal(appliedEdition ?? "Professional", fixture.Context.RuntimeState.ActualWindowsEditionId); + Assert.NotNull(fixture.Context.Request.DomainJoinIntent); + if (status != DomainJoinExecutionStatus.Pending) Assert.Null(fixture.Context.DomainJoinInput); + } + + [Fact] + public async Task DomainOnlyDeploymentPreparesPostInstallRuntimeBeforeDiskPreparation() + { + using var fixture = new PipelineFixture(); + fixture.DomainJoinRequest = new(Services.DomainJoin.DomainJoinDeploymentDisposition.Ready); + fixture.DomainJoinIntent = new("corp.test", "LAB01", null); + using var context = fixture.CreateContext(); + var resolver = new PreOobeContentResolver { RuntimeExecutablePath = NativeRuntimeFixture.CreateFiles(fixture.Root) }; + var result = await fixture.CreatePostInstallPreflight(resolver).ExecuteAsync(context, TestContext.Current.CancellationToken); + Assert.Equal(DeploymentStepState.Succeeded, result.State); + Assert.DoesNotContain("partition", fixture.Events); + } + + [Fact] + public async Task MissingPreparedDomainInputNeverErasesDisk() + { + using var fixture = new PipelineFixture(); + fixture.DomainJoinRequest = new(Foundry.Deploy.Services.DomainJoin.DomainJoinDeploymentDisposition.Ready); + fixture.DomainJoinIntent = new("corp.test", "LAB01", null); + DeploymentResult result = await fixture.RunOrchestratedAsync(); + Assert.False(result.IsSuccess); + Assert.DoesNotContain("partition", fixture.Events); + } + [Theory] [InlineData(DeploymentMode.Iso, false)] [InlineData(DeploymentMode.Usb, false)] @@ -621,8 +680,12 @@ private sealed class PipelineFixture : IDisposable public string? OptionalFeatureId { get; init; } public bool OptionalFeatureEnable { get; init; } = true; public string Failure { get; set; } = ""; + public string? AppliedEdition { get; init; } = "Professional"; + public string? EmbeddedAnswerFile { get; init; } public CustomImageSelection? CustomImage { get; set; } public Foundry.Core.Models.Configuration.Deploy.DeployPreOobeSettings PostInstall { get; set; } = new(); + public Foundry.Deploy.Services.DomainJoin.DomainJoinDeploymentRequest? DomainJoinRequest { get; set; } + public Foundry.Deploy.Services.DomainJoin.DomainJoinDeploymentIntent? DomainJoinIntent { get; set; } public DeploymentStepExecutionContext? Context { get; private set; } private readonly HttpClient _client; private readonly CancellationTokenSource _cancellation = new(); @@ -670,6 +733,8 @@ private DeploymentContext CreateRequest() TargetDiskIdentity = Identity, TargetComputerName = "LAB01", PreOobe = PostInstall, + DomainJoinRequest = DomainJoinRequest, + DomainJoinIntent = DomainJoinIntent, IsDryRun = IsDryRun, DriverPackSelectionKind = DeferredDriver || DriverPackOverride is not null ? DriverPackSelectionKind.OemCatalog : DriverPackSelectionKind.None, DriverPack = DriverPackOverride ?? (DeferredDriver ? new DriverPackCatalogItem { Manufacturer = "Lenovo", FileName = "drivers.exe", SizeBytes = 100 } : null), @@ -699,17 +764,21 @@ private DeploymentContext CreateRequest() public DeploymentStepExecutionContext CreateContext() { - Context = new DeploymentStepExecutionContext(CreateRequest(), - new DeploymentRuntimeState - { - WorkspaceRoot = Path.Combine(Root, "Workspace"), - Mode = Mode, - ResolvedCache = new CacheResolution { RootPath = Failure == "ram_cache" ? @"X:\Cache" : CacheRoot, Source = "test" } - }, [], new DriverApplicationOperationProgressService(), new DriverApplicationLogService(), new PipelineDisks(this), Progress.Add); + DeploymentContext request = CreateRequest(); + var runtimeState = new DeploymentRuntimeState + { + WorkspaceRoot = Path.Combine(Root, "Workspace"), + Mode = Mode, + ResolvedCache = new CacheResolution { RootPath = Failure == "ram_cache" ? @"X:\Cache" : CacheRoot, Source = "test" } + }; + DomainJoinRuntimeEligibility.Initialize(request, runtimeState); + Context = new DeploymentStepExecutionContext(request, runtimeState, [], new DriverApplicationOperationProgressService(), new DriverApplicationLogService(), new PipelineDisks(this), Progress.Add, + domainJoinInput: DomainJoinRequest?.Disposition == Services.DomainJoin.DomainJoinDeploymentDisposition.Ready + ? new(new("corp.test", "CORP\\joiner"), "LAB01", null, "secret") : null); return Context; } - public async Task RunOrchestratedAsync() + public async Task RunOrchestratedAsync(Services.DomainJoin.DomainJoinPreparedInput? preparedInput = null) { var disks = new PipelineDisks(this); IDeploymentStep[] steps = DeploymentStepNames.ExecutionOrder.Select(name => (IDeploymentStep)(name switch @@ -737,7 +806,9 @@ public async Task RunOrchestratedAsync() _cancellation.Cancel(); } }; - return await orchestrator.RunAsync(CreateRequest() with { ApplyFirmwareUpdates = false }, _cancellation.Token); + return preparedInput is null + ? await orchestrator.RunAsync(CreateRequest() with { ApplyFirmwareUpdates = false }, _cancellation.Token) + : await orchestrator.RunAsync(CreateRequest() with { ApplyFirmwareUpdates = false }, preparedInput, _cancellation.Token); } private sealed class FixturePostInstall(PipelineFixture fixture) : PreOobeContentResolver @@ -846,7 +917,16 @@ public override Task PrepareTargetDiskAsync(DiskIdentity return Task.FromResult(new DeploymentTargetLayout { DiskNumber = 1, SystemPartitionRoot = Path.Combine(_fixture.Root, "System"), WindowsPartitionRoot = _fixture.WindowsRoot, RecoveryPartitionRoot = Path.Combine(_fixture.Root, "Recovery"), RecoveryPartitionLetter = 'R' }); } public override Task ApplyImageAsync(string imagePath, int imageIndex, string windowsPartitionRoot, string scratchDirectory, string workingDirectory, CancellationToken cancellationToken = default, IProgress? progress = null) - { _fixture.Events.Add($"apply:{imageIndex}"); return Task.CompletedTask; } + { + _fixture.Events.Add($"apply:{imageIndex}"); + if (_fixture.EmbeddedAnswerFile is { } xml) + { + string answer = Path.Combine(windowsPartitionRoot, "Windows", "Panther", "unattend.xml"); + Directory.CreateDirectory(Path.GetDirectoryName(answer)!); + File.WriteAllText(answer, xml); + } + return Task.CompletedTask; + } public override Task ConfigureBootAsync(string windowsPartitionRoot, string systemPartitionRoot, int operatingSystemBuildMajor, string workingDirectory, CancellationToken cancellationToken = default) { Assert.Equal(_fixture.WindowsRoot, windowsPartitionRoot); @@ -854,7 +934,7 @@ public override Task ConfigureBootAsync(string windowsPartitionRoot, string syst _fixture.Events.Add("boot"); return Task.CompletedTask; } - public override Task GetAppliedWindowsEditionAsync(string windowsPartitionRoot, string workingDirectory, CancellationToken cancellationToken = default) => Task.FromResult("Professional"); + public override Task GetAppliedWindowsEditionAsync(string windowsPartitionRoot, string workingDirectory, CancellationToken cancellationToken = default) => Task.FromResult(_fixture.AppliedEdition); } private sealed class RejectingProcessRunner : IProcessRunner diff --git a/src/Foundry.Deploy.Tests/DeploymentSessionDebugPreviewTests.cs b/src/Foundry.Deploy.Tests/DeploymentSessionDebugPreviewTests.cs index 02d5000ec..816df2809 100644 --- a/src/Foundry.Deploy.Tests/DeploymentSessionDebugPreviewTests.cs +++ b/src/Foundry.Deploy.Tests/DeploymentSessionDebugPreviewTests.cs @@ -239,6 +239,8 @@ public event EventHandler? StepProgressChanged { add { } public event EventHandler? CompletionStarting { add { } remove { } } public Task RunAsync(DeploymentContext context, CancellationToken cancellationToken = default) => throw new InvalidOperationException("Previews must not execute deployment."); + public Task RunAsync(DeploymentContext context, Foundry.Deploy.Services.DomainJoin.DomainJoinPreparedInput? input, + CancellationToken cancellationToken = default) => RunAsync(context, cancellationToken); } private sealed class EmptyNetworkSnapshotProvider : INetworkAdapterSnapshotProvider diff --git a/src/Foundry.Deploy.Tests/DeploymentSummaryBuilderTests.cs b/src/Foundry.Deploy.Tests/DeploymentSummaryBuilderTests.cs index 1e00f9bb8..81720f458 100644 --- a/src/Foundry.Deploy.Tests/DeploymentSummaryBuilderTests.cs +++ b/src/Foundry.Deploy.Tests/DeploymentSummaryBuilderTests.cs @@ -10,7 +10,7 @@ namespace Foundry.Deploy.Tests; public sealed class DeploymentSummaryBuilderTests { [Fact] - public void Build_AlwaysReturnsSevenCategoriesInApprovedOrder() + public void Build_ReturnsTheCategoriesInApprovedOrder() { var builder = new DeploymentSummaryBuilder(key => key); @@ -29,6 +29,28 @@ public void Build_AlwaysReturnsSevenCategoriesInApprovedOrder() categories.Select(category => category.Title)); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Build_ListsDomainJoinAfterAutopilotOnlyWhenTheMediaEnablesIt(bool hasStep) + { + var builder = new DeploymentSummaryBuilder(key => key); + DeploymentSummarySource source = CreateSource() with + { + IsDomainJoinConfigured = true, + DomainJoinSummary = "corp.test", + HasDomainJoinStep = hasStep + }; + + IReadOnlyList categories = builder.Build(source); + + Assert.Equal(8, categories.Count); + Assert.Equal("Summary.Category.Autopilot", categories[3].Title); + Assert.Equal("DomainJoin.Title", categories[4].Title); + Assert.Equal("corp.test", categories[4].Summary); + Assert.Equal(hasStep ? DeploymentWizardStepId.DomainJoin : null, categories[4].EditStepId); + } + [Fact] public void Build_ShowsAbsentAutopilotAsNeutralWithoutEditAction() { diff --git a/src/Foundry.Deploy.Tests/DeploymentWizardStateServiceTests.cs b/src/Foundry.Deploy.Tests/DeploymentWizardStateServiceTests.cs index 233576a76..42c3494e4 100644 --- a/src/Foundry.Deploy.Tests/DeploymentWizardStateServiceTests.cs +++ b/src/Foundry.Deploy.Tests/DeploymentWizardStateServiceTests.cs @@ -20,6 +20,30 @@ public void CanGoNext_WhenFirstStepIsStillLoadingCatalog_ReturnsFalse() Assert.False(canGoNext); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void DomainJoinStep_BlocksNextAndStartUntilItsInputIsValid(bool hasValidDomainJoinInput) + { + var service = new DeploymentWizardStateService(); + DeploymentWizardStateSnapshot onStep = CreateSnapshot(currentStepId: DeploymentWizardStepId.DomainJoin) with + { + AvailableSteps = DeploymentWizardStepDefinition.CreateSequence(includeAutopilot: false, includeDomainJoin: true), + HasValidDomainJoinInput = hasValidDomainJoinInput + }; + DeploymentWizardStateSnapshot onSummary = CreateSnapshot( + currentStepId: DeploymentWizardStepId.Summary, + hasSelectedOperatingSystem: true, + hasTargetDiskSelection: true, + isTargetComputerNameValid: true, + hasValidDriverPackSelection: true, + hasValidAutopilotSelection: true) with + { HasValidDomainJoinInput = hasValidDomainJoinInput }; + + Assert.Equal(hasValidDomainJoinInput, service.CanGoNext(onStep)); + Assert.Equal(hasValidDomainJoinInput, service.CanStartDeployment(onSummary)); + } + [Fact] public void CanStartDeployment_WhenDebugSafeModeHasNoDiskSelection_ReturnsTrue() { diff --git a/src/Foundry.Deploy.Tests/DeploymentWizardStepDefinitionTests.cs b/src/Foundry.Deploy.Tests/DeploymentWizardStepDefinitionTests.cs index 814f93799..91ea54e49 100644 --- a/src/Foundry.Deploy.Tests/DeploymentWizardStepDefinitionTests.cs +++ b/src/Foundry.Deploy.Tests/DeploymentWizardStepDefinitionTests.cs @@ -33,4 +33,15 @@ public void CreateSequence_IncludesAutopilotImmediatelyBeforeSummary() Assert.Equal(DeploymentWizardStepId.Autopilot, steps[^2].Id); Assert.Equal(DeploymentWizardStepId.Summary, steps[^1].Id); } + + [Fact] + public void CreateSequence_IncludesDomainJoinImmediatelyBeforeSummary() + { + IReadOnlyList steps = + DeploymentWizardStepDefinition.CreateSequence(includeAutopilot: false, includeDomainJoin: true); + + Assert.Equal(DeploymentWizardStepId.Drivers, steps[^3].Id); + Assert.Equal(DeploymentWizardStepId.DomainJoin, steps[^2].Id); + Assert.Equal(DeploymentWizardStepId.Summary, steps[^1].Id); + } } diff --git a/src/Foundry.Deploy.Tests/DomainJoinOobeAccountScreensTests.cs b/src/Foundry.Deploy.Tests/DomainJoinOobeAccountScreensTests.cs new file mode 100644 index 000000000..98afa2455 --- /dev/null +++ b/src/Foundry.Deploy.Tests/DomainJoinOobeAccountScreensTests.cs @@ -0,0 +1,55 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Xml.Linq; +using Foundry.Deploy.Services.Deployment.Unattend; + +namespace Foundry.Deploy.Tests; + +public sealed class DomainJoinOobeAccountScreensTests +{ + private static readonly XNamespace Unattend = "urn:schemas-microsoft-com:unattend"; + + [Fact] + public void Hide_WithoutAnswerFile_CreatesTheOobeSettings() + { + using var workspace = new Workspace(); + + DomainJoinOobeAccountScreens.Hide(workspace.RootPath, "amd64"); + + XElement oobe = LoadOobe(workspace.RootPath); + Assert.Equal("true", oobe.Element(Unattend + "HideOnlineAccountScreens")?.Value); + Assert.Equal("true", oobe.Element(Unattend + "HideLocalAccountScreen")?.Value); + } + + [Fact] + public void Hide_OverridesTheGeneratedDefaultAndKeepsOtherOobeSettings() + { + using var workspace = new Workspace(); + DomainJoinOobeAccountScreens.Hide(workspace.RootPath, "amd64"); + string path = Path.Combine(workspace.RootPath, "Windows", "Panther", "unattend.xml"); + XDocument existing = XDocument.Load(path); + XElement oobe = existing.Descendants(Unattend + "OOBE").Single(); + oobe.SetElementValue(Unattend + "HideOnlineAccountScreens", "false"); + oobe.SetElementValue(Unattend + "HideLocalAccountScreen", null); + oobe.SetElementValue(Unattend + "HideEULAPage", "true"); + existing.Save(path); + + DomainJoinOobeAccountScreens.Hide(workspace.RootPath, "amd64"); + + XElement result = LoadOobe(workspace.RootPath); + Assert.Equal("true", result.Element(Unattend + "HideOnlineAccountScreens")?.Value); + Assert.Equal("true", result.Element(Unattend + "HideLocalAccountScreen")?.Value); + Assert.Equal("true", result.Element(Unattend + "HideEULAPage")?.Value); + } + + private sealed class Workspace : IDisposable + { + public string RootPath { get; } = Directory.CreateTempSubdirectory("foundry-oobe-").FullName; + public void Dispose() => Directory.Delete(RootPath, recursive: true); + } + + private static XElement LoadOobe(string windowsRoot) => + XDocument.Load(Path.Combine(windowsRoot, "Windows", "Panther", "unattend.xml")).Descendants(Unattend + "OOBE").Single(); +} diff --git a/src/Foundry.Deploy.Tests/DomainJoinPreparationServiceTests.cs b/src/Foundry.Deploy.Tests/DomainJoinPreparationServiceTests.cs new file mode 100644 index 000000000..48c533087 --- /dev/null +++ b/src/Foundry.Deploy.Tests/DomainJoinPreparationServiceTests.cs @@ -0,0 +1,488 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Security.Cryptography; +using Foundry.Core.Models.Configuration; +using Foundry.Core.Models.Configuration.Deploy; +using Foundry.Core.Services.Autopilot; +using Foundry.Core.Services.Configuration; +using Foundry.Deploy.Services.DomainJoin; +using Foundry.Deploy.Services.Security; + +namespace Foundry.Deploy.Tests; + +public sealed class DomainJoinPreparationServiceTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("OU=Field,DC=corp,DC=test")] + public void InteractiveWithoutListedDomainsAcceptsATypedDomainAndOptionalOu(string? ouDn) + { + using DomainJoinPreparationResult result = Prepare(new() { IsEnabled = true }, Typed("corp.test", typedOu: ouDn)); + + Assert.Equal(DomainJoinPreparationStatus.Ready, result.Status); + Assert.Equal("corp.test", result.Input!.CredentialContext.DomainName); + Assert.Equal(string.IsNullOrEmpty(ouDn) ? null : ouDn, result.Input.TargetOuDn); + } + + public static TheoryData InvalidTypedOus => new() + { + "not-a-dn", "OU=Field,DC=other,DC=test", "OU=Field\0,DC=corp,DC=test", "OU=" + new string('x', 4096) + ",DC=corp,DC=test", "CN=Computers,DC=corp,DC=test" + }; + + [Theory] + [MemberData(nameof(InvalidTypedOus))] + public void InteractiveRejectsAnInvalidOrForeignTypedOu(string ouDn) + { + using DomainJoinPreparationResult result = Prepare(new() { IsEnabled = true }, Typed("corp.test", typedOu: ouDn)); + + Assert.Equal(DomainJoinPreparationStatus.Invalid, result.Status); + Assert.Null(result.Input); + } + + [Fact] + public void InteractiveWithListedDomainsRefusesATypedDomain() + { + DeployDomainJoinSettings settings = TwoDomains(DomainJoinMode.Interactive); + + using DomainJoinPreparationResult refused = Prepare(settings, Typed("other.test")); + using DomainJoinPreparationResult accepted = Prepare(settings, Typed("CORP.test", ouId: "sales")); + + Assert.Equal(DomainJoinPreparationStatus.Invalid, refused.Status); + Assert.Null(refused.Input); + Assert.Equal(DomainJoinPreparationStatus.Ready, accepted.Status); + Assert.Equal("corp.test", accepted.Input!.CredentialContext.DomainName); + } + + [Fact] + public void InteractiveJoinsTheSelectedDomainWithTheTypedCredentials() + { + DeployDomainJoinSettings settings = TwoDomains(DomainJoinMode.Interactive); + using var submission = new DomainJoinSubmission("emea", "emea.test", "EMEA\\tech", null, " secret ".AsSpan()); + + using DomainJoinPreparationResult result = Prepare(settings, submission); + + Assert.Equal(DomainJoinPreparationStatus.Ready, result.Status); + Assert.Equal("emea.test", result.Input!.CredentialContext.DomainName); + Assert.Equal(" secret ", new string(result.Input.Password.Span)); + Assert.Null(result.Input.TargetOuDn); + } + + [Fact] + public void ZeroTouchWithoutASubmissionUsesTheDefaultDomainAndItsOnlyOu() + { + using var keys = Unlocked(out byte[] key); + DeployDomainJoinSettings settings = TwoDomains(DomainJoinMode.Automatic, key); + + using DomainJoinPreparationResult result = new DomainJoinPreparationService(keys).Prepare(settings, "LAB-01", null); + + Assert.Equal(DomainJoinPreparationStatus.Ready, result.Status); + Assert.Equal("corp.test", result.Input!.CredentialContext.DomainName); + Assert.Equal("OU=Sales,DC=corp,DC=test", result.Input.TargetOuDn); + } + + [Fact] + public void ZeroTouchUsesTheSelectedDomainAndItsOwnPayload() + { + using var keys = Unlocked(out byte[] key); + DeployDomainJoinSettings settings = TwoDomains(DomainJoinMode.Automatic, key); + using var submission = new DomainJoinSubmission("emea", "emea.test", string.Empty, null, default); + + using DomainJoinPreparationResult result = new DomainJoinPreparationService(keys).Prepare(settings, "LAB-01", submission); + + Assert.Equal(DomainJoinPreparationStatus.Ready, result.Status); + Assert.True(result.Input!.CredentialContext.Matches(new("emea.test", "EMEA\\join"))); + Assert.Equal("emea secret", new string(result.Input.Password.Span)); + } + + [Fact] + public void ASelectedDomainThatIsNotListedIsRefused() + { + using var keys = Unlocked(out byte[] key); + DeployDomainJoinSettings settings = TwoDomains(DomainJoinMode.Automatic, key); + using var submission = new DomainJoinSubmission("missing", "missing.test", string.Empty, null, default); + + using DomainJoinPreparationResult result = new DomainJoinPreparationService(keys).Prepare(settings, "LAB-01", submission); + + Assert.Equal(DomainJoinPreparationStatus.Invalid, result.Status); + Assert.Null(result.Input); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ADomainWithoutListedOusRequiresNoOu(bool automatic) + { + using var keys = Unlocked(out byte[] key); + DeployDomainJoinSettings settings = TwoDomains(automatic ? DomainJoinMode.Automatic : DomainJoinMode.Interactive, key); + using var submission = new DomainJoinSubmission("emea", "emea.test", automatic ? string.Empty : "EMEA\\tech", null, + automatic ? default : "secret".AsSpan()); + + using DomainJoinPreparationResult result = new DomainJoinPreparationService(keys).Prepare(settings, "LAB-01", submission); + + Assert.Equal(DomainJoinPreparationStatus.Ready, result.Status); + Assert.Null(result.Input!.TargetOuDn); + } + + [Fact] + public void ZeroTouchNeverAcceptsATypedOu() + { + using var keys = Unlocked(out byte[] key); + DeployDomainJoinSettings settings = TwoDomains(DomainJoinMode.Automatic, key); + using var submission = new DomainJoinSubmission("emea", "emea.test", string.Empty, null, default, "OU=Field,DC=emea,DC=test"); + + using DomainJoinPreparationResult result = new DomainJoinPreparationService(keys).Prepare(settings, "LAB-01", submission); + + Assert.Equal(DomainJoinPreparationStatus.Invalid, result.Status); + Assert.Null(result.Input); + } + + [Fact] + public void AnOuOfAnotherListedDomainIsRefused() + { + using var keys = Unlocked(out byte[] key); + DeployDomainJoinSettings settings = TwoDomains(DomainJoinMode.Automatic, key); + settings = settings with + { + Domains = [settings.Domains[0], settings.Domains[1] with { OrganizationalUnits = [new() { Id = "kiosk", DisplayName = "Kiosks", DistinguishedName = "OU=Kiosks,DC=emea,DC=test" }] }] + }; + using var submission = new DomainJoinSubmission("emea", "emea.test", string.Empty, "sales", default); + + using DomainJoinPreparationResult result = new DomainJoinPreparationService(keys).Prepare(settings, "LAB-01", submission); + + Assert.Equal(DomainJoinPreparationStatus.Invalid, result.Status); + Assert.Null(result.Input); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ADomainThatListsOusRejectsATypedOu(bool automatic) + { + using var keys = Unlocked(out byte[] key); + DeployDomainJoinSettings settings = WithOneOu(automatic ? Automatic(new("corp.test", "CORP\\join"), key) : new() { IsEnabled = true }); + using var submission = new DomainJoinSubmission("corp", "corp.test", "CORP\\join", "sales", "secret".AsSpan(), "OU=Unlisted,DC=corp,DC=test"); + + using DomainJoinPreparationResult result = new DomainJoinPreparationService(keys).Prepare(settings, "LAB-01", submission); + + Assert.Equal(DomainJoinPreparationStatus.Invalid, result.Status); + Assert.Null(result.Input); + } + + [Theory] + [InlineData(false, null)] + [InlineData(false, "sales")] + [InlineData(true, null)] + [InlineData(true, "sales")] + public void SeveralListedOusRequireASubmittedSelection(bool automatic, string? defaultOuId) + { + using var keys = Unlocked(out byte[] key); + // Even a marked default is only a preselection: the join uses what the technician confirmed on the step. + DeployDomainJoinSettings settings = WithTwoOus(WithDefaultOu(WithOneOu(automatic ? Automatic(new("corp.test", "CORP\\join"), key) : new() { IsEnabled = true }), defaultOuId)); + using var submission = new DomainJoinSubmission("corp", "corp.test", "CORP\\join", null, "secret".AsSpan()); + + using DomainJoinPreparationResult result = new DomainJoinPreparationService(keys).Prepare(settings, "LAB-01", submission); + + Assert.Equal(DomainJoinPreparationStatus.Invalid, result.Status); + Assert.Null(result.Input); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void SeveralListedOusUseTheSubmittedSelection(bool automatic) + { + using var keys = Unlocked(out byte[] key); + DeployDomainJoinSettings settings = WithTwoOus(WithDefaultOu(WithOneOu(automatic ? Automatic(new("corp.test", "CORP\\join"), key) : new() { IsEnabled = true }), "sales")); + using var preselected = new DomainJoinSubmission("corp", "corp.test", "CORP\\join", "sales", "secret".AsSpan()); + using var other = new DomainJoinSubmission("corp", "corp.test", "CORP\\join", "field", "secret".AsSpan()); + + using DomainJoinPreparationResult first = new DomainJoinPreparationService(keys).Prepare(settings, "LAB-01", preselected); + using DomainJoinPreparationResult second = new DomainJoinPreparationService(keys).Prepare(settings, "LAB-01", other); + + Assert.Equal(DomainJoinPreparationStatus.Ready, first.Status); + Assert.Equal("OU=Sales,DC=corp,DC=test", first.Input!.TargetOuDn); + Assert.Equal("OU=Field,DC=corp,DC=test", second.Input!.TargetOuDn); + } + + [Theory] + [InlineData(false, null)] + [InlineData(false, "sales")] + [InlineData(true, null)] + [InlineData(true, "sales")] + public void ASingleListedOuIsUsedWhetherOrNotItIsMarkedDefault(bool automatic, string? defaultOuId) + { + using var keys = Unlocked(out byte[] key); + DeployDomainJoinSettings settings = WithDefaultOu(WithOneOu(automatic ? Automatic(new("corp.test", "CORP\\join"), key) : new() { IsEnabled = true }), defaultOuId); + using var submission = new DomainJoinSubmission("corp", "corp.test", "CORP\\join", null, "secret".AsSpan()); + + using DomainJoinPreparationResult result = new DomainJoinPreparationService(keys).Prepare(settings, "LAB-01", submission); + + Assert.Equal(DomainJoinPreparationStatus.Ready, result.Status); + Assert.Equal("OU=Sales,DC=corp,DC=test", result.Input!.TargetOuDn); + } + + [Theory] + [InlineData(null, DomainJoinOuSource.None)] + [InlineData("OU=Sales,DC=corp,DC=test", DomainJoinOuSource.Default)] + [InlineData("OU=Field,DC=corp,DC=test", DomainJoinOuSource.Selected)] + public void OuSourceTellsDefaultAndListedTargetsApart(string? targetOuDn, DomainJoinOuSource expected) + { + var domain = new DeployDomainJoinDomainSettings + { + Id = "corp", + DomainName = "corp.test", + DefaultOuId = "sales", + OrganizationalUnits = + [ + new() { Id = "sales", DisplayName = "Sales", DistinguishedName = "OU=Sales,DC=corp,DC=test" }, + new() { Id = "field", DisplayName = "Field", DistinguishedName = "OU=Field,DC=corp,DC=test" } + ] + }; + + Assert.Equal(expected, DomainJoinPreparationService.ResolveOuSource(domain, new("corp.test", "LAB-01", targetOuDn))); + } + + [Fact] + public void AnOuOnADomainWithoutListedOusIsATypedOu() + { + var intent = new DomainJoinDeploymentIntent("emea.test", "LAB-01", "OU=Typed,DC=emea,DC=test"); + Assert.Equal(DomainJoinOuSource.Typed, DomainJoinPreparationService.ResolveOuSource(new() { Id = "emea", DomainName = "emea.test" }, intent)); + Assert.Equal(DomainJoinOuSource.Typed, DomainJoinPreparationService.ResolveOuSource(null, intent)); + } + + [Theory] + [InlineData("corp.test", DomainJoinDomainSource.Default)] + [InlineData("EMEA.test", DomainJoinDomainSource.Selected)] + public void DomainSourceTellsDefaultAndSelectedApart(string joinedDomain, DomainJoinDomainSource expected) + { + Assert.Equal(expected, DomainJoinPreparationService.ResolveDomainSource(TwoDomains(DomainJoinMode.Interactive), new(joinedDomain, "LAB-01", null))); + } + + [Fact] + public void DomainSourceIsTypedWithoutListedDomainsAndNoneWithoutAJoin() + { + Assert.Equal(DomainJoinDomainSource.Typed, DomainJoinPreparationService.ResolveDomainSource(new() { IsEnabled = true }, new("corp.test", "LAB-01", null))); + Assert.Equal(DomainJoinDomainSource.None, DomainJoinPreparationService.ResolveDomainSource(TwoDomains(DomainJoinMode.Interactive), null)); + } + + [Fact] + public void DefaultOrganizationalUnitBelongsToItsDomain() + { + DeployDomainJoinSettings settings = TwoDomains(DomainJoinMode.Interactive); + Assert.Equal("OU=Sales,DC=corp,DC=test", DomainJoinPreparationService.ResolveDefaultOrganizationalUnit(settings.Domains[0])?.DistinguishedName); + Assert.Null(DomainJoinPreparationService.ResolveDefaultOrganizationalUnit(settings.Domains[1])); + Assert.Null(DomainJoinPreparationService.ResolveDefaultOrganizationalUnit(null)); + } + + [Fact] + public void MalformedEnvelopeReturnsAllowlistedFailure() + { + using var keys = Unlocked(out byte[] key); + DeployDomainJoinSettings settings = Automatic(new("corp.test", "CORP\\join"), key); + settings = WithEnvelope(settings, envelope => envelope with { Ciphertext = "!invalid!" }); + + using DomainJoinPreparationResult result = new DomainJoinPreparationService(keys).Prepare(settings, "LAB-01", null); + + Assert.Equal(DomainJoinPreparationStatus.Invalid, result.Status); + Assert.Equal(DomainJoinPreparationFailure.CredentialsInvalid, result.FailureCode); + Assert.Null(result.Input); + } + + [Fact] + public void CredentialEnvelopeIsBoundedBeforeObtainingKeyCopy() + { + var keys = new RecordingKeys(); + DeployDomainJoinSettings settings = WithEnvelope(Automatic(new("corp.test", "CORP\\join"), new byte[32]), + envelope => envelope with { Ciphertext = new string('A', 50000) }); + + using DomainJoinPreparationResult result = new DomainJoinPreparationService(keys).Prepare(settings, "LAB-01", null); + + Assert.Equal(DomainJoinPreparationStatus.Invalid, result.Status); + Assert.False(keys.CopyRequested); + } + + private sealed class RecordingKeys : IDeploymentSecretKeySession + { + public bool IsUnlocked => true; + public bool CopyRequested { get; private set; } + public byte[] GetKeyCopy() { CopyRequested = true; return new byte[32]; } + public void SetKey(ReadOnlySpan value) { } + public void Clear() { } + public void Dispose() { } + } + + [Fact] + public void InteractiveCollectsCredentialsWithoutMediaUnlock() + { + using var keys = new DeploymentSecretKeySession(); + using var submission = new DomainJoinSubmission(null, "corp.test", "CORP\\join", null, " secret ".AsSpan()); + + using DomainJoinPreparationResult result = new DomainJoinPreparationService(keys).Prepare(new() { IsEnabled = true }, "LAB-01", submission); + + Assert.Equal(DomainJoinPreparationStatus.Ready, result.Status); + Assert.Equal("corp.test", result.Input!.CredentialContext.DomainName); + Assert.Equal("LAB-01", result.Input.ComputerName); + Assert.Equal(" secret ", new string(result.Input.Password.Span)); + Assert.False(keys.IsUnlocked); + } + + [Fact] + public void InteractiveWithoutWizardInputNeverProducesInput() + { + using DomainJoinPreparationResult result = Prepare(new() { IsEnabled = true }, null); + + Assert.Equal(DomainJoinPreparationStatus.Invalid, result.Status); + Assert.Equal(DomainJoinPreparationFailure.CredentialsInvalid, result.FailureCode); + Assert.Null(result.Input); + } + + [Fact] + public void AutomaticUsesExistingUnlockOnly() + { + using var keys = Unlocked(out byte[] key); + DeployDomainJoinSettings settings = Automatic(new("corp.test", "CORP\\join"), key); + + using DomainJoinPreparationResult result = new DomainJoinPreparationService(keys).Prepare(settings, "LAB-01", null); + + Assert.Equal(DomainJoinPreparationStatus.Ready, result.Status); + Assert.Equal(" secret ", new string(result.Input!.Password.Span)); + Assert.True(keys.IsUnlocked); + } + + [Fact] + public void AutomaticWithoutUnlockAsksForIt() + { + using var keys = new DeploymentSecretKeySession(); + DeployDomainJoinSettings settings = Automatic(new("corp.test", "CORP\\join"), new byte[32]); + + using DomainJoinPreparationResult result = new DomainJoinPreparationService(keys).Prepare(settings, "LAB-01", null); + + Assert.Equal(DomainJoinPreparationFailure.UnlockRequired, result.FailureCode); + } + + [Theory] + [InlineData("other.test", "CORP\\join")] + [InlineData("corp.test", "CORP\\different")] + public void AutomaticContextMismatchNeverAuthenticates(string domain, string account) + { + using var keys = Unlocked(out byte[] key); + DeployDomainJoinSettings settings = Automatic(new("corp.test", "CORP\\join"), key); + settings = settings with { Domains = [settings.Domains[0] with { DomainName = domain, AccountName = account }] }; + + using DomainJoinPreparationResult result = new DomainJoinPreparationService(keys).Prepare(settings, "LAB-01", null); + + Assert.Equal(DomainJoinPreparationStatus.Invalid, result.Status); + Assert.Equal(DomainJoinPreparationFailure.CredentialsInvalid, result.FailureCode); + Assert.Null(result.Input); + } + + [Theory] + [InlineData("", "CORP\\join", "secret")] + [InlineData("corp.test", "", "secret")] + [InlineData("corp.test", "join", "secret")] + [InlineData("corp.test", "CORP\\join", "")] + public void MissingOrInvalidInteractiveCredentialsFailClosed(string domain, string account, string password) + { + using var keys = new DeploymentSecretKeySession(); + using var submission = new DomainJoinSubmission(null, domain, account, null, password.AsSpan()); + + using DomainJoinPreparationResult result = new DomainJoinPreparationService(keys).Prepare(new() { IsEnabled = true }, "LAB-01", submission); + + Assert.Equal(DomainJoinPreparationStatus.Invalid, result.Status); + Assert.Null(result.Input); + } + + private static DomainJoinPreparationResult Prepare(DeployDomainJoinSettings settings, DomainJoinSubmission? submission) + { + using var keys = new DeploymentSecretKeySession(); + try { return new DomainJoinPreparationService(keys).Prepare(settings, "LAB-01", submission); } + finally { submission?.Dispose(); } + } + + private static DomainJoinSubmission Typed(string domain, string? ouId = null, string? typedOu = null) => + new(null, domain, "CORP\\join", ouId, "secret".AsSpan(), typedOu); + + private static DeploymentSecretKeySession Unlocked(out byte[] key) + { + key = RandomNumberGenerator.GetBytes(32); + var keys = new DeploymentSecretKeySession(); + keys.SetKey(key); + return keys; + } + + private static DeployDomainJoinSettings WithEnvelope(DeployDomainJoinSettings settings, Func change) => + settings with { Domains = [settings.Domains[0] with { EncryptedCredentials = change(settings.Domains[0].EncryptedCredentials!) }] }; + + private static DeployDomainJoinSettings WithDefaultOu(DeployDomainJoinSettings settings, string? defaultOuId) => + settings with { Domains = [settings.Domains[0] with { DefaultOuId = defaultOuId }] }; + + /// Two listed domains: corp.test (default, one OU that is its default) and emea.test (no OU). + internal static DeployDomainJoinSettings TwoDomains(DomainJoinMode mode, byte[]? key = null) => new() + { + IsEnabled = true, + Mode = mode, + DefaultDomainId = "corp", + Domains = + [ + new() + { + Id = "corp", + DomainName = "corp.test", + AccountName = mode == DomainJoinMode.Automatic ? "CORP\\join" : null, + DefaultOuId = "sales", + OrganizationalUnits = [new() { Id = "sales", DisplayName = "Sales", DistinguishedName = "OU=Sales,DC=corp,DC=test" }], + EncryptedCredentials = mode == DomainJoinMode.Automatic ? Protect(new("corp.test", "CORP\\join"), "corp secret", key!) : null + }, + new() + { + Id = "emea", + DomainName = "emea.test", + AccountName = mode == DomainJoinMode.Automatic ? "EMEA\\join" : null, + EncryptedCredentials = mode == DomainJoinMode.Automatic ? Protect(new("emea.test", "EMEA\\join"), "emea secret", key!) : null + } + ] + }; + + /// Adds a second OU to the first domain, which makes the technician choose between them. + internal static DeployDomainJoinSettings WithTwoOus(DeployDomainJoinSettings settings) => settings with + { + Domains = + [ + settings.Domains[0] with + { + OrganizationalUnits = [.. settings.Domains[0].OrganizationalUnits, new() { Id = "field", DisplayName = "Field", DistinguishedName = "OU=Field,DC=corp,DC=test" }] + }, + .. settings.Domains.Skip(1) + ] + }; + + /// Lists one OU on the single domain; adds corp.test when no domain is listed. + internal static DeployDomainJoinSettings WithOneOu(DeployDomainJoinSettings settings) + { + DeployDomainJoinDomainSettings domain = settings.Domains.FirstOrDefault() ?? new() { Id = "corp", DomainName = "corp.test" }; + return settings with + { + DefaultDomainId = domain.Id, + Domains = [domain with { OrganizationalUnits = [new() { Id = "sales", DisplayName = "Sales", DistinguishedName = "OU=Sales,DC=corp,DC=test" }] }] + }; + } + + /// Zero-touch media with one domain whose payload holds the password " secret ". + internal static DeployDomainJoinSettings Automatic(DomainJoinCredentialContext context, byte[] key) => new() + { + IsEnabled = true, + Mode = DomainJoinMode.Automatic, + DefaultDomainId = "corp", + Domains = [new() { Id = "corp", DomainName = context.DomainName, AccountName = context.AccountName, EncryptedCredentials = Protect(context, " secret ", key) }] + }; + + private static SecretEnvelope Protect(DomainJoinCredentialContext context, string password, byte[] key) + { + byte[] payload = DomainJoinCredentialPayloadCodec.Encode(context, password.AsSpan()); + try { return MediaSecretEnvelopeProtector.EncryptBytes(payload, key, MediaSecretEnvelopeProtector.DeploymentKeyId); } + finally { CryptographicOperations.ZeroMemory(payload); } + } +} diff --git a/src/Foundry.Deploy.Tests/DomainJoinStepViewModelTests.cs b/src/Foundry.Deploy.Tests/DomainJoinStepViewModelTests.cs new file mode 100644 index 000000000..a9e59180c --- /dev/null +++ b/src/Foundry.Deploy.Tests/DomainJoinStepViewModelTests.cs @@ -0,0 +1,352 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Models.Configuration; +using Foundry.Core.Models.Configuration.Deploy; +using Foundry.Deploy.Services.DomainJoin; +using Foundry.Deploy.ViewModels; + +namespace Foundry.Deploy.Tests; + +public sealed class DomainJoinStepViewModelTests +{ + [Theory] + [InlineData(false, DomainJoinMode.Interactive, 1, false)] + [InlineData(true, DomainJoinMode.Interactive, 1, true)] + [InlineData(true, DomainJoinMode.Automatic, 1, false)] + [InlineData(true, DomainJoinMode.Automatic, 2, true)] + public void StepIsNeededOnlyWhenTheTechnicianHasSomethingToEnter(bool enabled, DomainJoinMode mode, int ouCount, bool expected) + { + using var step = new DomainJoinStepViewModel(); + DeployDomainJoinSettings settings = OneDomain(mode) with { IsEnabled = enabled }; + step.Configure(ouCount == 2 ? DomainJoinPreparationServiceTests.WithTwoOus(settings) : settings); + + Assert.Equal(expected, step.HasInput); + } + + [Fact] + public void ZeroTouchStepIsSkippedWhenThereIsNothingToChoose() + { + using var step = new DomainJoinStepViewModel(); + step.Configure(OneDomain(DomainJoinMode.Automatic)); + + Assert.False(step.HasInput); + Assert.True(step.IsValid); + Assert.Equal("corp.test", step.DomainName); + Assert.Equal("OU=Sales,DC=corp,DC=test", step.EffectiveOuDistinguishedName); + } + + [Fact] + public void ZeroTouchStepIsSkippedForOneDomainWithoutOus() + { + using var step = new DomainJoinStepViewModel(); + DeployDomainJoinSettings settings = OneDomain(DomainJoinMode.Automatic); + step.Configure(settings with { Domains = [settings.Domains[0] with { OrganizationalUnits = [], DefaultOuId = null }] }); + + Assert.False(step.HasInput); + Assert.True(step.IsValid); + Assert.Null(step.EffectiveOuDistinguishedName); + } + + [Fact] + public void ZeroTouchStepIsShownForADomainChoiceAlone() + { + using var step = new DomainJoinStepViewModel(); + step.Configure(TwoDomains(DomainJoinMode.Automatic)); + + Assert.True(step.HasInput); + Assert.True(step.IsDomainListVisible); + Assert.False(step.IsDomainTextVisible); + Assert.False(step.IsOuListVisible); + Assert.True(step.IsValid); + } + + [Theory] + [InlineData(1, false)] + [InlineData(2, true)] + public void DomainListIsOfferedWhenSeveralDomainsAreListed(int domainCount, bool expected) + { + using var step = new DomainJoinStepViewModel(); + step.Configure(domainCount == 1 ? OneDomain(DomainJoinMode.Interactive) : TwoDomains(DomainJoinMode.Interactive)); + + Assert.Equal(expected, step.IsDomainListVisible); + Assert.True(step.IsDomainReadOnly); + Assert.Equal("corp.test", step.DomainName); + } + + [Fact] + public void ChangingTheDomainOffersItsOusAndPreselectsItsDefault() + { + using var step = new DomainJoinStepViewModel(); + DeployDomainJoinSettings settings = TwoDomains(DomainJoinMode.Interactive); + settings = settings with + { + Domains = + [ + settings.Domains[0], + settings.Domains[1] with + { + DefaultOuId = "kiosk", + OrganizationalUnits = + [ + new() { Id = "desk", DisplayName = "Desks", DistinguishedName = "OU=Desks,DC=emea,DC=test" }, + new() { Id = "kiosk", DisplayName = "Kiosks", DistinguishedName = "OU=Kiosks,DC=emea,DC=test" } + ] + } + ] + }; + step.Configure(settings); + Assert.Equal("sales", step.SelectedOrganizationalUnit?.Id); + Assert.False(step.IsOuListVisible); + + step.SelectedDomain = step.Domains[1]; + + Assert.True(step.IsOuListVisible); + Assert.Equal("emea.test", step.DomainName); + Assert.Equal("kiosk", step.SelectedOrganizationalUnit?.Id); + Assert.All(step.OrganizationalUnits, unit => Assert.EndsWith("DC=emea,DC=test", unit.DistinguishedName)); + Assert.Equal("OU=Kiosks,DC=emea,DC=test", step.EffectiveOuDistinguishedName); + } + + [Fact] + public void ChangingTheDomainKeepsTheTypedAccountAndPassword() + { + using var step = new DomainJoinStepViewModel(); + step.Configure(TwoDomains(DomainJoinMode.Interactive)); + step.AccountName = "CORP\\join"; + step.SetPassword("secret"); + + step.SelectedDomain = step.Domains[1]; + + Assert.Equal("CORP\\join", step.AccountName); + Assert.True(step.HasPassword); + } + + [Theory] + [InlineData(DomainJoinMode.Automatic)] + [InlineData(DomainJoinMode.Interactive)] + public void ADomainWithoutListedOusDoesNotBlock(DomainJoinMode mode) + { + using var step = new DomainJoinStepViewModel(); + step.Configure(TwoDomains(mode)); + step.AccountName = "EMEA\\tech"; + step.SetPassword("secret"); + + step.SelectedDomain = step.Domains[1]; + + Assert.False(step.IsOuListVisible); + Assert.Equal(mode == DomainJoinMode.Interactive, step.IsTypedOuVisible); + Assert.Null(step.EffectiveOuDistinguishedName); + Assert.True(step.IsValid); + } + + [Fact] + public void InteractiveWithOneListedDomainShowsItReadOnly() + { + using var step = new DomainJoinStepViewModel(); + step.Configure(OneDomain(DomainJoinMode.Interactive)); + + Assert.True(step.IsDomainTextVisible); + Assert.True(step.IsDomainReadOnly); + Assert.False(step.IsDomainInvalid); + } + + [Fact] + public void InteractiveWithoutListedDomainsIsValidOnlyWithDomainQualifiedAccountAndPassword() + { + using var step = new DomainJoinStepViewModel(); + step.Configure(new() { IsEnabled = true }); + Assert.False(step.IsDomainReadOnly); + Assert.False(step.IsValid); + + step.DomainName = "corp.test"; + step.AccountName = "join"; + step.SetPassword("secret"); + Assert.True(step.IsAccountInvalid); + Assert.False(step.IsValid); + + step.AccountName = "CORP\\join"; + Assert.True(step.IsValid); + + step.SetPassword(default); + Assert.False(step.IsValid); + } + + [Fact] + public void TypedOuMustBeAnOuOfTheTypedDomain() + { + using var step = new DomainJoinStepViewModel(); + step.Configure(new() { IsEnabled = true }); + step.DomainName = "other.test"; + + Assert.True(step.IsTypedOuVisible); + step.TypedOuDistinguishedName = "OU=Sales,DC=corp,DC=test"; + Assert.True(step.IsTypedOuInvalid); + + step.TypedOuDistinguishedName = "CN=Computers,DC=other,DC=test"; + Assert.True(step.IsTypedOuInvalid); + + step.TypedOuDistinguishedName = "OU=Field,DC=other,DC=test"; + Assert.False(step.IsTypedOuInvalid); + Assert.Equal("OU=Field,DC=other,DC=test", step.EffectiveOuDistinguishedName); + } + + [Fact] + public void SeveralListedOusRequireAChoiceAndPreselectTheDefault() + { + using var step = new DomainJoinStepViewModel(); + step.Configure(DomainJoinPreparationServiceTests.WithTwoOus(OneDomain(DomainJoinMode.Automatic))); + + Assert.True(step.IsOuListVisible); + Assert.False(step.IsTypedOuVisible); + Assert.Equal("OU=Sales,DC=corp,DC=test", step.EffectiveOuDistinguishedName); + Assert.True(step.IsValid); + + step.SelectedOrganizationalUnit = null; + Assert.False(step.IsValid); + } + + [Fact] + public void SeveralListedOusWithoutADefaultStartWithNoneSelected() + { + using var step = new DomainJoinStepViewModel(); + DeployDomainJoinSettings settings = DomainJoinPreparationServiceTests.WithTwoOus(OneDomain(DomainJoinMode.Automatic)); + step.Configure(settings with { Domains = [settings.Domains[0] with { DefaultOuId = null }] }); + + Assert.True(step.HasInput); + Assert.Null(step.SelectedOrganizationalUnit); + Assert.False(step.IsValid); + + step.SelectedOrganizationalUnit = step.OrganizationalUnits[1]; + Assert.True(step.IsValid); + Assert.Equal("OU=Field,DC=corp,DC=test", step.EffectiveOuDistinguishedName); + } + + [Theory] + [InlineData("sales")] + [InlineData(null)] + public void ASingleListedOuIsUsedWithoutAChoice(string? defaultOuId) + { + using var step = new DomainJoinStepViewModel(); + DeployDomainJoinSettings settings = OneDomain(DomainJoinMode.Interactive); + step.Configure(settings with { Domains = [settings.Domains[0] with { DefaultOuId = defaultOuId }] }); + + Assert.False(step.IsOuListVisible); + Assert.False(step.IsTypedOuVisible); + Assert.Equal("OU=Sales,DC=corp,DC=test", step.EffectiveOuDistinguishedName); + } + + [Fact] + public void SubmissionCarriesTheSelectedDomainTheInputsAndAnIndependentPasswordCopy() + { + using var step = new DomainJoinStepViewModel(); + step.Configure(TwoDomains(DomainJoinMode.Interactive)); + step.AccountName = "CORP\\join"; + step.SetPassword("secret"); + + using DomainJoinSubmission first = step.CreateSubmission()!; + step.SelectedDomain = step.Domains[1]; + using DomainJoinSubmission second = step.CreateSubmission()!; + step.ClearPassword(); + + Assert.Equal("corp", first.SelectedDomainId); + Assert.Equal("corp.test", first.DomainName); + Assert.Equal("CORP\\join", first.AccountName); + // The only OU of corp.test is used as is, so nothing is submitted for it. + Assert.Null(first.SelectedOuId); + Assert.Null(first.TypedOuDistinguishedName); + Assert.Equal("secret", new string(first.Password.Span)); + Assert.Equal("emea", second.SelectedDomainId); + Assert.Equal("emea.test", second.DomainName); + Assert.Null(second.SelectedOuId); + Assert.False(step.HasPassword); + } + + [Fact] + public void SubmissionWithoutListedDomainsCarriesTheTypedDomainOnly() + { + using var step = new DomainJoinStepViewModel(); + step.Configure(new() { IsEnabled = true }); + step.DomainName = "corp.test"; + + using DomainJoinSubmission submission = step.CreateSubmission()!; + + Assert.Null(submission.SelectedDomainId); + Assert.Equal("corp.test", submission.DomainName); + } + + [Fact] + public void ZeroTouchSubmissionNeverCarriesAnAccountOrPassword() + { + using var step = new DomainJoinStepViewModel(); + step.Configure(DomainJoinPreparationServiceTests.WithTwoOus(OneDomain(DomainJoinMode.Automatic))); + step.AccountName = "CORP\\ignored"; + step.SetPassword("ignored"); + + using DomainJoinSubmission submission = step.CreateSubmission()!; + + Assert.Equal(string.Empty, submission.AccountName); + Assert.True(submission.Password.IsEmpty); + Assert.Equal("sales", submission.SelectedOuId); + } + + [Fact] + public void ReconfiguringDiscardsEarlierInputsAndThePassword() + { + using var step = new DomainJoinStepViewModel(); + step.Configure(new() { IsEnabled = true }); + step.DomainName = "corp.test"; + step.AccountName = "CORP\\join"; + step.SetPassword("secret"); + int cleared = 0; + step.PasswordCleared += (_, _) => cleared++; + + step.Configure(new()); + + Assert.Equal(string.Empty, step.DomainName); + Assert.Equal(string.Empty, step.AccountName); + Assert.False(step.HasPassword); + Assert.Equal(1, cleared); + Assert.Null(step.CreateSubmission()); + } + + [Fact] + public void ReconfiguringWithListedDomainsShowsTheDefaultDomain() + { + using var step = new DomainJoinStepViewModel(); + step.Configure(new() { IsEnabled = true }); + step.DomainName = "typed.test"; + + step.Configure(TwoDomains(DomainJoinMode.Interactive) with { DefaultDomainId = "emea" }); + + Assert.Equal("emea.test", step.DomainName); + Assert.Equal("emea", step.SelectedDomain?.Id); + } + + [Theory] + [InlineData(Foundry.Deploy.Services.Runtime.DebugDomainJoinMode.Interactive)] + [InlineData(Foundry.Deploy.Services.Runtime.DebugDomainJoinMode.ZeroTouch)] + public void DebugScenariosReachTheDomainListAndBothKindsOfDomain(Foundry.Deploy.Services.Runtime.DebugDomainJoinMode mode) + { + using var step = new DomainJoinStepViewModel(); + step.Configure(Foundry.Deploy.Services.Runtime.DebugDomainJoinScenarios.Create(mode)); + + Assert.True(step.HasInput); + Assert.True(step.IsDomainListVisible); + Assert.True(step.IsOuListVisible); + + step.SelectedDomain = step.Domains[1]; + + Assert.False(step.IsOuListVisible); + Assert.Null(step.EffectiveOuDistinguishedName); + } + + private static DeployDomainJoinSettings TwoDomains(DomainJoinMode mode) => DomainJoinPreparationServiceTests.TwoDomains(mode, new byte[32]); + + private static DeployDomainJoinSettings OneDomain(DomainJoinMode mode) + { + DeployDomainJoinSettings settings = TwoDomains(mode); + return settings with { Domains = [settings.Domains[0]] }; + } +} diff --git a/src/Foundry.Deploy.Tests/FinalizeDeploymentAndWriteLogsStepTests.cs b/src/Foundry.Deploy.Tests/FinalizeDeploymentAndWriteLogsStepTests.cs index 479830355..a7affb248 100644 --- a/src/Foundry.Deploy.Tests/FinalizeDeploymentAndWriteLogsStepTests.cs +++ b/src/Foundry.Deploy.Tests/FinalizeDeploymentAndWriteLogsStepTests.cs @@ -13,6 +13,22 @@ namespace Foundry.Deploy.Tests; [Collection(nameof(SerilogCollection))] public sealed class FinalizeDeploymentAndWriteLogsStepTests { + [Fact] + public async Task FinalizeRecordsDomainSkipInSummary() + { + var logs = new RecordingDomainLogService(); + using var fixture = new Fixture(logs); + fixture.Context.RuntimeState.DomainJoinStatus = DomainJoinExecutionStatus.SkippedUnsupportedEdition; + fixture.Context.RuntimeState.DomainJoinSkipCode = DomainJoinSkipCode.UnsupportedEdition; + var result = await new FinalizeDeploymentAndWriteLogsStep().ExecuteAsync(fixture.Context, TestContext.Current.CancellationToken); + Assert.Equal(DeploymentStepState.Succeeded, result.State); + using var summary = System.Text.Json.JsonDocument.Parse(File.ReadAllBytes(fixture.Context.RuntimeState.DeploymentSummaryPath!)); + Assert.Equal((int)DomainJoinExecutionStatus.SkippedUnsupportedEdition, summary.RootElement.GetProperty("domainJoinStatus").GetInt32()); + Assert.Equal((int)DomainJoinSkipCode.UnsupportedEdition, summary.RootElement.GetProperty("domainJoinSkipCode").GetInt32()); + Assert.Equal(System.Text.Json.JsonValueKind.Null, summary.RootElement.GetProperty("preOobeManifestPath").ValueKind); + Assert.DoesNotContain(logs.Messages, entry => entry.Message.StartsWith("Domain joining skipped.", StringComparison.Ordinal)); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -316,6 +332,20 @@ public async Task SaveStateAsync(DeploymentLogSession session, T state, Cance } } + private sealed class RecordingDomainLogService : IDeploymentLogService + { + private readonly DeploymentLogService inner = new(); + public List<(DeploymentLogLevel Level, string Message)> Messages { get; } = []; + public DeploymentLogSession Initialize(string rootPath) => inner.Initialize(rootPath); + public Task SaveStateAsync(DeploymentLogSession session, T state, CancellationToken cancellationToken = default) => + inner.SaveStateAsync(session, state, cancellationToken); + public Task AppendAsync(DeploymentLogSession session, DeploymentLogLevel level, string message, CancellationToken cancellationToken = default) + { + Messages.Add((level, message)); + return inner.AppendAsync(session, level, message, cancellationToken); + } + } + private sealed class Fixture : IDisposable { public string Root { get; } = Path.Combine(Path.GetTempPath(), "foundry-finalize-" + Guid.NewGuid().ToString("N")); diff --git a/src/Foundry.Deploy.Tests/PreOobeUnattendHookServiceTests.cs b/src/Foundry.Deploy.Tests/PreOobeUnattendHookServiceTests.cs index 2977d7e79..1689be12a 100644 --- a/src/Foundry.Deploy.Tests/PreOobeUnattendHookServiceTests.cs +++ b/src/Foundry.Deploy.Tests/PreOobeUnattendHookServiceTests.cs @@ -14,6 +14,44 @@ namespace Foundry.Deploy.Tests; public sealed class PreOobeUnattendHookServiceTests { + [Theory] + [InlineData("OLD", false, false, true)] + [InlineData("OLD", false, true, false)] + [InlineData("LAB01", true, true, true)] + [InlineData("LAB01LAB01", false, false, false)] + [InlineData("", false, false, true)] + [InlineData("", false, true, false)] + public async Task DomainCompositionChecksNameCountAndFinalIdentity(string names, bool custom, bool final, bool compatible) + { + string root = Path.Combine(Path.GetTempPath(), "Foundry.Deploy.Tests", Guid.NewGuid().ToString("N")); + string answer = Path.Combine(root, "Windows", "Panther", "unattend.xml"); + Directory.CreateDirectory(Path.GetDirectoryName(answer)!); + try + { + File.WriteAllText(answer, $"{names}"); + var result = await PreOobeUnattendPrecedenceService.InspectDomainCompositionAsync(root, "x64", "LAB01", custom, final, TestContext.Current.CancellationToken); + Assert.Equal(compatible, result.IsCompatible); + Assert.Equal(compatible, result.SkipCode is null); + } + finally { Directory.Delete(root, recursive: true); } + } + + [Fact] + public async Task EmbeddedDomainJoinReturnsDomainOnlyIncompatibility() + { + string root = Path.Combine(Path.GetTempPath(), "Foundry.Deploy.Tests", Guid.NewGuid().ToString("N")); + string answer = Path.Combine(root, "Windows", "Panther", "unattend.xml"); + Directory.CreateDirectory(Path.GetDirectoryName(answer)!); + try + { + File.WriteAllText(answer, $""); + var result = await new PreOobeUnattendPrecedenceService(null!).CheckDomainCompositionAsync(root, "x64", "LAB01", false, TestContext.Current.CancellationToken); + Assert.False(result.IsCompatible); + Assert.Equal(Services.Deployment.DomainJoinSkipCode.EmbeddedDomainJoin, result.SkipCode); + } + finally { Directory.Delete(root, recursive: true); } + } + private const string Namespace = "urn:schemas-microsoft-com:unattend"; private readonly PreOobeUnattendHookService _service = new(); diff --git a/src/Foundry.Deploy.Tests/StagePreOobeCustomizationStepTests.cs b/src/Foundry.Deploy.Tests/StagePreOobeCustomizationStepTests.cs index 2dfad5878..36c39e1e8 100644 --- a/src/Foundry.Deploy.Tests/StagePreOobeCustomizationStepTests.cs +++ b/src/Foundry.Deploy.Tests/StagePreOobeCustomizationStepTests.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System.Text.Json; +using System.Text; using System.Security.Cryptography; using Foundry.Core.Models.PreOobe; using Foundry.Deploy.Models; @@ -24,6 +25,165 @@ namespace Foundry.Deploy.Tests; public sealed class StagePreOobeCustomizationStepTests { + [Fact] + public async Task FailedCredentialRollbackRetainsNonRunnableOwnershipAndFailsStaging() + { + using var temp = new TemporaryDirectory(); + using var context = CreateContext(temp, domain: true); + string answer = Path.Combine(temp.WindowsRoot, "Windows", "Panther", "unattend.xml"); + Directory.CreateDirectory(Path.GetDirectoryName(answer)!); + File.WriteAllText(answer, "LAB01"); + string root = Path.Combine(temp.WindowsRoot, "Windows", "Temp", "Foundry"); + string credential = Path.Combine(root, "Payloads", "DomainJoin", context.RuntimeState.OperationId, "credentials.bin"); + FileStream? held = null; + try + { + var service = new PreOobeTargetStagingService(path => Directory.CreateDirectory(path)) + { + BeforeHookPublication = () => + { + held = new FileStream(credential, FileMode.Open, FileAccess.Read, FileShare.Read); + throw new IOException("injected failure"); + } + }; + var result = await new StagePreOobeCustomizationStep(new FakeDriverPackStrategyResolver(), service).ExecuteAsync(context, TestContext.Current.CancellationToken); + Assert.Equal(DeploymentStepState.Failed, result.State); + Assert.True(File.Exists(credential)); + Assert.Null(context.DomainJoinInput); + using var plan = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "State", "PreOobe", "plan.json"))); + Assert.Contains(plan.RootElement.GetProperty("ownedPayloads").EnumerateArray(), payload => payload.GetProperty("relativePath").GetString() == $"Payloads/DomainJoin/{context.RuntimeState.OperationId}/credentials.bin"); + using var journal = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "State", "PreOobe", "execution-result.json"))); + Assert.Equal("Staging", journal.RootElement.GetProperty("status").GetString()); + Assert.Null(context.RuntimeState.PreOobeManifestPath); + } + finally { held?.Dispose(); } + } + + [Fact] + public async Task UnsupportedActualEditionRetainsEvidenceAndFrozenRequestWithoutStaging() + { + using var temp = new TemporaryDirectory(); + using var context = CreateContext(temp, domain: true); + var input = context.DomainJoinInput!; + var request = context.Request.DomainJoinRequest; + await DomainJoinRuntimeEligibility.ConfirmEditionAsync(context, "Core", TestContext.Current.CancellationToken); + await DomainJoinRuntimeEligibility.ConfirmEditionAsync(context, null, TestContext.Current.CancellationToken); + Assert.Equal("Core", context.RuntimeState.ActualWindowsEditionId); + Assert.Equal(DomainJoinExecutionStatus.SkippedUnsupportedEdition, context.RuntimeState.DomainJoinStatus); + Assert.Equal(DomainJoinSkipCode.UnsupportedEdition, context.RuntimeState.DomainJoinSkipCode); + Assert.Same(request, context.Request.DomainJoinRequest); + Assert.NotNull(context.Request.DomainJoinIntent); + Assert.Null(context.DomainJoinInput); + Assert.Throws(() => input.Password); + Assert.DoesNotContain(DeploymentPlan.Build(context.Request, context.RuntimeState), entry => entry.Name == DeploymentStepNames.StagePreOobeCustomization); + var result = await new StagePreOobeCustomizationStep(new FakeDriverPackStrategyResolver()).ExecuteAsync(context, TestContext.Current.CancellationToken); + Assert.Equal(DeploymentStepState.Skipped, result.State); + Assert.Null(context.RuntimeState.PreOobeManifestPath); + } + + [Fact] + public async Task FinalCompositionMismatchSkipsDomainWithoutCredentialOrInventedReport() + { + using var temp = new TemporaryDirectory(); + using var context = CreateContext(temp, domain: true); + var input = context.DomainJoinInput!; + string answer = Path.Combine(temp.WindowsRoot, "Windows", "Panther", "unattend.xml"); + Directory.CreateDirectory(Path.GetDirectoryName(answer)!); + File.WriteAllText(answer, "OTHER"); + var result = await new StagePreOobeCustomizationStep(new FakeDriverPackStrategyResolver(), new PreOobeTargetStagingService(path => Directory.CreateDirectory(path))) + .ExecuteAsync(context, TestContext.Current.CancellationToken); + Assert.Equal(DeploymentStepState.Skipped, result.State); + Assert.Equal(DomainJoinExecutionStatus.SkippedImageComposition, context.RuntimeState.DomainJoinStatus); + Assert.Equal(DomainJoinSkipCode.ComputerNameMismatch, context.RuntimeState.DomainJoinSkipCode); + Assert.Null(context.DomainJoinInput); + Assert.Throws(() => input.Password); + Assert.Null(context.RuntimeState.PreOobeManifestPath); + Assert.False(Directory.Exists(Path.Combine(temp.WindowsRoot, "Windows", "Temp", "Foundry", "Payloads", "DomainJoin"))); + Assert.False(File.Exists(Path.Combine(temp.WindowsRoot, "Windows", "Temp", "Foundry", "State", "PreOobe", "domain-join-result.json"))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DomainStagingPublishesBoundSeedsAndClearsInput(bool failPublication) + { + using var temp = new TemporaryDirectory(); + using var context = CreateContext(temp, domain: true); + var input = context.DomainJoinInput!; + string answer = Path.Combine(temp.WindowsRoot, "Windows", "Panther", "unattend.xml"); + Directory.CreateDirectory(Path.GetDirectoryName(answer)!); + File.WriteAllText(answer, "LAB01"); + string credential = Path.Combine(temp.WindowsRoot, "Windows", "Temp", "Foundry", "Payloads", "DomainJoin", context.RuntimeState.OperationId, "credentials.bin"); + bool protectedBeforeWrite = false; + var service = new PreOobeTargetStagingService(path => + { + Directory.CreateDirectory(path); + if (path == Path.GetDirectoryName(credential)) + { + protectedBeforeWrite = !File.Exists(credential); + } + }) + { + BeforeHookPublication = () => { if (failPublication) throw new IOException("injected publication failure"); } + }; + var result = await new StagePreOobeCustomizationStep(new FakeDriverPackStrategyResolver(), service).ExecuteAsync(context, TestContext.Current.CancellationToken); + Assert.Equal(failPublication ? DeploymentStepState.Failed : DeploymentStepState.Succeeded, result.State); + Assert.True(protectedBeforeWrite); + Assert.Null(context.DomainJoinInput); + Assert.Throws(() => input.Password); + Assert.Equal(!failPublication, File.Exists(credential)); + string stateRoot = Path.Combine(temp.WindowsRoot, "Windows", "Temp", "Foundry", "State", "PreOobe"); + if (failPublication) + { + Assert.False(File.Exists(Path.Combine(stateRoot, "domain-join-phase.json"))); + Assert.False(File.Exists(Path.Combine(stateRoot, "domain-join-result.json"))); + Assert.False(File.Exists(Path.Combine(stateRoot, DomainJoinStateFiles.SkipAccountCreationRequest))); + return; + } + // With the answer file Foundry generates, the runtime is asked to skip account creation after a verified join. + Assert.True(File.Exists(Path.Combine(stateRoot, DomainJoinStateFiles.SkipAccountCreationRequest))); + Assert.Contains("true", File.ReadAllText(answer)); + byte[] planBytes = File.ReadAllBytes(context.RuntimeState.PreOobeManifestPath!); + using var plan = JsonDocument.Parse(planBytes); + Assert.DoesNotContain("joiner", Encoding.UTF8.GetString(planBytes)); + Assert.DoesNotContain("secret", File.ReadAllText(Path.Combine(stateRoot, "execution-result.json"))); + Assert.Equal(new[] { "domain-join", "verify-domain-membership", "cleanup" }, plan.RootElement.GetProperty("actions").EnumerateArray().Select(action => action.GetProperty("id").GetString())); + var payload = Assert.Single(plan.RootElement.GetProperty("ownedPayloads").EnumerateArray(), item => item.GetProperty("isSensitive").GetBoolean()); + Assert.Equal("domain-join", Assert.Single(payload.GetProperty("consumerActionIds").EnumerateArray()).GetString()); + using var decoded = Foundry.Core.Services.Configuration.DomainJoinCredentialPayloadCodec.Decode(File.ReadAllBytes(credential), new("example.com", "EXAMPLE\\joiner")); + Assert.Equal("secret", new string(decoded.Password.Span)); + string hash = Convert.ToHexStringLower(SHA256.HashData(planBytes)); + using var phase = JsonDocument.Parse(File.ReadAllBytes(Path.Combine(stateRoot, "domain-join-phase.json"))); + Assert.Equal(hash, phase.RootElement.GetProperty("planHash").GetString()); + Assert.Equal(JsonValueKind.Null, phase.RootElement.GetProperty("originatingBootId").ValueKind); + Assert.Equal("Prepared", phase.RootElement.GetProperty("phase").GetString()); + Assert.Equal(JsonValueKind.Null, phase.RootElement.GetProperty("computerObjectGuid").ValueKind); + using var report = JsonDocument.Parse(File.ReadAllBytes(Path.Combine(stateRoot, "domain-join-result.json"))); + Assert.Equal(hash, report.RootElement.GetProperty("planHash").GetString()); + Assert.Equal("", report.RootElement.GetProperty("originatingBootId").GetString()); + } + + [Fact] + public async Task DomainStagingWithACustomAnswerFileLeavesAccountCreationToThatFile() + { + using var temp = new TemporaryDirectory(); + using var context = CreateContext(temp, usesCustomUnattend: true, domain: true); + string answer = Path.Combine(temp.WindowsRoot, "Windows", "Panther", "unattend.xml"); + Directory.CreateDirectory(Path.GetDirectoryName(answer)!); + await File.WriteAllBytesAsync(answer, new Foundry.Deploy.Services.Deployment.Unattend.PreOobeUnattendHookService().Prepare(Encoding.UTF8.GetBytes( + "LAB01"), "x64"), + TestContext.Current.CancellationToken); + var service = new PreOobeTargetStagingService(path => Directory.CreateDirectory(path)); + + var result = await new StagePreOobeCustomizationStep(new FakeDriverPackStrategyResolver(), service).ExecuteAsync(context, TestContext.Current.CancellationToken); + + Assert.Equal(DeploymentStepState.Succeeded, result.State); + string stateRoot = Path.Combine(temp.WindowsRoot, "Windows", "Temp", "Foundry", "State", "PreOobe"); + Assert.True(File.Exists(Path.Combine(stateRoot, "domain-join-result.json"))); + Assert.False(File.Exists(Path.Combine(stateRoot, DomainJoinStateFiles.SkipAccountCreationRequest))); + Assert.DoesNotContain("HideOnlineAccountScreens", File.ReadAllText(answer)); + } + [Fact] public async Task MissingBootstrapRuntime_IsRecoveredForBuiltInOemActivation() { @@ -374,11 +534,14 @@ private static DeploymentStepExecutionContext CreateContext( string licenseChannel = "", bool usesCustomUnattend = false, bool isDryRun = false, - Foundry.Core.Models.Configuration.Deploy.DeployPreOobeSettings? postInstall = null) + Foundry.Core.Models.Configuration.Deploy.DeployPreOobeSettings? postInstall = null, + bool domain = false) { var request = new DeploymentContext { PreOobe = postInstall ?? new(), + DomainJoinRequest = domain ? new(Services.DomainJoin.DomainJoinDeploymentDisposition.Ready) : null, + DomainJoinIntent = domain ? new("example.com", "LAB01", null) : null, Mode = DeploymentMode.Iso, IsDryRun = isDryRun, Unattend = usesCustomUnattend @@ -414,6 +577,7 @@ private static DeploymentStepExecutionContext CreateContext( } }; + DomainJoinRuntimeEligibility.Initialize(request, runtimeState); var context = new DeploymentStepExecutionContext( request, runtimeState, @@ -421,7 +585,7 @@ private static DeploymentStepExecutionContext CreateContext( new FakeOperationProgressService(), new FakeDeploymentLogService(), new FakeTargetDiskService(), - _ => { }); + _ => { }, domainJoinInput: domain ? new(new("example.com", "EXAMPLE\\joiner"), "LAB01", null, "secret") : null); string fixtureRuntime = Path.Combine(tempDirectory.RootPath, "context-runtime"); Directory.CreateDirectory(fixtureRuntime); context.PostInstallContent = NativeRuntimeFixture.Create(fixtureRuntime); diff --git a/src/Foundry.Deploy.Tests/UnattendRuntimeTests.cs b/src/Foundry.Deploy.Tests/UnattendRuntimeTests.cs index 6fe510c3a..cef444912 100644 --- a/src/Foundry.Deploy.Tests/UnattendRuntimeTests.cs +++ b/src/Foundry.Deploy.Tests/UnattendRuntimeTests.cs @@ -25,11 +25,116 @@ using Microsoft.Extensions.Logging.Abstractions; using CoreFile = Foundry.Core.Models.Configuration.Deploy.DeployUnattendFile; using CoreSettings = Foundry.Core.Models.Configuration.Deploy.DeployUnattendSettings; +using Foundry.Deploy.Services.DomainJoin; namespace Foundry.Deploy.Tests; public sealed class UnattendRuntimeTests { + [Theory] + [InlineData("*")] + [InlineData("CUSTOM-PCOTHER-PC")] + [InlineData("CUSTOM-PCcorp.test")] + public void DomainCompatibilityRejectsOwnedNamingOrJoinConflicts(string settings) + { + using var fixture = new Fixture(settings); + Assert.Throws(() => fixture.Service.Read(fixture.Selection, "amd64", false, + AutopilotProvisioningMode.JsonProfile, requiresDomainJoin: true)); + Assert.False(Directory.Exists(fixture.Target)); + } + + [Fact] + public async Task CustomNameIsFrozenWithoutXmlRewrite() + { + using var fixture = new Fixture("custom-Pc"); + var service = new DeploymentLaunchPreparationService(new Shell(), fixture.Service, new DomainJoinPreparationService(fixture.Session)); + using var submission = new DomainJoinSubmission(null, "corp.test", "CORP\\join", null, "domain-password-marker".AsSpan()); + using DeploymentLaunchPreparationResult launch = service.Prepare(fixture.CreateLaunchRequest(), new() { IsEnabled = true }, submission); + Assert.True(launch.IsReadyToStart); + Assert.Equal("custom-Pc", launch.Context!.DomainJoinIntent!.ComputerName); + Assert.Equal("", launch.Context.TargetComputerName); + using DomainJoinPreparedInput? input = launch.TakeDomainJoinInput(); + var logs = new Logs(); + using DeploymentStepExecutionContext execution = fixture.CreateContext(requestOverride: launch.Context, domainInput: input, logs: logs); + Assert.Equal(DeploymentStepState.Succeeded, + (await new ValidateCustomUnattendStep(fixture.Service).ExecuteAsync(execution, TestContext.Current.CancellationToken)).State); + await execution.UnattendSnapshot!.StageAsync(fixture.Target, TestContext.Current.CancellationToken); + Assert.Equal(fixture.Content, File.ReadAllBytes(Path.Combine(fixture.Target, "Windows", "Panther", "unattend.xml"))); + await DeploymentRunContextLogger.AppendRunContextAsync(execution, TestContext.Current.CancellationToken); + await execution.TrySaveRuntimeStateAsync(TestContext.Current.CancellationToken); + Assert.NotEmpty(logs.Messages); + Assert.NotEmpty(logs.States); + string diagnostics = string.Join("\n", logs.Messages.Concat(logs.States)) + JsonSerializer.Serialize(execution.Request); + Assert.DoesNotContain("CORP", diagnostics); + Assert.DoesNotContain("domain-password-marker", diagnostics); + Assert.DoesNotContain("encryptedCredentials", diagnostics, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task DirectConflictingDomainAndAutopilotRequestNeverExecutesSteps() + { + using var fixture = new Fixture(); + using DeploymentStepExecutionContext template = fixture.CreateContext(); + DeploymentContext request = template.Request with + { + IsAutopilotEnabled = true, + DomainJoinRequest = new(DomainJoinDeploymentDisposition.Ready), + DomainJoinIntent = new("corp.test", "CUSTOM-PC", null) + }; + var input = new DomainJoinPreparedInput(new("corp.test", "CORP\\join"), "CUSTOM-PC", null, "secret".AsSpan()); + ReadOnlyMemory password = input.Password; + bool executed = false; + IDeploymentStep[] steps = DeploymentStepNames.ExecutionOrder.Select(name => (IDeploymentStep)new CallbackStep(name, _ => executed = true)).ToArray(); + var orchestrator = new DeploymentOrchestrator(new OperationProgressService(), new Logs(), new Disks(), steps, + new Foundry.Telemetry.NullTelemetryService(), NullLogger.Instance); + DeploymentResult result = await orchestrator.RunAsync(request, input, TestContext.Current.CancellationToken); + Assert.False(result.IsSuccess); + Assert.False(executed); + Assert.All(password.ToArray(), value => Assert.Equal('\0', value)); + } + + [Fact] + public async Task ExecutionRejectsCustomNameDifferentFromFrozenIntent() + { + using var fixture = new Fixture(); + using var input = new DomainJoinPreparedInput(new("corp.test", "CORP\\join"), "DIFFERENT-PC", null, "secret".AsSpan()); + using DeploymentStepExecutionContext template = fixture.CreateContext(); + DeploymentContext request = template.Request with + { + DomainJoinRequest = new(DomainJoinDeploymentDisposition.Ready), + DomainJoinIntent = new("corp.test", "DIFFERENT-PC", null) + }; + using DeploymentStepExecutionContext execution = fixture.CreateContext(requestOverride: request, domainInput: input); + DeploymentStepResult result = await new ValidateCustomUnattendStep(fixture.Service).ExecuteAsync(execution, TestContext.Current.CancellationToken); + Assert.Equal(DeploymentStepState.Failed, result.State); + Assert.False(Directory.Exists(fixture.Target)); + } + + [Theory] + [InlineData("busy")] + [InlineData("constructor")] + public async Task OrchestratorDisposesDomainInputBeforeEarlyExit(string failure) + { + using var fixture = new Fixture(); + var operations = new OperationProgressService(); + if (failure == "busy") Assert.True(operations.TryStart(OperationKind.Deploy, "Busy", 0)); + using DeploymentStepExecutionContext template = fixture.CreateContext(); + DeploymentContext request = template.Request with + { + DomainJoinRequest = new(DomainJoinDeploymentDisposition.Ready), + DomainJoinIntent = new("corp.test", "CUSTOM-PC", null) + }; + var input = new DomainJoinPreparedInput(new("corp.test", "CORP\\join"), "CUSTOM-PC", null, "secret".AsSpan()); + ReadOnlyMemory password = input.Password; + IDeploymentStep[] steps = DeploymentStepNames.ExecutionOrder.Select(name => (IDeploymentStep)new CallbackStep(name, _ => { })).ToArray(); + var orchestrator = new DeploymentOrchestrator(operations, new Logs(failure == "constructor"), new Disks(), steps, + new Foundry.Telemetry.NullTelemetryService(), NullLogger.Instance); + DeploymentResult result = await orchestrator.RunAsync(request, input, TestContext.Current.CancellationToken); + Assert.False(result.IsSuccess); + Assert.All(password.ToArray(), value => Assert.Equal('\0', value)); + Assert.False(Directory.Exists(fixture.Target)); + } + [Fact] public async Task Snapshot_WhenMediaChanges_StagesOriginalBytesAndCannotBeReusedAfterDisposal() { @@ -223,6 +328,20 @@ public void Selection_WhenArchitectureChanges_RevalidatesAndRecovers() Assert.True(preparation.IsUnattendSelectionValid); } + [Fact] + public void Selection_WhenDomainJoinIsRequired_RejectsIncompatibleAnswerFile() + { + using var fixture = new Fixture("CUSTOM-PCOTHER-PC"); + using var preparation = new DeploymentPreparationViewModel(new LocalizationService(), false, fixture.Service); + preparation.ApplyUnattendConfiguration(new CoreSettings { IsEnabled = true, DefaultFileId = fixture.Selection.File.Id, Files = [fixture.Selection.File] }, fixture.ConfigurationPath); + preparation.UpdateUnattendContext("x64"); + Assert.True(preparation.IsUnattendSelectionValid); + preparation.UpdateUnattendContext("x64", requiresDomainJoin: true); + Assert.False(preparation.IsUnattendSelectionValid); + preparation.UpdateUnattendContext("x64"); + Assert.True(preparation.IsUnattendSelectionValid); + } + [Fact] public async Task CustomSteps_SkipNativeXmlAndAccountSecrets_WithoutApplyingAiPolicies() { @@ -445,10 +564,10 @@ public Fixture(string settings = "CUSTOM-PC", strin CryptographicOperations.ZeroMemory(key); Service = new UnattendContentService(Session); } - public DeploymentStepExecutionContext CreateContext(bool dryRun = false) + public DeploymentStepExecutionContext CreateContext(bool dryRun = false, DeploymentContext? requestOverride = null, DomainJoinPreparedInput? domainInput = null, Logs? logs = null) { var request = new DeploymentContext { Unattend = Selection, Mode = DeploymentMode.Iso, CacheRootPath = Root, TargetDiskNumber = 0, TargetComputerName = "", OperatingSystem = new OperatingSystemCatalogItem { Architecture = "amd64" }, DriverPackSelectionKind = DriverPackSelectionKind.None, IsDryRun = dryRun }; - return new DeploymentStepExecutionContext(request, new DeploymentRuntimeState { WorkspaceRoot = Root, TargetWindowsPartitionRoot = Target, TargetFoundryRoot = Path.Combine(Target, "Foundry"), IsDryRun = dryRun }, DeploymentStepNames.ExecutionOrder, new OperationProgressService(), new Logs(), new Disks(), _ => { }); + return new DeploymentStepExecutionContext(requestOverride ?? request, new DeploymentRuntimeState { WorkspaceRoot = Root, TargetWindowsPartitionRoot = Target, TargetFoundryRoot = Path.Combine(Target, "Foundry"), IsDryRun = dryRun }, DeploymentStepNames.ExecutionOrder, new OperationProgressService(), logs ?? new Logs(), new Disks(), _ => { }, domainJoinInput: domainInput); } public DeploymentLaunchRequest CreateLaunchRequest() => new() { @@ -474,11 +593,16 @@ public DeploymentStepExecutionContext CreateContext(bool dryRun = false) public void Dispose() { Session.Dispose(); CryptographicOperations.ZeroMemory(Content); Directory.Delete(Root, true); } } - private sealed class Logs : IDeploymentLogService + private sealed class Logs(bool failInitialization = false) : IDeploymentLogService { - public DeploymentLogSession Initialize(string rootPath) => new() { RootPath = rootPath, LogsDirectoryPath = Path.Combine(rootPath, "Logs"), StateDirectoryPath = Path.Combine(rootPath, "State"), StateFilePath = Path.Combine(rootPath, "State", "state.json") }; - public Task AppendAsync(DeploymentLogSession session, DeploymentLogLevel level, string message, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task SaveStateAsync(DeploymentLogSession session, TState state, CancellationToken cancellationToken = default) => Task.CompletedTask; + public List Messages { get; } = []; + public List States { get; } = []; + public DeploymentLogSession Initialize(string rootPath) => failInitialization ? throw new IOException("Synthetic initialization failure.") : + new() { RootPath = rootPath, LogsDirectoryPath = Path.Combine(rootPath, "Logs"), StateDirectoryPath = Path.Combine(rootPath, "State"), StateFilePath = Path.Combine(rootPath, "State", "state.json") }; + public Task AppendAsync(DeploymentLogSession session, DeploymentLogLevel level, string message, CancellationToken cancellationToken = default) + { Messages.Add(message); return Task.CompletedTask; } + public Task SaveStateAsync(DeploymentLogSession session, TState state, CancellationToken cancellationToken = default) + { States.Add(JsonSerializer.Serialize(state)); return Task.CompletedTask; } } private sealed class Disks : ITargetDiskService { diff --git a/src/Foundry.Deploy/DependencyInjection/ServiceCollectionExtensions.cs b/src/Foundry.Deploy/DependencyInjection/ServiceCollectionExtensions.cs index 7467942e6..247aa4f2b 100644 --- a/src/Foundry.Deploy/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Foundry.Deploy/DependencyInjection/ServiceCollectionExtensions.cs @@ -14,6 +14,7 @@ using Foundry.Deploy.Services.Deployment; using Foundry.Deploy.Services.Deployment.PreOobe; using Foundry.Deploy.Services.Deployment.Steps; +using Foundry.Deploy.Services.DomainJoin; using Foundry.Deploy.Services.Network; using Foundry.Deploy.Services.Download; using Foundry.Deploy.Services.DriverPacks; @@ -93,6 +94,8 @@ public static IServiceCollection AddFoundryDeployApplicationServices(this IServi logger); }); services.AddSingleton(); + + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Foundry.Deploy/MainWindow.xaml b/src/Foundry.Deploy/MainWindow.xaml index 61e738c8f..bf844b3d0 100644 --- a/src/Foundry.Deploy/MainWindow.xaml +++ b/src/Foundry.Deploy/MainWindow.xaml @@ -140,6 +140,26 @@ IsChecked="{Binding IsDebugAutopilotHardwareHashUploadNoDefaultGroupTagMode, Mode=OneWay}" /> + + + + + public sealed record FoundryDeployConfigurationDocument { + /// Gets runtime domain metadata and preparation-only encrypted credentials. + public Foundry.Core.Models.Configuration.Deploy.DeployDomainJoinSettings DomainJoin { get; init; } = new(); /// Gets ordered post-installation actions and their authenticated media binding. public Foundry.Core.Models.Configuration.Deploy.DeployPreOobeSettings PreOobe { get; init; } = new(); diff --git a/src/Foundry.Deploy/Services/Configuration/DeployConfigurationService.cs b/src/Foundry.Deploy/Services/Configuration/DeployConfigurationService.cs index 21dcf042d..fba3a6c55 100644 --- a/src/Foundry.Deploy/Services/Configuration/DeployConfigurationService.cs +++ b/src/Foundry.Deploy/Services/Configuration/DeployConfigurationService.cs @@ -80,6 +80,7 @@ public DeployConfigurationLoadResult LoadOptional() } Foundry.Deploy.Services.Deployment.Unattend.UnattendCatalog.Validate(document.Unattend, document.Protection?.IsEnabled == true); + ValidateDomainJoin(document); document = DeployConfigurationMigration.ApplySchemaMigrations(document); Foundry.Core.Services.Configuration.PreOobeConfigurationValidator.ThrowIfInvalid(new Foundry.Core.Models.Configuration.PreOobeSettings @@ -142,4 +143,22 @@ public DeployConfigurationLoadResult LoadOptional() return null; } } + + private static void ValidateDomainJoin(FoundryDeployConfigurationDocument document) + { + var settings = document.DomainJoin; + if (settings is null || settings.Domains is null || settings.Domains.Any(domain => domain?.OrganizationalUnits is null) || + document.Autopilot?.IsEnabled == true && settings.IsEnabled) + throw new InvalidDataException("The domain join configuration is invalid."); + bool interactive = settings.Mode == Foundry.Core.Models.Configuration.DomainJoinMode.Interactive; + // Zero-touch media carries credentials for every domain; Interactive media carries none. + HashSet withCredentials = settings.Domains.Where(domain => domain.EncryptedCredentials is not null) + .Select(domain => domain.AccountName ?? string.Empty).ToHashSet(StringComparer.OrdinalIgnoreCase); + if (!Foundry.Core.Services.Configuration.DomainJoinConfigurationValidator.EvaluateReadiness( + Foundry.Deploy.Services.DomainJoin.DomainJoinPreparationService.ToAuthored(settings), + withCredentials.Contains, document.Protection?.IsEnabled == true).IsValid || + settings.IsEnabled && !interactive && settings.Domains.Any(domain => domain.EncryptedCredentials is null) || + settings.IsEnabled && interactive && settings.Domains.Any(domain => domain.AccountName is not null || domain.EncryptedCredentials is not null)) + throw new InvalidDataException("The domain join configuration is invalid."); + } } diff --git a/src/Foundry.Deploy/Services/Deployment/DeploymentContext.cs b/src/Foundry.Deploy/Services/Deployment/DeploymentContext.cs index aab17af4c..1aba209df 100644 --- a/src/Foundry.Deploy/Services/Deployment/DeploymentContext.cs +++ b/src/Foundry.Deploy/Services/Deployment/DeploymentContext.cs @@ -15,6 +15,11 @@ namespace Foundry.Deploy.Services.Deployment; /// public sealed record DeploymentContext { + /// Gets requested mode/disposition even when credentials were intentionally bypassed. + public DomainJoin.DomainJoinDeploymentRequest? DomainJoinRequest { get; init; } + + /// Gets frozen domain/name/destination metadata without an account, key or password. + public DomainJoin.DomainJoinDeploymentIntent? DomainJoinIntent { get; init; } /// Gets ordered post-installation actions and their authenticated media binding. public Foundry.Core.Models.Configuration.Deploy.DeployPreOobeSettings PreOobe { get; init; } = new(); diff --git a/src/Foundry.Deploy/Services/Deployment/DeploymentExecutionService.cs b/src/Foundry.Deploy/Services/Deployment/DeploymentExecutionService.cs index 5e2371ce0..ecd368e00 100644 --- a/src/Foundry.Deploy/Services/Deployment/DeploymentExecutionService.cs +++ b/src/Foundry.Deploy/Services/Deployment/DeploymentExecutionService.cs @@ -28,8 +28,14 @@ public DeploymentExecutionService( _logger = logger; } - public async Task ExecuteAsync(DeploymentContext context, CancellationToken cancellationToken = default) + public Task ExecuteAsync(DeploymentContext context, CancellationToken cancellationToken = default) => + ExecuteAsync(context, null, cancellationToken); + + /// + public async Task ExecuteAsync(DeploymentContext context, DomainJoin.DomainJoinPreparedInput? domainJoinInput, + CancellationToken cancellationToken = default) { + using var ownedInput = domainJoinInput; ArgumentNullException.ThrowIfNull(context); try @@ -50,7 +56,7 @@ public async Task ExecuteAsync(DeploymentContext c } DeploymentResult result = await _deploymentOrchestrator - .RunAsync(context, cancellationToken) + .RunAsync(context, domainJoinInput, cancellationToken) .ConfigureAwait(false); _logger.LogInformation( diff --git a/src/Foundry.Deploy/Services/Deployment/DeploymentLaunchPreparationResult.cs b/src/Foundry.Deploy/Services/Deployment/DeploymentLaunchPreparationResult.cs index 9238e0900..16966dd19 100644 --- a/src/Foundry.Deploy/Services/Deployment/DeploymentLaunchPreparationResult.cs +++ b/src/Foundry.Deploy/Services/Deployment/DeploymentLaunchPreparationResult.cs @@ -3,12 +3,21 @@ // See the LICENSE file in the project root for more information. using Foundry.Deploy.Models; +using Foundry.Deploy.Services.DomainJoin; namespace Foundry.Deploy.Services.Deployment; -public sealed record DeploymentLaunchPreparationResult +public sealed class DeploymentLaunchPreparationResult : IDisposable { + private DomainJoinPreparedInput? domainJoinInput; + + /// Transfers credential ownership once; otherwise disposal clears the unconsumed input. + public DomainJoinPreparedInput? TakeDomainJoinInput() => Interlocked.Exchange(ref domainJoinInput, null); + public void Dispose() => TakeDomainJoinInput()?.Dispose(); public string? FailureMessage { get; init; } + + /// Indicates that invalidates the selected answer file rather than the launch attempt. + public bool IsUnattendFailure { get; init; } public required bool IsReadyToStart { get; init; } public required string NormalizedComputerName { get; init; } public TargetDiskInfo? EffectiveTargetDisk { get; init; } @@ -24,17 +33,30 @@ public static DeploymentLaunchPreparationResult Failure(string normalizedCompute }; } + public static DeploymentLaunchPreparationResult UnattendFailure(string normalizedComputerName, string failureMessage) + { + return new DeploymentLaunchPreparationResult + { + FailureMessage = failureMessage, + IsUnattendFailure = true, + IsReadyToStart = false, + NormalizedComputerName = normalizedComputerName + }; + } + public static DeploymentLaunchPreparationResult Success( string normalizedComputerName, TargetDiskInfo effectiveTargetDisk, - DeploymentContext context) + DeploymentContext context, + DomainJoinPreparedInput? domainJoinInput = null) { return new DeploymentLaunchPreparationResult { IsReadyToStart = true, NormalizedComputerName = normalizedComputerName, EffectiveTargetDisk = effectiveTargetDisk, - Context = context + Context = context, + domainJoinInput = domainJoinInput }; } } diff --git a/src/Foundry.Deploy/Services/Deployment/DeploymentLaunchPreparationService.cs b/src/Foundry.Deploy/Services/Deployment/DeploymentLaunchPreparationService.cs index 5457dc5e6..0bd313de7 100644 --- a/src/Foundry.Deploy/Services/Deployment/DeploymentLaunchPreparationService.cs +++ b/src/Foundry.Deploy/Services/Deployment/DeploymentLaunchPreparationService.cs @@ -8,6 +8,11 @@ using Foundry.Deploy.Services.Localization; using Foundry.Utilities.Storage; using ComputerNameRules = Foundry.Core.Services.Configuration.ComputerNameRules; +using Foundry.Deploy.Services.DomainJoin; +using Foundry.Core.Services.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using DeployDomainJoinSettings = Foundry.Core.Models.Configuration.Deploy.DeployDomainJoinSettings; namespace Foundry.Deploy.Services.Deployment; @@ -18,11 +23,16 @@ public sealed class DeploymentLaunchPreparationService : IDeploymentLaunchPrepar { private readonly IApplicationShellService _applicationShellService; private readonly Unattend.UnattendContentService? _unattendContentService; + private readonly IDomainJoinPreparationService? _domainJoinPreparationService; + private readonly ILogger _logger; - public DeploymentLaunchPreparationService(IApplicationShellService applicationShellService, Unattend.UnattendContentService? unattendContentService = null) + public DeploymentLaunchPreparationService(IApplicationShellService applicationShellService, Unattend.UnattendContentService? unattendContentService = null, + IDomainJoinPreparationService? domainJoinPreparationService = null, ILogger? logger = null) { _applicationShellService = applicationShellService; _unattendContentService = unattendContentService; + _domainJoinPreparationService = domainJoinPreparationService; + _logger = logger ?? NullLogger.Instance; } /// @@ -30,7 +40,11 @@ public DeploymentLaunchPreparationService(IApplicationShellService applicationSh /// /// The wizard selections and launch options to validate. /// The normalized launch result, including a deployment context when startup can continue. - public DeploymentLaunchPreparationResult Prepare(DeploymentLaunchRequest request) + public DeploymentLaunchPreparationResult Prepare(DeploymentLaunchRequest request) => Prepare(request, null); + + /// Accepts credential-bearing runtime settings and wizard input only as preparation-local input. + public DeploymentLaunchPreparationResult Prepare(DeploymentLaunchRequest request, DeployDomainJoinSettings? domainJoin, + DomainJoinSubmission? domainJoinSubmission = null) { ArgumentNullException.ThrowIfNull(request); @@ -39,6 +53,13 @@ public DeploymentLaunchPreparationResult Prepare(DeploymentLaunchRequest request return DeploymentLaunchPreparationResult.Failure(ComputerNameRules.Normalize(request.TargetComputerName)); } + bool requestedDomainJoin = domainJoin?.IsEnabled == true; + if (requestedDomainJoin && (request.IsAutopilotEnabled || + !DomainJoinConfigurationValidator.ValidateMetadata(DomainJoinPreparationService.ToAuthored(domainJoin!)).IsValid)) + return DeploymentLaunchPreparationResult.Failure(request.TargetComputerName, LocalizationText.GetString("DomainJoin.Invalid")); + bool requiresDomainJoin = DomainJoinPreparationService.IsRequiredFor(domainJoin, request.SelectedOperatingSystem); + bool unsupportedDomainJoin = requestedDomainJoin && !requiresDomainJoin; + string normalizedComputerName = ComputerNameRules.Normalize(request.TargetComputerName); if (!request.UsesCustomUnattend && !ComputerNameRules.IsValid(normalizedComputerName)) { @@ -52,12 +73,13 @@ public DeploymentLaunchPreparationResult Prepare(DeploymentLaunchRequest request { if (_unattendContentService is null) return DeploymentLaunchPreparationResult.Failure(normalizedComputerName); using Unattend.UnattendSnapshot snapshot = _unattendContentService.Read(request.Unattend, - request.SelectedOperatingSystem.Architecture, request.IsAutopilotEnabled, request.AutopilotProvisioningMode); + request.SelectedOperatingSystem.Architecture, request.IsAutopilotEnabled, request.AutopilotProvisioningMode, requiresDomainJoin); hasCustomCommands = snapshot.Inspection.HasCommands; + if (requiresDomainJoin) normalizedComputerName = snapshot.Inspection.ConcreteComputerName!; } catch (Exception ex) when (ex is global::System.IO.InvalidDataException or global::System.IO.IOException or InvalidOperationException) { - return DeploymentLaunchPreparationResult.Failure(normalizedComputerName, LocalizationText.GetString( + return DeploymentLaunchPreparationResult.UnattendFailure(normalizedComputerName, LocalizationText.GetString( ex is InvalidOperationException ? "Unattend.AutopilotConflict" : "Unattend.Invalid")); } } @@ -99,13 +121,46 @@ public DeploymentLaunchPreparationResult Prepare(DeploymentLaunchRequest request return DeploymentLaunchPreparationResult.Failure(normalizedComputerName); } - if (!request.IsDryRun && !ConfirmDestructiveDeployment(effectiveTargetDisk, request.SelectedOperatingSystem, request, hasCustomCommands)) + DomainJoinDeploymentRequest? domainRequest = null; + DomainJoinDeploymentIntent? domainIntent = null; + using DomainJoinPreparationResult? prepared = requiresDomainJoin && !request.IsDryRun + ? _domainJoinPreparationService?.Prepare(domainJoin!, normalizedComputerName, domainJoinSubmission) : null; + if (requestedDomainJoin) + { + DomainJoinDeploymentDisposition disposition = unsupportedDomainJoin ? DomainJoinDeploymentDisposition.UnsupportedEdition : + request.IsDryRun ? DomainJoinDeploymentDisposition.DryRun : DomainJoinDeploymentDisposition.Ready; + domainRequest = new(disposition, domainJoin!.Mode); + if (disposition == DomainJoinDeploymentDisposition.Ready) + { + if (prepared?.Status != DomainJoinPreparationStatus.Ready || prepared.Input is null) + { + _logger.LogWarning("Domain join preparation failed before deployment start. FailureCode={FailureCode}", prepared?.FailureCode); + return DeploymentLaunchPreparationResult.Failure(normalizedComputerName, LocalizationText.GetString("DomainJoin.Invalid")); + } + domainIntent = new(prepared.Input.CredentialContext.DomainName, prepared.Input.ComputerName, prepared.Input.TargetOuDn); + } + else if (disposition == DomainJoinDeploymentDisposition.DryRun) + { + domainIntent = CreateDryRunIntent(domainJoin!, domainJoinSubmission, normalizedComputerName); + } + + domainRequest = domainRequest with + { + OuSource = DomainJoinPreparationService.ResolveOuSource( + DomainJoinPreparationService.ResolveDomain(domainJoin!, domainJoinSubmission?.SelectedDomainId), domainIntent), + DomainSource = DomainJoinPreparationService.ResolveDomainSource(domainJoin!, domainIntent) + }; + } + + if (!request.IsDryRun && !ConfirmDestructiveDeployment(effectiveTargetDisk, request.SelectedOperatingSystem, request, hasCustomCommands, domainRequest)) { return DeploymentLaunchPreparationResult.Failure(normalizedComputerName); } DeploymentContext context = new() { + DomainJoinRequest = domainRequest, + DomainJoinIntent = domainIntent, Mode = request.Mode, CacheRootPath = request.CacheRootPath, TargetDiskNumber = effectiveTargetDisk.DiskNumber, @@ -134,7 +189,25 @@ public DeploymentLaunchPreparationResult Prepare(DeploymentLaunchRequest request return DeploymentLaunchPreparationResult.Success( normalizedComputerName, effectiveTargetDisk, - context); + context, + prepared?.TakeInput()); + } + + /// Simulates the join target from the wizard input without touching credentials; an unusable input yields no intent. + private static DomainJoinDeploymentIntent? CreateDryRunIntent(DeployDomainJoinSettings settings, DomainJoinSubmission? submission, string computerName) + { + var retained = DomainJoinPreparationService.ResolveDomain(settings, submission?.SelectedDomainId); + string? domain = settings.Domains.Count > 0 ? retained?.DomainName : submission?.DomainName; + if (!Foundry.Core.Models.Configuration.DomainJoinCredentialContext.IsValidDomainName(domain)) return null; + try + { + return new(domain!, computerName, DomainJoinPreparationService.ResolveOrganizationalUnit( + settings, retained, submission?.SelectedOuId, submission?.TypedOuDistinguishedName)); + } + catch (global::System.IO.InvalidDataException) + { + return null; + } } /// @@ -144,8 +217,10 @@ public DeploymentLaunchPreparationResult Prepare(DeploymentLaunchRequest request /// The operating system image that will be applied. /// Effective customization and answer-file ownership shown in the confirmation. /// Whether preserved commands require an overlap warning. + /// Secret-free disposition, including any intentional edition skip. /// when the user confirms the destructive operation. - private bool ConfirmDestructiveDeployment(TargetDiskInfo targetDisk, OperatingSystemMetadata operatingSystem, DeploymentLaunchRequest request, bool hasCustomCommands) + private bool ConfirmDestructiveDeployment(TargetDiskInfo targetDisk, OperatingSystemMetadata operatingSystem, DeploymentLaunchRequest request, bool hasCustomCommands, + DomainJoinDeploymentRequest? domainRequest) { string sizeGiB = targetDisk.SizeBytes > 0 ? $"{(targetDisk.SizeBytes / 1024d / 1024d / 1024d):0.0} GiB" @@ -169,6 +244,9 @@ private bool ConfirmDestructiveDeployment(TargetDiskInfo targetDisk, OperatingSy message += Environment.NewLine + LocalizationText.GetString("Unattend.HashWarning"); } + // The domain and OU are reviewed on the summary page; only the edition warning belongs to this confirmation. + if (domainRequest?.Disposition == DomainJoinDeploymentDisposition.UnsupportedEdition) + message += Environment.NewLine + LocalizationText.GetString("DomainJoin.UnsupportedEdition"); return _applicationShellService.ConfirmWarning(LocalizationText.GetString("Launch.ConfirmDiskEraseTitle"), message); } } diff --git a/src/Foundry.Deploy/Services/Deployment/DeploymentOrchestrator.cs b/src/Foundry.Deploy/Services/Deployment/DeploymentOrchestrator.cs index 6703d02ed..70b5c32f3 100644 --- a/src/Foundry.Deploy/Services/Deployment/DeploymentOrchestrator.cs +++ b/src/Foundry.Deploy/Services/Deployment/DeploymentOrchestrator.cs @@ -88,7 +88,22 @@ private static string FormatStepNames(IReadOnlyCollection stepNames) } /// - public async Task RunAsync(DeploymentContext context, CancellationToken cancellationToken = default) + public Task RunAsync(DeploymentContext context, CancellationToken cancellationToken = default) => + RunAsync(context, null, cancellationToken); + + /// + public async Task RunAsync(DeploymentContext context, DomainJoin.DomainJoinPreparedInput? domainJoinInput, + CancellationToken cancellationToken = default) + { + using var ownedInput = domainJoinInput; + ArgumentNullException.ThrowIfNull(context); + if (!DomainJoin.DomainJoinPreparedInput.IsValidFor(context, domainJoinInput)) + return new DeploymentResult { IsSuccess = false, Message = "The prepared domain input is unavailable or inconsistent." }; + return await RunOwnedAsync(context, domainJoinInput, cancellationToken).ConfigureAwait(false); + } + + private async Task RunOwnedAsync(DeploymentContext context, DomainJoin.DomainJoinPreparedInput? domainJoinInput, + CancellationToken cancellationToken) { Stopwatch stopwatch = Stopwatch.StartNew(); string operationId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); @@ -196,6 +211,7 @@ void BeginCompletion() try { cancellationToken.ThrowIfCancellationRequested(); + PreOobe.DomainJoinRuntimeEligibility.Initialize(context, runtimeState); IReadOnlyList plan = DeploymentPlan.Build(context, runtimeState); runtimeState.DriverPackInstallMode = DeploymentPlan.ResolveDriverMode(context); _logger.LogInformation("Deployment workspace root resolved to '{WorkspaceRoot}'.", runtimeState.WorkspaceRoot); @@ -206,8 +222,9 @@ void BeginCompletion() _operationProgressService, _deploymentLogService, _targetDiskService, - progress => StepProgressChanged?.Invoke(this, progress)); + progress => StepProgressChanged?.Invoke(this, progress), domainJoinInput: domainJoinInput); await DeploymentRunContextLogger.AppendRunContextAsync(executionContext, cancellationToken).ConfigureAwait(false); + await PreOobe.DomainJoinRuntimeEligibility.ReportLaunchSkipAsync(executionContext, cancellationToken).ConfigureAwait(false); for (int i = 0; i < plan.Count; i++) { @@ -504,6 +521,11 @@ private Task TrackDeploymentCompletedAsync( ["deploy_autopilot_provisioning_mode"] = NormalizeTelemetryString(ResolveAutopilotProvisioningMode(context)), ["deploy_autopilot_hash_upload_state"] = NormalizeTelemetryString(runtimeState?.AutopilotHardwareHashUploadState.ToString()), ["deploy_autopilot_hash_group_tag_selected"] = !string.IsNullOrWhiteSpace(runtimeState?.AutopilotHardwareHashGroupTag), + ["deploy_domain_join_enabled"] = context.DomainJoinRequest is not null, + ["deploy_domain_join_mode"] = ResolveDomainJoinMode(context.DomainJoinRequest), + ["deploy_domain_join_domain_source"] = (context.DomainJoinRequest?.DomainSource ?? DomainJoin.DomainJoinDomainSource.None).ToString().ToLowerInvariant(), + ["deploy_domain_join_ou_source"] = (context.DomainJoinRequest?.OuSource ?? DomainJoin.DomainJoinOuSource.None).ToString().ToLowerInvariant(), + ["deploy_domain_join_status"] = ResolveDomainJoinStatus(runtimeState), ["deploy_unattend_mode"] = context.UsesCustomUnattend ? "custom" : "native", ["deploy_oobe_enabled"] = !context.UsesCustomUnattend && context.Oobe.IsEnabled, ["deploy_oobe_administrator_enabled"] = !context.UsesCustomUnattend && context.Oobe.EnableAdministratorAccount, @@ -590,6 +612,23 @@ private static string ResolveOperatingSystemProduct(OperatingSystemMetadata oper : $"windows_{NormalizeTelemetryString(operatingSystem.WindowsRelease)}"; } + private static string ResolveDomainJoinMode(DomainJoin.DomainJoinDeploymentRequest? request) => request switch + { + null => "disabled", + { Mode: Foundry.Core.Models.Configuration.DomainJoinMode.Automatic } => "zero_touch", + _ => "interactive" + }; + + /// Reports how far Deploy took the join; the join itself runs later in installed Windows and is not reported. + private static string ResolveDomainJoinStatus(DeploymentRuntimeState? runtimeState) => runtimeState?.DomainJoinStatus switch + { + DomainJoinExecutionStatus.Pending => "pending", + DomainJoinExecutionStatus.Ready => "staged", + DomainJoinExecutionStatus.SkippedUnsupportedEdition => "skipped_unsupported_edition", + DomainJoinExecutionStatus.SkippedImageComposition => "skipped_image_composition", + _ => "disabled" + }; + private static string ResolveAutopilotProvisioningMode(DeploymentContext context) { if (!context.IsAutopilotEnabled) diff --git a/src/Foundry.Deploy/Services/Deployment/DeploymentPlan.cs b/src/Foundry.Deploy/Services/Deployment/DeploymentPlan.cs index a932680f7..3e124e002 100644 --- a/src/Foundry.Deploy/Services/Deployment/DeploymentPlan.cs +++ b/src/Foundry.Deploy/Services/Deployment/DeploymentPlan.cs @@ -83,7 +83,7 @@ public static IReadOnlyList Build( hasDrivers && driverMode == DriverPackInstallMode.DeferredSetupComplete, hasNetworkPayload || (!networkResolved && request.Network.ProfileRoaming.IsAnyEnabled), StagePreOobeCustomizationStep.ShouldActivateWindowsOem(request)); - if (setupTasks || request.PreOobe.IsEnabled && request.PreOobe.Actions.Any(action => action.IsEnabled)) names.Add(DeploymentStepNames.StagePreOobeCustomization); + if (setupTasks || PreOobeContentResolver.HasDomainTasks(request, state) || request.PreOobe.IsEnabled && request.PreOobe.Actions.Any(action => action.IsEnabled)) names.Add(DeploymentStepNames.StagePreOobeCustomization); names.Add(DeploymentStepNames.ConfigureRecoveryEnvironment); if (hasDrivers && driverMode == DriverPackInstallMode.OfflineInf) names.Add(DeploymentStepNames.ApplyRecoveryDrivers); names.Add(DeploymentStepNames.SealRecoveryPartition); diff --git a/src/Foundry.Deploy/Services/Deployment/DeploymentRuntimeState.cs b/src/Foundry.Deploy/Services/Deployment/DeploymentRuntimeState.cs index 11910e0af..7117ceaa5 100644 --- a/src/Foundry.Deploy/Services/Deployment/DeploymentRuntimeState.cs +++ b/src/Foundry.Deploy/Services/Deployment/DeploymentRuntimeState.cs @@ -10,11 +10,20 @@ namespace Foundry.Deploy.Services.Deployment; +/// Tracks effective domain eligibility without modifying the technician's frozen request. +public enum DomainJoinExecutionStatus { Disabled, Pending, Ready, SkippedUnsupportedEdition, SkippedImageComposition } + +/// Allowlisted reasons for a WinPE domain warning retained without a fabricated installed-Windows report. +public enum DomainJoinSkipCode { UnsupportedEdition, EmbeddedDomainJoin, AmbiguousComputerName, ComputerNameMismatch } + /// /// Captures mutable deployment state persisted to logs and reused across deployment steps. /// public sealed record DeploymentRuntimeState { + public DomainJoinExecutionStatus DomainJoinStatus { get; set; } + public DomainJoinSkipCode? DomainJoinSkipCode { get; set; } + public string? ActualWindowsEditionId { get; set; } /// /// Gets the UTC time when deployment started. /// diff --git a/src/Foundry.Deploy/Services/Deployment/DeploymentStepExecutionContext.cs b/src/Foundry.Deploy/Services/Deployment/DeploymentStepExecutionContext.cs index 01eb44696..b8e676136 100644 --- a/src/Foundry.Deploy/Services/Deployment/DeploymentStepExecutionContext.cs +++ b/src/Foundry.Deploy/Services/Deployment/DeploymentStepExecutionContext.cs @@ -23,6 +23,15 @@ namespace Foundry.Deploy.Services.Deployment; /// public sealed class DeploymentStepExecutionContext : IDisposable { + /// Owns prepared domain input only in memory; target staging must dispose and clear it. + internal DomainJoin.DomainJoinPreparedInput? DomainJoinInput { get; set; } + + /// Ends prepared credential ownership on consume, skip or failure. + internal void ClearDomainJoinInput() + { + DomainJoinInput?.Dispose(); + DomainJoinInput = null; + } /// Holds captured network data only for the active deployment, outside persisted diagnostics. internal PreOobe.PreOobeNetworkProfileRoamingPayload? NetworkProfileRoamingPayload { get; set; } @@ -68,6 +77,8 @@ internal async Task IsCustomSourceSeparateAsync(Models.CustomImageAsset as /// Releases sensitive answer-file content on every terminal deployment outcome. public void Dispose() { + DomainJoinInput?.Dispose(); + DomainJoinInput = null; UnattendSnapshot?.Dispose(); UnattendSnapshot = null; Preflight?.Dispose(); @@ -113,9 +124,12 @@ public DeploymentStepExecutionContext( IDeploymentLogService deploymentLogService, ITargetDiskService targetDiskService, Action emitStepProgress, - IDeploymentStorageService? storageService = null) + IDeploymentStorageService? storageService = null, + DomainJoin.DomainJoinPreparedInput? domainJoinInput = null) { Request = request ?? throw new ArgumentNullException(nameof(request)); + if (!DomainJoin.DomainJoinPreparedInput.IsValidFor(request, domainJoinInput)) + throw new InvalidOperationException("The prepared domain input is unavailable or inconsistent."); RuntimeState = runtimeState ?? throw new ArgumentNullException(nameof(runtimeState)); PlannedSteps = plannedSteps ?? throw new ArgumentNullException(nameof(plannedSteps)); _operationProgressService = operationProgressService ?? throw new ArgumentNullException(nameof(operationProgressService)); @@ -126,6 +140,7 @@ public DeploymentStepExecutionContext( EnsureWorkspaceFolders(); LogSession = _deploymentLogService.Initialize(RuntimeState.WorkspaceRoot); + DomainJoinInput = domainJoinInput; } /// diff --git a/src/Foundry.Deploy/Services/Deployment/IDeploymentExecutionService.cs b/src/Foundry.Deploy/Services/Deployment/IDeploymentExecutionService.cs index ba96bd55b..985194eab 100644 --- a/src/Foundry.Deploy/Services/Deployment/IDeploymentExecutionService.cs +++ b/src/Foundry.Deploy/Services/Deployment/IDeploymentExecutionService.cs @@ -10,4 +10,7 @@ public interface IDeploymentExecutionService /// Executes an authorized deployment, observing cancellation only after active mutation and cleanup finish safely. /// Task ExecuteAsync(DeploymentContext context, CancellationToken cancellationToken = default); + /// Accepts ownership of domain credentials on every exit, including authorization denial. + Task ExecuteAsync(DeploymentContext context, DomainJoin.DomainJoinPreparedInput? domainJoinInput, + CancellationToken cancellationToken = default); } diff --git a/src/Foundry.Deploy/Services/Deployment/IDeploymentLaunchPreparationService.cs b/src/Foundry.Deploy/Services/Deployment/IDeploymentLaunchPreparationService.cs index 33c7da457..a88cd342a 100644 --- a/src/Foundry.Deploy/Services/Deployment/IDeploymentLaunchPreparationService.cs +++ b/src/Foundry.Deploy/Services/Deployment/IDeploymentLaunchPreparationService.cs @@ -7,4 +7,11 @@ namespace Foundry.Deploy.Services.Deployment; public interface IDeploymentLaunchPreparationService { DeploymentLaunchPreparationResult Prepare(DeploymentLaunchRequest request); + /// + /// Consumes domain runtime settings and the technician's wizard input only during pre-confirmation preparation; + /// the caller keeps ownership of . + /// + DeploymentLaunchPreparationResult Prepare(DeploymentLaunchRequest request, + Foundry.Core.Models.Configuration.Deploy.DeployDomainJoinSettings? domainJoin, + Foundry.Deploy.Services.DomainJoin.DomainJoinSubmission? domainJoinSubmission = null); } diff --git a/src/Foundry.Deploy/Services/Deployment/IDeploymentOrchestrator.cs b/src/Foundry.Deploy/Services/Deployment/IDeploymentOrchestrator.cs index f5c193f84..5f11c6da1 100644 --- a/src/Foundry.Deploy/Services/Deployment/IDeploymentOrchestrator.cs +++ b/src/Foundry.Deploy/Services/Deployment/IDeploymentOrchestrator.cs @@ -28,4 +28,7 @@ public interface IDeploymentOrchestrator /// Token that cancels the deployment. /// The final deployment result, or a failed result when the single-operation gate rejects the request. Task RunAsync(DeploymentContext context, CancellationToken cancellationToken = default); + /// Accepts ownership on every exit, including a busy gate or execution-context construction failure. + Task RunAsync(DeploymentContext context, DomainJoin.DomainJoinPreparedInput? domainJoinInput, + CancellationToken cancellationToken = default); } diff --git a/src/Foundry.Deploy/Services/Deployment/PreOobe/DomainJoinRuntimeEligibility.cs b/src/Foundry.Deploy/Services/Deployment/PreOobe/DomainJoinRuntimeEligibility.cs new file mode 100644 index 000000000..fb394220b --- /dev/null +++ b/src/Foundry.Deploy/Services/Deployment/PreOobe/DomainJoinRuntimeEligibility.cs @@ -0,0 +1,47 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Services.Configuration; +using Foundry.Deploy.Services.Logging; + +namespace Foundry.Deploy.Services.Deployment.PreOobe; + +/// Retains inspected edition evidence and domain-only warnings without changing the frozen request. +internal static class DomainJoinRuntimeEligibility +{ + internal static void Initialize(DeploymentContext request, DeploymentRuntimeState state) + { + state.DomainJoinStatus = request.DomainJoinRequest?.Disposition switch + { + Services.DomainJoin.DomainJoinDeploymentDisposition.UnsupportedEdition => DomainJoinExecutionStatus.SkippedUnsupportedEdition, + Services.DomainJoin.DomainJoinDeploymentDisposition.Ready or Services.DomainJoin.DomainJoinDeploymentDisposition.DryRun => DomainJoinExecutionStatus.Pending, + _ => DomainJoinExecutionStatus.Disabled + }; + if (state.DomainJoinStatus == DomainJoinExecutionStatus.SkippedUnsupportedEdition) + state.DomainJoinSkipCode = DomainJoinSkipCode.UnsupportedEdition; + } + + /// Records the skip decided at launch; skips found during execution are recorded by . + internal static Task ReportLaunchSkipAsync(DeploymentStepExecutionContext context, CancellationToken cancellationToken) => + context.RuntimeState.DomainJoinSkipCode is { } code ? AppendSkipLogAsync(context, code, cancellationToken) : Task.CompletedTask; + + internal static async Task ConfirmEditionAsync(DeploymentStepExecutionContext context, string? edition, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(edition)) context.RuntimeState.ActualWindowsEditionId = edition; + if (!PreOobeContentResolver.HasDomainTasks(context.Request, context.RuntimeState)) return; + if (DomainJoinEditionRules.Evaluate(context.RuntimeState.ActualWindowsEditionId) == DomainJoinEditionSupport.Unsupported) + await SkipAsync(context, DomainJoinExecutionStatus.SkippedUnsupportedEdition, DomainJoinSkipCode.UnsupportedEdition, cancellationToken).ConfigureAwait(false); + } + + internal static async Task SkipAsync(DeploymentStepExecutionContext context, DomainJoinExecutionStatus status, DomainJoinSkipCode code, CancellationToken cancellationToken) + { + context.RuntimeState.DomainJoinStatus = status; + context.RuntimeState.DomainJoinSkipCode = code; + context.ClearDomainJoinInput(); + await AppendSkipLogAsync(context, code, cancellationToken).ConfigureAwait(false); + } + + private static Task AppendSkipLogAsync(DeploymentStepExecutionContext context, DomainJoinSkipCode code, CancellationToken cancellationToken) => + context.AppendLogAsync(DeploymentLogLevel.Warning, $"Domain joining skipped. Reason={code}.", cancellationToken); +} diff --git a/src/Foundry.Deploy/Services/Deployment/PreOobe/PreOobeContentResolver.cs b/src/Foundry.Deploy/Services/Deployment/PreOobe/PreOobeContentResolver.cs index b8c52b239..685f5cdc4 100644 --- a/src/Foundry.Deploy/Services/Deployment/PreOobe/PreOobeContentResolver.cs +++ b/src/Foundry.Deploy/Services/Deployment/PreOobe/PreOobeContentResolver.cs @@ -21,12 +21,16 @@ public class PreOobeContentResolver internal PostInstallRuntimeRecovery RuntimeRecovery { get; init; } = new(); internal Func MediaRoots { get; init; } = () => DriveInfo.GetDrives().Where(drive => drive.IsReady).Select(drive => drive.RootDirectory.FullName).ToArray(); - internal static bool IsRequired(DeploymentContext request) => + internal static bool IsRequired(DeploymentContext request, DeploymentRuntimeState? state = null) => + HasDomainTasks(request, state) || request.PreOobe.IsEnabled && request.PreOobe.Actions.Any(action => action.IsEnabled) || HasBuiltInTasks(request.AppxRemoval, request.AiComponentRemoval, DeploymentPlan.ResolveDriverMode(request) == Services.DriverPacks.DriverPackInstallMode.DeferredSetupComplete, request.Network.ProfileRoaming.IsAnyEnabled, StagePreOobeCustomizationStep.ShouldActivateWindowsOem(request)); + internal static bool HasDomainTasks(DeploymentContext request, DeploymentRuntimeState? state = null) => + state is null ? request.DomainJoinIntent is not null : state.DomainJoinStatus is DomainJoinExecutionStatus.Pending or DomainJoinExecutionStatus.Ready; + internal static bool HasBuiltInTasks(Models.Configuration.DeployAppxRemovalSettings appxRemoval, Models.Configuration.DeployAiComponentRemovalSettings aiRemoval, bool deferredDriver, bool network, bool activation) => deferredDriver || network || activation || appxRemoval.IsEnabled && appxRemoval.PackageNames.Any(name => !string.IsNullOrWhiteSpace(name)) || @@ -46,7 +50,7 @@ internal static IEnumerable ApplicableActions(DeploymentC { Foundry.Core.Services.Configuration.PreOobeConfigurationValidator.ThrowIfInvalid(new PreOobeSettings { IsEnabled = context.Request.PreOobe.IsEnabled, Actions = context.Request.PreOobe.Actions }); - if (!IsRequired(context.Request)) return null; + if (!IsRequired(context.Request, context.RuntimeState)) return null; string rid = ResolveRid(context.Request.OperatingSystem.Architecture); PreOobePreparedContent prepared; if (string.IsNullOrWhiteSpace(RuntimeExecutablePath)) diff --git a/src/Foundry.Deploy/Services/Deployment/PreOobe/PreOobeTargetStagingService.cs b/src/Foundry.Deploy/Services/Deployment/PreOobe/PreOobeTargetStagingService.cs index 804473b29..00f2e3250 100644 --- a/src/Foundry.Deploy/Services/Deployment/PreOobe/PreOobeTargetStagingService.cs +++ b/src/Foundry.Deploy/Services/Deployment/PreOobe/PreOobeTargetStagingService.cs @@ -8,6 +8,8 @@ using System.Security.Principal; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; +using Foundry.Core.Services.Configuration; using Foundry.Core.Models.PreOobe; using Foundry.Core.Services.Packages; using Foundry.Deploy.Services.Configuration; @@ -19,6 +21,7 @@ namespace Foundry.Deploy.Services.Deployment.PreOobe; /// Publishes verified local inputs and initial state before enabling the installed-Windows launch hook. public sealed class PreOobeTargetStagingService { + private const string DomainJoinActionId = "domain-join"; private readonly Action _protectDirectory; internal Action? BeforeHookPublication { get; init; } public PreOobeTargetStagingService() : this(ProtectDirectory) { } @@ -35,9 +38,24 @@ internal async Task StageAsync(DeploymentStepExecutionContext context, PreOobeDr string packageRoot = Path.Combine(layout.Root, "Payloads", "PostInstall", operationId); var unpublishedNetworkFiles = new List(); var unpublishedStateFiles = new List(); + string? domainCredentialPath = null; + bool domainCredentialCreated = false; bool hookPublished = false; try { + await DomainJoinRuntimeEligibility.ConfirmEditionAsync(context, context.RuntimeState.ActualWindowsEditionId, cancellationToken).ConfigureAwait(false); + if (PreOobeContentResolver.HasDomainTasks(context.Request, context.RuntimeState)) + { + var composition = await PreOobeUnattendPrecedenceService.InspectDomainCompositionAsync(partition, + context.Request.OperatingSystem.Architecture, context.Request.DomainJoinIntent!.ComputerName, + context.Request.UsesCustomUnattend, true, cancellationToken).ConfigureAwait(false); + if (!composition.IsCompatible) + await DomainJoinRuntimeEligibility.SkipAsync(context, DomainJoinExecutionStatus.SkippedImageComposition, + composition.SkipCode!.Value, cancellationToken).ConfigureAwait(false); + else context.RuntimeState.DomainJoinStatus = DomainJoinExecutionStatus.Ready; + } + if (!PreOobeContentResolver.IsRequired(context.Request, context.RuntimeState) && driver is null && context.NetworkProfileRoamingPayload?.DataFiles.Count is not > 0) + return; foreach (string path in new[] { layout.RuntimePreOobe, layout.StatePreOobe, layout.LogsPreOobe, work, packageRoot, Path.Combine(layout.RuntimePreOobe, "Bundle") }) _protectDirectory(path); string planPath = Path.Combine(layout.StatePreOobe, "plan.json"); string journalPath = Path.Combine(layout.StatePreOobe, "execution-result.json"); @@ -87,6 +105,21 @@ internal async Task StageAsync(DeploymentStepExecutionContext context, PreOobeDr AddBuiltIn("network-profile-roaming", PreOobeBuiltInKind.Network, new { settingsPath = "Payloads/NetworkProfiles/import-settings.json" }); owned.Add(new() { RelativePath = "Payloads/NetworkProfiles", IsDirectory = true, IsSensitive = true, ConsumerActionIds = ["network-profile-roaming"] }); } + if (PreOobeContentResolver.HasDomainTasks(context.Request, context.RuntimeState)) + { + var intent = context.Request.DomainJoinIntent!; + var input = context.DomainJoinInput ?? throw new InvalidDataException("Prepared domain credentials are unavailable."); + if (!input.Matches(intent)) throw new InvalidDataException("Prepared domain credentials do not match the frozen intent."); + string relative = $"Payloads/DomainJoin/{operationId}/credentials.bin"; + string destination = Path.Combine(layout.Root, relative.Replace('/', Path.DirectorySeparatorChar)); + _protectDirectory(Path.GetDirectoryName(destination)!); + domainCredentialPath = destination; + AddBuiltIn(DomainJoinActionId, PreOobeBuiltInKind.DomainJoinAndPlacement, + new DomainJoinActionParameters(intent.DomainName, intent.ComputerName, intent.TargetOuDn, relative)); + AddBuiltIn("verify-domain-membership", PreOobeBuiltInKind.VerifyDomainMembership, + new DomainMembershipVerificationParameters(intent.DomainName, intent.ComputerName, DomainJoinActionId)); + owned.Add(new() { RelativePath = relative, IsSensitive = true, ConsumerActionIds = [DomainJoinActionId] }); + } var ai = context.Request.AiComponentRemoval; string[] aiNames = ai.IsEnabled ? new[] { ai.RemoveCopilot ? "Microsoft.Copilot" : null, ai.RemoveAiHub ? "Microsoft.Windows.AIHub" : null }.OfType().ToArray() : []; if (aiNames.Length > 0) AddBuiltIn("remove-ai-components", PreOobeBuiltInKind.AiRemoval, new { packageNames = aiNames }); @@ -153,10 +186,55 @@ internal async Task StageAsync(DeploymentStepExecutionContext context, PreOobeDr unpublishedStateFiles.Add(journalPath); DeploymentFilePublication.WriteAllText(planPath, Encoding.UTF8.GetString(planBytes), new UTF8Encoding(false)); unpublishedStateFiles.Add(planPath); + if (PreOobeContentResolver.HasDomainTasks(context.Request, context.RuntimeState)) + { + var intent = context.Request.DomainJoinIntent!; + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + Converters = { new JsonStringEnumConverter() } + }; + var phase = DomainJoinPhaseReceipt.CreateSeed(operationId, plan.AttemptId, hash, DomainJoinActionId); + var result = DomainJoinResult.CreateSeed(operationId, plan.AttemptId, hash, intent.ComputerName, intent.DomainName, intent.TargetOuDn); + foreach (var seed in new[] + { + (Name: "domain-join-phase.json", Json: JsonSerializer.Serialize(phase, options)), + (Name: "domain-join-result.json", Json: JsonSerializer.Serialize(result, options)) + }) + { + string path = Path.Combine(layout.StatePreOobe, seed.Name); + if (File.Exists(path)) throw new InvalidDataException("An existing domain operation cannot be overwritten."); + DeploymentFilePublication.WriteAllText(path, seed.Json, new UTF8Encoding(false)); + unpublishedStateFiles.Add(path); + } + // Durable Staging ownership must precede plaintext creation, so a failed rollback cannot orphan credentials. + var input = context.DomainJoinInput!; + byte[] bytes = DomainJoinCredentialPayloadCodec.Encode(input.CredentialContext, input.Password.Span); + try + { + await using var output = new FileStream(domainCredentialPath!, FileMode.CreateNew, FileAccess.Write, FileShare.None); + domainCredentialCreated = true; + await output.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + output.Flush(flushToDisk: true); + } + finally { CryptographicOperations.ZeroMemory(bytes); context.ClearDomainJoinInput(); } + } cancellationToken.ThrowIfCancellationRequested(); string setupComplete = Path.Combine(partition, "Windows", "Setup", "Scripts", "SetupComplete.cmd"); new SetupCompleteScriptService().RemoveBlock(setupComplete, "FOUNDRY PRE-OOBE"); new SetupCompleteScriptService().RemoveBlock(setupComplete, "FOUNDRY DRIVERPACK"); + if (context.RuntimeState.DomainJoinStatus == DomainJoinExecutionStatus.Ready && !context.Request.UsesCustomUnattend) + { + // Runs after the OOBE customization step, which otherwise leaves the Microsoft account sign-in visible. + DomainJoinOobeAccountScreens.Hide(partition, context.Request.OperatingSystem.Architecture); + // The runtime skips the account creation page only once it has verified the membership. + string skipRequest = Path.Combine(layout.StatePreOobe, Foundry.Core.Models.PreOobe.DomainJoinStateFiles.SkipAccountCreationRequest); + DeploymentFilePublication.WriteAllText(skipRequest, string.Empty, new UTF8Encoding(false)); + unpublishedStateFiles.Add(skipRequest); + await context.AppendLogAsync(Foundry.Deploy.Services.Logging.DeploymentLogLevel.Info, "OOBE Microsoft account sign-in hidden; account creation is skipped after a verified join.", cancellationToken).ConfigureAwait(false); + } BeforeHookPublication?.Invoke(); new PreOobeUnattendHookService(_protectDirectory).Publish(partition, context.Request.OperatingSystem.Architecture); // An imported answer file may already contain the launch hook; arm its journal only after every publication succeeds. @@ -170,12 +248,25 @@ void AddBuiltIn(string id, PreOobeBuiltInKind kind, object parameters) => action } finally { + context.ClearDomainJoinInput(); if (!hookPublished) - foreach (string path in unpublishedStateFiles.AsEnumerable().Reverse().Concat(unpublishedNetworkFiles)) + { + Exception? domainRollbackFailure = null; + if (domainCredentialCreated) + { + try { PreOobePackagePathPolicy.ValidateNoReparsePoints(domainCredentialPath!); File.Delete(domainCredentialPath!); } + catch (Exception exception) when (exception is IOException or InvalidDataException or UnauthorizedAccessException) + { domainRollbackFailure = exception; } + } + foreach (string path in unpublishedNetworkFiles.Concat( + domainRollbackFailure is null ? unpublishedStateFiles.AsEnumerable().Reverse() : [])) { try { PreOobePackagePathPolicy.ValidateNoReparsePoints(path); File.Delete(path); } catch (Exception exception) when (exception is IOException or InvalidDataException or UnauthorizedAccessException) { } } + if (domainRollbackFailure is not null) + throw new InvalidDataException("Sensitive domain payload rollback failed; non-runnable operation ownership was retained.", domainRollbackFailure); + } } } diff --git a/src/Foundry.Deploy/Services/Deployment/Steps/ApplyOperatingSystemImageStep.cs b/src/Foundry.Deploy/Services/Deployment/Steps/ApplyOperatingSystemImageStep.cs index 4caf977d0..24abfadd7 100644 --- a/src/Foundry.Deploy/Services/Deployment/Steps/ApplyOperatingSystemImageStep.cs +++ b/src/Foundry.Deploy/Services/Deployment/Steps/ApplyOperatingSystemImageStep.cs @@ -112,7 +112,25 @@ await _windowsDeploymentService applyImageProgress) .ConfigureAwait(false); - if (PreOobe.PreOobeContentResolver.IsRequired(context.Request)) + await PreOobe.DomainJoinRuntimeEligibility.ConfirmEditionAsync(context, metadata.EditionId, cancellationToken).ConfigureAwait(false); + if (PreOobe.PreOobeContentResolver.HasDomainTasks(context.Request, context.RuntimeState)) + { + Unattend.DomainCompositionResult composition; + try + { + if (_postInstallPrecedence is null) throw new InvalidOperationException("Post-installation answer-file inspection is unavailable."); + composition = await _postInstallPrecedence.CheckDomainCompositionAsync(context.RuntimeState.TargetWindowsPartitionRoot, + context.Request.OperatingSystem.Architecture, context.Request.DomainJoinIntent!.ComputerName, context.Request.UsesCustomUnattend, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (IsAnswerFileInspectionFailure(exception)) + { + return AnswerFileInspectionFailed("domain_unattend_composition"); + } + if (!composition.IsCompatible) + await PreOobe.DomainJoinRuntimeEligibility.SkipAsync(context, DomainJoinExecutionStatus.SkippedImageComposition, + composition.SkipCode!.Value, cancellationToken).ConfigureAwait(false); + } + if (PreOobe.PreOobeContentResolver.IsRequired(context.Request, context.RuntimeState)) { try { @@ -120,10 +138,9 @@ await _windowsDeploymentService await _postInstallPrecedence.ValidateAsync(context.RuntimeState.TargetWindowsPartitionRoot, context.Request.OperatingSystem.Architecture, cancellationToken).ConfigureAwait(false); } - catch (Exception exception) when (exception is InvalidDataException or IOException or UnauthorizedAccessException or InvalidOperationException or DeploymentProcessException or global::System.Xml.XmlException) + catch (Exception exception) when (IsAnswerFileInspectionFailure(exception)) { - return DeploymentStepResult.Failed(Services.Localization.LocalizationText.GetString("PostInstall.StagingFailed"), - DeploymentFailure.Guard(DeploymentOperationNames.ApplyOperatingSystemImage, DeploymentFailureReasons.InvalidInput, "postinstall_unattend_precedence")); + return AnswerFileInspectionFailed("postinstall_unattend_precedence"); } } @@ -138,6 +155,8 @@ await _postInstallPrecedence.ValidateAsync(context.RuntimeState.TargetWindowsPar .GetAppliedWindowsEditionAsync(context.RuntimeState.TargetWindowsPartitionRoot, workingDirectory, cancellationToken) .ConfigureAwait(false); + await PreOobe.DomainJoinRuntimeEligibility.ConfirmEditionAsync(context, appliedEdition, cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(appliedEdition)) { string? requestedEdition = context.Request.OperatingSystem is Models.CustomImageSelection ? metadata.EditionId : WindowsEditionCatalog.Find(context.Request.OperatingSystem.Edition)?.EditionId; @@ -199,4 +218,11 @@ await File.WriteAllTextAsync( return DeploymentStepResult.Succeeded("Operating system image applied (simulation)."); } + private static bool IsAnswerFileInspectionFailure(Exception exception) => + exception is InvalidDataException or IOException or UnauthorizedAccessException or InvalidOperationException + or DeploymentProcessException or global::System.Xml.XmlException; + + private static DeploymentStepResult AnswerFileInspectionFailed(string failureCode) => + DeploymentStepResult.Failed(Services.Localization.LocalizationText.GetString("PostInstall.StagingFailed"), + DeploymentFailure.Guard(DeploymentOperationNames.ApplyOperatingSystemImage, DeploymentFailureReasons.InvalidInput, failureCode)); } diff --git a/src/Foundry.Deploy/Services/Deployment/Steps/CheckWindowsImageStep.cs b/src/Foundry.Deploy/Services/Deployment/Steps/CheckWindowsImageStep.cs index efd42a66f..941c84177 100644 --- a/src/Foundry.Deploy/Services/Deployment/Steps/CheckWindowsImageStep.cs +++ b/src/Foundry.Deploy/Services/Deployment/Steps/CheckWindowsImageStep.cs @@ -17,10 +17,13 @@ public sealed class CheckWindowsImageStep( protected override async Task ExecuteLiveAsync(DeploymentStepExecutionContext context, CancellationToken cancellationToken) { if (context.Request.OperatingSystem is Models.CustomImageSelection) + { + await PreOobe.DomainJoinRuntimeEligibility.ConfirmEditionAsync(context, context.Preflight?.Image?.EditionId, cancellationToken).ConfigureAwait(false); return context.Preflight?.CustomSourceLease is not null && context.Preflight.Matches(context) ? DeploymentStepResult.Succeeded("Custom image and exact index verified.") : DeploymentStepResult.Failed("Custom image is not ready.", DeploymentFailure.Guard(DeploymentOperationNames.PreflightDeployment, DeploymentFailureReasons.InvalidState, "preflight_not_ready")); + } try { DeploymentPreflightState? prepared = context.Preflight; @@ -57,6 +60,7 @@ protected override async Task ExecuteLiveAsync(DeploymentS prepared.SourceSizeBytes = sourceBytes; prepared.TargetDriverBytes = driverBytes; prepared.Image = image; + await PreOobe.DomainJoinRuntimeEligibility.ConfirmEditionAsync(context, image.EditionId, cancellationToken).ConfigureAwait(false); await context.AppendLogAsync(DeploymentLogLevel.Info, $"Image checked. ImageIndex={image.Index}; ImageExpandedBytes={image.SizeBytes}; SourceBytes={sourceBytes}; TargetDriverArchiveBytes={driverBytes}; OptionalSetupMediaBytes={image.SetupMediaSizeBytes?.ToString() ?? "unknown"}.", cancellationToken).ConfigureAwait(false); diff --git a/src/Foundry.Deploy/Services/Deployment/Steps/FinalizeDeploymentAndWriteLogsStep.cs b/src/Foundry.Deploy/Services/Deployment/Steps/FinalizeDeploymentAndWriteLogsStep.cs index 7b5aa8fe6..938172c1c 100644 --- a/src/Foundry.Deploy/Services/Deployment/Steps/FinalizeDeploymentAndWriteLogsStep.cs +++ b/src/Foundry.Deploy/Services/Deployment/Steps/FinalizeDeploymentAndWriteLogsStep.cs @@ -96,6 +96,9 @@ private static async Task WriteDeploymentSummaryAsync( deferredDriverPackagePath = runtimeState.DeferredDriverPackagePath, preOobeRunnerPath = runtimeState.PreOobeRunnerPath, preOobeManifestPath = runtimeState.PreOobeManifestPath, + domainJoinStatus = runtimeState.DomainJoinStatus, + domainJoinSkipCode = runtimeState.DomainJoinSkipCode, + actualWindowsEditionId = runtimeState.ActualWindowsEditionId, applyFirmwareUpdates = runtimeState.ApplyFirmwareUpdates, downloadedFirmwarePath = runtimeState.DownloadedFirmwarePath, extractedFirmwarePath = runtimeState.ExtractedFirmwarePath, diff --git a/src/Foundry.Deploy/Services/Deployment/Steps/PreflightDeploymentStep.cs b/src/Foundry.Deploy/Services/Deployment/Steps/PreflightDeploymentStep.cs index 6fde7ce67..04bdb5935 100644 --- a/src/Foundry.Deploy/Services/Deployment/Steps/PreflightDeploymentStep.cs +++ b/src/Foundry.Deploy/Services/Deployment/Steps/PreflightDeploymentStep.cs @@ -47,11 +47,13 @@ await nativeService.EnsureAvailableAsync(Path.GetExtension(sourcePath).Equals(". } catch (PreOobe.PostInstallRuntimeUnavailableException exception) { + context.ClearDomainJoinInput(); Serilog.Log.ForContext().Error(exception, "Post-installation runtime recovery failed."); return Failed("PostInstall.RuntimeUnavailable", "postinstall_runtime_unavailable"); } catch (Exception exception) when (exception is InvalidDataException or IOException or UnauthorizedAccessException or InvalidOperationException or HttpRequestException) { + context.ClearDomainJoinInput(); Serilog.Log.ForContext().Error(exception, "Post-installation readiness validation failed."); return Failed("PostInstall.PreflightFailed", "postinstall_preflight_failed"); } @@ -164,6 +166,7 @@ private async Task PrepareCustomImageAsync(DeploymentStepE var setupImages = images.Where(image => image.Name.Equals("Windows Setup Media", StringComparison.OrdinalIgnoreCase)).ToArray(); long? setupSize = setupImages.Length == 1 && setupImages[0].ExpandedSizeBytes > 0 ? setupImages[0].ExpandedSizeBytes : null; prepared.Image = new WindowsImageMetadata(selected.Index, selected.EditionId, selected.ExpandedSizeBytes, setupSize, selected.Architecture); + await PreOobe.DomainJoinRuntimeEligibility.ConfirmEditionAsync(context, selected.EditionId, cancellationToken).ConfigureAwait(false); prepared.SourceSizeBytes = prepared.CustomSourceLease.Length; prepared.TargetDriverBytes = ResolveTargetDriverBytes(context, null); DeploymentCapacityPolicy.EnsureTargetCapacity(context, prepared.Image, 0, prepared.TargetDriverBytes); diff --git a/src/Foundry.Deploy/Services/Deployment/Steps/PrepareTargetDiskLayoutStep.cs b/src/Foundry.Deploy/Services/Deployment/Steps/PrepareTargetDiskLayoutStep.cs index 5285c990f..698042f46 100644 --- a/src/Foundry.Deploy/Services/Deployment/Steps/PrepareTargetDiskLayoutStep.cs +++ b/src/Foundry.Deploy/Services/Deployment/Steps/PrepareTargetDiskLayoutStep.cs @@ -37,7 +37,7 @@ protected override async Task ExecuteLiveAsync(DeploymentS try { cancellationToken.ThrowIfCancellationRequested(); - if (PreOobe.PreOobeContentResolver.IsRequired(context.Request)) + if (PreOobe.PreOobeContentResolver.IsRequired(context.Request, context.RuntimeState)) { if (context.PostInstallContent is null) throw new InvalidDataException("Post-installation readiness is missing."); await PreOobe.PreOobeContentResolver.RevalidateAsync(context, context.PostInstallContent, cancellationToken).ConfigureAwait(false); diff --git a/src/Foundry.Deploy/Services/Deployment/Steps/StagePreOobeCustomizationStep.cs b/src/Foundry.Deploy/Services/Deployment/Steps/StagePreOobeCustomizationStep.cs index f8c10c635..5d52d79db 100644 --- a/src/Foundry.Deploy/Services/Deployment/Steps/StagePreOobeCustomizationStep.cs +++ b/src/Foundry.Deploy/Services/Deployment/Steps/StagePreOobeCustomizationStep.cs @@ -29,6 +29,12 @@ public StagePreOobeCustomizationStep(IDriverPackStrategyResolver driverPackStrat protected override async Task ExecuteLiveAsync( DeploymentStepExecutionContext context, CancellationToken cancellationToken) + { + try { return await ExecuteStagingAsync(context, cancellationToken).ConfigureAwait(false); } + finally { context.ClearDomainJoinInput(); } + } + + private async Task ExecuteStagingAsync(DeploymentStepExecutionContext context, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(context.RuntimeState.TargetWindowsPartitionRoot)) { @@ -51,6 +57,8 @@ protected override async Task ExecuteLiveAsync( try { await _stagingService.StageAsync(context, driverPackSettings, cancellationToken).ConfigureAwait(false); + if (context.RuntimeState.PreOobeManifestPath is null) + return DeploymentStepResult.Skipped(LocalizationText.GetString("PostInstall.NoTasks")); return DeploymentStepResult.Succeeded(LocalizationText.GetString("PostInstall.Staged")); } catch (Exception exception) when (exception is InvalidDataException or IOException or UnauthorizedAccessException or InvalidOperationException or global::System.Xml.XmlException) @@ -84,7 +92,7 @@ protected override async Task ExecuteDryRunAsync( return DeploymentStepResult.Succeeded(LocalizationText.GetString("PostInstall.Simulated")); } - private static bool HasTasks(DeploymentStepExecutionContext context) => PreOobeContentResolver.IsRequired(context.Request) || + private static bool HasTasks(DeploymentStepExecutionContext context) => PreOobeContentResolver.IsRequired(context.Request, context.RuntimeState) || context.RuntimeState.DriverPackInstallMode == DriverPackInstallMode.DeferredSetupComplete || context.NetworkProfileRoamingPayload?.DataFiles.Count > 0; private (PreOobeDriverPackScriptSettings? Settings, DeploymentStepResult? Failure) ResolveStagedDriverPackage( diff --git a/src/Foundry.Deploy/Services/Deployment/Steps/ValidateCustomUnattendStep.cs b/src/Foundry.Deploy/Services/Deployment/Steps/ValidateCustomUnattendStep.cs index e06bf84f8..96cf4a694 100644 --- a/src/Foundry.Deploy/Services/Deployment/Steps/ValidateCustomUnattendStep.cs +++ b/src/Foundry.Deploy/Services/Deployment/Steps/ValidateCustomUnattendStep.cs @@ -21,8 +21,14 @@ protected override async Task ExecuteLiveAsync(DeploymentS cancellationToken.ThrowIfCancellationRequested(); context.UnattendSnapshot?.Dispose(); context.UnattendSnapshot = contentService.Read(context.Request.Unattend, context.Request.OperatingSystem.Architecture, - context.Request.IsAutopilotEnabled, context.Request.AutopilotProvisioningMode); - if (PreOobe.PreOobeContentResolver.IsRequired(context.Request)) + context.Request.IsAutopilotEnabled, context.Request.AutopilotProvisioningMode, + context.Request.DomainJoinRequest?.Disposition is DomainJoin.DomainJoinDeploymentDisposition.Ready or DomainJoin.DomainJoinDeploymentDisposition.DryRun); + if (context.Request.DomainJoinIntent is { } intent && !string.Equals( + context.UnattendSnapshot.Inspection.ConcreteComputerName, intent.ComputerName, StringComparison.Ordinal)) + return DeploymentStepResult.Failed("The custom answer-file computer name differs from the computer name confirmed for the domain join.", + DeploymentFailure.Guard(DeploymentOperationNames.ValidateTarget, DeploymentFailureReasons.InvalidInput, + "domain_custom_name_mismatch")); + if (PreOobe.PreOobeContentResolver.IsRequired(context.Request, context.RuntimeState)) context.UnattendSnapshot.ValidatePostInstallHook(context.Request.OperatingSystem.Architecture); if (context.UnattendSnapshot.Inspection.HasCommands) { diff --git a/src/Foundry.Deploy/Services/Deployment/Unattend/DomainJoinOobeAccountScreens.cs b/src/Foundry.Deploy/Services/Deployment/Unattend/DomainJoinOobeAccountScreens.cs new file mode 100644 index 000000000..606eb104d --- /dev/null +++ b/src/Foundry.Deploy/Services/Deployment/Unattend/DomainJoinOobeAccountScreens.cs @@ -0,0 +1,39 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Xml.Linq; + +namespace Foundry.Deploy.Services.Deployment.Unattend; + +/// +/// Removes the Microsoft account sign-in from OOBE for a computer that Foundry joins to a domain, so Windows no +/// longer asks whether the device is personal or for work. +/// +/// +/// Applies only to the answer file Foundry generates; an imported answer file keeps ownership of its OOBE section. +/// Microsoft documents HideLocalAccountScreen for Windows Server only: on Windows client it is ignored, and +/// setup still asks who will use the device. That page is skipped by the post-installation runtime instead, and +/// only after it has verified the domain membership, so a failed join still lets someone create an account. +/// +internal static class DomainJoinOobeAccountScreens +{ + /// Writes the two settings into the oobeSystem pass, keeping every other OOBE setting already present. + public static void Hide(string windowsPartitionRoot, string processorArchitecture) + { + var documents = new UnattendDocumentService(); + XNamespace unattend = UnattendDocumentService.Namespace; + XDocument document = documents.LoadOrCreate(windowsPartitionRoot); + XElement component = documents.EnsureShellSetupComponent(document, "oobeSystem", processorArchitecture); + XElement? oobe = component.Element(unattend + "OOBE"); + if (oobe is null) + { + oobe = new XElement(unattend + "OOBE"); + component.Add(oobe); + } + + oobe.SetElementValue(unattend + "HideOnlineAccountScreens", "true"); + oobe.SetElementValue(unattend + "HideLocalAccountScreen", "true"); + documents.Save(windowsPartitionRoot, document); + } +} diff --git a/src/Foundry.Deploy/Services/Deployment/Unattend/PreOobeUnattendPrecedenceService.cs b/src/Foundry.Deploy/Services/Deployment/Unattend/PreOobeUnattendPrecedenceService.cs index b54c53a62..1821f769e 100644 --- a/src/Foundry.Deploy/Services/Deployment/Unattend/PreOobeUnattendPrecedenceService.cs +++ b/src/Foundry.Deploy/Services/Deployment/Unattend/PreOobeUnattendPrecedenceService.cs @@ -4,14 +4,59 @@ using System.IO; using System.Security.Cryptography; +using System.Xml.Linq; +using Foundry.Core.Services.Configuration; using Foundry.Deploy.Services.System; using Microsoft.Win32; namespace Foundry.Deploy.Services.Deployment.Unattend; +/// Returns allowlisted composition facts without escalating domain-only incompatibility. +public sealed record DomainCompositionResult(bool IsCompatible, DomainJoinSkipCode? SkipCode = null); + /// Rejects installed-image answer-file precedence conflicts before Foundry customizations can obscure them. public class PreOobeUnattendPrecedenceService(IProcessRunner processRunner) { + /// Checks embedded domain components and name ambiguity before later customization can hide them. + public virtual Task CheckDomainCompositionAsync(string partition, string architecture, string expectedName, + bool usesCustomUnattend, CancellationToken cancellationToken) => + InspectDomainCompositionAsync(partition, architecture, expectedName, usesCustomUnattend, false, cancellationToken); + + internal static async Task InspectDomainCompositionAsync(string partition, string architecture, + string expectedName, bool usesCustomUnattend, bool final, CancellationToken cancellationToken) + { + string path = Path.Combine(partition, "Windows", "Panther", "unattend.xml"); + if (!File.Exists(path)) return final ? new(false, DomainJoinSkipCode.ComputerNameMismatch) : new(true); + Foundry.Core.Services.Packages.PreOobePackagePathPolicy.ValidateNoReparsePoints(path); + if (new FileInfo(path).Length > UnattendFileService.MaximumFileSizeBytes) + throw new InvalidDataException("The answer file exceeds the supported size limit."); + byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false); + try + { + if (bytes.Length > UnattendFileService.MaximumFileSizeBytes) + throw new InvalidDataException("The answer file exceeds the supported size limit."); + using var stream = new MemoryStream(bytes, writable: false); + var document = XDocument.Load(stream); + XNamespace ns = "urn:schemas-microsoft-com:unattend"; + if (document.Root?.Name != ns + "unattend") throw new InvalidDataException("The answer file namespace is invalid."); + var components = document.Descendants(ns + "component").ToArray(); + if (components.Any(component => (string?)component.Attribute("name") == "Microsoft-Windows-UnattendedJoin")) + return new(false, DomainJoinSkipCode.EmbeddedDomainJoin); + string target = architecture.Equals("x64", StringComparison.OrdinalIgnoreCase) ? "amd64" : architecture.ToLowerInvariant(); + var names = components.Where(component => (string?)component.Attribute("name") == "Microsoft-Windows-Shell-Setup" && + (string?)component.Parent?.Attribute("pass") == "specialize" && + ((string?)component.Attribute("processorArchitecture") is "*" or "neutral" || + string.Equals((string?)component.Attribute("processorArchitecture"), target, StringComparison.OrdinalIgnoreCase))) + .SelectMany(component => component.Elements(ns + "ComputerName")).ToArray(); + if (names.Length > 1) return new(false, DomainJoinSkipCode.AmbiguousComputerName); + if ((final || usesCustomUnattend && names.Length == 1) && + (names.Length != 1 || !names[0].Value.Equals(expectedName, StringComparison.Ordinal))) + return new(false, DomainJoinSkipCode.ComputerNameMismatch); + return new(true); + } + finally { CryptographicOperations.ZeroMemory(bytes); } + } + public virtual async Task ValidateAsync(string partition, string architecture, CancellationToken cancellationToken) { string panther = Path.Combine(partition, "Windows", "Panther"); diff --git a/src/Foundry.Deploy/Services/Deployment/Unattend/UnattendContentService.cs b/src/Foundry.Deploy/Services/Deployment/Unattend/UnattendContentService.cs index f77e49a6c..531c286c7 100644 --- a/src/Foundry.Deploy/Services/Deployment/Unattend/UnattendContentService.cs +++ b/src/Foundry.Deploy/Services/Deployment/Unattend/UnattendContentService.cs @@ -21,9 +21,9 @@ public sealed class UnattendContentService(IDeploymentSecretKeySession keySessio /// Returns a disposable snapshot only after integrity, architecture and enrollment checks pass. public UnattendSnapshot Read(UnattendSelection selection, string? architecture, - bool isAutopilotEnabled, AutopilotProvisioningMode autopilotMode) + bool isAutopilotEnabled, AutopilotProvisioningMode autopilotMode, bool requiresDomainJoin = false) { - UnattendSnapshot snapshot = ReadValidated(selection, architecture); + UnattendSnapshot snapshot = ReadValidated(selection, architecture, requiresDomainJoin); if (isAutopilotEnabled && autopilotMode != AutopilotProvisioningMode.HardwareHashUpload && snapshot.Inspection.ConflictsWithAutopilot) { snapshot.Dispose(); @@ -32,7 +32,7 @@ public UnattendSnapshot Read(UnattendSelection selection, string? architecture, return snapshot; } - private UnattendSnapshot ReadValidated(UnattendSelection selection, string? architecture) + private UnattendSnapshot ReadValidated(UnattendSelection selection, string? architecture, bool requiresDomainJoin) { byte[]? key = null; byte[]? content = null; @@ -60,7 +60,7 @@ private UnattendSnapshot ReadValidated(UnattendSelection selection, string? arch { throw new InvalidDataException(); } - UnattendInspection inspection = UnattendFileService.Inspect(content, architecture); + UnattendInspection inspection = UnattendFileService.Inspect(content, architecture, requiresDomainJoin); var snapshot = new UnattendSnapshot(content, inspection); content = null; return snapshot; diff --git a/src/Foundry.Deploy/Services/DomainJoin/DomainJoinDeploymentIntent.cs b/src/Foundry.Deploy/Services/DomainJoin/DomainJoinDeploymentIntent.cs new file mode 100644 index 000000000..025542df9 --- /dev/null +++ b/src/Foundry.Deploy/Services/DomainJoin/DomainJoinDeploymentIntent.cs @@ -0,0 +1,26 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +namespace Foundry.Deploy.Services.DomainJoin; + +/// Freezes the non-secret domain, computer name and OU selected before destructive confirmation. +public sealed record DomainJoinDeploymentIntent(string DomainName, string ComputerName, string? TargetOuDn); + +/// +/// Records that domain joining was requested even when an unsupported edition or simulation bypasses credentials, +/// with the mode and the origin of the chosen domain and OU for usage telemetry. +/// +public sealed record DomainJoinDeploymentRequest(DomainJoinDeploymentDisposition Disposition, + Foundry.Core.Models.Configuration.DomainJoinMode Mode = Foundry.Core.Models.Configuration.DomainJoinMode.Interactive, + DomainJoinOuSource OuSource = DomainJoinOuSource.None, + DomainJoinDomainSource DomainSource = DomainJoinDomainSource.None); + +/// Tells where the joined domain came from, without naming it. +public enum DomainJoinDomainSource { None, Default, Selected, Typed } + +/// Tells where the join target OU came from, without naming it. +public enum DomainJoinOuSource { None, Default, Selected, Typed } + +/// Distinguishes actual prepared execution, edition skip and secret-free simulation. +public enum DomainJoinDeploymentDisposition { Ready, UnsupportedEdition, DryRun } diff --git a/src/Foundry.Deploy/Services/DomainJoin/DomainJoinPreparationService.cs b/src/Foundry.Deploy/Services/DomainJoin/DomainJoinPreparationService.cs new file mode 100644 index 000000000..748b9104b --- /dev/null +++ b/src/Foundry.Deploy/Services/DomainJoin/DomainJoinPreparationService.cs @@ -0,0 +1,204 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.IO; +using System.Security.Cryptography; +using Foundry.Core.Models.Configuration; +using Foundry.Core.Models.Configuration.Deploy; +using Foundry.Core.Services.Autopilot; +using Foundry.Core.Services.Configuration; +using Foundry.Deploy.Models; +using Foundry.Deploy.Services.Security; + +namespace Foundry.Deploy.Services.DomainJoin; + +/// Provides owned input to the pre-confirmation launch boundary. +public interface IDomainJoinPreparationService +{ + /// Combines the media configuration with what the technician entered on the Domain Join wizard step. + DomainJoinPreparationResult Prepare(DeployDomainJoinSettings settings, string computerName, DomainJoinSubmission? submission); +} + +/// Resolves owned inputs without contacting AD or acquiring another media unlock. +public sealed class DomainJoinPreparationService(IDeploymentSecretKeySession keys) : IDomainJoinPreparationService +{ + /// Requires a concrete final name and validated runtime metadata before accepting credentials. + public DomainJoinPreparationResult Prepare(DeployDomainJoinSettings settings, string computerName, DomainJoinSubmission? submission) + { + if (settings is null || !settings.IsEnabled || settings.Domains is null || !ComputerNameRules.IsValid(computerName) || + !DomainJoinConfigurationValidator.ValidateMetadata(ToAuthored(settings)).IsValid) + return DomainJoinPreparationResult.Invalid(DomainJoinPreparationFailure.MetadataInvalid); + try + { + DeployDomainJoinDomainSettings? domain = ResolveDomain(settings, submission?.SelectedDomainId); + return settings.Mode == DomainJoinMode.Automatic + ? PrepareAutomatic(settings, domain, computerName, submission) + : PrepareInteractive(settings, domain, computerName, submission); + } + catch (Exception ex) when (ex is CryptographicException or InvalidDataException or ArgumentException or InvalidOperationException or FormatException) + { + return DomainJoinPreparationResult.Invalid(DomainJoinPreparationFailure.CredentialsInvalid); + } + } + + private static DomainJoinPreparationResult PrepareInteractive(DeployDomainJoinSettings settings, DeployDomainJoinDomainSettings? domain, + string computerName, DomainJoinSubmission? submission) + { + if (submission is null) return DomainJoinPreparationResult.Invalid(DomainJoinPreparationFailure.CredentialsInvalid); + string domainName = submission.DomainName; + if (settings.Domains.Count > 0) + { + // A media that lists domains only joins one of them; a different typed name is never accepted. + if (domain is null || !string.IsNullOrWhiteSpace(domainName) && !SameDomain(domainName, domain.DomainName)) + throw new InvalidDataException("The domain must be one of the listed domains."); + domainName = domain.DomainName; + } + + string? organizationalUnit = ResolveOrganizationalUnit(settings, domain, submission.SelectedOuId, submission.TypedOuDistinguishedName); + return DomainJoinPreparationResult.Ready(new(new(domainName, submission.AccountName), + computerName, organizationalUnit, submission.Password.Span)); + } + + private DomainJoinPreparationResult PrepareAutomatic(DeployDomainJoinSettings settings, DeployDomainJoinDomainSettings? domain, + string computerName, DomainJoinSubmission? submission) + { + if (domain is null) return DomainJoinPreparationResult.Invalid(DomainJoinPreparationFailure.MetadataInvalid); + SecretEnvelope? envelope = domain.EncryptedCredentials; + if (!keys.IsUnlocked || envelope is null) + return DomainJoinPreparationResult.Invalid(DomainJoinPreparationFailure.UnlockRequired); + if (envelope.Ciphertext is null || + envelope.Ciphertext.Length > (DomainJoinConfigurationValidator.MaximumCredentialPayloadBytes + 2) / 3 * 4 || + envelope.Nonce is null || envelope.Nonce.Length > 24 || + envelope.Tag is null || envelope.Tag.Length > 24) + return DomainJoinPreparationResult.Invalid(DomainJoinPreparationFailure.CredentialsInvalid); + // The payload only decodes for the domain and account it was written for. + var expected = new DomainJoinCredentialContext(domain.DomainName, domain.AccountName ?? ""); + byte[]? key = null; + byte[]? plaintext = null; + DomainJoinCredentialPayload? payload = null; + try + { + key = keys.GetKeyCopy(); + plaintext = MediaSecretEnvelopeProtector.DecryptBytes(envelope, key, MediaSecretEnvelopeProtector.DeploymentKeyId); + payload = DomainJoinCredentialPayloadCodec.Decode(plaintext, expected); + } + finally + { + if (key is not null) CryptographicOperations.ZeroMemory(key); + if (plaintext is not null) CryptographicOperations.ZeroMemory(plaintext); + } + using (payload) + { + return DomainJoinPreparationResult.Ready(new(payload.Context, computerName, + ResolveOrganizationalUnit(settings, domain, submission?.SelectedOuId, submission?.TypedOuDistinguishedName), payload.Password.Span)); + } + } + + /// + /// Returns the domain the join will use: the one the technician chose among several, otherwise the default. + /// It returns when the media lists no domain or the choice is not listed. + /// + internal static DeployDomainJoinDomainSettings? ResolveDomain(DeployDomainJoinSettings settings, string? selectedDomainId) + { + string? id = selectedDomainId ?? settings.DefaultDomainId; + return id is null ? null : settings.Domains.FirstOrDefault(domain => string.Equals(domain.Id, id, StringComparison.OrdinalIgnoreCase)); + } + + /// Classifies the frozen domain as the authored default, another listed domain, a typed domain, or none. + internal static DomainJoinDomainSource ResolveDomainSource(DeployDomainJoinSettings settings, DomainJoinDeploymentIntent? intent) + { + if (string.IsNullOrWhiteSpace(intent?.DomainName)) return DomainJoinDomainSource.None; + if (settings.Domains.Count == 0) return DomainJoinDomainSource.Typed; + DeployDomainJoinDomainSettings? defaultDomain = ResolveDomain(settings, null); + return defaultDomain is not null && SameDomain(defaultDomain.DomainName, intent.DomainName) ? DomainJoinDomainSource.Default : DomainJoinDomainSource.Selected; + } + + /// Classifies the frozen target OU as the domain's default, another listed OU, a typed OU, or none. + internal static DomainJoinOuSource ResolveOuSource(DeployDomainJoinDomainSettings? domain, DomainJoinDeploymentIntent? intent) + { + if (string.IsNullOrWhiteSpace(intent?.TargetOuDn)) return DomainJoinOuSource.None; + if (domain is null || domain.OrganizationalUnits.Count == 0) return DomainJoinOuSource.Typed; + string? defaultDn = ResolveDefaultOrganizationalUnit(domain)?.DistinguishedName; + return string.Equals(defaultDn, intent.TargetOuDn, StringComparison.OrdinalIgnoreCase) ? DomainJoinOuSource.Default : DomainJoinOuSource.Selected; + } + + /// + /// Resolves the OU a domain uses or preselects: its only OU, or its default among several. + /// means the domain's default location, or that the technician must choose. + /// + internal static DomainJoinOrganizationalUnitSettings? ResolveDefaultOrganizationalUnit(DeployDomainJoinDomainSettings? domain) => + DomainJoinOrganizationalUnitCatalog.ResolveDefault(domain?.OrganizationalUnits, domain?.DefaultOuId); + + private static bool SameDomain(string first, string second) => string.Equals( + DomainJoinCredentialContext.CanonicalizeDomainName(first), DomainJoinCredentialContext.CanonicalizeDomainName(second), StringComparison.Ordinal); + + /// Determines whether the join applies to the selected image; positively unsupported editions skip it. + internal static bool IsRequiredFor(DeployDomainJoinSettings? settings, OperatingSystemMetadata? operatingSystem) + { + if (settings?.IsEnabled != true) return false; + string? edition = operatingSystem?.Edition; + string? editionId = operatingSystem is OperatingSystemCatalogItem ? WindowsEditionCatalog.Find(edition)?.EditionId ?? edition : edition; + return DomainJoinEditionRules.Evaluate(editionId) != DomainJoinEditionSupport.Unsupported; + } + + /// + /// Resolves the OU the join will use within the retained domain; means the domain's + /// default location. A single listed OU is always used; among several, the technician's choice is required. + /// A typed OU is accepted only in Interactive mode and only when the domain lists no OU. + /// + internal static string? ResolveOrganizationalUnit(DeployDomainJoinSettings settings, DeployDomainJoinDomainSettings? domain, + string? selectedId, string? typedOuDistinguishedName) + { + bool hasTypedOu = !string.IsNullOrWhiteSpace(typedOuDistinguishedName); + IReadOnlyList units = domain?.OrganizationalUnits ?? []; + if (units.Count == 0) + { + if (settings.Mode == DomainJoinMode.Automatic && hasTypedOu) + throw new InvalidDataException("A Zero-touch OU must come from the saved OU list."); + return hasTypedOu ? typedOuDistinguishedName : null; + } + if (hasTypedOu) + throw new InvalidDataException("The OU must come from the saved OU list."); + string? id = selectedId ?? (units.Count == 1 ? units[0].Id : null); + return units.FirstOrDefault(unit => + string.Equals(unit.Id, id, StringComparison.OrdinalIgnoreCase))?.DistinguishedName + ?? throw new InvalidDataException("The selected OU is not in the saved OU list."); + } + + /// Maps the media shape back to the authoring shape so both sides validate with the same rules. + internal static DomainJoinSettings ToAuthored(DeployDomainJoinSettings settings) => new() + { + IsEnabled = settings.IsEnabled, + Mode = settings.Mode, + Domains = settings.Domains.Select(domain => new DomainJoinDomainSettings + { + Id = domain.Id, + DomainName = domain.DomainName, + AccountName = domain.AccountName, + OrganizationalUnits = domain.OrganizationalUnits ?? [], + DefaultOuId = domain.DefaultOuId + }).ToArray(), + DefaultDomainId = settings.DefaultDomainId + }; +} +/// Defines stable non-secret launch failures; exception text never crosses preparation boundaries. +public enum DomainJoinPreparationFailure { MetadataInvalid, CredentialsInvalid, UnlockRequired } +/// Only a ready result authorizes continuing to destructive confirmation. +public enum DomainJoinPreparationStatus { Ready, Invalid } + +/// Owns prepared credentials until transferred into the launch result. +public sealed class DomainJoinPreparationResult : IDisposable +{ + private DomainJoinPreparedInput? input; + private DomainJoinPreparationResult(DomainJoinPreparationStatus status, DomainJoinPreparedInput? input, DomainJoinPreparationFailure? failureCode) + { Status = status; this.input = input; FailureCode = failureCode; } + public DomainJoinPreparationStatus Status { get; } + internal DomainJoinPreparedInput? Input => input; + public DomainJoinPreparationFailure? FailureCode { get; } + internal DomainJoinPreparedInput? TakeInput() => Interlocked.Exchange(ref input, null); + internal static DomainJoinPreparationResult Ready(DomainJoinPreparedInput input) => new(DomainJoinPreparationStatus.Ready, input, null); + + internal static DomainJoinPreparationResult Invalid(DomainJoinPreparationFailure code) => new(DomainJoinPreparationStatus.Invalid, null, code); + public void Dispose() => TakeInput()?.Dispose(); +} diff --git a/src/Foundry.Deploy/Services/DomainJoin/DomainJoinPreparedInput.cs b/src/Foundry.Deploy/Services/DomainJoin/DomainJoinPreparedInput.cs new file mode 100644 index 000000000..4fd1df968 --- /dev/null +++ b/src/Foundry.Deploy/Services/DomainJoin/DomainJoinPreparedInput.cs @@ -0,0 +1,71 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using Foundry.Core.Models.Configuration; +using Foundry.Core.Services.Configuration; +using Foundry.Deploy.Services.Deployment; + +namespace Foundry.Deploy.Services.DomainJoin; + +/// Owns one private password copy until target staging or a terminal launch/execution exit. +public sealed class DomainJoinPreparedInput : IDisposable +{ + private char[]? password; + + public DomainJoinPreparedInput(DomainJoinCredentialContext credentialContext, string computerName, + string? targetOuDn, ReadOnlySpan password) + { + if (!DomainJoinCredentialContext.IsValidDomainName(credentialContext.DomainName) || + !DomainJoinConfigurationValidator.IsQualifiedAccount(credentialContext.AccountName) || + !ComputerNameRules.IsValid(computerName) || + targetOuDn is not null && !(DistinguishedNameRules.IsOrganizationalUnit(targetOuDn) && + DistinguishedNameRules.IsWithinDomain(targetOuDn, credentialContext.DomainName))) + throw new ArgumentException("The prepared domain input is invalid."); + byte[] validated = DomainJoinCredentialPayloadCodec.Encode(credentialContext, password); + CryptographicOperations.ZeroMemory(validated); + CredentialContext = credentialContext; + ComputerName = computerName; + TargetOuDn = targetOuDn; + this.password = password.ToArray(); + } + + /// Gets authenticated credential ownership for the binary target payload; never persist this as diagnostics. + internal DomainJoinCredentialContext CredentialContext { get; } + /// Provides staging access only while this owner remains alive; callers must not retain or serialize it. + internal ReadOnlyMemory Password => password ?? throw new ObjectDisposedException(nameof(DomainJoinPreparedInput)); + public string ComputerName { get; } + public string? TargetOuDn { get; } + + /// Compares frozen metadata without exposing the account or password to durable state. + internal bool Matches(DomainJoinDeploymentIntent intent) => + password is not null && + string.Equals(DomainJoinCredentialContext.CanonicalizeDomainName(CredentialContext.DomainName), + DomainJoinCredentialContext.CanonicalizeDomainName(intent.DomainName), StringComparison.Ordinal) && + string.Equals(ComputerName, intent.ComputerName, StringComparison.Ordinal) && + string.Equals(TargetOuDn, intent.TargetOuDn, StringComparison.Ordinal); + + /// Rejects missing, foreign or contradictory ownership before any execution workspace or disk mutation. + internal static bool IsValidFor(DeploymentContext request, DomainJoinPreparedInput? input) + { + if (request.DomainJoinRequest is null) return request.DomainJoinIntent is null && input is null; + if (request.IsAutopilotEnabled) return false; + return request.DomainJoinRequest.Disposition switch + { + DomainJoinDeploymentDisposition.UnsupportedEdition => input is null && request.DomainJoinIntent is null, + DomainJoinDeploymentDisposition.DryRun => input is null && request.IsDryRun, + DomainJoinDeploymentDisposition.Ready => !request.IsDryRun && request.DomainJoinIntent is { } intent && + DomainJoinCredentialContext.IsValidDomainName(intent.DomainName) && input?.Matches(intent) == true && + (request.UsesCustomUnattend || string.Equals(request.TargetComputerName, intent.ComputerName, StringComparison.Ordinal)), + _ => false + }; + } + + public void Dispose() + { + char[]? owned = Interlocked.Exchange(ref password, null); + if (owned is not null) CryptographicOperations.ZeroMemory(MemoryMarshal.AsBytes(owned.AsSpan())); + } +} diff --git a/src/Foundry.Deploy/Services/DomainJoin/DomainJoinSubmission.cs b/src/Foundry.Deploy/Services/DomainJoin/DomainJoinSubmission.cs new file mode 100644 index 000000000..e2e317c95 --- /dev/null +++ b/src/Foundry.Deploy/Services/DomainJoin/DomainJoinSubmission.cs @@ -0,0 +1,40 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; +using System.Security.Cryptography; + +namespace Foundry.Deploy.Services.DomainJoin; + +/// +/// Carries what the technician entered on the Domain Join wizard step. It owns a transient copy of the password, +/// and no diagnostic string is ever built from it. +/// +public sealed class DomainJoinSubmission : IDisposable +{ + private readonly char[] password; + + public DomainJoinSubmission(string? selectedDomainId, string domainName, string accountName, string? selectedOuId, ReadOnlySpan password, + string? typedOuDistinguishedName = null) + { + SelectedDomainId = selectedDomainId; + DomainName = domainName; + AccountName = accountName; + SelectedOuId = selectedOuId; + TypedOuDistinguishedName = typedOuDistinguishedName; + this.password = password.ToArray(); + } + + /// Listed domain the technician retained; when the media lists no domain. + internal string? SelectedDomainId { get; } + /// Domain typed in Interactive mode when the media lists none; otherwise the retained domain's name. + internal string DomainName { get; } + internal string AccountName { get; } + internal string? SelectedOuId { get; } + /// Optional OU typed in Interactive mode, accepted only when no saved OU list applies to the domain. + internal string? TypedOuDistinguishedName { get; } + internal ReadOnlyMemory Password => password; + + public void Dispose() => CryptographicOperations.ZeroMemory(MemoryMarshal.AsBytes(password.AsSpan())); +} diff --git a/src/Foundry.Deploy/Services/Runtime/DebugDomainJoinMode.cs b/src/Foundry.Deploy/Services/Runtime/DebugDomainJoinMode.cs new file mode 100644 index 000000000..b3a9496a4 --- /dev/null +++ b/src/Foundry.Deploy/Services/Runtime/DebugDomainJoinMode.cs @@ -0,0 +1,68 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Models.Configuration; +using Foundry.Core.Models.Configuration.Deploy; + +namespace Foundry.Deploy.Services.Runtime; + +/// +/// Selects the in-memory Domain Join scenario used by Foundry.Deploy debug safe mode. +/// +public enum DebugDomainJoinMode +{ + /// + /// Disables Domain Join for the debug session. + /// + None, + + /// + /// Shows the wizard step as Interactive media would: domain, account, password and OU choice. + /// + Interactive, + + /// + /// Shows the wizard step as Zero-touch media that lists several domains and OUs would: the domain list and the OU list. + /// + ZeroTouch +} + +/// +/// Builds sample Domain Join configurations for debug safe mode. They carry no credentials, because a dry run +/// never decrypts or stages any. +/// +public static class DebugDomainJoinScenarios +{ + public static DeployDomainJoinSettings Create(DebugDomainJoinMode mode) => mode switch + { + DebugDomainJoinMode.Interactive => CreateEnabled(DomainJoinMode.Interactive, accountName: null), + DebugDomainJoinMode.ZeroTouch => CreateEnabled(DomainJoinMode.Automatic, accountName: @"CORP\svc-domainjoin"), + _ => new() + }; + + /// Lists two domains, one with OUs and one without, so every state of the step can be reached. + private static DeployDomainJoinSettings CreateEnabled(DomainJoinMode mode, string? accountName) => new() + { + IsEnabled = true, + Mode = mode, + DefaultDomainId = "corp", + Domains = + [ + new() + { + Id = "corp", + DomainName = "corp.contoso.com", + AccountName = accountName, + DefaultOuId = "workstations", + OrganizationalUnits = + [ + new() { Id = "workstations", DisplayName = "Workstations", DistinguishedName = "OU=Workstations,DC=corp,DC=contoso,DC=com" }, + new() { Id = "laptops", DisplayName = "Laptops", DistinguishedName = "OU=Laptops,OU=Workstations,DC=corp,DC=contoso,DC=com" }, + new() { Id = "kiosks", DisplayName = "Kiosks", DistinguishedName = "OU=Kiosks,DC=corp,DC=contoso,DC=com" } + ] + }, + new() { Id = "emea", DomainName = "emea.contoso.com", AccountName = accountName } + ] + }; +} diff --git a/src/Foundry.Deploy/Services/Security/DeploymentProtectionDetector.cs b/src/Foundry.Deploy/Services/Security/DeploymentProtectionDetector.cs index f7bf119ef..482189c7e 100644 --- a/src/Foundry.Deploy/Services/Security/DeploymentProtectionDetector.cs +++ b/src/Foundry.Deploy/Services/Security/DeploymentProtectionDetector.cs @@ -37,6 +37,9 @@ public static bool HasProtectedArtifacts( string? workspaceRootPath = null) { ArgumentNullException.ThrowIfNull(configuration); + if (configuration.Document?.DomainJoin.Domains?.Any(domain => domain?.EncryptedCredentials is not null) == true || + configuration.Document?.DomainJoin is { IsEnabled: true, Mode: Foundry.Core.Models.Configuration.DomainJoinMode.Automatic }) + return true; if (configuration.Document is null && configuration.Exists) { return true; diff --git a/src/Foundry.Deploy/Services/Wizard/DeploymentWizardContext.cs b/src/Foundry.Deploy/Services/Wizard/DeploymentWizardContext.cs index 616aefba6..689c5bff2 100644 --- a/src/Foundry.Deploy/Services/Wizard/DeploymentWizardContext.cs +++ b/src/Foundry.Deploy/Services/Wizard/DeploymentWizardContext.cs @@ -5,6 +5,7 @@ using Foundry.Deploy.Models; using Foundry.Deploy.Models.Configuration; using Foundry.Deploy.Services.Catalog; +using Foundry.Deploy.Services.DomainJoin; using Foundry.Deploy.Services.Startup; using Foundry.Deploy.ViewModels; using CoreDeployNetworkSettings = Foundry.Core.Models.Configuration.Deploy.DeployNetworkSettings; @@ -24,8 +25,10 @@ public DeploymentWizardContext( OperatingSystemCatalog = operatingSystemCatalog ?? throw new ArgumentNullException(nameof(operatingSystemCatalog)); DriverPackSelection = driverPackSelection ?? throw new ArgumentNullException(nameof(driverPackSelection)); CustomImages = new(); + DomainJoinStep = new(); Preparation.StateChanged += OnPreparationStateChanged; + DomainJoinStep.StateChanged += OnDomainJoinStepStateChanged; OperatingSystemCatalog.StateChanged += OnOperatingSystemCatalogStateChanged; DriverPackSelection.StateChanged += OnDriverPackSelectionStateChanged; CustomImages.StateChanged += OnOperatingSystemCatalogStateChanged; @@ -37,8 +40,10 @@ public DeploymentWizardContext( public OperatingSystemCatalogViewModel OperatingSystemCatalog { get; } public DriverPackSelectionViewModel DriverPackSelection { get; } public CustomImageSelectionViewModel CustomImages { get; } + public DomainJoinStepViewModel DomainJoinStep { get; } public OperatingSystemMetadata? SelectedOperatingSystem => CustomImages.IsCustom ? CustomImages.Selection : OperatingSystemCatalog.SelectedOperatingSystem; public Foundry.Core.Models.Configuration.Deploy.DeployPreOobeSettings PreOobe { get; private set; } = new(); + public Foundry.Core.Models.Configuration.Deploy.DeployDomainJoinSettings DomainJoin { get; private set; } = new(); public DeployCompletionSettings Completion { get; private set; } = new(); public CoreDeployNetworkSettings Network { get; private set; } = new(); public DeployOobeSettings Oobe { get; private set; } = new(); @@ -85,6 +90,15 @@ public void ApplyStartupSnapshot(DeploymentStartupSnapshot startupSnapshot) ApplyCatalogSnapshot(startupSnapshot.CatalogSnapshot); } + /// Replaces the media Domain Join configuration with a debug scenario and resets the technician's inputs. + public void ApplyDebugDomainJoin(Foundry.Core.Models.Configuration.Deploy.DeployDomainJoinSettings domainJoin) + { + DomainJoin = domainJoin ?? throw new ArgumentNullException(nameof(domainJoin)); + DomainJoinStep.Configure(DomainJoin); + RefreshDriverPackSelectionContext(); + StateChanged?.Invoke(this, EventArgs.Empty); + } + public void ApplyCatalogSnapshot(DeploymentCatalogSnapshot snapshot) { ArgumentNullException.ThrowIfNull(snapshot); @@ -105,6 +119,8 @@ public void Dispose() OperatingSystemCatalog.StateChanged -= OnOperatingSystemCatalogStateChanged; DriverPackSelection.StateChanged -= OnDriverPackSelectionStateChanged; CustomImages.StateChanged -= OnOperatingSystemCatalogStateChanged; + DomainJoinStep.StateChanged -= OnDomainJoinStepStateChanged; + DomainJoinStep.Dispose(); CustomImages.Dispose(); Preparation.Dispose(); DriverPackSelection.Dispose(); @@ -116,6 +132,8 @@ private void ApplyDeployConfiguration( IReadOnlyList autopilotProfiles) { PreOobe = document.PreOobe ?? new(); + DomainJoin = document.DomainJoin ?? new(); + DomainJoinStep.Configure(DomainJoin); Completion = document.Completion ?? new DeployCompletionSettings(); OperatingSystemCatalog.ApplyOperatingSystemSelection(document.OperatingSystemSelection); CustomImages.Configure(document.CustomImages ?? new()); @@ -142,6 +160,11 @@ private void OnOperatingSystemCatalogStateChanged(object? sender, EventArgs e) StateChanged?.Invoke(this, EventArgs.Empty); } + private void OnDomainJoinStepStateChanged(object? sender, EventArgs e) + { + StateChanged?.Invoke(this, EventArgs.Empty); + } + private void OnDriverPackSelectionStateChanged(object? sender, EventArgs e) { StateChanged?.Invoke(this, EventArgs.Empty); @@ -149,7 +172,9 @@ private void OnDriverPackSelectionStateChanged(object? sender, EventArgs e) private void RefreshDriverPackSelectionContext() { - Preparation.UpdateUnattendContext(SelectedOperatingSystem?.Architecture ?? OperatingSystemCatalog.EffectiveOsArchitecture); + Preparation.UpdateUnattendContext( + SelectedOperatingSystem?.Architecture ?? OperatingSystemCatalog.EffectiveOsArchitecture, + DomainJoinPreparationService.IsRequiredFor(DomainJoin, SelectedOperatingSystem)); DriverPackSelection.UpdateSelectionContext( Preparation.DetectedHardware, SelectedOperatingSystem, diff --git a/src/Foundry.Deploy/Services/Wizard/DeploymentWizardStateService.cs b/src/Foundry.Deploy/Services/Wizard/DeploymentWizardStateService.cs index e7e0f422f..61549ffc9 100644 --- a/src/Foundry.Deploy/Services/Wizard/DeploymentWizardStateService.cs +++ b/src/Foundry.Deploy/Services/Wizard/DeploymentWizardStateService.cs @@ -32,6 +32,11 @@ public bool CanGoNext(DeploymentWizardStateSnapshot snapshot) return snapshot.HasValidAutopilotSelection; } + if (snapshot.CurrentStepId == DeploymentWizardStepId.DomainJoin) + { + return snapshot.HasValidDomainJoinInput; + } + if (snapshot.CurrentStepId == DeploymentWizardStepId.OperatingSystem && snapshot.IsCustomImageMode) return snapshot.HasSelectedOperatingSystem; @@ -59,7 +64,8 @@ public bool CanStartDeployment(DeploymentWizardStateSnapshot snapshot) snapshot.HasSelectedOperatingSystem && hasTargetDisk && snapshot.HasValidDriverPackSelection && - snapshot.HasValidAutopilotSelection; + snapshot.HasValidAutopilotSelection && + snapshot.HasValidDomainJoinInput; } private static int GetCurrentStepIndex(DeploymentWizardStateSnapshot snapshot) diff --git a/src/Foundry.Deploy/Services/Wizard/DeploymentWizardStateSnapshot.cs b/src/Foundry.Deploy/Services/Wizard/DeploymentWizardStateSnapshot.cs index 21c01c5ec..184b953ae 100644 --- a/src/Foundry.Deploy/Services/Wizard/DeploymentWizardStateSnapshot.cs +++ b/src/Foundry.Deploy/Services/Wizard/DeploymentWizardStateSnapshot.cs @@ -20,5 +20,7 @@ public sealed record DeploymentWizardStateSnapshot public required bool IsSelectedTargetDiskSelectable { get; init; } public required bool HasValidDriverPackSelection { get; init; } public required bool HasValidAutopilotSelection { get; init; } + /// Gets whether the Domain Join step, when present, holds inputs a deployment can start with. + public bool HasValidDomainJoinInput { get; init; } = true; public required bool IsOperatingSystemCatalogReadyForNavigation { get; init; } } diff --git a/src/Foundry.Deploy/Services/Wizard/DeploymentWizardStepDefinition.cs b/src/Foundry.Deploy/Services/Wizard/DeploymentWizardStepDefinition.cs index c5ec79088..4322151d5 100644 --- a/src/Foundry.Deploy/Services/Wizard/DeploymentWizardStepDefinition.cs +++ b/src/Foundry.Deploy/Services/Wizard/DeploymentWizardStepDefinition.cs @@ -20,13 +20,19 @@ public sealed record DeploymentWizardStepDefinition( private static readonly DeploymentWizardStepDefinition Autopilot = new(DeploymentWizardStepId.Autopilot, "Wizard.Step.Autopilot"); + private static readonly DeploymentWizardStepDefinition DomainJoin = + new(DeploymentWizardStepId.DomainJoin, "DomainJoin.Title"); + private static readonly DeploymentWizardStepDefinition Summary = new(DeploymentWizardStepId.Summary, "Wizard.Step.Summary"); - public static IReadOnlyList CreateSequence(bool includeAutopilot) + /// Builds the wizard steps; the provisioning steps appear only when the media needs technician input for them. + public static IReadOnlyList CreateSequence(bool includeAutopilot, bool includeDomainJoin = false) { - return includeAutopilot - ? [TargetDevice, OperatingSystem, Drivers, Autopilot, Summary] - : [TargetDevice, OperatingSystem, Drivers, Summary]; + List steps = [TargetDevice, OperatingSystem, Drivers]; + if (includeAutopilot) steps.Add(Autopilot); + if (includeDomainJoin) steps.Add(DomainJoin); + steps.Add(Summary); + return steps; } } diff --git a/src/Foundry.Deploy/Services/Wizard/DeploymentWizardStepId.cs b/src/Foundry.Deploy/Services/Wizard/DeploymentWizardStepId.cs index 2de926afb..aea3cf810 100644 --- a/src/Foundry.Deploy/Services/Wizard/DeploymentWizardStepId.cs +++ b/src/Foundry.Deploy/Services/Wizard/DeploymentWizardStepId.cs @@ -10,5 +10,6 @@ public enum DeploymentWizardStepId OperatingSystem, Drivers, Autopilot, + DomainJoin, Summary } diff --git a/src/Foundry.Deploy/Strings/ar-SA/Resources.resx b/src/Foundry.Deploy/Strings/ar-SA/Resources.resx index 428d4c256..9cd11ff4d 100644 --- a/src/Foundry.Deploy/Strings/ar-SA/Resources.resx +++ b/src/Foundry.Deploy/Strings/ar-SA/Resources.resx @@ -256,6 +256,12 @@ صفحات النشر + + تفاعلي + + + Zero-Touch + صفحة الخطأ @@ -357,6 +363,48 @@ | {0} + + الحساب (DOMAIN\user أو user@domain) + + + أدخل الحساب بالصيغة DOMAIN\user أو user@domain. + + + الموقع الافتراضي للنطاق + + + أدخل تفاصيل الانضمام إلى النطاق لهذا الكمبيوتر. يتم الانضمام بعد تثبيت Windows. + + + الوحدة التنظيمية + + + أدخل اسماً مميزاً صالحاً لوحدة OU في هذا النطاق، أو اترك الحقل فارغاً. + + + اسم النطاق + + + أدخل اسم نطاق صالحاً، مثل corp.contoso.com. + + + معلومات الانضمام إلى النطاق مفقودة أو غير صالحة. لم يبدأ النشر. + + + الاسم المميز لـ OU (اختياري) + + + اترك الحقل فارغاً لاستخدام الموقع الافتراضي للنطاق. + + + كلمة المرور + + + الانضمام إلى النطاق + + + لا يمكن لإصدار Windows هذا الانضمام إلى نطاق. سيستمر تثبيت Windows دون ذلك. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/bg-BG/Resources.resx b/src/Foundry.Deploy/Strings/bg-BG/Resources.resx index b5ecc5c35..c6e49fb68 100644 --- a/src/Foundry.Deploy/Strings/bg-BG/Resources.resx +++ b/src/Foundry.Deploy/Strings/bg-BG/Resources.resx @@ -256,6 +256,12 @@ Страници за внедряване + + Интерактивно + + + Zero-Touch + Страница с грешки @@ -357,6 +363,48 @@ | {0} + + Акаунт (DOMAIN\user или user@domain) + + + Въведете акаунта като DOMAIN\user или user@domain. + + + Местоположение по подразбиране в домейна + + + Въведете данните за присъединяване на този компютър към домейна. Присъединяването се извършва след инсталирането на Windows. + + + Организационна единица + + + Въведете валидно отличително име на OU в този домейн или оставете полето празно. + + + Име на домейна + + + Въведете валидно име на домейн, например corp.contoso.com. + + + Данните за присъединяване към домейна липсват или са невалидни. Разполагането не е започнало. + + + Отличително име на OU (незадължително) + + + Оставете празно, за да се използва местоположението по подразбиране в домейна. + + + Парола + + + Присъединяване към домейн + + + Това издание на Windows не може да се присъедини към домейн. Инсталирането на Windows продължава без присъединяване. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/cs-CZ/Resources.resx b/src/Foundry.Deploy/Strings/cs-CZ/Resources.resx index 5ba7c60b6..d0d46bd0b 100644 --- a/src/Foundry.Deploy/Strings/cs-CZ/Resources.resx +++ b/src/Foundry.Deploy/Strings/cs-CZ/Resources.resx @@ -256,6 +256,12 @@ Licencováno pod licencí MIT. Stránky nasazení + + Interaktivní + + + Zero-Touch + Chybová stránka @@ -357,6 +363,48 @@ Akce: Ověřte atributy disku a opakujte nasazení. | {0} + + Účet (DOMAIN\user nebo user@domain) + + + Zadejte účet ve tvaru DOMAIN\user nebo user@domain. + + + Výchozí umístění domény + + + Zadejte údaje pro připojení tohoto počítače k doméně. Připojení proběhne po instalaci systému Windows. + + + Organizační jednotka + + + Zadejte platný rozlišující název OU v této doméně, nebo nechte pole prázdné. + + + Název domény + + + Zadejte platný název domény, například corp.contoso.com. + + + Údaje pro připojení k doméně chybí nebo nejsou platné. Nasazení nebylo zahájeno. + + + Rozlišující název OU (volitelné) + + + Ponechte prázdné, chcete-li použít výchozí umístění domény. + + + Heslo + + + Připojení k doméně + + + Tuto edici Windows nelze připojit k doméně. Instalace Windows pokračuje bez připojení. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/da-DK/Resources.resx b/src/Foundry.Deploy/Strings/da-DK/Resources.resx index b47453440..b25cb1265 100644 --- a/src/Foundry.Deploy/Strings/da-DK/Resources.resx +++ b/src/Foundry.Deploy/Strings/da-DK/Resources.resx @@ -256,6 +256,12 @@ Licenseret under MIT-licensen. Implementeringssider + + Interaktiv + + + Zero-Touch + Fejlside @@ -357,6 +363,48 @@ Handling: Bekræft diskattributter, og prøv implementeringen igen. | {0} + + Konto (DOMAIN\user eller user@domain) + + + Angiv kontoen som DOMAIN\user eller user@domain. + + + Domænets standardplacering + + + Angiv oplysningerne til domænetilslutning for denne computer. Tilslutningen sker, når Windows er installeret. + + + Organisationsenhed + + + Angiv et gyldigt entydigt OU-navn i dette domæne, eller lad feltet være tomt. + + + Domænenavn + + + Angiv et gyldigt domænenavn, f.eks. corp.contoso.com. + + + Oplysningerne til domænetilslutning mangler eller er ugyldige. Udrulningen er ikke startet. + + + Entydigt navn for OU (valgfrit) + + + Lad feltet være tomt for at bruge domænets standardplacering. + + + Adgangskode + + + Domænetilslutning + + + Denne Windows-udgave kan ikke tilsluttes et domæne. Installationen af Windows fortsætter uden. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/de-DE/Resources.resx b/src/Foundry.Deploy/Strings/de-DE/Resources.resx index 1fc1e6e9c..974b7f889 100644 --- a/src/Foundry.Deploy/Strings/de-DE/Resources.resx +++ b/src/Foundry.Deploy/Strings/de-DE/Resources.resx @@ -256,6 +256,12 @@ Lizenziert unter der MIT-Lizenz. Bereitstellungsseiten + + Interaktiv + + + Zero-Touch + Fehlerseite @@ -357,6 +363,48 @@ Aktion: Überprüfen Sie die Festplattenattribute und wiederholen Sie die Bereit | {0} + + Konto (DOMAIN\user oder user@domain) + + + Geben Sie das Konto als DOMAIN\user oder user@domain ein. + + + Standardort der Domäne + + + Geben Sie die Angaben für den Domänenbeitritt dieses Computers ein. Der Beitritt erfolgt nach der Installation von Windows. + + + Organisationseinheit + + + Geben Sie einen gültigen Distinguished Name einer OU in dieser Domäne ein oder lassen Sie das Feld leer. + + + Domänenname + + + Geben Sie einen gültigen Domänennamen ein, z. B. corp.contoso.com. + + + Die Angaben für den Domänenbeitritt fehlen oder sind ungültig. Die Bereitstellung wurde nicht gestartet. + + + Distinguished Name der OU (optional) + + + Leer lassen, um den Standardort der Domäne zu verwenden. + + + Kennwort + + + Domänenbeitritt + + + Diese Windows-Edition kann keiner Domäne beitreten. Die Windows-Installation wird ohne Domänenbeitritt fortgesetzt. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/el-GR/Resources.resx b/src/Foundry.Deploy/Strings/el-GR/Resources.resx index 6f02b3e7d..b01f89924 100644 --- a/src/Foundry.Deploy/Strings/el-GR/Resources.resx +++ b/src/Foundry.Deploy/Strings/el-GR/Resources.resx @@ -256,6 +256,12 @@ Σελίδες ανάπτυξης + + Διαδραστική + + + Zero-Touch + Σελίδα σφάλματος @@ -357,6 +363,48 @@ | {0} + + Λογαριασμός (DOMAIN\user ή user@domain) + + + Εισαγάγετε τον λογαριασμό ως DOMAIN\user ή user@domain. + + + Προεπιλεγμένη θέση τομέα + + + Εισαγάγετε τα στοιχεία σύνδεσης σε τομέα για αυτόν τον υπολογιστή. Η σύνδεση γίνεται μετά την εγκατάσταση των Windows. + + + Οργανωτική μονάδα + + + Εισαγάγετε ένα έγκυρο διακεκριμένο όνομα OU σε αυτόν τον τομέα ή αφήστε το πεδίο κενό. + + + Όνομα τομέα + + + Εισαγάγετε ένα έγκυρο όνομα τομέα, όπως corp.contoso.com. + + + Τα στοιχεία σύνδεσης σε τομέα λείπουν ή δεν είναι έγκυρα. Η ανάπτυξη δεν έχει ξεκινήσει. + + + Διακεκριμένο όνομα OU (προαιρετικό) + + + Αφήστε το κενό για να χρησιμοποιηθεί η προεπιλεγμένη θέση του τομέα. + + + Κωδικός πρόσβασης + + + Σύνδεση σε τομέα + + + Αυτή η έκδοση των Windows δεν μπορεί να συνδεθεί σε τομέα. Η εγκατάσταση των Windows συνεχίζεται χωρίς σύνδεση. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/en-GB/Resources.resx b/src/Foundry.Deploy/Strings/en-GB/Resources.resx index 529114f1a..33a3089c6 100644 --- a/src/Foundry.Deploy/Strings/en-GB/Resources.resx +++ b/src/Foundry.Deploy/Strings/en-GB/Resources.resx @@ -256,6 +256,12 @@ Licensed under the MIT License. Deployment pages + + Interactive + + + Zero-Touch + Error page @@ -357,6 +363,48 @@ Action: Verify disk attributes and retry deployment. | {0} + + Account (DOMAIN\user or user@domain) + + + Enter the account as DOMAIN\user or user@domain. + + + Domain default location + + + Provide the domain join details for this computer. The join happens after Windows is installed. + + + Organisational unit + + + Enter a valid OU distinguished name in this domain, or leave it empty. + + + Domain name + + + Enter a valid domain name, such as corp.contoso.com. + + + The domain join information is missing or not valid. Deployment has not started. + + + OU distinguished name (optional) + + + Leave empty to use the domain's default location. + + + Password + + + Domain join + + + This Windows edition cannot join a domain. Windows installation continues without it. + Microsoft Update Catalogue diff --git a/src/Foundry.Deploy/Strings/en-US/Resources.resx b/src/Foundry.Deploy/Strings/en-US/Resources.resx index eee3e5cfd..947bf4980 100644 --- a/src/Foundry.Deploy/Strings/en-US/Resources.resx +++ b/src/Foundry.Deploy/Strings/en-US/Resources.resx @@ -256,6 +256,12 @@ Licensed under the MIT License. Deployment pages + + Interactive + + + Zero-Touch + Error page @@ -357,6 +363,48 @@ Action: Verify disk attributes and retry deployment. | {0} + + Account (DOMAIN\user or user@domain) + + + Enter the account as DOMAIN\user or user@domain. + + + Domain default location + + + Provide the domain join details for this computer. The join happens after Windows is installed. + + + Organizational unit + + + Enter a valid OU distinguished name in this domain, or leave it empty. + + + Domain name + + + Enter a valid domain name, such as corp.contoso.com. + + + The domain join information is missing or not valid. Deployment has not started. + + + OU distinguished name (optional) + + + Leave empty to use the domain's default location. + + + Password + + + Domain join + + + This Windows edition cannot join a domain. Windows installation continues without it. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/es-ES/Resources.resx b/src/Foundry.Deploy/Strings/es-ES/Resources.resx index 4e4f4c176..0ee2e21f8 100644 --- a/src/Foundry.Deploy/Strings/es-ES/Resources.resx +++ b/src/Foundry.Deploy/Strings/es-ES/Resources.resx @@ -256,6 +256,12 @@ Licenciado bajo la Licencia MIT. Páginas de implementación + + Interactivo + + + Zero-Touch + página de errores @@ -357,6 +363,48 @@ Acción: Verifique los atributos del disco y vuelva a intentar la implementació | {0} + + Cuenta (DOMAIN\user o user@domain) + + + Introduzca la cuenta como DOMAIN\user o user@domain. + + + Ubicación predeterminada del dominio + + + Proporcione los datos de unión al dominio de este equipo. La unión se realiza después de instalar Windows. + + + Unidad organizativa + + + Introduzca un nombre distintivo de OU válido en este dominio, o deje el campo vacío. + + + Nombre de dominio + + + Introduzca un nombre de dominio válido, como corp.contoso.com. + + + Los datos de unión al dominio faltan o no son válidos. La implementación no ha comenzado. + + + Nombre distintivo de la OU (opcional) + + + Deje el campo vacío para usar la ubicación predeterminada del dominio. + + + Contraseña + + + Unión al dominio + + + Esta edición de Windows no puede unirse a un dominio. La instalación de Windows continúa sin la unión. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/es-MX/Resources.resx b/src/Foundry.Deploy/Strings/es-MX/Resources.resx index 9963fa46f..b3e88ba49 100644 --- a/src/Foundry.Deploy/Strings/es-MX/Resources.resx +++ b/src/Foundry.Deploy/Strings/es-MX/Resources.resx @@ -256,6 +256,12 @@ Licenciado bajo la Licencia MIT. Páginas de implementación + + Interactivo + + + Zero-Touch + página de errores @@ -357,6 +363,48 @@ Acción: Verifique los atributos del disco y vuelva a intentar la implementació | {0} + + Cuenta (DOMAIN\user o user@domain) + + + Introduzca la cuenta como DOMAIN\user o user@domain. + + + Ubicación predeterminada del dominio + + + Proporcione los datos de unión al dominio de este equipo. La unión se realiza después de instalar Windows. + + + Unidad organizacional + + + Introduzca un nombre distintivo de OU válido en este dominio, o deje el campo vacío. + + + Nombre de dominio + + + Introduzca un nombre de dominio válido, como corp.contoso.com. + + + Los datos de unión al dominio faltan o no son válidos. La implementación no ha comenzado. + + + Nombre distintivo de la OU (opcional) + + + Deje el campo vacío para usar la ubicación predeterminada del dominio. + + + Contraseña + + + Unión al dominio + + + Esta edición de Windows no puede unirse a un dominio. La instalación de Windows continúa sin la unión. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/et-EE/Resources.resx b/src/Foundry.Deploy/Strings/et-EE/Resources.resx index 7d7d5c270..d18985cfe 100644 --- a/src/Foundry.Deploy/Strings/et-EE/Resources.resx +++ b/src/Foundry.Deploy/Strings/et-EE/Resources.resx @@ -256,6 +256,12 @@ Litsentsitud MIT-i litsentsi alusel. Juurutamise lehed + + Interaktiivne + + + Zero-Touch + Vealeht @@ -357,6 +363,48 @@ Toiming: kontrollige ketta atribuute ja proovige juurutamist uuesti. | {0} + + Konto (DOMAIN\user või user@domain) + + + Sisestage konto kujul DOMAIN\user või user@domain. + + + Domeeni vaikeasukoht + + + Sisestage selle arvuti domeeniga liitumise andmed. Liitumine toimub pärast Windowsi installimist. + + + Organisatsiooniüksus + + + Sisestage selles domeenis kehtiv OU eristusnimi või jätke väli tühjaks. + + + Domeeninimi + + + Sisestage kehtiv domeeninimi, näiteks corp.contoso.com. + + + Domeeniga liitumise teave puudub või ei kehti. Juurutamine pole alanud. + + + OU eristusnimi (valikuline) + + + Domeeni vaikeasukoha kasutamiseks jätke tühjaks. + + + Parool + + + Domeeniga liitumine + + + See Windowsi väljaanne ei saa domeeniga liituda. Windowsi installimine jätkub ilma selleta. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/fi-FI/Resources.resx b/src/Foundry.Deploy/Strings/fi-FI/Resources.resx index 88c8dac60..2d6d8313c 100644 --- a/src/Foundry.Deploy/Strings/fi-FI/Resources.resx +++ b/src/Foundry.Deploy/Strings/fi-FI/Resources.resx @@ -256,6 +256,12 @@ Lisensoitu MIT-lisenssillä. Käyttöönottosivut + + Interaktiivinen + + + Zero-Touch + Virhesivu @@ -357,6 +363,48 @@ Toimi: Tarkista levyn attribuutit ja yritä käyttöönottoa uudelleen. | {0} + + Tili (DOMAIN\user tai user@domain) + + + Anna tili muodossa DOMAIN\user tai user@domain. + + + Toimialueen oletussijainti + + + Anna tämän tietokoneen toimialueeseen liittymisen tiedot. Liittyminen tehdään Windowsin asennuksen jälkeen. + + + Organisaatioyksikkö + + + Anna kelvollinen OU:n yksilöivä nimi tästä toimialueesta tai jätä kenttä tyhjäksi. + + + Toimialueen nimi + + + Anna kelvollinen toimialueen nimi, esimerkiksi corp.contoso.com. + + + Toimialueeseen liittymisen tiedot puuttuvat tai ovat virheellisiä. Käyttöönotto ei ole alkanut. + + + OU:n yksilöivä nimi (valinnainen) + + + Jätä tyhjäksi, jos haluat käyttää toimialueen oletussijaintia. + + + Salasana + + + Toimialueeseen liittyminen + + + Tätä Windows-versiota ei voi liittää toimialueeseen. Windowsin asennus jatkuu ilman liittämistä. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/fr-CA/Resources.resx b/src/Foundry.Deploy/Strings/fr-CA/Resources.resx index c591dd986..80added21 100644 --- a/src/Foundry.Deploy/Strings/fr-CA/Resources.resx +++ b/src/Foundry.Deploy/Strings/fr-CA/Resources.resx @@ -256,6 +256,12 @@ Sous licence MIT. Pages de déploiement + + Interactif + + + Zero-Touch + Page d'erreur @@ -357,6 +363,48 @@ Action: Vérifiez les attributs du disque et réessayez le déploiement. | {0} + + Compte (DOMAIN\user ou user@domain) + + + Saisissez le compte au format DOMAIN\user ou user@domain. + + + Emplacement par défaut du domaine + + + Indiquez les informations de jonction au domaine pour cet ordinateur. La jonction a lieu après l’installation de Windows. + + + Unité d’organisation + + + Saisissez un nom distinctif d’OU valide dans ce domaine, ou laissez le champ vide. + + + Nom de domaine + + + Saisissez un nom de domaine valide, par exemple corp.contoso.com. + + + Les informations de jonction au domaine sont manquantes ou non valides. Le déploiement n’a pas commencé. + + + Nom distinctif de l’OU (facultatif) + + + Laissez vide pour utiliser l’emplacement par défaut du domaine. + + + Mot de passe + + + Jonction au domaine + + + Cette édition de Windows ne peut pas joindre un domaine. L’installation de Windows se poursuit sans jonction. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/fr-FR/Resources.resx b/src/Foundry.Deploy/Strings/fr-FR/Resources.resx index 3f1441ec9..95026cc4f 100644 --- a/src/Foundry.Deploy/Strings/fr-FR/Resources.resx +++ b/src/Foundry.Deploy/Strings/fr-FR/Resources.resx @@ -256,6 +256,12 @@ Sous licence MIT. Pages de déploiement + + Interactif + + + Zero-Touch + Page d’erreur @@ -357,6 +363,48 @@ Action : vérifiez les attributs du disque puis relancez le déploiement. | {0} + + Compte (DOMAIN\user ou user@domain) + + + Saisissez le compte au format DOMAIN\user ou user@domain. + + + Emplacement par défaut du domaine + + + Indiquez les informations de jonction au domaine pour cet ordinateur. La jonction a lieu après l’installation de Windows. + + + Unité d’organisation + + + Saisissez un nom distinctif d’OU valide dans ce domaine, ou laissez le champ vide. + + + Nom de domaine + + + Saisissez un nom de domaine valide, par exemple corp.contoso.com. + + + Les informations de jonction au domaine sont manquantes ou non valides. Le déploiement n’a pas commencé. + + + Nom distinctif de l’OU (facultatif) + + + Laissez vide pour utiliser l’emplacement par défaut du domaine. + + + Mot de passe + + + Jonction au domaine + + + Cette édition de Windows ne peut pas joindre un domaine. L’installation de Windows se poursuit sans jonction. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/he-IL/Resources.resx b/src/Foundry.Deploy/Strings/he-IL/Resources.resx index 1427bc10a..95cee8add 100644 --- a/src/Foundry.Deploy/Strings/he-IL/Resources.resx +++ b/src/Foundry.Deploy/Strings/he-IL/Resources.resx @@ -256,6 +256,12 @@ דפי פריסה + + אינטראקטיבי + + + Zero-Touch + דף שגיאה @@ -357,6 +363,48 @@ ErrorCode=0x80070005 | {0} + + חשבון (DOMAIN\user או user@domain) + + + הזן את החשבון בתבנית DOMAIN\user או user@domain. + + + מיקום ברירת המחדל של התחום + + + ספק את פרטי ההצטרפות לתחום עבור מחשב זה. ההצטרפות מתבצעת לאחר התקנת Windows. + + + יחידה ארגונית + + + הזן שם מובחן תקין של OU בתחום זה, או השאר ריק. + + + שם התחום + + + הזן שם תחום תקין, כגון corp.contoso.com. + + + פרטי ההצטרפות לתחום חסרים או אינם תקינים. הפריסה לא התחילה. + + + שם מובחן של OU (אופציונלי) + + + השאר ריק כדי להשתמש במיקום ברירת המחדל של התחום. + + + סיסמה + + + הצטרפות לתחום + + + מהדורת Windows זו אינה יכולה להצטרף לתחום. התקנת Windows ממשיכה ללא הצטרפות. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/hr-HR/Resources.resx b/src/Foundry.Deploy/Strings/hr-HR/Resources.resx index 5d22805fd..dfa705d9c 100644 --- a/src/Foundry.Deploy/Strings/hr-HR/Resources.resx +++ b/src/Foundry.Deploy/Strings/hr-HR/Resources.resx @@ -256,6 +256,12 @@ Licenciran prema MIT licenci. Stranice za implementaciju + + Interaktivno + + + Zero-Touch + Stranica s pogreškom @@ -357,6 +363,48 @@ Radnja: Provjerite atribute diska i ponovno pokušajte implementaciju. | {0} + + Račun (DOMAIN\user ili user@domain) + + + Unesite račun u obliku DOMAIN\user ili user@domain. + + + Zadano mjesto domene + + + Unesite podatke za pridruživanje ovog računala domeni. Pridruživanje se izvršava nakon instalacije sustava Windows. + + + Organizacijska jedinica + + + Unesite valjan razlikovni naziv OU-a u ovoj domeni ili ostavite prazno. + + + Naziv domene + + + Unesite valjan naziv domene, na primjer corp.contoso.com. + + + Podaci za pridruživanje domeni nedostaju ili nisu valjani. Implementacija nije započela. + + + Razlikovni naziv OU-a (neobavezno) + + + Ostavite prazno za zadano mjesto domene. + + + Lozinka + + + Pridruživanje domeni + + + Ovo izdanje sustava Windows ne može se pridružiti domeni. Instalacija sustava Windows nastavlja se bez pridruživanja. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/hu-HU/Resources.resx b/src/Foundry.Deploy/Strings/hu-HU/Resources.resx index bedf15017..39282591e 100644 --- a/src/Foundry.Deploy/Strings/hu-HU/Resources.resx +++ b/src/Foundry.Deploy/Strings/hu-HU/Resources.resx @@ -256,6 +256,12 @@ Licenc az MIT licenc alatt. Bevezetési oldalak + + Interaktív + + + Zero-Touch + Hiba oldal @@ -357,6 +363,48 @@ Művelet: Ellenőrizze a lemezattribútumokat, és próbálkozzon újra a közpo | {0} + + Fiók (DOMAIN\user vagy user@domain) + + + A fiókot DOMAIN\user vagy user@domain formában adja meg. + + + A tartomány alapértelmezett helye + + + Adja meg a számítógép tartományhoz csatlakoztatásának adatait. A csatlakozás a Windows telepítése után történik. + + + Szervezeti egység + + + Adjon meg érvényes OU megkülönböztető nevet ebben a tartományban, vagy hagyja üresen. + + + Tartománynév + + + Adjon meg érvényes tartománynevet, például: corp.contoso.com. + + + A tartományhoz csatlakozás adatai hiányoznak vagy érvénytelenek. A telepítés nem indult el. + + + OU megkülönböztető neve (nem kötelező) + + + Hagyja üresen a tartomány alapértelmezett helyének használatához. + + + Jelszó + + + Tartományhoz csatlakozás + + + Ez a Windows-kiadás nem csatlakoztatható tartományhoz. A Windows telepítése enélkül folytatódik. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/it-IT/Resources.resx b/src/Foundry.Deploy/Strings/it-IT/Resources.resx index 16fba7d03..7a420593e 100644 --- a/src/Foundry.Deploy/Strings/it-IT/Resources.resx +++ b/src/Foundry.Deploy/Strings/it-IT/Resources.resx @@ -256,6 +256,12 @@ Concesso in licenza con la licenza MIT. Pagine di distribuzione + + Interattivo + + + Zero-Touch + Pagina di errore @@ -357,6 +363,48 @@ Azione: verificare gli attributi del disco e riprovare la distribuzione. | {0} + + Account (DOMAIN\user o user@domain) + + + Inserisci l'account come DOMAIN\user o user@domain. + + + Posizione predefinita del dominio + + + Fornisci i dati per l'aggiunta al dominio di questo computer. L'aggiunta avviene dopo l'installazione di Windows. + + + Unità organizzativa + + + Inserisci un nome distinto di OU valido in questo dominio, oppure lascia il campo vuoto. + + + Nome di dominio + + + Inserisci un nome di dominio valido, ad esempio corp.contoso.com. + + + I dati per l'aggiunta al dominio sono mancanti o non validi. La distribuzione non è iniziata. + + + Nome distinto della OU (facoltativo) + + + Lascia vuoto per usare la posizione predefinita del dominio. + + + Password + + + Aggiunta al dominio + + + Questa edizione di Windows non può essere aggiunta a un dominio. L'installazione di Windows continua senza l'aggiunta. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/ja-JP/Resources.resx b/src/Foundry.Deploy/Strings/ja-JP/Resources.resx index 47a3fe74a..3b2a5bbaf 100644 --- a/src/Foundry.Deploy/Strings/ja-JP/Resources.resx +++ b/src/Foundry.Deploy/Strings/ja-JP/Resources.resx @@ -256,6 +256,12 @@ MITライセンスに基づいてライセンスされています。 導入ページ + + 対話型 + + + ゼロタッチ + エラーページ @@ -357,6 +363,48 @@ MITライセンスに基づいてライセンスされています。 | {0} + + アカウント (DOMAIN\userまたはuser@domain) + + + アカウントはDOMAIN\userまたはuser@domainの形式で入力してください。 + + + ドメインの既定の場所 + + + このコンピューターのドメイン参加情報を入力してください。参加はWindowsのインストール後に実行されます。 + + + 組織単位 + + + このドメイン内の有効なOUの識別名を入力するか、空欄にしてください。 + + + ドメイン名 + + + corp.contoso.comなどの有効なドメイン名を入力してください。 + + + ドメイン参加の情報がないか、無効です。展開は開始されていません。 + + + OUの識別名 (省略可) + + + 空欄にするとドメインの既定の場所が使用されます。 + + + パスワード + + + ドメイン参加 + + + このWindowsエディションはドメインに参加できません。ドメインに参加せずにWindowsのインストールを続行します。 + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/ko-KR/Resources.resx b/src/Foundry.Deploy/Strings/ko-KR/Resources.resx index 206e9bf36..d761c5c12 100644 --- a/src/Foundry.Deploy/Strings/ko-KR/Resources.resx +++ b/src/Foundry.Deploy/Strings/ko-KR/Resources.resx @@ -256,6 +256,12 @@ MIT 라이선스에 따라 라이선스가 부여됩니다. 배포 페이지 + + 대화형 + + + 제로 터치 + 오류 페이지 @@ -357,6 +363,48 @@ MIT 라이선스에 따라 라이선스가 부여됩니다. | {0} + + 계정 (DOMAIN\user 또는 user@domain) + + + 계정을 DOMAIN\user 또는 user@domain 형식으로 입력하세요. + + + 도메인 기본 위치 + + + 이 컴퓨터의 도메인 가입 정보를 입력하세요. 가입은 Windows 설치 후에 진행됩니다. + + + 조직 구성 단위 + + + 이 도메인 내의 유효한 OU 고유 이름을 입력하거나 비워 두세요. + + + 도메인 이름 + + + corp.contoso.com과 같은 유효한 도메인 이름을 입력하세요. + + + 도메인 가입 정보가 없거나 유효하지 않습니다. 배포가 시작되지 않았습니다. + + + OU 고유 이름 (선택 사항) + + + 도메인의 기본 위치를 사용하려면 비워 두세요. + + + 암호 + + + 도메인 가입 + + + 이 Windows 에디션은 도메인에 가입할 수 없습니다. 도메인에 가입하지 않고 Windows 설치를 계속합니다. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/lt-LT/Resources.resx b/src/Foundry.Deploy/Strings/lt-LT/Resources.resx index 9c8abdbf2..25418ec38 100644 --- a/src/Foundry.Deploy/Strings/lt-LT/Resources.resx +++ b/src/Foundry.Deploy/Strings/lt-LT/Resources.resx @@ -256,6 +256,12 @@ Licencijuota pagal MIT licenciją. Diegimo puslapiai + + Interaktyvus + + + Zero-Touch + Klaidos puslapis @@ -357,6 +363,48 @@ Veiksmas: patikrinkite disko atributus ir bandykite iš naujo įdiegti. | {0} + + Paskyra (DOMAIN\user arba user@domain) + + + Įveskite paskyrą formatu DOMAIN\user arba user@domain. + + + Numatytoji domeno vieta + + + Pateikite šio kompiuterio prisijungimo prie domeno duomenis. Prie domeno prisijungiama įdiegus Windows. + + + Organizacinis vienetas + + + Įveskite tinkamą šio domeno OU skiriamąjį vardą arba palikite tuščią. + + + Domeno vardas + + + Įveskite tinkamą domeno vardą, pvz., corp.contoso.com. + + + Prisijungimo prie domeno informacijos trūksta arba ji netinkama. Diegimas nepradėtas. + + + OU skiriamasis vardas (nebūtinas) + + + Palikite tuščią, kad būtų naudojama numatytoji domeno vieta. + + + Slaptažodis + + + Prisijungimas prie domeno + + + Šio Windows leidimo negalima prijungti prie domeno. Windows diegimas tęsiamas be prisijungimo prie domeno. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/lv-LV/Resources.resx b/src/Foundry.Deploy/Strings/lv-LV/Resources.resx index c9b7f76e6..3f04f1844 100644 --- a/src/Foundry.Deploy/Strings/lv-LV/Resources.resx +++ b/src/Foundry.Deploy/Strings/lv-LV/Resources.resx @@ -256,6 +256,12 @@ Licencēts saskaņā ar MIT licenci. Izvietošanas lapas + + Interaktīvi + + + Zero-Touch + Kļūdas lapa @@ -357,6 +363,48 @@ Darbība: pārbaudiet diska atribūtus un mēģiniet atkārtoti izvietot. | {0} + + Konts (DOMAIN\user vai user@domain) + + + Ievadiet kontu formātā DOMAIN\user vai user@domain. + + + Domēna noklusējuma atrašanās vieta + + + Norādiet šī datora pievienošanās domēnam datus. Pievienošanās notiek pēc Windows instalēšanas. + + + Organizatoriskā vienība + + + Ievadiet derīgu OU atšķirīgo nosaukumu šajā domēnā vai atstājiet tukšu. + + + Domēna nosaukums + + + Ievadiet derīgu domēna nosaukumu, piemēram, corp.contoso.com. + + + Pievienošanās domēnam informācijas trūkst vai tā nav derīga. Izvietošana nav sākta. + + + OU atšķirīgais nosaukums (neobligāts) + + + Atstājiet tukšu, lai izmantotu domēna noklusējuma atrašanās vietu. + + + Parole + + + Pievienošanās domēnam + + + Šo Windows izdevumu nevar pievienot domēnam. Windows instalēšana turpinās bez pievienošanās domēnam. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/nb-NO/Resources.resx b/src/Foundry.Deploy/Strings/nb-NO/Resources.resx index cd30f3416..00287fd7c 100644 --- a/src/Foundry.Deploy/Strings/nb-NO/Resources.resx +++ b/src/Foundry.Deploy/Strings/nb-NO/Resources.resx @@ -256,6 +256,12 @@ Lisensiert under MIT-lisensen. Implementeringssider + + Interaktiv + + + Zero-Touch + Feilside @@ -357,6 +363,48 @@ Handling: Bekreft diskattributter og prøv distribusjon på nytt. | {0} + + Konto (DOMAIN\user eller user@domain) + + + Angi kontoen som DOMAIN\user eller user@domain. + + + Domenets standardplassering + + + Oppgi opplysningene for domenetilknytning for denne datamaskinen. Tilknytningen skjer etter at Windows er installert. + + + Organisasjonsenhet + + + Angi et gyldig unikt navn for en OU i dette domenet, eller la feltet stå tomt. + + + Domenenavn + + + Angi et gyldig domenenavn, for eksempel corp.contoso.com. + + + Informasjonen for domenetilknytning mangler eller er ugyldig. Distribusjonen har ikke startet. + + + Unikt navn for OU (valgfritt) + + + La stå tomt for å bruke domenets standardplassering. + + + Passord + + + Domenetilknytning + + + Denne Windows-utgaven kan ikke knyttes til et domene. Windows-installasjonen fortsetter uten domenetilknytning. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/nl-NL/Resources.resx b/src/Foundry.Deploy/Strings/nl-NL/Resources.resx index f69d72ab7..a96bb241d 100644 --- a/src/Foundry.Deploy/Strings/nl-NL/Resources.resx +++ b/src/Foundry.Deploy/Strings/nl-NL/Resources.resx @@ -256,6 +256,12 @@ Gelicentieerd onder de MIT-licentie. Implementatiepagina's + + Interactief + + + Zero-Touch + Foutpagina @@ -357,6 +363,48 @@ Actie: controleer de schijfkenmerken en probeer de implementatie opnieuw. | {0} + + Account (DOMAIN\user of user@domain) + + + Voer het account in als DOMAIN\user of user@domain. + + + Standaardlocatie van het domein + + + Geef de gegevens voor domeintoetreding van deze computer op. De toetreding vindt plaats nadat Windows is geïnstalleerd. + + + Organisatie-eenheid + + + Voer een geldige DN-naam van een OU in dit domein in, of laat het veld leeg. + + + Domeinnaam + + + Voer een geldige domeinnaam in, zoals corp.contoso.com. + + + De gegevens voor domeintoetreding ontbreken of zijn ongeldig. De implementatie is niet gestart. + + + DN-naam van OU (optioneel) + + + Laat leeg om de standaardlocatie van het domein te gebruiken. + + + Wachtwoord + + + Domeintoetreding + + + Deze Windows-editie kan niet toetreden tot een domein. De Windows-installatie gaat verder zonder domeintoetreding. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/pl-PL/Resources.resx b/src/Foundry.Deploy/Strings/pl-PL/Resources.resx index 96671740f..c3cf2d7e8 100644 --- a/src/Foundry.Deploy/Strings/pl-PL/Resources.resx +++ b/src/Foundry.Deploy/Strings/pl-PL/Resources.resx @@ -256,6 +256,12 @@ Licencjonowany na licencji MIT. Strony wdrożeniowe + + Interaktywnie + + + Zero-Touch + Strona błędu @@ -357,6 +363,48 @@ Akcja: Sprawdź atrybuty dysku i ponów próbę wdrożenia. | {0} + + Konto (DOMAIN\user lub user@domain) + + + Wprowadź konto w formacie DOMAIN\user lub user@domain. + + + Domyślna lokalizacja domeny + + + Podaj dane dołączania tego komputera do domeny. Dołączenie następuje po zainstalowaniu systemu Windows. + + + Jednostka organizacyjna + + + Wprowadź prawidłową nazwę wyróżniającą jednostki OU w tej domenie lub pozostaw pole puste. + + + Nazwa domeny + + + Wprowadź prawidłową nazwę domeny, na przykład corp.contoso.com. + + + Brak informacji o dołączaniu do domeny lub są one nieprawidłowe. Wdrażanie nie zostało rozpoczęte. + + + Nazwa wyróżniająca OU (opcjonalna) + + + Pozostaw puste, aby użyć domyślnej lokalizacji domeny. + + + Hasło + + + Dołączanie do domeny + + + Ta edycja systemu Windows nie może dołączyć do domeny. Instalacja systemu Windows jest kontynuowana bez dołączania. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/pt-BR/Resources.resx b/src/Foundry.Deploy/Strings/pt-BR/Resources.resx index f975eec93..da2b0c0e6 100644 --- a/src/Foundry.Deploy/Strings/pt-BR/Resources.resx +++ b/src/Foundry.Deploy/Strings/pt-BR/Resources.resx @@ -256,6 +256,12 @@ Licenciado sob a licença MIT. Páginas de implantação + + Interativo + + + Zero-Touch + Página de erro @@ -357,6 +363,48 @@ Ação: Verifique os atributos do disco e tente novamente a implantação. | {0} + + Conta (DOMAIN\user ou user@domain) + + + Insira a conta como DOMAIN\user ou user@domain. + + + Local padrão do domínio + + + Informe os dados de ingresso no domínio para este computador. O ingresso ocorre depois que o Windows é instalado. + + + Unidade organizacional + + + Insira um nome diferenciado de OU válido neste domínio ou deixe o campo vazio. + + + Nome do domínio + + + Insira um nome de domínio válido, como corp.contoso.com. + + + As informações de ingresso no domínio estão ausentes ou são inválidas. A implantação não foi iniciada. + + + Nome diferenciado da OU (opcional) + + + Deixe vazio para usar o local padrão do domínio. + + + Senha + + + Ingresso no domínio + + + Esta edição do Windows não pode ingressar em um domínio. A instalação do Windows continua sem o ingresso. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/pt-PT/Resources.resx b/src/Foundry.Deploy/Strings/pt-PT/Resources.resx index 55f6327d4..1596f3be1 100644 --- a/src/Foundry.Deploy/Strings/pt-PT/Resources.resx +++ b/src/Foundry.Deploy/Strings/pt-PT/Resources.resx @@ -256,6 +256,12 @@ Licenciado sob a licença MIT. Páginas de implantação + + Interativo + + + Zero-Touch + Página de erro @@ -357,6 +363,48 @@ Ação: Verifique os atributos do disco e tente novamente a implantação. | {0} + + Conta (DOMAIN\user ou user@domain) + + + Introduza a conta como DOMAIN\user ou user@domain. + + + Localização predefinida do domínio + + + Indique os dados de associação ao domínio para este computador. A associação é feita depois de o Windows ser instalado. + + + Unidade organizacional + + + Introduza um nome distinto de OU válido neste domínio, ou deixe o campo vazio. + + + Nome do domínio + + + Introduza um nome de domínio válido, como corp.contoso.com. + + + As informações de associação ao domínio estão em falta ou não são válidas. A implementação não foi iniciada. + + + Nome distinto da OU (opcional) + + + Deixe vazio para utilizar a localização predefinida do domínio. + + + Palavra-passe + + + Associação ao domínio + + + Esta edição do Windows não pode ser associada a um domínio. A instalação do Windows continua sem a associação. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/ro-RO/Resources.resx b/src/Foundry.Deploy/Strings/ro-RO/Resources.resx index 8d9fd7ccf..0665d752f 100644 --- a/src/Foundry.Deploy/Strings/ro-RO/Resources.resx +++ b/src/Foundry.Deploy/Strings/ro-RO/Resources.resx @@ -256,6 +256,12 @@ Licențiat sub licența MIT. Pagini de implementare + + Interactiv + + + Zero-Touch + Pagina de eroare @@ -357,6 +363,48 @@ Acțiune: Verificați atributele discului și reîncercați implementarea. | {0} + + Cont (DOMAIN\user sau user@domain) + + + Introduceți contul ca DOMAIN\user sau user@domain. + + + Locația implicită a domeniului + + + Furnizați datele de asociere la domeniu pentru acest computer. Asocierea are loc după instalarea Windows. + + + Unitate organizațională + + + Introduceți un nume distinctiv de OU valid din acest domeniu sau lăsați câmpul gol. + + + Nume de domeniu + + + Introduceți un nume de domeniu valid, de exemplu corp.contoso.com. + + + Informațiile de asociere la domeniu lipsesc sau nu sunt valide. Implementarea nu a început. + + + Nume distinctiv OU (opțional) + + + Lăsați gol pentru a folosi locația implicită a domeniului. + + + Parolă + + + Asociere la domeniu + + + Această ediție Windows nu se poate asocia la un domeniu. Instalarea Windows continuă fără asociere. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/ru-RU/Resources.resx b/src/Foundry.Deploy/Strings/ru-RU/Resources.resx index c640c7e5a..6c4344fdf 100644 --- a/src/Foundry.Deploy/Strings/ru-RU/Resources.resx +++ b/src/Foundry.Deploy/Strings/ru-RU/Resources.resx @@ -256,6 +256,12 @@ Страницы развертывания + + Интерактивно + + + Zero-Touch + Страница ошибки @@ -357,6 +363,48 @@ | {0} + + Учетная запись (DOMAIN\user или user@domain) + + + Укажите учетную запись в формате DOMAIN\user или user@domain. + + + Расположение домена по умолчанию + + + Укажите сведения для присоединения этого компьютера к домену. Присоединение выполняется после установки Windows. + + + Подразделение + + + Укажите допустимое различающееся имя OU в этом домене или оставьте поле пустым. + + + Имя домена + + + Укажите допустимое имя домена, например corp.contoso.com. + + + Сведения для присоединения к домену отсутствуют или недопустимы. Развертывание не начато. + + + Различающееся имя OU (необязательно) + + + Оставьте пустым, чтобы использовать расположение домена по умолчанию. + + + Пароль + + + Присоединение к домену + + + Эта редакция Windows не может присоединиться к домену. Установка Windows продолжается без присоединения. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/sk-SK/Resources.resx b/src/Foundry.Deploy/Strings/sk-SK/Resources.resx index 408c46794..ce3d26b5c 100644 --- a/src/Foundry.Deploy/Strings/sk-SK/Resources.resx +++ b/src/Foundry.Deploy/Strings/sk-SK/Resources.resx @@ -256,6 +256,12 @@ Licencované podľa licencie MIT. Stránky nasadenia + + Interaktívne + + + Zero-Touch + Chybová stránka @@ -357,6 +363,48 @@ Akcia: Overte atribúty disku a zopakujte nasadenie. | {0} + + Účet (DOMAIN\user alebo user@domain) + + + Zadajte účet v tvare DOMAIN\user alebo user@domain. + + + Predvolené umiestnenie domény + + + Zadajte údaje na pripojenie tohto počítača k doméne. Pripojenie sa vykoná po inštalácii systému Windows. + + + Organizačná jednotka + + + Zadajte platný rozlišujúci názov OU v tejto doméne alebo nechajte pole prázdne. + + + Názov domény + + + Zadajte platný názov domény, napríklad corp.contoso.com. + + + Údaje na pripojenie k doméne chýbajú alebo sú neplatné. Nasadenie sa nezačalo. + + + Rozlišujúci názov OU (voliteľné) + + + Nechajte prázdne, ak chcete použiť predvolené umiestnenie domény. + + + Heslo + + + Pripojenie k doméne + + + Túto edíciu systému Windows nemožno pripojiť k doméne. Inštalácia systému Windows pokračuje bez pripojenia. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/sl-SI/Resources.resx b/src/Foundry.Deploy/Strings/sl-SI/Resources.resx index d89499365..53c73a15f 100644 --- a/src/Foundry.Deploy/Strings/sl-SI/Resources.resx +++ b/src/Foundry.Deploy/Strings/sl-SI/Resources.resx @@ -256,6 +256,12 @@ Licencirano pod licenco MIT. Strani za uvajanje + + Interaktivno + + + Zero-Touch + Stran z napako @@ -357,6 +363,48 @@ Ukrep: Preverite atribute diska in znova poskusite razmestiti. | {0} + + Račun (DOMAIN\user ali user@domain) + + + Račun vnesite v obliki DOMAIN\user ali user@domain. + + + Privzeto mesto domene + + + Vnesite podatke za pridružitev tega računalnika domeni. Pridružitev se izvede po namestitvi sistema Windows. + + + Organizacijska enota + + + Vnesite veljavno razločevalno ime OU v tej domeni ali pustite polje prazno. + + + Ime domene + + + Vnesite veljavno ime domene, na primer corp.contoso.com. + + + Podatki za pridružitev domeni manjkajo ali niso veljavni. Uvajanje se ni začelo. + + + Razločevalno ime OU (izbirno) + + + Pustite prazno, da uporabite privzeto mesto domene. + + + Geslo + + + Pridružitev domeni + + + Te izdaje sistema Windows ni mogoče pridružiti domeni. Namestitev sistema Windows se nadaljuje brez pridružitve. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/sr-Latn-RS/Resources.resx b/src/Foundry.Deploy/Strings/sr-Latn-RS/Resources.resx index 3e8bfdb1b..418fe0388 100644 --- a/src/Foundry.Deploy/Strings/sr-Latn-RS/Resources.resx +++ b/src/Foundry.Deploy/Strings/sr-Latn-RS/Resources.resx @@ -256,6 +256,12 @@ Licencirano pod MIT licencom. Stranice za primenu + + Interaktivno + + + Zero-Touch + Stranica sa greškom @@ -357,6 +363,48 @@ Radnja: Proverite atribute diska i pokušajte ponovo da ga primenite. | {0} + + Nalog (DOMAIN\user ili user@domain) + + + Unesite nalog u obliku DOMAIN\user ili user@domain. + + + Podrazumevana lokacija domena + + + Unesite podatke za pridruživanje ovog računara domenu. Pridruživanje se izvršava nakon instalacije sistema Windows. + + + Organizaciona jedinica + + + Unesite važeće jedinstveno ime OU u ovom domenu ili ostavite polje prazno. + + + Ime domena + + + Unesite važeće ime domena, na primer corp.contoso.com. + + + Podaci za pridruživanje domenu nedostaju ili nisu važeći. Primena nije započeta. + + + Jedinstveno ime OU (opciono) + + + Ostavite prazno da biste koristili podrazumevanu lokaciju domena. + + + Lozinka + + + Pridruživanje domenu + + + Ovo izdanje sistema Windows ne može da se pridruži domenu. Instalacija sistema Windows se nastavlja bez pridruživanja. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/sv-SE/Resources.resx b/src/Foundry.Deploy/Strings/sv-SE/Resources.resx index 0bfefec39..ef50e6b81 100644 --- a/src/Foundry.Deploy/Strings/sv-SE/Resources.resx +++ b/src/Foundry.Deploy/Strings/sv-SE/Resources.resx @@ -256,6 +256,12 @@ Licensierad under MIT-licensen. Implementeringssidor + + Interaktiv + + + Zero-Touch + Felsida @@ -357,6 +363,48 @@ Detaljer: Åtkomst nekad vid montering av bild till målsökväg. | {0} + + Konto (DOMAIN\user eller user@domain) + + + Ange kontot som DOMAIN\user eller user@domain. + + + Domänens standardplats + + + Ange uppgifterna för att ansluta den här datorn till domänen. Anslutningen sker när Windows har installerats. + + + Organisationsenhet + + + Ange ett giltigt unikt namn för en OU i den här domänen, eller lämna fältet tomt. + + + Domännamn + + + Ange ett giltigt domännamn, till exempel corp.contoso.com. + + + Uppgifterna för domänanslutning saknas eller är ogiltiga. Distributionen har inte startat. + + + Unikt namn för OU (valfritt) + + + Lämna tomt för att använda domänens standardplats. + + + Lösenord + + + Domänanslutning + + + Den här Windows-utgåvan kan inte anslutas till en domän. Windows-installationen fortsätter utan domänanslutning. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/th-TH/Resources.resx b/src/Foundry.Deploy/Strings/th-TH/Resources.resx index b5b9cd050..a6de21573 100644 --- a/src/Foundry.Deploy/Strings/th-TH/Resources.resx +++ b/src/Foundry.Deploy/Strings/th-TH/Resources.resx @@ -256,6 +256,12 @@ หน้าการปรับใช้ + + แบบโต้ตอบ + + + Zero-Touch + หน้าข้อผิดพลาด @@ -357,6 +363,48 @@ | {0} + + บัญชี (DOMAIN\user หรือ user@domain) + + + ป้อนบัญชีในรูปแบบ DOMAIN\user หรือ user@domain + + + ตำแหน่งเริ่มต้นของโดเมน + + + ระบุรายละเอียดการเข้าร่วมโดเมนสำหรับคอมพิวเตอร์เครื่องนี้ การเข้าร่วมจะเกิดขึ้นหลังจากติดตั้ง Windows แล้ว + + + หน่วยองค์กร + + + ป้อนชื่อเฉพาะของ OU ที่ถูกต้องในโดเมนนี้ หรือปล่อยว่างไว้ + + + ชื่อโดเมน + + + ป้อนชื่อโดเมนที่ถูกต้อง เช่น corp.contoso.com + + + ข้อมูลการเข้าร่วมโดเมนขาดหายไปหรือไม่ถูกต้อง การปรับใช้ยังไม่เริ่มต้น + + + ชื่อเฉพาะของ OU (ไม่บังคับ) + + + ปล่อยว่างไว้เพื่อใช้ตำแหน่งเริ่มต้นของโดเมน + + + รหัสผ่าน + + + การเข้าร่วมโดเมน + + + Windows รุ่นนี้ไม่สามารถเข้าร่วมโดเมนได้ การติดตั้ง Windows จะดำเนินต่อไปโดยไม่เข้าร่วมโดเมน + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/tr-TR/Resources.resx b/src/Foundry.Deploy/Strings/tr-TR/Resources.resx index 800d0e0fa..d9e2e8bd4 100644 --- a/src/Foundry.Deploy/Strings/tr-TR/Resources.resx +++ b/src/Foundry.Deploy/Strings/tr-TR/Resources.resx @@ -256,6 +256,12 @@ MIT Lisansı kapsamında lisanslanmıştır. Dağıtım sayfaları + + Etkileşimli + + + Zero-Touch + Hata sayfası @@ -357,6 +363,48 @@ Eylem: Disk özniteliklerini doğrulayın ve dağıtımı yeniden deneyin. | {0} + + Hesap (DOMAIN\user veya user@domain) + + + Hesabı DOMAIN\user veya user@domain biçiminde girin. + + + Etki alanının varsayılan konumu + + + Bu bilgisayar için etki alanına katılma bilgilerini girin. Katılma işlemi Windows yüklendikten sonra gerçekleşir. + + + Kuruluş birimi + + + Bu etki alanında geçerli bir OU ayırt edici adı girin veya boş bırakın. + + + Etki alanı adı + + + corp.contoso.com gibi geçerli bir etki alanı adı girin. + + + Etki alanına katılma bilgileri eksik veya geçersiz. Dağıtım başlamadı. + + + OU ayırt edici adı (isteğe bağlı) + + + Etki alanının varsayılan konumunu kullanmak için boş bırakın. + + + Parola + + + Etki alanına katılma + + + Bu Windows sürümü etki alanına katılamaz. Windows kurulumu etki alanına katılmadan devam eder. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/uk-UA/Resources.resx b/src/Foundry.Deploy/Strings/uk-UA/Resources.resx index 2e85b5a03..957b538fa 100644 --- a/src/Foundry.Deploy/Strings/uk-UA/Resources.resx +++ b/src/Foundry.Deploy/Strings/uk-UA/Resources.resx @@ -256,6 +256,12 @@ Сторінки розгортання + + Інтерактивно + + + Zero-Touch + Сторінка помилки @@ -357,6 +363,48 @@ ErrorCode=0x80070005 | {0} + + Обліковий запис (DOMAIN\user або user@domain) + + + Введіть обліковий запис у форматі DOMAIN\user або user@domain. + + + Розташування домену за замовчуванням + + + Укажіть дані для приєднання цього комп’ютера до домену. Приєднання виконується після інсталяції Windows. + + + Організаційна одиниця + + + Введіть дійсне розрізнювальне ім’я OU в цьому домені або залиште поле порожнім. + + + Ім’я домену + + + Введіть дійсне ім’я домену, наприклад corp.contoso.com. + + + Дані для приєднання до домену відсутні або недійсні. Розгортання не розпочато. + + + Розрізнювальне ім’я OU (необов’язково) + + + Залиште порожнім, щоб використовувати розташування домену за замовчуванням. + + + Пароль + + + Приєднання до домену + + + Цю редакцію Windows не можна приєднати до домену. Інсталяція Windows продовжується без приєднання. + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/zh-CN/Resources.resx b/src/Foundry.Deploy/Strings/zh-CN/Resources.resx index aa7638302..3ea6bd952 100644 --- a/src/Foundry.Deploy/Strings/zh-CN/Resources.resx +++ b/src/Foundry.Deploy/Strings/zh-CN/Resources.resx @@ -256,6 +256,12 @@ 部署页面 + + 交互式 + + + Zero-Touch + 错误页面 @@ -357,6 +363,48 @@ | {0} + + 账户(DOMAIN\user 或 user@domain) + + + 请以 DOMAIN\user 或 user@domain 格式输入账户。 + + + 域默认位置 + + + 请提供此计算机的域加入信息。加入操作将在 Windows 安装完成后执行。 + + + 组织单位 + + + 请输入此域内有效的 OU 可分辨名称,或留空。 + + + 域名 + + + 请输入有效的域名,例如 corp.contoso.com。 + + + 域加入信息缺失或无效。部署尚未开始。 + + + OU 可分辨名称(可选) + + + 留空以使用域的默认位置。 + + + 密码 + + + 域加入 + + + 此 Windows 版本无法加入域。Windows 安装将继续进行,不会加入域。 + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/Strings/zh-TW/Resources.resx b/src/Foundry.Deploy/Strings/zh-TW/Resources.resx index e6d0ec7eb..df27ffdb9 100644 --- a/src/Foundry.Deploy/Strings/zh-TW/Resources.resx +++ b/src/Foundry.Deploy/Strings/zh-TW/Resources.resx @@ -256,6 +256,12 @@ 部署頁面 + + 互動式 + + + Zero-Touch + 錯誤頁面 @@ -357,6 +363,48 @@ | {0} + + 帳戶(DOMAIN\user 或 user@domain) + + + 請以 DOMAIN\user 或 user@domain 格式輸入帳戶。 + + + 網域預設位置 + + + 請提供此電腦的網域加入資訊。加入作業會在 Windows 安裝完成後執行。 + + + 組織單位 + + + 請輸入此網域內有效的 OU 辨別名稱,或留空。 + + + 網域名稱 + + + 請輸入有效的網域名稱,例如 corp.contoso.com。 + + + 網域加入資訊遺漏或無效。部署尚未開始。 + + + OU 辨別名稱(選用) + + + 留空以使用網域的預設位置。 + + + 密碼 + + + 網域加入 + + + 此 Windows 版本無法加入網域。Windows 安裝將繼續進行,不會加入網域。 + Microsoft Update Catalog diff --git a/src/Foundry.Deploy/ViewModels/DeploymentPreparationViewModel.Unattend.cs b/src/Foundry.Deploy/ViewModels/DeploymentPreparationViewModel.Unattend.cs index 65c450cd4..6745c79e8 100644 --- a/src/Foundry.Deploy/ViewModels/DeploymentPreparationViewModel.Unattend.cs +++ b/src/Foundry.Deploy/ViewModels/DeploymentPreparationViewModel.Unattend.cs @@ -19,6 +19,7 @@ public sealed partial class DeploymentPreparationViewModel private readonly UnattendContentService? _unattendContentService; private string? _unattendArchitecture; private string? _unattendValidationContext; + private bool _unattendRequiresDomainJoin; private string _unattendCatalogError = string.Empty; [ObservableProperty] @@ -68,13 +69,14 @@ public void ApplyUnattendConfiguration(DeployUnattendSettings settings, string c RevalidateUnattend(); } - /// Rechecks file applicability when the target architecture or enrollment mode changes. - public void UpdateUnattendContext(string? architecture) + /// Rechecks file applicability when the target architecture, enrollment mode or domain join requirement changes. + public void UpdateUnattendContext(string? architecture, bool requiresDomainJoin = false) { - string context = $"{architecture}|{IsAutopilotEnabled}|{AutopilotProvisioningMode}"; + string context = $"{architecture}|{IsAutopilotEnabled}|{AutopilotProvisioningMode}|{requiresDomainJoin}"; if (context == _unattendValidationContext) return; _unattendValidationContext = context; _unattendArchitecture = architecture; + _unattendRequiresDomainJoin = requiresDomainJoin; RevalidateUnattend(); } @@ -126,7 +128,7 @@ private void RevalidateUnattend() { if (_unattendContentService is null) throw new InvalidDataException(); using UnattendSnapshot snapshot = _unattendContentService.Read(SelectedUnattend, _unattendArchitecture, - IsAutopilotEnabled, AutopilotProvisioningMode); + IsAutopilotEnabled, AutopilotProvisioningMode, _unattendRequiresDomainJoin); var warnings = new List { GetString("Unattend.HookCompatibility") }; if (snapshot.Inspection.HasCommands) warnings.Add(GetString("Unattend.CommandsWarning")); if (IsAutopilotEnabled && IsHardwareHashUploadMode) warnings.Add(GetString("Unattend.HashWarning")); diff --git a/src/Foundry.Deploy/ViewModels/DeploymentSummaryCategoryViewModel.cs b/src/Foundry.Deploy/ViewModels/DeploymentSummaryCategoryViewModel.cs index ecf17d2a3..cc59fbb17 100644 --- a/src/Foundry.Deploy/ViewModels/DeploymentSummaryCategoryViewModel.cs +++ b/src/Foundry.Deploy/ViewModels/DeploymentSummaryCategoryViewModel.cs @@ -100,6 +100,12 @@ public sealed record DeploymentSummarySource public required IReadOnlyList AutopilotRows { get; init; } public bool IsAutopilotConfigured { get; init; } public bool HasAutopilotStep { get; init; } + /// Gets the Domain Join rows; the category is listed only when the media enables Domain Join. + public IReadOnlyList DomainJoinRows { get; init; } = []; + public string DomainJoinSummary { get; init; } = string.Empty; + public bool IsDomainJoinConfigured { get; init; } + public bool HasDomainJoinWarning { get; init; } + public bool HasDomainJoinStep { get; init; } public required string WindowsCustomizationSummary { get; init; } public required IReadOnlyList WindowsCustomizationRows { get; init; } public bool IsWindowsCustomizationConfigured { get; init; } @@ -123,7 +129,7 @@ public IReadOnlyList Build(DeploymentSummary { ArgumentNullException.ThrowIfNull(source); - return + List categories = [ new( _localize("Summary.Category.TargetDevice"), @@ -172,5 +178,17 @@ public IReadOnlyList Build(DeploymentSummary source.CompletionRows, null) ]; + if (source.IsDomainJoinConfigured) + { + // Listed right after Autopilot, the other provisioning category. + categories.Insert(4, new( + _localize("DomainJoin.Title"), + source.DomainJoinSummary, + source.HasDomainJoinWarning ? DeploymentSummaryStatus.Caution : DeploymentSummaryStatus.Configured, + source.DomainJoinRows, + source.HasDomainJoinStep ? DeploymentWizardStepId.DomainJoin : null)); + } + + return categories; } } diff --git a/src/Foundry.Deploy/ViewModels/DomainJoinStepViewModel.cs b/src/Foundry.Deploy/ViewModels/DomainJoinStepViewModel.cs new file mode 100644 index 000000000..cda9dc877 --- /dev/null +++ b/src/Foundry.Deploy/ViewModels/DomainJoinStepViewModel.cs @@ -0,0 +1,192 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using CommunityToolkit.Mvvm.ComponentModel; +using Foundry.Core.Models.Configuration; +using Foundry.Core.Models.Configuration.Deploy; +using Foundry.Core.Services.Configuration; +using Foundry.Deploy.Services.DomainJoin; + +namespace Foundry.Deploy.ViewModels; + +/// +/// Holds what the technician enters on the Domain Join wizard step: the domain when the media lists several, +/// credentials in Interactive mode, and the OU when the retained domain lists several. The password is an owned +/// buffer that never enters wizard state, logs or the deployment request. +/// +public sealed partial class DomainJoinStepViewModel : ObservableObject, IDisposable +{ + private DeployDomainJoinSettings settings = new(); + private char[]? password; + + /// Raised when an input or its validity changes, so the wizard can refresh navigation. + public event EventHandler? StateChanged; + + /// Raised when the owned password is erased, so the view can empty its password box. + public event EventHandler? PasswordCleared; + + /// Listed domain shown in the domain list; the default is preselected. + [ObservableProperty] + private DeployDomainJoinDomainSettings? selectedDomain; + + /// Name of the retained domain, or the name typed when the media lists no domain. + [ObservableProperty] + private string domainName = string.Empty; + + [ObservableProperty] + private string accountName = string.Empty; + + [ObservableProperty] + private DomainJoinOrganizationalUnitSettings? selectedOrganizationalUnit; + + [ObservableProperty] + private string typedOuDistinguishedName = string.Empty; + + public IReadOnlyList Domains => settings.Domains; + + /// Gets the OUs of the retained domain; they change with the domain. + public IReadOnlyList OrganizationalUnits => RetainedDomain?.OrganizationalUnits ?? []; + + /// Gets whether the technician supplies the account and password. + public bool RequiresCredentials => settings.IsEnabled && settings.Mode == DomainJoinMode.Interactive; + + /// Gets whether the technician picks the domain, which is the case when the media lists more than one. + public bool IsDomainListVisible => settings.IsEnabled && settings.Domains.Count > 1; + + /// Gets whether the domain is shown as text: read-only for a listed domain, editable when the media lists none. + public bool IsDomainTextVisible => !IsDomainListVisible; + + public bool IsDomainReadOnly => HasListedDomains; + + /// Gets whether the technician picks an OU: the retained domain lists more than one. A single listed OU is used as is. + public bool IsOuListVisible => OrganizationalUnits.Count > 1; + + /// Gets whether an OU may be typed, which is only offered in Interactive mode for a domain without listed OUs. + public bool IsTypedOuVisible => RequiresCredentials && OrganizationalUnits.Count == 0; + + /// Gets whether the step has anything for the technician to enter or choose. + public bool HasInput => settings.IsEnabled && (RequiresCredentials || IsDomainListVisible || IsOuListVisible); + + public bool HasPassword => password is { Length: > 0 }; + + public bool IsDomainInvalid => !HasListedDomains && DomainName.Length > 0 && !DomainJoinCredentialContext.IsValidDomainName(DomainName); + + public bool IsAccountInvalid => AccountName.Length > 0 && !DomainJoinConfigurationValidator.IsQualifiedAccount(AccountName); + + public bool IsTypedOuInvalid => IsTypedOuVisible && TypedOu.Length > 0 && + !(DistinguishedNameRules.IsOrganizationalUnit(TypedOu) && DistinguishedNameRules.IsWithinDomain(TypedOu, DomainName)); + + /// Gets whether the wizard may leave the step and start a deployment with these inputs. + public bool IsValid => !HasInput || + (HasListedDomains ? RetainedDomain is not null : DomainJoinCredentialContext.IsValidDomainName(DomainName)) && + (!RequiresCredentials || DomainJoinConfigurationValidator.IsQualifiedAccount(AccountName) && HasPassword) && + (!IsOuListVisible || SelectedOrganizationalUnit is not null) && + !IsTypedOuInvalid; + + /// Gets the OU the join will use; means the domain's default location. + public string? EffectiveOuDistinguishedName => + IsOuListVisible ? SelectedOrganizationalUnit?.DistinguishedName : + IsTypedOuVisible ? (TypedOu.Length > 0 && !IsTypedOuInvalid ? TypedOu : null) : + DomainJoinPreparationService.ResolveDefaultOrganizationalUnit(RetainedDomain)?.DistinguishedName; + + private bool HasListedDomains => settings.Domains.Count > 0; + + /// The domain the join will use: the technician's choice among several, otherwise the media default. + private DeployDomainJoinDomainSettings? RetainedDomain => DomainJoinPreparationService.ResolveDomain(settings, SelectedDomain?.Id); + + private string TypedOu => TypedOuDistinguishedName.Trim(); + + /// Loads the media configuration and discards every earlier input, including the password. + public void Configure(DeployDomainJoinSettings domainJoin) + { + settings = domainJoin ?? throw new ArgumentNullException(nameof(domainJoin)); + ClearPassword(); + // A typed domain belongs to the earlier configuration; a listed one is shown again below. + DomainName = string.Empty; + AccountName = string.Empty; + TypedOuDistinguishedName = string.Empty; + OnPropertyChanged(nameof(Domains)); + SelectedDomain = DomainJoinPreparationService.ResolveDomain(settings, null); + ApplyRetainedDomain(); + } + + /// Replaces the owned password with a copy of the supplied characters. + public void SetPassword(ReadOnlySpan value) + { + ErasePassword(); + password = value.IsEmpty ? null : value.ToArray(); + RaiseDerivedStateChanged(); + } + + /// Returns an independent copy for refilling the password box; the caller clears it. + public char[]? GetPasswordCopy() => password?.ToArray(); + + /// Erases the owned password and tells the view to empty its password box. + public void ClearPassword() + { + if (password is null) return; + ErasePassword(); + PasswordCleared?.Invoke(this, EventArgs.Empty); + RaiseDerivedStateChanged(); + } + + /// Copies the current inputs for launch preparation; the caller disposes the result. + public DomainJoinSubmission? CreateSubmission() => !settings.IsEnabled ? null : new( + HasListedDomains ? RetainedDomain?.Id : null, + DomainName, + RequiresCredentials ? AccountName : string.Empty, + IsOuListVisible ? SelectedOrganizationalUnit?.Id : null, + RequiresCredentials ? password : null, + IsTypedOuVisible && TypedOu.Length > 0 ? TypedOu : null); + + public void Dispose() => ErasePassword(); + + partial void OnSelectedDomainChanged(DeployDomainJoinDomainSettings? value) => ApplyRetainedDomain(); + + partial void OnDomainNameChanged(string value) => RaiseDerivedStateChanged(); + + /// + /// Shows the retained domain and resets the OU to the one that domain uses or preselects. The account and password are kept, + /// because one account may serve several domains. + /// + private void ApplyRetainedDomain() + { + DeployDomainJoinDomainSettings? retained = RetainedDomain; + if (HasListedDomains) DomainName = retained?.DomainName ?? string.Empty; + TypedOuDistinguishedName = string.Empty; + OnPropertyChanged(nameof(OrganizationalUnits)); + SelectedOrganizationalUnit = DomainJoinPreparationService.ResolveDefaultOrganizationalUnit(retained); + RaiseDerivedStateChanged(); + } + + partial void OnAccountNameChanged(string value) => RaiseDerivedStateChanged(); + partial void OnSelectedOrganizationalUnitChanged(DomainJoinOrganizationalUnitSettings? value) => RaiseDerivedStateChanged(); + partial void OnTypedOuDistinguishedNameChanged(string value) => RaiseDerivedStateChanged(); + + private void ErasePassword() + { + if (password is not null) CryptographicOperations.ZeroMemory(MemoryMarshal.AsBytes(password.AsSpan())); + password = null; + } + + private void RaiseDerivedStateChanged() + { + OnPropertyChanged(nameof(RequiresCredentials)); + OnPropertyChanged(nameof(HasInput)); + OnPropertyChanged(nameof(IsDomainListVisible)); + OnPropertyChanged(nameof(IsDomainTextVisible)); + OnPropertyChanged(nameof(IsDomainReadOnly)); + OnPropertyChanged(nameof(IsOuListVisible)); + OnPropertyChanged(nameof(IsTypedOuVisible)); + OnPropertyChanged(nameof(HasPassword)); + OnPropertyChanged(nameof(IsDomainInvalid)); + OnPropertyChanged(nameof(IsAccountInvalid)); + OnPropertyChanged(nameof(IsTypedOuInvalid)); + OnPropertyChanged(nameof(IsValid)); + OnPropertyChanged(nameof(EffectiveOuDistinguishedName)); + StateChanged?.Invoke(this, EventArgs.Empty); + } +} diff --git a/src/Foundry.Deploy/ViewModels/MainWindowViewModel.cs b/src/Foundry.Deploy/ViewModels/MainWindowViewModel.cs index 3af1c1d18..e768904ff 100644 --- a/src/Foundry.Deploy/ViewModels/MainWindowViewModel.cs +++ b/src/Foundry.Deploy/ViewModels/MainWindowViewModel.cs @@ -15,6 +15,7 @@ using Foundry.Deploy.Models; using Foundry.Deploy.Models.Configuration; using Foundry.Deploy.Services.Deployment; +using Foundry.Deploy.Services.DomainJoin; using Foundry.Deploy.Services.Operations; using Foundry.Deploy.Services.Runtime; using Foundry.Deploy.Services.Security; @@ -48,6 +49,7 @@ public partial class MainWindowViewModel : LocalizedViewModelBase private readonly DeploymentWizardContext _wizardContext; private readonly DeploymentWizardNavigationState _wizardNavigationState; private DebugAutopilotMode _debugAutopilotMode = DebugAutopilotMode.None; + private DebugDomainJoinMode _debugDomainJoinMode = DebugDomainJoinMode.None; private BootMediaUpdateReason _bootMediaUpdateReason; private BootMediaUpdateReason? _debugBootMediaUpdateReasonOverride; private bool _isInitialized; @@ -55,6 +57,7 @@ public partial class MainWindowViewModel : LocalizedViewModelBase private Task? _initializationTask; private readonly CancellationTokenSource _startupCancellation = new(); private CancellationTokenSource? _deploymentCancellation; + private string? _launchFailureMessage; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(PreviousWizardStepCommand))] @@ -75,6 +78,7 @@ public partial class MainWindowViewModel : LocalizedViewModelBase [NotifyCanExecuteChangedFor(nameof(ShowDebugErrorPageCommand))] [NotifyCanExecuteChangedFor(nameof(ShowDebugCancelledPageCommand))] [NotifyCanExecuteChangedFor(nameof(SetDebugAutopilotModeCommand))] + [NotifyCanExecuteChangedFor(nameof(SetDebugDomainJoinModeCommand))] [NotifyCanExecuteChangedFor(nameof(ToggleDebugBootMediaUpdateReasonCommand))] [NotifyCanExecuteChangedFor(nameof(SetDebugCustomImageScenarioCommand))] [NotifyCanExecuteChangedFor(nameof(CancelDeploymentCommand))] @@ -101,6 +105,7 @@ public partial class MainWindowViewModel : LocalizedViewModelBase public bool IsDebugSafeMode => DebugSafetyMode.IsEnabled; public string EffectiveOsArchitecture => OperatingSystemCatalog.EffectiveOsArchitecture; public CustomImageSelectionViewModel CustomImages => _wizardContext.CustomImages; + public DomainJoinStepViewModel DomainJoinStep => _wizardContext.DomainJoinStep; public OperatingSystemMetadata? SelectedOperatingSystem => _wizardContext.SelectedOperatingSystem; public string WindowTitle => GetString("App.WindowTitle"); public string VersionDisplay => Format("Common.VersionFormat", FoundryDeployApplicationInfo.Version); @@ -142,6 +147,7 @@ public partial class MainWindowViewModel : LocalizedViewModelBase public bool IsOperatingSystemStep => CurrentWizardStepId == DeploymentWizardStepId.OperatingSystem; public bool IsDriversStep => CurrentWizardStepId == DeploymentWizardStepId.Drivers; public bool IsAutopilotStep => CurrentWizardStepId == DeploymentWizardStepId.Autopilot; + public bool IsDomainJoinStep => CurrentWizardStepId == DeploymentWizardStepId.DomainJoin; public bool IsSummaryStep => CurrentWizardStepId == DeploymentWizardStepId.Summary; public bool IsReturningToSummary => _wizardNavigationState.IsReturningToSummary; public bool IsDebugAutopilotNoneMode => IsDebugAutopilotMode(DebugAutopilotMode.None); @@ -150,6 +156,9 @@ public partial class MainWindowViewModel : LocalizedViewModelBase public bool IsDebugAutopilotHardwareHashUploadExpiredCertificateMode => IsDebugAutopilotMode(DebugAutopilotMode.HardwareHashUploadExpiredCertificate); public bool IsDebugAutopilotHardwareHashUploadMissingCertificateMetadataMode => IsDebugAutopilotMode(DebugAutopilotMode.HardwareHashUploadMissingCertificateMetadata); public bool IsDebugAutopilotHardwareHashUploadNoDefaultGroupTagMode => IsDebugAutopilotMode(DebugAutopilotMode.HardwareHashUploadNoDefaultGroupTag); + public bool IsDebugDomainJoinNoneMode => IsDebugDomainJoinMode(DebugDomainJoinMode.None); + public bool IsDebugDomainJoinInteractiveMode => IsDebugDomainJoinMode(DebugDomainJoinMode.Interactive); + public bool IsDebugDomainJoinZeroTouchMode => IsDebugDomainJoinMode(DebugDomainJoinMode.ZeroTouch); public MainWindowViewModel( ILocalizationService localizationService, @@ -186,7 +195,7 @@ public MainWindowViewModel( OperatingSystemCatalog = _wizardContext.OperatingSystemCatalog; DriverPackSelection = _wizardContext.DriverPackSelection; _wizardNavigationState = new DeploymentWizardNavigationState( - DeploymentWizardStepDefinition.CreateSequence(HasAutopilotConfigurationStep())); + DeploymentWizardStepDefinition.CreateSequence(HasAutopilotConfigurationStep(), HasDomainJoinStep())); RefreshWizardSteps(); RefreshSummaryCategories(); Session = new DeploymentSessionViewModel( @@ -316,6 +325,12 @@ private void SetDebugAutopilotMode(DebugAutopilotMode mode) return; } + // Autopilot and Domain Join are exclusive, so a debug Autopilot scenario replaces a debug Domain Join one. + if (mode != DebugAutopilotMode.None && _debugDomainJoinMode != DebugDomainJoinMode.None) + { + ApplyDebugDomainJoinMode(DebugDomainJoinMode.None); + } + _debugAutopilotMode = mode; Preparation.ApplyDebugAutopilotMode(mode); RaiseDebugAutopilotModePropertiesChanged(); @@ -371,9 +386,11 @@ private void BeginWizard() private async Task StartDeploymentAsync() { _logger.LogInformation("Start deployment requested."); + ReportLaunchFailure(null); DriverPackSelectionKind effectiveDriverPackKind = DriverPackSelection.EffectiveSelectionKind; DriverPackCatalogItem? effectiveDriverPack = DriverPackSelection.ResolveEffectiveSelection(); - DeploymentLaunchPreparationResult launchPreparation = _deploymentLaunchPreparationService.Prepare( + using DomainJoinSubmission? domainJoinSubmission = DomainJoinStep.CreateSubmission(); + using DeploymentLaunchPreparationResult launchPreparation = _deploymentLaunchPreparationService.Prepare( new DeploymentLaunchRequest { Mode = _deploymentRuntimeContext.Mode, @@ -398,20 +415,26 @@ private async Task StartDeploymentAsync() WindowsOptionalFeatures = _wizardContext.WindowsOptionalFeatures, Completion = _wizardContext.Completion, IsDryRun = IsDebugSafeMode - }); + }, _wizardContext.DomainJoin, domainJoinSubmission); - if (!string.Equals(Preparation.TargetComputerName, launchPreparation.NormalizedComputerName, StringComparison.Ordinal)) + if (!Preparation.UsesCustomUnattend && + !string.Equals(Preparation.TargetComputerName, launchPreparation.NormalizedComputerName, StringComparison.Ordinal)) { Preparation.TargetComputerName = launchPreparation.NormalizedComputerName; } if (!launchPreparation.IsReadyToStart || launchPreparation.Context is null) { - if (launchPreparation.FailureMessage is not null) - Preparation.ReportUnattendFailure(launchPreparation.FailureMessage); + if (launchPreparation.IsUnattendFailure) + Preparation.ReportUnattendFailure(launchPreparation.FailureMessage!); + else + ReportLaunchFailure(launchPreparation.FailureMessage); return; } + // The prepared input now owns its own copy; the wizard keeps no password once a deployment starts. + RunOnUi(DomainJoinStep.ClearPassword); + RunOnUi(() => { _deploymentCancellation = new CancellationTokenSource(); @@ -424,7 +447,7 @@ private async Task StartDeploymentAsync() try { DeploymentExecutionRunResult executionRunResult = await _deploymentExecutionService - .ExecuteAsync(launchPreparation.Context, _deploymentCancellation!.Token) + .ExecuteAsync(launchPreparation.Context, launchPreparation.TakeDomainJoinInput(), _deploymentCancellation!.Token) .ConfigureAwait(false); RunOnUi(() => Session.ApplyExecutionRunResult(executionRunResult)); @@ -441,6 +464,18 @@ private async Task StartDeploymentAsync() } } + /// Shows a launch failure in the review summary without invalidating any wizard selection, so Start stays available. + private void ReportLaunchFailure(string? message) + { + if (string.Equals(_launchFailureMessage, message, StringComparison.Ordinal)) + { + return; + } + + _launchFailureMessage = message; + RefreshSummaryCategories(); + } + [RelayCommand(CanExecute = nameof(CanCancelDeployment))] private void CancelDeployment() { @@ -517,6 +552,7 @@ private void ShowDebugCancelledPage() private void OnWizardContextStateChanged(object? sender, EventArgs e) { + _launchFailureMessage = null; RefreshWizardSteps(); RefreshSummaryCategories(); OnPropertyChanged(nameof(EffectiveOsArchitecture)); @@ -544,6 +580,31 @@ e.PropertyName is not nameof(DeploymentSessionViewModel.CurrentPage)) SetDebugCustomImageScenarioCommand.NotifyCanExecuteChanged(); } + [RelayCommand(CanExecute = nameof(CanUseDebugTools))] + private void SetDebugDomainJoinMode(DebugDomainJoinMode mode) + { + if (!IsDebugSafeMode) + { + return; + } + + if (mode != DebugDomainJoinMode.None && _debugAutopilotMode != DebugAutopilotMode.None) + { + SetDebugAutopilotMode(DebugAutopilotMode.None); + } + + ApplyDebugDomainJoinMode(mode); + } + + private void ApplyDebugDomainJoinMode(DebugDomainJoinMode mode) + { + _debugDomainJoinMode = mode; + _wizardContext.ApplyDebugDomainJoin(DebugDomainJoinScenarios.Create(mode)); + OnPropertyChanged(nameof(IsDebugDomainJoinNoneMode)); + OnPropertyChanged(nameof(IsDebugDomainJoinInteractiveMode)); + OnPropertyChanged(nameof(IsDebugDomainJoinZeroTouchMode)); + } + private bool CanShowDebugPages() { return IsDebugSafeMode && !IsDeploymentRunning; @@ -568,6 +629,11 @@ private bool IsDebugAutopilotMode(DebugAutopilotMode mode) return IsDebugSafeMode && _debugAutopilotMode == mode; } + private bool IsDebugDomainJoinMode(DebugDomainJoinMode mode) + { + return IsDebugSafeMode && _debugDomainJoinMode == mode; + } + private void RaiseDebugAutopilotModePropertiesChanged() { OnPropertyChanged(nameof(IsDebugAutopilotNoneMode)); @@ -628,6 +694,7 @@ private DeploymentWizardStateSnapshot BuildWizardStateSnapshot() Preparation.AutopilotProvisioningMode == AutopilotProvisioningMode.HardwareHashUpload || Preparation.AutopilotProvisioningMode == AutopilotProvisioningMode.InteractiveHardwareHashUpload || Preparation.SelectedAutopilotProfile is not null, + HasValidDomainJoinInput = !HasDomainJoinStep() || DomainJoinStep.IsValid, IsCustomImageMode = CustomImages.IsCustom, IsOperatingSystemCatalogReadyForNavigation = !IsCatalogLoading && (CustomImages.IsEnabled || OperatingSystemCatalog.IsReadyForNavigation()) }; @@ -640,6 +707,7 @@ partial void OnWizardStepIndexChanged(int value) OnPropertyChanged(nameof(IsOperatingSystemStep)); OnPropertyChanged(nameof(IsDriversStep)); OnPropertyChanged(nameof(IsAutopilotStep)); + OnPropertyChanged(nameof(IsDomainJoinStep)); OnPropertyChanged(nameof(IsSummaryStep)); OnPropertyChanged(nameof(IsReturningToSummary)); } @@ -647,7 +715,7 @@ partial void OnWizardStepIndexChanged(int value) private void RefreshWizardSteps() { IReadOnlyList definitions = - DeploymentWizardStepDefinition.CreateSequence(HasAutopilotConfigurationStep()); + DeploymentWizardStepDefinition.CreateSequence(HasAutopilotConfigurationStep(), HasDomainJoinStep()); bool sequenceChanged = WizardSteps.Count != definitions.Count || WizardSteps.Select(step => step.Id).Where((id, index) => id != definitions[index].Id).Any(); if (sequenceChanged) @@ -707,7 +775,8 @@ private void RefreshSummaryCategories() TargetSummary = Preparation.EffectiveComputerName, IsTargetConfigured = Preparation.IsUnattendSelectionValid && Preparation.IsTargetComputerNameValid && (IsDebugSafeMode || Preparation.SelectedTargetDisk?.IsSelectable == true), - HasTargetWarning = Preparation.HasUnattendWarning || Preparation.HasUnattendValidationError || Preparation.SelectedTargetDisk is { IsSelectable: false }, + HasTargetWarning = Preparation.HasUnattendWarning || Preparation.HasUnattendValidationError || _launchFailureMessage is not null || + Preparation.SelectedTargetDisk is { IsSelectable: false }, TargetRows = BuildTargetSummaryRows(), OperatingSystemSummary = SummaryOperatingSystemText, IsOperatingSystemConfigured = operatingSystem is not null, @@ -739,6 +808,11 @@ private void RefreshSummaryCategories() AutopilotRows = BuildAutopilotSummaryRows(), IsAutopilotConfigured = Preparation.IsAutopilotEnabled, HasAutopilotStep = HasAutopilotConfigurationStep(), + DomainJoinSummary = IsDomainJoinApplicable() ? DomainJoinStep.DomainName : GetString("Summary.Status.NotConfigured"), + DomainJoinRows = BuildDomainJoinSummaryRows(), + IsDomainJoinConfigured = _wizardContext.DomainJoin.IsEnabled, + HasDomainJoinWarning = !IsDomainJoinApplicable(), + HasDomainJoinStep = HasDomainJoinStep(), WindowsCustomizationSummary = hasCustomization ? GetString("Summary.Status.Configured") : GetString("Summary.Status.NoChanges"), @@ -786,9 +860,42 @@ private IReadOnlyList BuildTargetSummaryRows() rows.Add(new(GetString("Summary.WarningDetails"), Preparation.UnattendWarning)); if (Preparation.HasUnattendValidationError) rows.Add(new(GetString("Summary.Status"), Preparation.UnattendValidationMessage)); + else if (_launchFailureMessage is not null) + rows.Add(new(GetString("Summary.Status"), _launchFailureMessage)); return rows; } + private IReadOnlyList BuildDomainJoinSummaryRows() + { + if (!_wizardContext.DomainJoin.IsEnabled) + { + return []; + } + + if (!IsDomainJoinApplicable()) + { + return [new(GetString("Summary.Status"), GetString("DomainJoin.UnsupportedEdition"))]; + } + + return + [ + new(GetString("DomainJoin.Domain"), DomainJoinStep.DomainName), + new(GetString("DomainJoin.Destination"), DomainJoinStep.EffectiveOuDistinguishedName ?? GetString("DomainJoin.DefaultDestination")) + ]; + } + + /// Gets whether the join applies to the selected image; an unsupported edition skips it. + private bool IsDomainJoinApplicable() + { + return DomainJoinPreparationService.IsRequiredFor(_wizardContext.DomainJoin, SelectedOperatingSystem); + } + + /// Gets whether the wizard shows the Domain Join step, which needs something for the technician to enter or choose. + private bool HasDomainJoinStep() + { + return DomainJoinStep.HasInput && IsDomainJoinApplicable(); + } + private IReadOnlyList BuildDriverSummaryRows() { if (DriverPackSelection.EffectiveSelectionKind == DriverPackSelectionKind.None) diff --git a/src/Foundry.Deploy/Views/Wizard/DomainJoinStepView.xaml b/src/Foundry.Deploy/Views/Wizard/DomainJoinStepView.xaml new file mode 100644 index 000000000..dffd5196e --- /dev/null +++ b/src/Foundry.Deploy/Views/Wizard/DomainJoinStepView.xaml @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Foundry.Deploy/Views/Wizard/DomainJoinStepView.xaml.cs b/src/Foundry.Deploy/Views/Wizard/DomainJoinStepView.xaml.cs new file mode 100644 index 000000000..d29eb82ca --- /dev/null +++ b/src/Foundry.Deploy/Views/Wizard/DomainJoinStepView.xaml.cs @@ -0,0 +1,140 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Windows; +using System.Windows.Controls; +using Foundry.Deploy.ViewModels; + +namespace Foundry.Deploy.Views.Wizard; + +/// +/// Bridges the password editors, which cannot be data-bound, to the step's owned password buffer. The view is +/// rebuilt each time the technician returns to the step, so it restores the masked editor from that buffer when +/// it loads. The reveal button swaps the masked editor for a plain one, as the boot media password dialog does. +/// +public partial class DomainJoinStepView : UserControl +{ + private DomainJoinStepViewModel? step; + private bool isPasswordRevealed; + private bool isSynchronizingPasswordEditors; + + public DomainJoinStepView() + { + InitializeComponent(); + Loaded += OnLoaded; + Unloaded += OnUnloaded; + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + step = (DataContext as MainWindowViewModel)?.DomainJoinStep; + if (step is null) return; + step.PasswordCleared += OnPasswordCleared; + RestorePassword(step); + } + + private void OnUnloaded(object sender, RoutedEventArgs e) + { + if (step is not null) step.PasswordCleared -= OnPasswordCleared; + step = null; + ClearPasswordEditors(); + } + + private void PasswordInput_OnPasswordChanged(object sender, RoutedEventArgs e) + { + if (isSynchronizingPasswordEditors || step is null) return; + using var secure = PasswordInput.SecurePassword; + char[] characters = new char[secure.Length]; + IntPtr plaintext = IntPtr.Zero; + try + { + plaintext = Marshal.SecureStringToGlobalAllocUnicode(secure); + if (characters.Length > 0) Marshal.Copy(plaintext, characters, 0, characters.Length); + step.SetPassword(characters); + } + finally + { + if (plaintext != IntPtr.Zero) Marshal.ZeroFreeGlobalAllocUnicode(plaintext); + CryptographicOperations.ZeroMemory(MemoryMarshal.AsBytes(characters.AsSpan())); + } + } + + private void PasswordRevealInput_OnTextChanged(object sender, TextChangedEventArgs e) + { + if (isSynchronizingPasswordEditors || step is null) return; + step.SetPassword(PasswordRevealInput.Text); + } + + private void PasswordRevealButton_OnClick(object sender, RoutedEventArgs e) + { + isSynchronizingPasswordEditors = true; + try + { + if (isPasswordRevealed) + { + PasswordInput.Password = PasswordRevealInput.Text; + PasswordRevealInput.Text = string.Empty; + PasswordRevealInput.Visibility = Visibility.Collapsed; + PasswordInput.Visibility = Visibility.Visible; + PasswordRevealButton.ClearValue(StyleProperty); + PasswordInput.Focus(); + } + else + { + PasswordRevealInput.Text = PasswordInput.Password; + PasswordInput.Password = string.Empty; + PasswordInput.Visibility = Visibility.Collapsed; + PasswordRevealInput.Visibility = Visibility.Visible; + PasswordRevealButton.SetResourceReference(StyleProperty, "AccentButtonStyle"); + PasswordRevealInput.Focus(); + PasswordRevealInput.CaretIndex = PasswordRevealInput.Text.Length; + } + + isPasswordRevealed = !isPasswordRevealed; + } + finally + { + isSynchronizingPasswordEditors = false; + } + } + + private void OnPasswordCleared(object? sender, EventArgs e) => Dispatcher.Invoke(ClearPasswordEditors); + + private void RestorePassword(DomainJoinStepViewModel source) + { + char[]? password = source.GetPasswordCopy(); + if (password is null) return; + try + { + isSynchronizingPasswordEditors = true; + PasswordInput.Password = new string(password); + } + finally + { + isSynchronizingPasswordEditors = false; + CryptographicOperations.ZeroMemory(MemoryMarshal.AsBytes(password.AsSpan())); + } + } + + /// Empties both editors and returns to the masked one, without changing the step's owned password. + private void ClearPasswordEditors() + { + isSynchronizingPasswordEditors = true; + try + { + PasswordInput.Clear(); + PasswordRevealInput.Clear(); + PasswordRevealInput.Visibility = Visibility.Collapsed; + PasswordInput.Visibility = Visibility.Visible; + PasswordRevealButton.ClearValue(StyleProperty); + isPasswordRevealed = false; + } + finally + { + isSynchronizingPasswordEditors = false; + } + } +} diff --git a/src/Foundry.Deploy/Views/WizardView.xaml b/src/Foundry.Deploy/Views/WizardView.xaml index 062f3ef1c..bd5b49f8f 100644 --- a/src/Foundry.Deploy/Views/WizardView.xaml +++ b/src/Foundry.Deploy/Views/WizardView.xaml @@ -20,6 +20,9 @@ + + + @@ -155,6 +158,13 @@ Property="ContentTemplate" Value="{StaticResource AutopilotStepTemplate}" /> + + + diff --git a/src/Foundry.PostInstall.Tests/DomainJoinActionTests.cs b/src/Foundry.PostInstall.Tests/DomainJoinActionTests.cs new file mode 100644 index 000000000..1594a0416 --- /dev/null +++ b/src/Foundry.PostInstall.Tests/DomainJoinActionTests.cs @@ -0,0 +1,132 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Models.PreOobe; +using Foundry.PostInstall.Actions; +using Foundry.PostInstall.Execution; +namespace Foundry.PostInstall.Tests; + +public sealed class DomainJoinActionTests +{ + [Fact] + public async Task JoinAndFailedPlacement_StillRequestsRestartAndWritesSeparateReport() + { + using var f = new DomainFixture(); + f.Directory.Existing = f.Directory.Computer; f.Directory.StaleGuid = true; + DomainSeeds.WriteResult(f.Root, f.Plan, f.Hash); + var processes = new WorkerProcess(f); + var result = await new DomainJoinAction(f.Root, f.Plan, f.Hash, "installed-boot", processes, "runtime.exe").ExecuteAsync(f.Plan.Actions[0], CancellationToken.None); + Assert.False(result.Succeeded); Assert.True(result.RestartRequested); Assert.True(result.HasWarnings); + var report = new DomainJoinResultStore(f.Root, f.Plan, f.Hash).Read(); + Assert.Equal(DomainJoinPhaseState.Succeeded, report.Join.State); + Assert.Equal(DomainJoinPhaseState.Failed, report.Placement.State); + Assert.Equal(["--domain-join-worker"], processes.Command!.Arguments); + Assert.Null(processes.Command.OutputPath); + Assert.Equal(DomainJoinAction.SupervisionTimeout, processes.Command.Timeout); + Assert.True(DomainJoinAction.SupervisionTimeout > Foundry.PostInstall.Windows.DomainJoinWorker.Budget); + } + [Fact] + public async Task TimeoutWithJoinStarted_ReportsUnknownAndCannotReplay() + { + using var f = new DomainFixture(); DomainSeeds.WriteResult(f.Root, f.Plan, f.Hash); + var processes = new WorkerProcess(f) { Interrupt = true }; + var action = new DomainJoinAction(f.Root, f.Plan, f.Hash, "installed-boot", processes, "runtime.exe"); + var outcome = await action.ExecuteAsync(f.Plan.Actions[0], CancellationToken.None); + Assert.True(outcome.TerminationUncertain); Assert.True(outcome.HasWarnings); + Assert.Equal(DomainJoinPhaseState.Unknown, new DomainJoinResultStore(f.Root, f.Plan, f.Hash).Read().Join.State); + await Assert.ThrowsAsync(() => action.ExecuteAsync(f.Plan.Actions[0], CancellationToken.None)); + Assert.Equal(1, processes.Starts); + } + [Theory] + [InlineData("installed-boot", "PC-01", false)] + [InlineData("next-boot", "OLD-NAME", false)] + [InlineData("next-boot", "PC-01", true)] + public async Task Verification_RequiresLaterBootAndExpectedActiveName(string boot, string name, bool success) + { + using var f = new DomainFixture(); + var report = new DomainJoinResultStore(f.Root, f.Plan, f.Hash); DomainSeeds.WriteResult(f.Root, f.Plan, f.Hash); + report.Write(report.Read() with { OriginatingBootId = "installed-boot", Join = new() { State = DomainJoinPhaseState.Succeeded }, Restart = DomainJoinRestartState.Required }); + var journal = new ExecutionJournal(f.Root); var state = journal.Read(); state.Cursor = 1; state.BootIdentity = boot; + state.Actions["verify"] = new() { Status = "Running" }; journal.Write(state); + File.Delete(OwnedPaths.Resolve(f.Root, f.Parameters.CredentialPayloadPath)); f.Native.Name = name; + var outcome = await new DomainMembershipVerificationAction(f.Root, f.Plan, f.Hash, boot, f.Native).ExecuteAsync(f.Plan.Actions[1], CancellationToken.None); + Assert.Equal(success, outcome.Succeeded); Assert.Equal(!success, outcome.HasWarnings); + Assert.Equal(success, report.Read().Membership.State == DomainJoinPhaseState.Succeeded); + } + [Theory] + [InlineData("next-boot", "PC-01", true, 1)] + // Without the request, an imported answer file keeps its own OOBE choices. + [InlineData("next-boot", "PC-01", false, 0)] + // An unverified or mismatched membership leaves the page, so someone can still create an account. + [InlineData("installed-boot", "PC-01", true, 0)] + [InlineData("next-boot", "OLD-NAME", true, 0)] + public async Task Verification_SkipsAccountCreationOnlyWhenRequestedAndVerified(string boot, string name, bool requested, int expectedSkips) + { + using var f = new DomainFixture(); + var report = new DomainJoinResultStore(f.Root, f.Plan, f.Hash); DomainSeeds.WriteResult(f.Root, f.Plan, f.Hash); + report.Write(report.Read() with { OriginatingBootId = "installed-boot", Join = new() { State = DomainJoinPhaseState.Succeeded }, Restart = DomainJoinRestartState.Required }); + var journal = new ExecutionJournal(f.Root); var state = journal.Read(); state.Cursor = 1; state.BootIdentity = boot; + state.Actions["verify"] = new() { Status = "Running" }; journal.Write(state); + File.Delete(OwnedPaths.Resolve(f.Root, f.Parameters.CredentialPayloadPath)); f.Native.Name = name; + if (requested) File.WriteAllText(OwnedPaths.Resolve(f.Root, "State/PreOobe/" + DomainJoinStateFiles.SkipAccountCreationRequest), string.Empty); + + await new DomainMembershipVerificationAction(f.Root, f.Plan, f.Hash, boot, f.Native).ExecuteAsync(f.Plan.Actions[1], CancellationToken.None); + + Assert.Equal(expectedSkips, f.Native.AccountCreationSkips); + } + + [Fact] + public async Task Verification_KeepsTheAccountPageWhenTheJoinFailed() + { + using var f = new DomainFixture(); + var report = new DomainJoinResultStore(f.Root, f.Plan, f.Hash); DomainSeeds.WriteResult(f.Root, f.Plan, f.Hash); + report.Write(report.Read() with { OriginatingBootId = "installed-boot", Join = new() { State = DomainJoinPhaseState.Failed, FailureCode = DomainJoinFailureCode.JoinFailed } }); + var journal = new ExecutionJournal(f.Root); var state = journal.Read(); state.Cursor = 1; state.BootIdentity = "next-boot"; + state.Actions["verify"] = new() { Status = "Running" }; journal.Write(state); + File.Delete(OwnedPaths.Resolve(f.Root, f.Parameters.CredentialPayloadPath)); + File.WriteAllText(OwnedPaths.Resolve(f.Root, "State/PreOobe/" + DomainJoinStateFiles.SkipAccountCreationRequest), string.Empty); + + await new DomainMembershipVerificationAction(f.Root, f.Plan, f.Hash, "next-boot", f.Native).ExecuteAsync(f.Plan.Actions[1], CancellationToken.None); + + Assert.Equal(DomainJoinPhaseState.Skipped, report.Read().Membership.State); + Assert.Equal(0, f.Native.AccountCreationSkips); + } + + [Fact] + public async Task Verification_StaysSuccessfulWhenTheAccountPageCannotBeSkipped() + { + using var f = new DomainFixture(); + var report = new DomainJoinResultStore(f.Root, f.Plan, f.Hash); DomainSeeds.WriteResult(f.Root, f.Plan, f.Hash); + report.Write(report.Read() with { OriginatingBootId = "installed-boot", Join = new() { State = DomainJoinPhaseState.Succeeded }, Restart = DomainJoinRestartState.Required }); + var journal = new ExecutionJournal(f.Root); var state = journal.Read(); state.Cursor = 1; state.BootIdentity = "next-boot"; + state.Actions["verify"] = new() { Status = "Running" }; journal.Write(state); + File.Delete(OwnedPaths.Resolve(f.Root, f.Parameters.CredentialPayloadPath)); + File.WriteAllText(OwnedPaths.Resolve(f.Root, "State/PreOobe/" + DomainJoinStateFiles.SkipAccountCreationRequest), string.Empty); + f.Native.ThrowOnAccountCreationSkip = true; + + var outcome = await new DomainMembershipVerificationAction(f.Root, f.Plan, f.Hash, "next-boot", f.Native).ExecuteAsync(f.Plan.Actions[1], CancellationToken.None); + + Assert.True(outcome.Succeeded); + Assert.Equal(1, f.Native.AccountCreationSkips); + Assert.Equal(DomainJoinPhaseState.Succeeded, report.Read().Membership.State); + } + + private sealed class WorkerProcess(DomainFixture fixture) : IPreOobeProcessExecutor + { + public ProcessCommand? Command; + public bool Interrupt; + public int Starts; + public async Task RunAsync(ProcessCommand command, CancellationToken token) + { + Starts++; Command = command; + if (Interrupt) + { + var store = new DomainJoinPhaseStore(fixture.Root, fixture.Plan, fixture.Hash); + var bound = store.BindOrigin("installed-boot"); store.Write(bound with { Generation = 2, Phase = DomainJoinReceiptPhase.JoinStarted }); + return new(null, "untrusted stdout", TimedOut: true, TerminationUncertain: true); + } + await fixture.Run(); return new(0, "untrusted stdout"); + } + } +} diff --git a/src/Foundry.PostInstall.Tests/DomainJoinRecoveryTests.cs b/src/Foundry.PostInstall.Tests/DomainJoinRecoveryTests.cs new file mode 100644 index 000000000..06d7ca44c --- /dev/null +++ b/src/Foundry.PostInstall.Tests/DomainJoinRecoveryTests.cs @@ -0,0 +1,546 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Nodes; +using Foundry.Core.Models.Configuration; +using Foundry.Core.Models.PreOobe; +using Foundry.PostInstall.Actions; +using Foundry.PostInstall.Execution; + +namespace Foundry.PostInstall.Tests; + +public sealed class DomainJoinRecoveryTests +{ + [Theory] + [InlineData("join")] + [InlineData("placement")] + [InlineData("membership")] + public async Task MissingReportPhase_RejectsContinuationAfterRestart(string phase) + { + using var f = new RecoveryFixture(); + f.Domain.Directory.Existing = f.Domain.Directory.Computer; + f.Domain.Directory.StaleGuid = true; + Assert.Equal(2, await f.Run()); + string path = Path.Combine(f.Domain.Root, "State", "PreOobe", "domain-join-result.json"); + var value = JsonNode.Parse(File.ReadAllText(path))!.AsObject(); + Assert.True(value.Remove(phase)); + File.WriteAllText(path, value.ToJsonString()); + Assert.Equal(3, await f.Run("next-boot")); + Assert.Equal(["join"], f.Executed); + Assert.False(JsonNode.Parse(File.ReadAllText(path))!.AsObject().ContainsKey(phase)); + } + + [Fact] + public async Task CompletedReportWithoutMembership_IsRejectedBeforeTerminalCleanup() + { + using var f = new RecoveryFixture(); + Assert.Equal(2, await f.Run()); + Assert.Equal(0, await f.Run("next-boot")); + Assert.Equal(DomainJoinPhaseState.Succeeded, f.Report.Read().Membership.State); + string[] executed = [.. f.Executed]; + string path = Path.Combine(f.Domain.Root, "State", "PreOobe", "domain-join-result.json"); + var value = JsonNode.Parse(File.ReadAllText(path))!.AsObject(); + Assert.True(value.Remove("membership")); + File.WriteAllText(path, value.ToJsonString()); + Assert.Equal(3, await f.Run("next-boot")); + Assert.Equal(executed, f.Executed); + Assert.False(JsonNode.Parse(File.ReadAllText(path))!.AsObject().ContainsKey("membership")); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UnknownPlacement_LateSuccessfulReceiptCannotReplaceMissingReportEvidence(bool omitPlacement) + { + using var f = new RecoveryFixture(); + f.PrepareCrash(DomainJoinReceiptPhase.PlacementStarted, false, "Running"); + Assert.Equal(2, await f.Run()); + Assert.Equal(DomainJoinPhaseState.Unknown, f.Report.Read().Placement.State); + var phases = new DomainJoinPhaseStore(f.Domain.Root, f.Domain.Plan, f.Domain.Hash); + var receipt = phases.Read(); + phases.Write(receipt = receipt with + { + Generation = receipt.Generation + 1, + Phase = DomainJoinReceiptPhase.PlacementReturned, + Placement = new() { State = DomainJoinPhaseState.Succeeded } + }); + phases.Write(receipt with { Generation = receipt.Generation + 1, Phase = DomainJoinReceiptPhase.Finished }); + string path = Path.Combine(f.Domain.Root, "State", "PreOobe", "domain-join-result.json"); + if (omitPlacement) + { + var value = JsonNode.Parse(File.ReadAllText(path))!.AsObject(); + Assert.True(value.Remove("placement")); + File.WriteAllText(path, value.ToJsonString()); + } + Assert.Equal(omitPlacement ? 3 : 0, await f.Run("next-boot")); + if (omitPlacement) + { + Assert.Empty(f.Executed); + Assert.False(JsonNode.Parse(File.ReadAllText(path))!.AsObject().ContainsKey("placement")); + } + else + { + Assert.Equal(DomainJoinPhaseState.Unknown, f.Report.Read().Placement.State); + Assert.Equal(DomainJoinPhaseState.Succeeded, f.Report.Read().Membership.State); + } + } + + [Fact] + public async Task FailedPlacement_RestartsThenVerifiesAndContinues() + { + using var f = new RecoveryFixture(); + f.Domain.Directory.Existing = f.Domain.Directory.Computer; + f.Domain.Directory.StaleGuid = true; + Assert.Equal(2, await f.Run()); + Assert.Equal(DomainJoinRestartState.Requested, f.Report.Read().Restart); + Assert.Equal(3, await f.Run()); + Assert.Equal(0, await f.Run("next-boot")); + Assert.Equal("CompletedWithErrors", f.Journal.Read().Status); + Assert.Equal(DomainJoinPhaseState.Succeeded, f.Report.Read().Membership.State); + Assert.Equal(["join", "verify", "custom", "cleanup"], f.Executed); + Assert.Equal(1, f.Domain.Native.Joins); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FailedJoinOrWorkerStart_ContinuesWithoutRestartOrMembershipQuery(bool startFailure) + { + using var f = new RecoveryFixture { StartFailure = startFailure }; + f.Domain.Native.JoinCode = 5; + Assert.Equal(0, await f.Run()); + Assert.Equal("CompletedWithErrors", f.Journal.Read().Status); + Assert.Equal(0, f.Journal.Read().RestartCount); + Assert.Equal(DomainJoinPhaseState.Failed, f.Report.Read().Join.State); + Assert.Equal(DomainJoinPhaseState.Skipped, f.Report.Read().Membership.State); + Assert.Equal(DomainJoinRestartState.NotRequired, f.Report.Read().Restart); + Assert.Contains("custom", f.Executed); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UnknownJoin_RequestsOneRestartAndKeepsUnknownAfterMatchingMembership(bool liveWorker) + { + using var f = new RecoveryFixture { Interrupt = true, LiveWorker = liveWorker }; + Assert.Equal(2, await f.Run()); + Assert.Equal(3, await f.Run()); + Assert.Equal(1, f.Journal.Read().RestartCount); + if (liveWorker) Assert.True(File.Exists(f.Credentials)); + Assert.Equal(0, await f.Run("next-boot")); + Assert.Equal(DomainJoinPhaseState.Unknown, f.Report.Read().Join.State); + Assert.Equal(DomainJoinPhaseState.Succeeded, f.Report.Read().Membership.State); + Assert.Equal(DomainJoinRestartState.Completed, f.Report.Read().Restart); + Assert.False(File.Exists(f.Credentials)); + Assert.Equal(1, f.Executed.Count(id => id == "join")); + } + + [Theory] + [InlineData(DomainJoinReceiptPhase.Prepared, false, "Running", 0)] + [InlineData(DomainJoinReceiptPhase.JoinStarted, false, "Running", 2)] + [InlineData(DomainJoinReceiptPhase.JoinReturned, false, "Running", 2)] + [InlineData(DomainJoinReceiptPhase.PlacementStarted, false, "Running", 2)] + [InlineData(DomainJoinReceiptPhase.Finished, true, "Running", 2)] + [InlineData(DomainJoinReceiptPhase.Finished, true, "Completing", 2)] + [InlineData(DomainJoinReceiptPhase.Finished, true, "CompletedWithErrors", 2)] + [InlineData(DomainJoinReceiptPhase.Finished, true, "Failed", 2)] + public async Task Recovery_ReconcilesBeforeDispatchAndNeverReplays(DomainJoinReceiptPhase phase, bool advanced, string status, int firstExit) + { + using var f = new RecoveryFixture(); + f.PrepareCrash(phase, advanced, status); + Assert.Equal(firstExit, await f.Run()); + if (firstExit == 2) + { + Assert.Equal(3, await f.Run()); + Assert.DoesNotContain("verify", f.Executed); + Assert.Equal(0, await f.Run("next-boot")); + } + Assert.DoesNotContain("join", f.Executed); + Assert.Contains("custom", f.Executed); + Assert.Equal(0, f.Domain.Native.Joins); + Assert.Equal(0, f.Domain.Directory.Moves); + if (phase == DomainJoinReceiptPhase.JoinStarted) Assert.Equal(DomainJoinPhaseState.Unknown, f.Report.Read().Join.State); + if (phase == DomainJoinReceiptPhase.PlacementStarted) Assert.Equal(DomainJoinPhaseState.Unknown, f.Report.Read().Placement.State); + } + + [Theory] + [InlineData("operationId")] + [InlineData("attemptId")] + [InlineData("planHash")] + [InlineData("actionId")] + [InlineData("originatingBootId")] + [InlineData("torn")] + public async Task InvalidReceipt_IsRejectedBeforeContinuation(string field) + { + using var f = new RecoveryFixture(); + f.PrepareCrash(DomainJoinReceiptPhase.JoinStarted, false, "Running"); + string path = Path.Combine(f.Domain.Root, "State", "PreOobe", "domain-join-phase.json"); + var json = JsonNode.Parse(File.ReadAllText(path))!; + json[field] = "foreign"; + File.WriteAllText(path, field == "torn" ? "{" : json.ToJsonString()); + Assert.Equal(3, await f.Run()); + Assert.Empty(f.Executed); + Assert.Equal(0, f.Journal.Read().RestartCount); + } + + [Fact] + public async Task LockedMalformedDomainPayload_RemainsWarningThroughCustomAndFinalCleanupThenRetriesNextBoot() + { + using var f = new RecoveryFixture { StartFailure = true }; + File.WriteAllText(f.Credentials, "malformed"); + using (var locked = new FileStream(f.Credentials, FileMode.Open, FileAccess.Read, FileShare.None)) + { + Assert.Equal(0, await f.Run()); + Assert.True(f.PendingAtCustom); + Assert.Equal("CompletedWithErrors", f.Journal.Read().Status); + Assert.Equal("CleanupPending", f.Journal.Read().PayloadDispositions[f.Domain.Parameters.CredentialPayloadPath]); + Assert.Equal(DomainJoinCleanupState.Pending, f.Report.Read().Cleanup); + } + Assert.Equal(0, await f.Run("next-boot")); + Assert.False(File.Exists(f.Credentials)); + Assert.Equal(DomainJoinCleanupState.Disposed, f.Report.Read().Cleanup); + Assert.Equal(1, f.Executed.Count(id => id == "custom")); + } + + [Fact] + public async Task DomainStagingRecords_NeverArmMutation() + { + using var f = new RecoveryFixture(); + var state = f.Journal.Read(); state.Status = "Staging"; f.Journal.Write(state); + Assert.Equal(3, await f.Run()); + Assert.Empty(f.Executed); + Assert.True(File.Exists(f.Credentials)); + Assert.Equal("Staging", f.Journal.Read().Status); + } + + [Theory] + [InlineData("before-checkpoint", 2)] + [InlineData("after-checkpoint", 3)] + [InlineData("cursor", 2)] + public async Task RestartPublicationCrash_RecoversWithoutSecondMutationOrDuplicateRestart(string fault, int nextExit) + { + using var f = new RecoveryFixture(fault); + Assert.Equal(3, await f.Run()); + Assert.Equal(nextExit, await f.Run()); + Assert.Equal(3, await f.Run()); + Assert.Equal(1, f.Journal.Read().RestartCount); + Assert.Equal(0, await f.Run("next-boot")); + Assert.Equal(1, f.Domain.Native.Joins); + Assert.Equal(1, f.Executed.Count(id => id == "verify")); + } + + [Fact] + public async Task CorruptReceiptDuringRunningWork_DoesNotDeletePossiblyLiveCredentials() + { + using var f = new RecoveryFixture(); + f.PrepareCrash(DomainJoinReceiptPhase.JoinStarted, false, "Running"); + File.WriteAllText(Path.Combine(f.Domain.Root, "State", "PreOobe", "domain-join-phase.json"), "{"); + Assert.Equal(3, await f.Run()); + Assert.True(File.Exists(f.Credentials)); + } + + [Fact] + public async Task UnrelatedUncertainCustomAction_RemainsFatalAfterDomainWarning() + { + using var f = new RecoveryFixture { StartFailure = true, UncertainCustom = true }; + Assert.Equal(3, await f.Run()); + Assert.Equal("Failed", f.Journal.Read().Status); + Assert.Equal(3, await f.Run("next-boot")); + Assert.Equal(1, f.Executed.Count(id => id == "custom")); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CleanupJournalWriteFailure_RemainsFatalEvenForDomainOwnedPayload(bool finalGate) + { + using var f = new RecoveryFixture(finalGate ? "cleanup-final" : "cleanup-immediate") { StartFailure = true }; + Assert.Equal(3, await f.Run()); + Assert.Equal("Failed", f.Journal.Read().Status); + Assert.False(File.Exists(f.Credentials)); + } + + [Fact] + public async Task RolledBackValidReceipt_IsRejectedAfterRestartPublication() + { + using var f = new RecoveryFixture(); + string path = Path.Combine(f.Domain.Root, "State", "PreOobe", "domain-join-phase.json"); + byte[] seed = File.ReadAllBytes(path); + Assert.Equal(2, await f.Run()); + File.WriteAllBytes(path, seed); + Assert.Equal(3, await f.Run("next-boot")); + Assert.DoesNotContain("verify", f.Executed); + } + + [Fact] + public async Task ReportWriteFailureAfterCheckpoint_NeverConsumesRestartTwice() + { + using var f = new RecoveryFixture("report-after-checkpoint"); + Assert.Equal(3, await f.Run()); + f.ReleaseReportLock(); + Assert.Equal(3, await f.Run()); + Assert.Equal(1, f.Journal.Read().RestartCount); + Assert.Equal(0, await f.Run("next-boot")); + Assert.Equal(1, f.Domain.Native.Joins); + } + + [Fact] + public async Task UnsettledWorkerWithPreparedReceipt_RequiresConservativeRestart() + { + using var f = new RecoveryFixture { InterruptBeforeStart = true }; + Assert.Equal(2, await f.Run()); + Assert.True(File.Exists(f.Credentials)); + Assert.Equal(DomainJoinPhaseState.Unknown, f.Report.Read().Join.State); + Assert.Equal(0, await f.Run("next-boot")); + Assert.Equal(DomainJoinPhaseState.Unknown, f.Report.Read().Join.State); + } + + [Fact] + public async Task RecoveryWhilePreparedWorkerLeaseIsHeld_DoesNotAssumeMutationWasPrevented() + { + using var f = new RecoveryFixture(); + f.PrepareCrash(DomainJoinReceiptPhase.Prepared, false, "Running"); + using var lease = new DomainJoinPhaseStore(f.Domain.Root, f.Domain.Plan, f.Domain.Hash).AcquireWorkerLease(); + Assert.Equal(2, await f.Run()); + Assert.True(File.Exists(f.Credentials)); + Assert.Equal(DomainJoinPhaseState.Unknown, f.Report.Read().Join.State); + } + + [Fact] + public async Task SettledPreparedRecovery_ExecutesIndependentCleanupOnSameBoot() + { + using var f = new RecoveryFixture(); + f.PrepareCrash(DomainJoinReceiptPhase.Prepared, false, "Running"); + Assert.Equal(0, await f.Run()); + Assert.Equal(["verify", "custom", "cleanup"], f.Executed); + Assert.Equal("Succeeded", f.Journal.Read().Actions["cleanup"].Status); + Assert.False(File.Exists(f.Credentials)); + Assert.Null(f.Journal.Read().UnsafePayloadBootIdentity); + Assert.Equal(0, f.Journal.Read().RestartCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SettledPreparedRecovery_PreservesOrdinaryCustomContinuePolicy(bool previousDomainMarker) + { + using var f = new RecoveryFixture(continueCustomFailures: true) { OrdinaryCustomFailure = true }; + f.PrepareCrash(DomainJoinReceiptPhase.Prepared, false, "Running"); + if (previousDomainMarker) + { + var state = f.Journal.Read(); + state.UnsafePayloadBootIdentity = "installed-boot"; + state.UnsafeActionId = "join"; + f.Journal.Write(state); + } + Assert.Equal(0, await f.Run()); + Assert.Equal("CompletedWithErrors", f.Journal.Read().Status); + Assert.Equal("Failed", f.Journal.Read().Actions["custom"].Status); + Assert.Equal("Succeeded", f.Journal.Read().Actions["cleanup"].Status); + Assert.Null(f.Journal.Read().UnsafePayloadBootIdentity); + Assert.Equal(DomainJoinPhaseState.Failed, f.Report.Read().Join.State); + Assert.Equal(DomainJoinPhaseState.Skipped, f.Report.Read().Membership.State); + Assert.Equal(0, f.Journal.Read().RestartCount); + } + + [Theory] + [InlineData(null)] + [InlineData("custom")] + public async Task SettledPreparedRecovery_PreservesUnrelatedUnsafeOwnership(string? unsafeAction) + { + using var f = new RecoveryFixture(); + f.PrepareCrash(DomainJoinReceiptPhase.Prepared, false, "Running"); + var state = f.Journal.Read(); + state.UnsafePayloadBootIdentity = "installed-boot"; + state.UnsafeActionId = unsafeAction; + f.Journal.Write(state); + Assert.Equal(3, await f.Run()); + Assert.Equal("Failed", f.Journal.Read().Status); + Assert.Equal("installed-boot", f.Journal.Read().UnsafePayloadBootIdentity); + Assert.Equal(unsafeAction, f.Journal.Read().UnsafeActionId); + Assert.Empty(f.Executed); + } + + [Fact] + public async Task LostNativeReturn_ContinuesAfterRestartWithoutRepeatingJoin() + { + using var f = new RecoveryFixture(); + f.Domain.Native.ThrowOnJoin = true; + Assert.Equal(2, await f.Run()); + Assert.Equal(0, await f.Run("next-boot")); + Assert.Equal(1, f.Domain.Native.Joins); + Assert.Equal(DomainJoinPhaseState.Unknown, f.Report.Read().Join.State); + Assert.Equal(DomainJoinPhaseState.Succeeded, f.Report.Read().Membership.State); + } + + [Fact] + public async Task TerminalSkippedUnstartedJoin_DoesNotResumeAbortedDeployment() + { + using var f = new RecoveryFixture(); + var state = f.Journal.Read(); state.Status = "Failed"; state.BootIdentity = "installed-boot"; + state.Actions["join"] = new() { Status = "Skipped" }; f.Journal.Write(state); + Assert.Equal(3, await f.Run()); + Assert.Equal(["cleanup"], f.Executed); + Assert.Equal(DomainJoinPhaseState.NotStarted, f.Report.Read().Join.State); + Assert.Equal(0, f.Journal.Read().RestartCount); + } + + [Fact] + public async Task JoinReturnWithoutOu_DoesNotInventPlacementWarning() + { + using var f = new RecoveryFixture(targetOu: false); + f.PrepareCrash(DomainJoinReceiptPhase.JoinReturned, false, "Running"); + Assert.Equal(2, await f.Run()); + Assert.Equal(0, await f.Run("next-boot")); + Assert.Equal(DomainJoinPhaseState.Skipped, f.Report.Read().Placement.State); + Assert.Equal("Succeeded", f.Journal.Read().Status); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DomainCleanup_RetriesBoundedlyAndClassifiesWithoutDecoding(bool release) + { + using var f = new RecoveryFixture(); + File.WriteAllText(f.Credentials, "malformed"); + using var locked = new FileStream(f.Credentials, FileMode.Open, FileAccess.Read, FileShare.None); + var waits = new List(); + var cleanup = new OwnedPayloadCleanup(f.Domain.Root, f.Journal, duration => + { + waits.Add(duration); + if (release && waits.Count == 2) locked.Dispose(); + }); + var outcome = cleanup.Dispose(f.Domain.Plan, f.Journal.Read(), true, "installed-boot"); + Assert.False(outcome.HasFatalSensitiveFailure); + Assert.Equal(!release, outcome.HasDomainCleanupPending); + Assert.Equal(new[] { TimeSpan.FromMilliseconds(250), TimeSpan.FromMilliseconds(250) }, waits); + Assert.Equal(!release, File.Exists(f.Credentials)); + } + + private sealed class RecoveryFixture : IDisposable, IPreOobeActionExecutor, IPreOobeProcessExecutor + { + public DomainFixture Domain { get; } + public ExecutionJournal Journal { get; } + public DomainJoinResultStore Report { get; } + public string Credentials => OwnedPaths.Resolve(Domain.Root, Domain.Parameters.CredentialPayloadPath); + public List Executed { get; } = []; + public bool StartFailure; + public bool Interrupt; + public bool InterruptBeforeStart; + public bool LiveWorker; + public bool PendingAtCustom; + public bool UncertainCustom; + public bool OrdinaryCustomFailure; + + private string boot = "installed-boot"; + public RecoveryFixture(string? fault = null, bool targetOu = true, bool continueCustomFailures = false) + { + Domain = new(targetOu, configure: plan => plan with + { + Actions = [.. plan.Actions, + new() { Id = "custom", CustomAction = new() { Kind = PreOobeActionKind.Command, Process = new() { ErrorPolicy = continueCustomFailures ? PreOobeErrorPolicy.Continue : PreOobeErrorPolicy.Stop } } }, + new() { Id = "cleanup", BuiltInKind = PreOobeBuiltInKind.Cleanup }] + }); + Journal = fault is null ? new ExecutionJournal(Domain.Root) : new FaultJournal(Domain.Root, fault); + Journal.Write(ExecutionJournal.CreateSeed(Domain.Plan, Domain.Hash)); + Report = new(Domain.Root, Domain.Plan, Domain.Hash); DomainSeeds.WriteResult(Domain.Root, Domain.Plan, Domain.Hash); + } + public async Task Run(string currentBoot = "installed-boot") + { + boot = currentBoot; + return (await new PreOobeOrchestrator(Domain.Root, Domain.Hash, Journal, this, () => boot) + .RunAsync(Domain.Plan, TestContext.Current.CancellationToken)).ExitCode; + } + public async Task ExecuteAsync(PreOobeExecutionAction action, int substep, CancellationToken token) + { + Executed.Add(action.Id); + if (action.Id == "join") return await new DomainJoinAction(Domain.Root, Domain.Plan, Domain.Hash, boot, this, "runtime.exe").ExecuteAsync(action, token); + if (action.Id == "verify") return await new DomainMembershipVerificationAction(Domain.Root, Domain.Plan, Domain.Hash, boot, Domain.Native).ExecuteAsync(action, token); + if (action.Id == "custom") + { + PendingAtCustom = Report.Read().Cleanup == DomainJoinCleanupState.Pending; + if (UncertainCustom) return new(false, TerminationUncertain: true); + if (OrdinaryCustomFailure) return new(false, ExitCode: 42, FailureCode: "custom_failure"); + } + return new(true); + } + public async Task RunAsync(ProcessCommand command, CancellationToken token) + { + if (StartFailure) throw new System.ComponentModel.Win32Exception(2); + if (InterruptBeforeStart) return new(null, "", TerminationUncertain: true); + if (Interrupt) + { + var store = new DomainJoinPhaseStore(Domain.Root, Domain.Plan, Domain.Hash); + var receipt = store.BindOrigin(boot); + store.Write(receipt with { Generation = 2, Phase = DomainJoinReceiptPhase.JoinStarted }); + return new(null, "", TimedOut: true, TerminationUncertain: LiveWorker); + } + await Domain.Run(); return new(0, ""); + } + public void PrepareCrash(DomainJoinReceiptPhase phase, bool advanced, string status) + { + var state = Journal.Read(); state.Status = "Running"; state.BootIdentity = boot; + state.Actions["join"] = new() { Status = "Running" }; Journal.Write(state); + var store = new DomainJoinPhaseStore(Domain.Root, Domain.Plan, Domain.Hash); + var receipt = store.BindOrigin(boot); + if (phase >= DomainJoinReceiptPhase.JoinStarted) store.Write(receipt = receipt with { Generation = 2, Phase = DomainJoinReceiptPhase.JoinStarted }); + if (phase >= DomainJoinReceiptPhase.JoinReturned) store.Write(receipt = receipt with + { + Generation = 3, + Phase = DomainJoinReceiptPhase.JoinReturned, + Join = new() { State = DomainJoinPhaseState.Succeeded }, + RestartRequired = true + }); + if (phase == DomainJoinReceiptPhase.PlacementStarted) store.Write(receipt with + { + Generation = 4, + Phase = phase, + ComputerObjectGuid = Domain.Directory.Computer.Guid, + DestinationObjectGuid = Domain.Directory.Target.Guid + }); + if (phase == DomainJoinReceiptPhase.Finished) store.Write(receipt with + { + Generation = 4, + Phase = phase, + Placement = new() { State = DomainJoinPhaseState.Failed, FailureCode = DomainJoinFailureCode.Interrupted } + }); + state.Status = status; state.CompletionStatus = status == "Completing" ? "CompletedWithErrors" : null; + if (advanced) { state.Cursor = 1; state.Actions["join"] = new() { Status = "Failed" }; } + Journal.Write(state); + } + public void Dispose() { ReleaseReportLock(); Domain.Dispose(); } + public void ReleaseReportLock() => (Journal as FaultJournal)?.ReportLock?.Dispose(); + } + + private sealed class FaultJournal(string root, string fault) : ExecutionJournal(root) + { + private bool failed; + public FileStream? ReportLock; + public override void Write(JournalState state) + { + bool trigger = fault switch + { + "before-checkpoint" or "after-checkpoint" => state.Status == "AwaitingRestart", + "report-after-checkpoint" => state.Status == "AwaitingRestart", + "cursor" => state.Status == "Running" && state.Cursor == 1, + "cleanup-immediate" => state.PayloadDispositions.Values.Contains("CleanupPending"), + "cleanup-final" => state.Status == "Completing", + _ => false + }; + if (!failed && trigger) + { + failed = true; + if (fault == "report-after-checkpoint") + { + base.Write(state); + ReportLock = new FileStream(System.IO.Path.Combine(System.IO.Path.GetDirectoryName(Path)!, "domain-join-result.json"), FileMode.Open, FileAccess.Read, FileShare.None); + return; + } + if (fault == "after-checkpoint") base.Write(state); + throw new IOException("Injected journal publication failure."); + } + base.Write(state); + } + } +} diff --git a/src/Foundry.PostInstall.Tests/DomainJoinResultStoreTests.cs b/src/Foundry.PostInstall.Tests/DomainJoinResultStoreTests.cs new file mode 100644 index 000000000..b8ef92acf --- /dev/null +++ b/src/Foundry.PostInstall.Tests/DomainJoinResultStoreTests.cs @@ -0,0 +1,128 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using System.Text.Json.Nodes; +using Foundry.Core.Models.PreOobe; +using Foundry.PostInstall.Execution; +namespace Foundry.PostInstall.Tests; + +public sealed class DomainJoinResultStoreTests +{ + [Theory] + [InlineData("schemaVersion")] + [InlineData("operationId")] + [InlineData("attemptId")] + [InlineData("planHash")] + [InlineData("originatingBootId")] + [InlineData("expectedComputerName")] + [InlineData("expectedDomainName")] + [InlineData("targetOuDn")] + [InlineData("join")] + [InlineData("placement")] + [InlineData("membership")] + [InlineData("restart")] + [InlineData("cleanup")] + [InlineData("computerObjectGuid")] + public void MissingAuthoritativeReportField_RejectsDefaultSubstitution(string field) + { + using var f = new DomainFixture(target: false); + var store = new DomainJoinResultStore(f.Root, f.Plan, f.Hash); DomainSeeds.WriteResult(f.Root, f.Plan, f.Hash); + string path = Path.Combine(f.Root, "State", "PreOobe", "domain-join-result.json"); + var value = JsonNode.Parse(File.ReadAllText(path))!.AsObject(); + Assert.True(value.Remove(field)); + File.WriteAllText(path, value.ToJsonString()); + Assert.Throws(() => store.Read()); + } + + [Theory] + [InlineData("nativeErrorCode")] + [InlineData("ldapErrorCode")] + [InlineData("directoryResultCode")] + public void OptionalNumericReportDiagnostics_RemainCompatibleWhenOmitted(string field) + { + using var f = new DomainFixture(); + var store = new DomainJoinResultStore(f.Root, f.Plan, f.Hash); DomainSeeds.WriteResult(f.Root, f.Plan, f.Hash); + store.Write(store.Read() with + { + OriginatingBootId = "installed-boot", + Join = new() { State = DomainJoinPhaseState.Failed, FailureCode = DomainJoinFailureCode.JoinFailed } + }); + string path = Path.Combine(f.Root, "State", "PreOobe", "domain-join-result.json"); + var value = JsonNode.Parse(File.ReadAllText(path))!.AsObject(); + foreach (string phase in new[] { "join", "placement", "membership" }) + Assert.True(value[phase]!.AsObject().Remove(field)); + File.WriteAllText(path, value.ToJsonString()); + Assert.Equal(DomainJoinPhaseState.Failed, store.Read().Join.State); + Assert.Equal(DomainJoinFailureCode.JoinFailed, store.Read().Join.FailureCode); + } + + [Theory] + [InlineData("phase")] + [InlineData("generation")] + [InlineData("originatingBootId")] + [InlineData("join")] + [InlineData("placement")] + [InlineData("restartRequired")] + [InlineData("join.state")] + public async Task MissingAuthoritativeReceiptField_NeverAuthorizesExecution(string field) + { + using var f = new DomainFixture(); + string path = Path.Combine(f.Root, "State", "PreOobe", "domain-join-phase.json"); + var value = JsonNode.Parse(File.ReadAllText(path))!.AsObject(); + if (field == "join.state") value["join"]!.AsObject().Remove("state"); else value.Remove(field); + File.WriteAllText(path, value.ToJsonString()); + await Assert.ThrowsAsync(f.Run); + Assert.Equal(0, f.Directory.Prepares); Assert.Equal(0, f.Native.Joins); + } + [Fact] + public void PreparedSeed_BindsOriginOnceAndRejectsStartedReplay() + { + using var f = new DomainFixture(); + var store = new DomainJoinPhaseStore(f.Root, f.Plan, f.Hash); + Assert.Null(store.Read().OriginatingBootId); + var bound = store.BindOrigin("installed-boot"); + Assert.Equal(1, bound.Generation); + Assert.Throws(() => store.BindOrigin("other-boot")); + store.Write(bound with { Generation = 2, Phase = DomainJoinReceiptPhase.JoinStarted }); + Assert.Throws(() => store.Write(bound with { Generation = 3 })); + } + [Fact] + public void Receipt_RejectsWrongIdentityAndUnassignedMutation() + { + using var f = new DomainFixture(); + var store = new DomainJoinPhaseStore(f.Root, f.Plan, f.Hash); + var seed = store.Read(); + Assert.Throws(() => store.Write(seed with { Generation = 1, OperationId = Guid.NewGuid().ToString("N") })); + Assert.Throws(() => store.Write(seed with { Generation = 1, Phase = DomainJoinReceiptPhase.JoinStarted })); + } + [Fact] + public void Report_RejectsForeignIdentityAndSuccessfulJoinWithoutRestart() + { + using var f = new DomainFixture(); + var store = new DomainJoinResultStore(f.Root, f.Plan, f.Hash); DomainSeeds.WriteResult(f.Root, f.Plan, f.Hash); + var seed = store.Read(); + Assert.Throws(() => store.Write(seed with { PlanHash = new string('b', 64) })); + Assert.Throws(() => store.Write(seed with { OriginatingBootId = "installed-boot", Join = new() { State = DomainJoinPhaseState.Succeeded } })); + } + [Fact] + public void Report_RejectsConflatedErrorFamilies() + { + using var f = new DomainFixture(); var store = new DomainJoinResultStore(f.Root, f.Plan, f.Hash); DomainSeeds.WriteResult(f.Root, f.Plan, f.Hash); + Assert.Throws(() => store.Write(store.Read() with + { + OriginatingBootId = "installed-boot", + Join = new() { State = DomainJoinPhaseState.Failed, FailureCode = DomainJoinFailureCode.DomainUnavailable, LdapErrorCode = 49, DirectoryResultCode = 50 } + })); + } + [Fact] + public void Report_AcceptsRecordedOriginAfterRebootButNeverChangesIt() + { + using var f = new DomainFixture(); + var store = new DomainJoinResultStore(f.Root, f.Plan, f.Hash); DomainSeeds.WriteResult(f.Root, f.Plan, f.Hash); + store.Write(store.Read() with { OriginatingBootId = "installed-boot", Join = new() { State = DomainJoinPhaseState.Succeeded }, Restart = DomainJoinRestartState.Required }); + var resumed = new DomainJoinResultStore(f.Root, f.Plan, f.Hash); Assert.Equal("installed-boot", resumed.Read().OriginatingBootId); + Assert.Throws(() => resumed.Write(resumed.Read() with { OriginatingBootId = "next-boot" })); + } +} diff --git a/src/Foundry.PostInstall.Tests/DomainJoinWorkerTests.cs b/src/Foundry.PostInstall.Tests/DomainJoinWorkerTests.cs new file mode 100644 index 000000000..2dcc6a4ec --- /dev/null +++ b/src/Foundry.PostInstall.Tests/DomainJoinWorkerTests.cs @@ -0,0 +1,361 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Security.Cryptography; +using System.Text.Json; +using Foundry.Core.Models.Configuration; +using Foundry.Core.Models.PreOobe; +using Foundry.Core.Services.Configuration; +using Foundry.PostInstall.Execution; +using Foundry.PostInstall.Windows; + +namespace Foundry.PostInstall.Tests; + +public sealed class DomainJoinWorkerTests +{ + [Fact] + public async Task NewVisibleAccount_UsesResolvedCreationOuWithoutMoving() + { + using var f = new DomainFixture(); + var result = await f.Run(); + Assert.Equal(DomainJoinPhaseState.Succeeded, result.Join.State); + Assert.Equal(f.Directory.Target.DistinguishedName, f.Native.CreationOu); + Assert.Equal("example.test\\dc.example.test", f.Native.DomainAndDc); + Assert.Equal(0, f.Directory.Moves); + Assert.True(result.RestartRequired); + } + [Fact] + public async Task ExistingAccount_MovesSameGuidAfterJoin() + { + using var f = new DomainFixture(); + f.Directory.Existing = f.Directory.Computer; + var result = await f.Run(); + Assert.Null(f.Native.CreationOu); + Assert.Equal(1, f.Directory.Moves); + Assert.Equal(f.Directory.Computer.Guid, result.ComputerObjectGuid); + Assert.Equal(DomainJoinPhaseState.Succeeded, result.Placement.State); + } + [Theory] + [InlineData(@"CN=Original\, RDN")] + [InlineData(@"CN=Original\2C RDN")] + [InlineData(@"CN=Original\, RDN+UID=Serial\+42")] + public void MoveRequest_PreservesSourceDestinationAndCompleteEscapedRdn(string originalRdn) + { + string sourceDn = originalRdn + ",OU=Old,DC=example,DC=test"; + const string destinationDn = "OU=Workstations,DC=example,DC=test"; + + var request = DomainComputerAccountDirectory.CreateMoveRequest(sourceDn, destinationDn); + + Assert.Equal(sourceDn, request.DistinguishedName); + Assert.Equal(destinationDn, request.NewParentDistinguishedName); + Assert.Equal(originalRdn, request.NewName); + Assert.True(request.DeleteOldRdn); + } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task SameParentOrNoDestination_DoesNotMove(bool sameParent) + { + using var f = new DomainFixture(sameParent); + f.Directory.Existing = f.Directory.Computer with { ParentGuid = f.Directory.Target.Guid }; + var result = await f.Run(); + Assert.Equal(DomainJoinPhaseState.Succeeded, result.Join.State); + Assert.Equal(0, f.Directory.Moves); + } + [Fact] + public async Task IncompleteReadiness_NeverJoins() + { + using var f = new DomainFixture(); + f.Directory.ReadinessFailure = true; + var result = await f.Run(); + Assert.Equal(DomainJoinPhaseState.Failed, result.Join.State); + Assert.Equal(0, f.Native.Joins); + Assert.Equal(1, f.Directory.Prepares); + } + [Fact] + public async Task MissingTargetOu_JoinsInTheDefaultLocationAndReportsPlacementFailure() + { + using var f = new DomainFixture(); + f.Directory.MissingTargetOu = true; + var result = await f.Run(); + Assert.Equal(DomainJoinPhaseState.Succeeded, result.Join.State); + Assert.True(result.RestartRequired); + Assert.Null(f.Native.CreationOu); + Assert.Equal(DomainJoinPhaseState.Failed, result.Placement.State); + Assert.Equal(DomainJoinFailureCode.OrganizationalUnitNotFound, result.Placement.FailureCode); + Assert.Equal(0, f.Directory.Moves); + } + + [Fact] + public async Task UnreachableDirectory_IsRetriedUntilReadyThenJoins() + { + using var f = new DomainFixture(); + f.Directory.TransientFailures = 2; + int waits = 0; + f.Delay = (_, _) => { waits++; return Task.CompletedTask; }; + var result = await f.Run(); + Assert.Equal(DomainJoinPhaseState.Succeeded, result.Join.State); + Assert.Equal(3, f.Directory.Prepares); + Assert.Equal(2, waits); + Assert.Equal(1, f.Native.Joins); + } + [Fact] + public async Task UnreachableDirectoryBeyondReadinessBudget_ReportsTimeoutWithoutJoining() + { + using var f = new DomainFixture(); + f.Directory.TransientFailures = int.MaxValue; + int waits = 0; + f.Delay = (_, token) => ++waits < 3 ? Task.CompletedTask : Task.FromCanceled(new CancellationToken(true)); + var result = await f.Run(); + Assert.Equal(DomainJoinPhaseState.Failed, result.Join.State); + Assert.Equal(DomainJoinFailureCode.ReadinessTimeout, result.Join.FailureCode); + Assert.Equal(1355, result.Join.NativeErrorCode); + Assert.Equal(0, f.Native.Joins); + } + [Fact] + public async Task FailedJoin_NeverMoves() + { + using var f = new DomainFixture(); + f.Directory.Existing = f.Directory.Computer; + f.Native.JoinCode = 5; + var result = await f.Run(); + Assert.Equal(DomainJoinPhaseState.Failed, result.Join.State); + Assert.Equal(0, f.Directory.Moves); + Assert.False(result.RestartRequired); + } + [Fact] + public async Task ChangedActiveName_IsPreparedBeforeJoinWithPendingFlag() + { + using var f = new DomainFixture(); + f.Native.Name = "OLD"; + await f.Run(); + Assert.Equal(["set", "join"], f.Native.Calls); + Assert.True(f.Native.PendingName); + } + [Fact] + public async Task LostNativeResponse_IsUnknownAndNeverReplayed() + { + using var f = new DomainFixture(); + f.Native.ThrowOnJoin = true; + var result = await f.Run(); + Assert.Equal(DomainJoinPhaseState.Unknown, result.Join.State); + await Assert.ThrowsAsync(f.Run); + Assert.Equal(1, f.Native.Joins); + } + [Fact] + public async Task AcknowledgedMoveWithDeniedReadback_IsUnverified() + { + using var f = new DomainFixture(); + f.Directory.Existing = f.Directory.Computer; + f.Directory.DenyReadback = true; + var result = await f.Run(); + Assert.Equal(DomainJoinPhaseState.Unverified, result.Placement.State); + Assert.True(result.RestartRequired); + } + [Fact] + public async Task LostMoveResponse_RemainsUnknownEvenWhenReadbackShowsDestination() + { + using var f = new DomainFixture(); + f.Directory.Existing = f.Directory.Computer; + f.Directory.LoseMoveResponse = true; + var result = await f.Run(); + Assert.Equal(DomainJoinPhaseState.Unknown, result.Placement.State); + Assert.Equal(1, f.Directory.Moves); + } + [Fact] + public async Task HiddenExistingAccount_IsNeverRelocatedWithoutPreJoinGuid() + { + using var f = new DomainFixture(); + f.Directory.NewParent = Guid.NewGuid(); + var result = await f.Run(); + Assert.Equal(DomainJoinPhaseState.Unverified, result.Placement.State); + Assert.Equal(0, f.Directory.Moves); + } + [Fact] + public async Task StaleIdentity_PreventsMove() + { + using var f = new DomainFixture(); + f.Directory.Existing = f.Directory.Computer; + f.Directory.StaleGuid = true; + var result = await f.Run(); + Assert.Equal(0, f.Directory.Moves); + Assert.Equal(DomainJoinPhaseState.Failed, result.Placement.State); + } + [Fact] + public async Task MutationCalls_ObserveDurableStartedAndJoinReturnReceipts() + { + using var f = new DomainFixture(); f.Directory.Existing = f.Directory.Computer; + DomainJoinPhaseReceipt? joinReceipt = null; + DomainJoinPhaseReceipt? moveReceipt = null; + f.Native.BeforeJoin = () => joinReceipt = new DomainJoinPhaseStore(f.Root, f.Plan, f.Hash).Read(); + f.Directory.BeforeMove = () => moveReceipt = new DomainJoinPhaseStore(f.Root, f.Plan, f.Hash).Read(); + var result = await f.Run(); + Assert.Equal(DomainJoinPhaseState.Succeeded, result.Placement.State); + Assert.NotNull(joinReceipt); Assert.Equal(DomainJoinReceiptPhase.JoinStarted, joinReceipt.Phase); + Assert.NotNull(moveReceipt); Assert.Equal(DomainJoinReceiptPhase.PlacementStarted, moveReceipt.Phase); + Assert.Equal(DomainJoinPhaseState.Succeeded, moveReceipt.Join.State); Assert.True(moveReceipt.RestartRequired); + } + [Fact] + public async Task ReceiptWriteFailure_PreventsNativeMutation() + { + using var f = new DomainFixture(); + f.Directory.AfterPrepare = () => File.Delete(Path.Combine(f.Root, "State", "PreOobe", "domain-join-phase.json")); + await Assert.ThrowsAsync(f.Run); + Assert.Equal(0, f.Native.Joins); Assert.Empty(f.Native.Calls); + } + [Fact] + public async Task CredentialDomainMismatch_PreventsAuthentication() + { + using var f = new DomainFixture(); + byte[] bytes = DomainJoinCredentialPayloadCodec.Encode(new("foreign.test", "EXAMPLE\\joiner"), "secret".AsSpan()); + File.WriteAllBytes(OwnedPaths.Resolve(f.Root, f.Parameters.CredentialPayloadPath), bytes); CryptographicOperations.ZeroMemory(bytes); + var result = await f.Run(); + Assert.Equal(DomainJoinFailureCode.ContextMismatch, result.Join.FailureCode); + Assert.Equal(0, f.Directory.Prepares); Assert.Equal(0, f.Native.Joins); + } + [Fact] + public async Task ForeignDestination_PreventsJoin() + { + using var f = new DomainFixture(); f.Directory.Target = f.Directory.Target with { DistinguishedName = "OU=Target,DC=foreign,DC=test" }; + var result = await f.Run(); + Assert.Equal(DomainJoinPhaseState.Failed, result.Join.State); Assert.Equal(0, f.Native.Joins); + } + [Fact] + public async Task TargetCollision_IsReportedWithoutRetryingTheMove() + { + using var f = new DomainFixture(); f.Directory.Existing = f.Directory.Computer; f.Directory.RejectMove = true; + var result = await f.Run(); + Assert.Equal(DomainJoinPhaseState.Failed, result.Placement.State); Assert.Equal(1, f.Directory.Moves); + Assert.Equal(68, result.Placement.DirectoryResultCode); Assert.Null(result.Placement.LdapErrorCode); + } + [Fact] + public async Task Worker_ReleasesDecodedPasswordWhenDirectoryReadinessFails() + { + using var f = new DomainFixture(); f.Directory.ReadinessFailure = true; + await f.Run(); + Assert.Equal(6, f.Directory.ObservedPassword.Length); + Assert.All(f.Directory.ObservedPassword.ToArray(), character => Assert.Equal('\0', character)); + } + +} + +internal sealed class DomainFixture : IDisposable +{ + public string Root { get; } = Path.Combine(Path.GetTempPath(), "foundry-domain-" + Guid.NewGuid().ToString("N")); + public PreOobeExecutionPlan Plan { get; } + public DomainJoinActionParameters Parameters { get; } + public string Hash { get; } + public FakeNative Native { get; } = new(); + public FakeDirectory Directory { get; } = new(); + public DomainFixture(bool target = true, Func? configure = null) + { + string operation = Guid.NewGuid().ToString("N"); + Parameters = new("example.test", "PC-01", target ? Directory.Target.DistinguishedName : null, $"Payloads/DomainJoin/{operation}/credentials.bin"); + Plan = new() + { + OperationId = operation, + AttemptId = Guid.NewGuid().ToString("N"), + Actions = [new() { Id = "join", BuiltInKind = PreOobeBuiltInKind.DomainJoinAndPlacement, Parameters = JsonSerializer.SerializeToElement(Parameters, ExecutionJournal.JsonOptions) }, + new() { Id = "verify", BuiltInKind = PreOobeBuiltInKind.VerifyDomainMembership, Parameters = JsonSerializer.SerializeToElement(new DomainMembershipVerificationParameters("example.test", "PC-01", "join"), ExecutionJournal.JsonOptions) }], + OwnedPayloads = [new() { RelativePath = Parameters.CredentialPayloadPath, IsSensitive = true, ConsumerActionIds = ["join"] }] + }; + if (configure is not null) Plan = configure(Plan); + System.IO.Directory.CreateDirectory(Path.Combine(Root, "State", "PreOobe")); + byte[] planBytes = JsonSerializer.SerializeToUtf8Bytes(Plan, ExecutionJournal.JsonOptions); + File.WriteAllBytes(Path.Combine(Root, "State", "PreOobe", "plan.json"), planBytes); + Hash = Convert.ToHexStringLower(SHA256.HashData(planBytes)); + DomainSeeds.WritePhase(Root, Plan, Hash); + var journal = new ExecutionJournal(Root); + journal.Seed(Plan, Hash); + var state = journal.Read(); state.Status = "Running"; state.BootIdentity = "installed-boot"; + state.Actions["join"] = new() { Status = "Running" }; journal.Write(state); + string credentials = OwnedPaths.Resolve(Root, Parameters.CredentialPayloadPath); + System.IO.Directory.CreateDirectory(Path.GetDirectoryName(credentials)!); + byte[] bytes = DomainJoinCredentialPayloadCodec.Encode(new("example.test", "EXAMPLE\\joiner"), "secret".AsSpan()); + File.WriteAllBytes(credentials, bytes); CryptographicOperations.ZeroMemory(bytes); + } + public Func? Delay; + public Task Run() => new DomainJoinWorker(Root, Plan, Hash, "installed-boot", Native, Directory, Delay).RunAsync(Parameters, CancellationToken.None); + public void Dispose() => System.IO.Directory.Delete(Root, true); +} + +/// Writes the two state files as Deploy stages them, so tests start from the production seed shape. +internal static class DomainSeeds +{ + public static void WriteResult(string root, PreOobeExecutionPlan plan, string hash) => + Stage(root, "State/PreOobe/domain-join-result.json", new DomainJoinResultStore(root, plan, hash).CreateSeed()); + + public static void WritePhase(string root, PreOobeExecutionPlan plan, string hash) => Stage(root, "State/PreOobe/domain-join-phase.json", + DomainJoinPhaseReceipt.CreateSeed(plan.OperationId, plan.AttemptId, hash, DomainJoinBinding.Validate(plan).JoinAction.Id)); + + private static void Stage(string root, string relativePath, T seed) => + File.WriteAllBytes(OwnedPaths.Resolve(root, relativePath), JsonSerializer.SerializeToUtf8Bytes(seed, ExecutionJournal.JsonOptions)); +} + +internal sealed class FakeNative : INativeDomainJoin +{ + public string Name = "PC-01"; + public int Joins; + public int JoinCode; + public bool ThrowOnJoin; + public string? CreationOu; + public string? DomainAndDc; + public bool PendingName; + public List Calls = []; + public Action? BeforeJoin; + public int AccountCreationSkips; + public bool ThrowOnAccountCreationSkip; + public void SkipAccountCreation() { AccountCreationSkips++; if (ThrowOnAccountCreationSkip) throw new UnauthorizedAccessException(); } + public string GetActiveComputerName() => Name; + public int SetComputerName(string name) { Calls.Add("set"); return 0; } + public int Join(string domainAndDc, string? creationOu, DomainJoinCredentialContext context, ReadOnlySpan password, bool usePendingName) + { + BeforeJoin?.Invoke(); Joins++; Calls.Add("join"); CreationOu = creationOu; DomainAndDc = domainAndDc; PendingName = usePendingName; + if (ThrowOnJoin) throw new IOException(); + return JoinCode; + } + public DomainMembershipSnapshot GetMembership() => new(3, "example.test", Name); +} +internal sealed class FakeDirectory : IDomainComputerAccountDirectory +{ + public DomainDirectoryObject Target = new(Guid.NewGuid(), "OU=Workstations,DC=example,DC=test"); + public DomainDirectoryObject Computer = new(Guid.NewGuid(), "CN=Original\\, RDN,OU=Old,DC=example,DC=test", Guid.NewGuid()); + public DomainDirectoryObject? Existing; + public Guid? NewParent; + public bool ReadinessFailure; + public bool MissingTargetOu; + public int TransientFailures; + public bool DenyReadback; + public bool LoseMoveResponse; + public bool StaleGuid; + public int Moves; + public int Prepares; + public bool RejectMove; + public Action? BeforeMove; + public Action? AfterPrepare; + public ReadOnlyMemory ObservedPassword; + public Task PrepareAsync(DomainJoinCredentialContext context, ReadOnlyMemory password, string computerName, string? targetOuDn, CancellationToken token) + { + Prepares++; ObservedPassword = password; AfterPrepare?.Invoke(); + if (Prepares <= TransientFailures) throw new DomainDirectoryException(nativeError: 1355, transient: true); + if (ReadinessFailure) throw new DomainDirectoryException(ldapError: 50); + if (MissingTargetOu && targetOuDn is not null) return Task.FromResult(new DomainDirectoryReady("example.test", "dc.example.test", null, null, DestinationMissing: true)); + return Task.FromResult(new DomainDirectoryReady("example.test", "dc.example.test", targetOuDn is null ? null : Target, Existing)); + } + public Task FindComputerAsync(string name, CancellationToken token) => Task.FromResult(Computer with { ParentGuid = NewParent ?? Target.Guid }); + public Task ReadComputerAsync(Guid guid, CancellationToken token) + { + if (Moves > 0 && DenyReadback) throw new DomainDirectoryException(ldapError: 50); + return Task.FromResult((Existing ?? Computer) with { Guid = StaleGuid ? Guid.NewGuid() : guid, ParentGuid = Moves > 0 ? Target.Guid : (Existing ?? Computer).ParentGuid }); + } + public Task ReadDestinationAsync(Guid guid, CancellationToken token) => Task.FromResult(Target); + public Task MoveAsync(DomainDirectoryObject computer, DomainDirectoryObject destination, CancellationToken token) + { + BeforeMove?.Invoke(); Moves++; + if (RejectMove) throw new DomainDirectoryException(directoryResult: 68, rejected: true); + if (LoseMoveResponse) throw new DomainDirectoryException(); + return Task.CompletedTask; + } + public void Dispose() { } +} diff --git a/src/Foundry.PostInstall.Tests/DomainPlanBindingTests.cs b/src/Foundry.PostInstall.Tests/DomainPlanBindingTests.cs new file mode 100644 index 000000000..7cc6a75c9 --- /dev/null +++ b/src/Foundry.PostInstall.Tests/DomainPlanBindingTests.cs @@ -0,0 +1,142 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Models.PreOobe; +using Foundry.PostInstall.Execution; + +namespace Foundry.PostInstall.Tests; + +public sealed class DomainPlanBindingTests +{ + [Fact] + public async Task PlanWithoutDomainActionsRunsWithNonGuidIdentity() + { + string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try + { + var plan = new PreOobeExecutionPlan { OperationId = "operation", AttemptId = "attempt" }; + var journal = new ExecutionJournal(root); + journal.Seed(plan, new string('a', 64)); + var outcome = await new PreOobeOrchestrator(root, new string('a', 64), journal, new Executor(), () => "boot") + .RunAsync(plan, CancellationToken.None); + Assert.Equal(0, outcome.ExitCode); + } + finally { Directory.Delete(root, true); } + } + [Theory] + [InlineData("contract")] + [InlineData("consumer")] + [InlineData("verification")] + [InlineData("payload")] + [InlineData("attempt")] + [InlineData("container")] + public void DomainBinding_RejectsUnsafePairOrCredentialOwnership(string fault) + { + using var f = new DomainFixture(); + var plan = fault switch + { + "contract" => f.Plan with { RuntimeContractVersion = 2 }, + "consumer" => f.Plan with { OwnedPayloads = [f.Plan.OwnedPayloads[0] with { ConsumerActionIds = ["join", "verify"] }] }, + "verification" => f.Plan with { Actions = [f.Plan.Actions[0]] }, + "payload" => f.Plan with { OwnedPayloads = [f.Plan.OwnedPayloads[0] with { RelativePath = "Payloads/wrong.bin" }] }, + // The target must be an organizational unit, as Core and Deploy require; a container of the domain is refused. + "container" => f.Plan with + { + Actions = [f.Plan.Actions[0] with + { + Parameters = System.Text.Json.JsonSerializer.SerializeToElement( + f.Parameters with { TargetOuDn = "CN=Computers,DC=example,DC=test" }, ExecutionJournal.JsonOptions) + }, f.Plan.Actions[1]] + }, + _ => f.Plan with { AttemptId = "not-an-attempt" } + }; + Assert.Throws(() => PreOobePlanValidator.ValidatePlan(plan)); + } + [Theory] + [InlineData("boot")] + [InlineData("cursor")] + [InlineData("hash")] + [InlineData("operation")] + [InlineData("attempt")] + [InlineData("status")] + public async Task Worker_RejectsJournalMismatchBeforeAnyAuthentication(string fault) + { + using var f = new DomainFixture(); + var journal = new ExecutionJournal(f.Root); var state = journal.Read(); + switch (fault) + { + case "boot": state.BootIdentity = "other"; break; + case "cursor": state.Cursor = 1; break; + case "hash": state.PlanHash = new string('b', 64); break; + case "operation": state.OperationId = Guid.NewGuid().ToString("N"); break; + case "attempt": state.AttemptId = Guid.NewGuid().ToString("N"); break; + case "status": state.Status = "Pending"; break; + } + journal.Write(state); + await Assert.ThrowsAsync(f.Run); + Assert.Equal(0, f.Directory.Prepares); Assert.Equal(0, f.Native.Joins); + } + [Fact] + public async Task Loader_HashesExactSnapshotAndRejectsOversizedFiles() + { + using var f = new DomainFixture(); + var snapshot = await PreOobePlanLoader.LoadAsync(f.Root, CancellationToken.None); + Assert.Equal(f.Hash, snapshot.Hash); + string path = Path.Combine(f.Root, "State", "PreOobe", "plan.json"); + await File.AppendAllTextAsync(path, " ", TestContext.Current.CancellationToken); + snapshot = await PreOobePlanLoader.LoadAsync(f.Root, CancellationToken.None); + Assert.NotEqual(f.Hash, snapshot.Hash); + await Assert.ThrowsAsync(f.Run); + using (var file = File.OpenWrite(path)) file.SetLength(8 * 1024 * 1024 + 1); + await Assert.ThrowsAsync(() => PreOobePlanLoader.LoadAsync(f.Root, CancellationToken.None)); + } + [Fact] + public async Task Worker_DoesNotAcquireParentsRunnerLease() + { + using var f = new DomainFixture(); + using var lease = new ExecutionJournal(f.Root).AcquireLease(); + var result = await f.Run(); + Assert.Equal(DomainJoinPhaseState.Succeeded, result.Join.State); + } + [Theory] + [InlineData("nonadjacent")] + [InlineData("network-after")] + [InlineData("overlapping-ownership")] + public void RecoveryBinding_RejectsAmbiguousSequenceAndPayloadOwnership(string fault) + { + using var f = new DomainFixture(); + var plan = fault switch + { + "nonadjacent" => f.Plan with { Actions = [f.Plan.Actions[0], new() { Id = "appx", BuiltInKind = PreOobeBuiltInKind.Appx }, f.Plan.Actions[1]] }, + "network-after" => f.Plan with { Actions = [.. f.Plan.Actions, new() { Id = "network", BuiltInKind = PreOobeBuiltInKind.Network }] }, + _ => f.Plan with { OwnedPayloads = [.. f.Plan.OwnedPayloads, new() { RelativePath = "Payloads/DomainJoin", IsDirectory = true }] } + }; + Assert.Throws(() => PreOobePlanValidator.ValidatePlan(plan)); + } + + [Fact] + public void RecoveryBinding_AcceptsStagedSequenceAndIndependentPayloads() + { + using var f = new DomainFixture(); + var plan = f.Plan with + { + Actions = [new() { Id = "driver", BuiltInKind = PreOobeBuiltInKind.Driver }, + new() { Id = "network", BuiltInKind = PreOobeBuiltInKind.Network }, .. f.Plan.Actions, + new() { Id = "custom", CustomAction = new() { Kind = Foundry.Core.Models.Configuration.PreOobeActionKind.Command, Process = new() } }, + new() { Id = "cleanup", BuiltInKind = PreOobeBuiltInKind.Cleanup }], + OwnedPayloads = [.. f.Plan.OwnedPayloads, + new() { RelativePath = "Payloads/Network/profile.xml", IsSensitive = true, ConsumerActionIds = ["network"] }, + new() { RelativePath = "Payloads/Drivers", IsDirectory = true, ConsumerActionIds = ["driver"] }, + new() { RelativePath = "Work/PreOobe/" + f.Plan.OperationId, IsDirectory = true, ConsumerActionIds = ["custom"] }] + }; + PreOobePlanValidator.ValidatePlan(plan); + Assert.Equal(f.Parameters, DomainJoinBinding.Validate(plan).Parameters); + } + + private sealed class Executor : IPreOobeActionExecutor + { + public Task ExecuteAsync(PreOobeExecutionAction action, int substep, CancellationToken token) => Task.FromResult(new ActionStepOutcome(true)); + } +} diff --git a/src/Foundry.PostInstall.Tests/LifecycleSafetyTests.cs b/src/Foundry.PostInstall.Tests/LifecycleSafetyTests.cs index 4563a9789..088729714 100644 --- a/src/Foundry.PostInstall.Tests/LifecycleSafetyTests.cs +++ b/src/Foundry.PostInstall.Tests/LifecycleSafetyTests.cs @@ -10,6 +10,16 @@ namespace Foundry.PostInstall.Tests; public sealed class LifecycleSafetyTests { + [Fact] + public async Task LockedUnrelatedSensitivePayload_StillStopsContinuation() + { + using var fixture = new Fixture(sensitive: true); + using var locked = new FileStream(fixture.Payload, FileMode.Open, FileAccess.Read, FileShare.None); + Assert.Equal(3, await fixture.Run("boot")); + Assert.Equal("Failed", fixture.Journal.Read().Status); + Assert.Equal("CleanupPending", fixture.Journal.Read().PayloadDispositions["Payloads/secret.bin"]); + } + [Fact] public async Task UncommittedStagingJournal_CannotStartThroughExistingManualHook() { diff --git a/src/Foundry.PostInstall.Tests/PostInstallConsoleTests.cs b/src/Foundry.PostInstall.Tests/PostInstallConsoleTests.cs index e7ab9c9a6..a6053916d 100644 --- a/src/Foundry.PostInstall.Tests/PostInstallConsoleTests.cs +++ b/src/Foundry.PostInstall.Tests/PostInstallConsoleTests.cs @@ -10,6 +10,72 @@ namespace Foundry.PostInstall.Tests; public sealed class PostInstallConsoleTests { + [Fact] + public void DomainOutcomes_ShowIndependentPhasesAndPlacementWarning() + { + using var f = new DomainFixture(); + var report = new DomainJoinResultStore(f.Root, f.Plan, f.Hash); DomainSeeds.WriteResult(f.Root, f.Plan, f.Hash); + report.Write(report.Read() with + { + OriginatingBootId = "installed-boot", + Join = new() { State = DomainJoinPhaseState.Succeeded }, + Placement = new() { State = DomainJoinPhaseState.Failed, FailureCode = DomainJoinFailureCode.PlacementFailed }, + Restart = DomainJoinRestartState.Required + }); + using var output = new StringWriter(); + using var console = new PostInstallConsole(f.Plan, "test.log", output); + console.Report(new([], "CompletedWithErrors", DomainResult: report.Read(), WarningCount: 1)); + console.Complete(new("CompletedWithErrors", 0)); + string text = output.ToString(); + Assert.Contains("Domain joined; target OU placement failed", text); + Assert.Contains("Join: Succeeded", text); + Assert.Contains("Placement: Failed", text); + Assert.Contains("Membership: NotStarted", text); + Assert.Contains("Restart: Required", text); + Assert.Contains("Cleanup: Pending", text); + Assert.Contains("Warnings: 1", text); + } + + [Theory] + [InlineData(DomainJoinPhaseState.Succeeded, DomainJoinPhaseState.Unverified, "Domain joined; target OU placement not confirmed")] + [InlineData(DomainJoinPhaseState.Succeeded, DomainJoinPhaseState.Unknown, "Domain joined; target OU placement not confirmed")] + [InlineData(DomainJoinPhaseState.Unknown, DomainJoinPhaseState.Skipped, "Domain join outcome unknown; membership is checked after restart")] + [InlineData(DomainJoinPhaseState.Succeeded, DomainJoinPhaseState.Failed, "Domain joined in the default location; target OU not found")] + public void DomainOutcomes_ExplainUnconfirmedStates(DomainJoinPhaseState join, DomainJoinPhaseState placement, string expected) + { + using var f = new DomainFixture(); + var report = new DomainJoinResultStore(f.Root, f.Plan, f.Hash); DomainSeeds.WriteResult(f.Root, f.Plan, f.Hash); + DomainJoinResult result = report.Read() with + { + Join = new() { State = join }, + Placement = new() { State = placement, FailureCode = placement == DomainJoinPhaseState.Failed ? DomainJoinFailureCode.OrganizationalUnitNotFound : null } + }; + using var output = new StringWriter(); + using var console = new PostInstallConsole(f.Plan, "test.log", output); + console.Report(new([], "Running", DomainResult: result, WarningCount: 1)); + Assert.Contains(expected, output.ToString()); + } + + [Theory] + [InlineData(DomainJoinPhaseState.Succeeded)] + [InlineData(DomainJoinPhaseState.Failed)] + [InlineData(DomainJoinPhaseState.Unverified)] + public void UnknownJoin_StopsAnnouncingTheCheckOnceMembershipWasChecked(DomainJoinPhaseState membership) + { + using var f = new DomainFixture(); + var report = new DomainJoinResultStore(f.Root, f.Plan, f.Hash); DomainSeeds.WriteResult(f.Root, f.Plan, f.Hash); + DomainJoinResult result = report.Read() with + { + Join = new() { State = DomainJoinPhaseState.Unknown }, + Membership = new() { State = membership } + }; + using var output = new StringWriter(); + using var console = new PostInstallConsole(f.Plan, "test.log", output); + console.Report(new([], "Running", DomainResult: result, WarningCount: 1)); + Assert.Contains("Membership: " + membership, output.ToString()); + Assert.DoesNotContain("is checked after restart", output.ToString()); + } + [Fact] public void RedirectedOutput_ReportsResumedResultsAndOnlyChangedActions() { diff --git a/src/Foundry.PostInstall/Actions/DomainJoinAction.cs b/src/Foundry.PostInstall/Actions/DomainJoinAction.cs new file mode 100644 index 000000000..65f88b98c --- /dev/null +++ b/src/Foundry.PostInstall/Actions/DomainJoinAction.cs @@ -0,0 +1,48 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Models.PreOobe; +using Foundry.PostInstall.Execution; +using Foundry.PostInstall.Windows; +using Serilog; +namespace Foundry.PostInstall.Actions; + +/// Supervises the credential-owning child; the parent retains journal and report ownership. +internal sealed class DomainJoinAction(string root, PreOobeExecutionPlan plan, string planHash, string boot, + IPreOobeProcessExecutor processes, string executablePath) +{ + /// Outlasts the worker's own budget so a slow worker settles its receipt instead of being terminated mid-phase. + internal static readonly TimeSpan SupervisionTimeout = DomainJoinWorker.Budget + TimeSpan.FromSeconds(30); + + public async Task ExecuteAsync(PreOobeExecutionAction action, CancellationToken token) + { + PreOobePlanLoader.RequireMatching(await PreOobePlanLoader.LoadAsync(root, token).ConfigureAwait(false), plan, planHash); + var binding = DomainJoinBinding.Validate(plan); binding.RequireAction(action); + DomainJoinBinding.RequireRunning(root, plan, planHash, boot, action.Id); + var phases = new DomainJoinPhaseStore(root, plan, planHash); + var initial = phases.Read(); + if (initial.Phase != DomainJoinReceiptPhase.Prepared || initial.OriginatingBootId is not null || initial.Generation != 0) + throw new InvalidDataException("Domain work cannot be replayed."); + var reports = new DomainJoinResultStore(root, plan, planHash); + var report = reports.Read(); + if (report.Join.State != DomainJoinPhaseState.NotStarted || report.OriginatingBootId.Length != 0) + throw new InvalidDataException("Domain results already exist."); + ProcessOutcome process; + try { process = await processes.RunAsync(new(executablePath, ["--domain-join-worker"], root, SupervisionTimeout), token).ConfigureAwait(false); } + catch (System.ComponentModel.Win32Exception) { process = new(null, string.Empty); } + catch (Exception error) when (DomainJoinWorker.Recoverable(error)) { process = new(null, string.Empty, TerminationUncertain: true); } + Log.Information("Domain join worker ended; exit code {ExitCode}, timed out {TimedOut}, termination uncertain {TerminationUncertain}", + process.ExitCode, process.TimedOut, process.TerminationUncertain); + DomainJoinPhaseReceipt receipt; + try { receipt = phases.Read(); } + // A worker that could not be stopped may still be replacing the receipt; its outcome stays unknown. + catch (IOException) when (process.TerminationUncertain) { receipt = initial; } + report = reports.Reconcile(receipt, boot, process.TimedOut ? DomainJoinFailureCode.WorkerTimeout : DomainJoinFailureCode.Interrupted, + workerUnsettled: process.TerminationUncertain); + bool uncertain = process.TerminationUncertain; + bool warning = uncertain || DomainJoinResultStore.HasWarnings(report); + return new(!warning, FailureCode: warning ? "domain_join_warning" : null, + RestartRequested: report.Restart == DomainJoinRestartState.Required, TerminationUncertain: uncertain, HasWarnings: warning); + } +} diff --git a/src/Foundry.PostInstall/Actions/DomainMembershipVerificationAction.cs b/src/Foundry.PostInstall/Actions/DomainMembershipVerificationAction.cs new file mode 100644 index 000000000..f504556f4 --- /dev/null +++ b/src/Foundry.PostInstall/Actions/DomainMembershipVerificationAction.cs @@ -0,0 +1,75 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Models.PreOobe; +using Foundry.Core.Models.Configuration; +using Foundry.PostInstall.Execution; +using Foundry.PostInstall.Windows; +using Serilog; +namespace Foundry.PostInstall.Actions; + +/// +/// Checks only passwordless local DNS membership and the active final name on a later boot, then lets a +/// verified member skip the Windows account creation page. +/// +internal sealed class DomainMembershipVerificationAction(string root, PreOobeExecutionPlan plan, string planHash, string boot, INativeDomainJoin native) +{ + public async Task ExecuteAsync(PreOobeExecutionAction action, CancellationToken token) + { + PreOobePlanLoader.RequireMatching(await PreOobePlanLoader.LoadAsync(root, token).ConfigureAwait(false), plan, planHash); + var binding = DomainJoinBinding.Validate(plan); binding.RequireAction(action, verification: true); + DomainJoinBinding.RequireRunning(root, plan, planHash, boot, action.Id); + var store = new DomainJoinResultStore(root, plan, planHash); + var report = store.Read(); + DomainJoinPhaseResult membership; + bool laterBoot = report.OriginatingBootId.Length > 0 && report.OriginatingBootId != boot; + if (report.Join.State is DomainJoinPhaseState.Failed or DomainJoinPhaseState.Skipped) + membership = new() { State = DomainJoinPhaseState.Skipped }; + else if (!laterBoot) membership = DomainJoinWorker.Failure(DomainJoinPhaseState.Unverified, DomainJoinFailureCode.MembershipUnverified); + else + { + try + { + token.ThrowIfCancellationRequested(); + var snapshot = native.GetMembership(); + if (snapshot.NativeErrorCode is { } error) + membership = DomainJoinWorker.Failure(DomainJoinPhaseState.Unverified, DomainJoinFailureCode.MembershipUnverified) with { NativeErrorCode = error }; + else if (snapshot.JoinStatus == 3 && DomainJoinCredentialContext.CanonicalizeDomainName(snapshot.DomainName) == + DomainJoinCredentialContext.CanonicalizeDomainName(binding.Parameters.DomainName) && + string.Equals(snapshot.ActiveComputerName, binding.Parameters.ComputerName, StringComparison.OrdinalIgnoreCase)) + membership = new() { State = DomainJoinPhaseState.Succeeded }; + else membership = DomainJoinWorker.Failure(DomainJoinPhaseState.Failed, DomainJoinFailureCode.MembershipMismatch); + } + catch (Exception error) when (DomainJoinWorker.Recoverable(error)) + { membership = DomainJoinWorker.Failure(DomainJoinPhaseState.Unverified, DomainJoinFailureCode.MembershipUnverified, error); } + } + store.Write(report with + { + Membership = membership, + Restart = laterBoot && report.Restart is DomainJoinRestartState.Required or DomainJoinRestartState.Requested ? DomainJoinRestartState.Completed : report.Restart + }); + if (membership.State == DomainJoinPhaseState.Succeeded) SkipAccountCreationWhenRequested(); + bool warning = membership.State is not (DomainJoinPhaseState.Succeeded or DomainJoinPhaseState.Skipped); + return new(!warning, FailureCode: warning ? "domain_membership_warning" : null, HasWarnings: warning); + } + + /// + /// A verified domain member signs in with domain accounts, so Windows must not ask to create a local one. + /// It is done here, not in the answer file, so a failed or unverified join keeps the page and someone can + /// still create an account. A refusal is logged and does not change the verified membership. + /// + private void SkipAccountCreationWhenRequested() + { + if (!File.Exists(OwnedPaths.Resolve(root, "State/PreOobe/" + DomainJoinStateFiles.SkipAccountCreationRequest))) return; + try + { + native.SkipAccountCreation(); + Log.Information("Windows account creation page skipped for the verified domain member"); + } + catch (Exception error) when (error is UnauthorizedAccessException or IOException or System.Security.SecurityException) + { + Log.Warning("Windows account creation page could not be skipped; failure type {FailureType}", error.GetType().Name); + } + } +} diff --git a/src/Foundry.PostInstall/Actions/PreOobeActionExecutor.cs b/src/Foundry.PostInstall/Actions/PreOobeActionExecutor.cs index a8101a8e4..230d85f0a 100644 --- a/src/Foundry.PostInstall/Actions/PreOobeActionExecutor.cs +++ b/src/Foundry.PostInstall/Actions/PreOobeActionExecutor.cs @@ -4,11 +4,12 @@ using Foundry.Core.Models.PreOobe; using Foundry.PostInstall.Execution; +using Foundry.PostInstall.Windows; namespace Foundry.PostInstall.Actions; public sealed class PreOobeActionExecutor(string root, string windowsRoot, PreOobeExecutionPlan plan, - IPreOobeProcessExecutor processes, ICertificateImporter certificates, string executablePath) : IPreOobeActionExecutor + IPreOobeProcessExecutor processes, ICertificateImporter certificates, string executablePath, string planHash, string bootIdentity) : IPreOobeActionExecutor { public async Task ExecuteAsync(PreOobeExecutionAction action, int substep, CancellationToken cancellationToken) { @@ -20,11 +21,16 @@ public async Task ExecuteAsync(PreOobeExecutionAction action, PreOobeBuiltInKind.Network => await new NetworkAction(root, windowsRoot, processes, certificates).ExecuteAsync(action, cancellationToken).ConfigureAwait(false), PreOobeBuiltInKind.Appx or PreOobeBuiltInKind.AiRemoval => await new AppxAction(root, windowsRoot, processes).ExecuteAsync(action, substep, cancellationToken).ConfigureAwait(false), PreOobeBuiltInKind.Activation => await ActivateAsync(cancellationToken).ConfigureAwait(false), + PreOobeBuiltInKind.DomainJoinAndPlacement => await new DomainJoinAction(root, plan, planHash, + bootIdentity, processes, executablePath).ExecuteAsync(action, cancellationToken).ConfigureAwait(false), + PreOobeBuiltInKind.VerifyDomainMembership => await new DomainMembershipVerificationAction(root, plan, planHash, + bootIdentity, new NativeDomainJoin()).ExecuteAsync(action, cancellationToken).ConfigureAwait(false), PreOobeBuiltInKind.Cleanup => await CleanupAsync(cancellationToken).ConfigureAwait(false), _ => throw new InvalidDataException("Unsupported built-in action.") }; } + private async Task ActivateAsync(CancellationToken cancellationToken) { ProcessOutcome result = await processes.RunAsync(new(executablePath, ["--activation-worker"], root, diff --git a/src/Foundry.PostInstall/Console/PostInstallConsole.cs b/src/Foundry.PostInstall/Console/PostInstallConsole.cs index 029e0712b..281da0466 100644 --- a/src/Foundry.PostInstall/Console/PostInstallConsole.cs +++ b/src/Foundry.PostInstall/Console/PostInstallConsole.cs @@ -31,6 +31,7 @@ internal sealed class PostInstallConsole : IProgress, IDisp private int? setupSecondsRemaining; private string? reportedActivity; private string? reportedHeading; + private string? reportedDomain; internal PostInstallConsole(PreOobeExecutionPlan plan, string logPath, TextWriter? output = null) { @@ -144,6 +145,9 @@ private void WriteChanges() string activity = Activity(); if (reportedActivity != activity) WriteLine(activity); reportedActivity = activity; + string domain = DomainText(); + if (reportedDomain != domain && domain.Length > 0) WriteLine(domain); + reportedDomain = domain; } private string Activity() => current.RestartSecondsRemaining switch @@ -167,7 +171,28 @@ private string HandoffText() => setupSecondsRemaining > 0 private string Summary() => $"Succeeded: {current.Actions.Count(action => action.Status == "Succeeded")} " + $"Failed: {current.Actions.Count(action => action.Status is "Failed" or "Interrupted")} " + - $"Skipped: {current.Actions.Count(action => action.Status == "Skipped")}"; + $"Skipped: {current.Actions.Count(action => action.Status == "Skipped")} Warnings: {current.WarningCount}"; + + private string DomainText() => current.DomainResult is not { } domain ? string.Empty : + $"Domain - Join: {domain.Join.State}; Placement: {domain.Placement.State}; Membership: {domain.Membership.State}; " + + $"Restart: {domain.Restart}; Cleanup: {domain.Cleanup}" + + (DomainWarning(domain) is { } warning ? ". " + warning : string.Empty); + + /// Explains a domain outcome the technician must follow up, or null when the phase states say enough. + private static string? DomainWarning(DomainJoinResult domain) => domain.Join.State switch + { + DomainJoinPhaseState.Unknown when domain.Membership.State == DomainJoinPhaseState.NotStarted => + "Domain join outcome unknown; membership is checked after restart", + DomainJoinPhaseState.Succeeded => domain.Placement.State switch + { + DomainJoinPhaseState.Failed when domain.Placement.FailureCode == DomainJoinFailureCode.OrganizationalUnitNotFound => + "Domain joined in the default location; target OU not found", + DomainJoinPhaseState.Failed => "Domain joined; target OU placement failed", + DomainJoinPhaseState.Unverified or DomainJoinPhaseState.Unknown => "Domain joined; target OU placement not confirmed", + _ => null + }, + _ => null + }; private int ActiveActionIndex() { @@ -228,7 +253,7 @@ private bool Render() Add("Foundry Post-installation", ConsoleColor.White); Add(current.IsResuming ? "Resuming after restart" : "Preparing Windows before OOBE"); Add(""); - int capacity = height - 13; + int capacity = Math.Max(1, height - 16); int active = ActiveActionIndex(); int first = Math.Clamp(active - capacity / 2, 0, Math.Max(0, current.Actions.Count - capacity)); int last = Math.Min(first + capacity, current.Actions.Count); @@ -242,6 +267,12 @@ private bool Render() Add(""); Add(""); Add(Activity(), StatusColor(current.Status)); + if (current.DomainResult is { } domain) + { + Add($"Domain - Join: {domain.Join.State}; Placement: {domain.Placement.State}; Membership: {domain.Membership.State}"); + Add($"Restart: {domain.Restart}; Cleanup: {domain.Cleanup}"); + if (DomainWarning(domain) is { } warning) Add(warning, ConsoleColor.Yellow); + } if (result is not null) Add(Summary()); if (setupSecondsRemaining is not null) { diff --git a/src/Foundry.PostInstall/Execution/DomainJoinBinding.cs b/src/Foundry.PostInstall/Execution/DomainJoinBinding.cs new file mode 100644 index 000000000..421a3f8fa --- /dev/null +++ b/src/Foundry.PostInstall/Execution/DomainJoinBinding.cs @@ -0,0 +1,80 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using Foundry.Core.Models.Configuration; +using Foundry.Core.Models.PreOobe; +using Foundry.Core.Services.Configuration; + +namespace Foundry.PostInstall.Execution; + +/// Validates the sole join/verification pair and its exclusive sensitive credential ownership before authentication. +internal sealed record DomainJoinBinding(PreOobeExecutionAction JoinAction, DomainJoinActionParameters Parameters, + PreOobeExecutionAction VerificationAction) +{ + public static DomainJoinBinding Validate(PreOobeExecutionPlan plan) + { + if (!Guid.TryParseExact(plan.OperationId, "N", out _) || !Guid.TryParseExact(plan.AttemptId, "N", out _)) + throw new InvalidDataException("Domain operation identity is invalid."); + var joins = plan.Actions.Where(a => a.BuiltInKind == PreOobeBuiltInKind.DomainJoinAndPlacement).ToArray(); + var checks = plan.Actions.Where(a => a.BuiltInKind == PreOobeBuiltInKind.VerifyDomainMembership).ToArray(); + if (joins.Length != 1 || checks.Length != 1 || Array.IndexOf(plan.Actions.ToArray(), joins[0]) >= Array.IndexOf(plan.Actions.ToArray(), checks[0])) + throw new InvalidDataException("Domain action pairing is invalid."); + int joinIndex = Array.IndexOf(plan.Actions.ToArray(), joins[0]); + if (plan.Actions[joinIndex + 1] != checks[0] || plan.Actions.Skip(joinIndex + 1).Any(action => + action.BuiltInKind is PreOobeBuiltInKind.Driver or PreOobeBuiltInKind.Network)) + throw new InvalidDataException("Domain action order is invalid."); + var parameters = joins[0].Parameters?.Deserialize(ExecutionJournal.JsonOptions) + ?? throw new InvalidDataException("Domain parameters are missing."); + var verification = checks[0].Parameters?.Deserialize(ExecutionJournal.JsonOptions) + ?? throw new InvalidDataException("Domain verification is missing."); + string expected = $"Payloads/DomainJoin/{plan.OperationId}/credentials.bin"; + if (!DomainJoinCredentialContext.IsValidDomainName(parameters.DomainName) || !ComputerNameRules.IsValid(parameters.ComputerName) || + parameters.TargetOuDn is not null && !(DistinguishedNameRules.IsOrganizationalUnit(parameters.TargetOuDn) && + DistinguishedNameRules.IsWithinDomain(parameters.TargetOuDn, parameters.DomainName)) || + parameters.CredentialPayloadPath != expected || verification.JoinActionId != joins[0].Id || + verification.DomainName != parameters.DomainName || verification.ComputerName != parameters.ComputerName) + throw new InvalidDataException("Domain parameters are invalid."); + var payloads = plan.OwnedPayloads.Where(p => p.RelativePath.Replace('\\', '/').Equals(expected, StringComparison.OrdinalIgnoreCase)).ToArray(); + if (payloads.Length != 1 || payloads[0].RelativePath != expected || !payloads[0].IsSensitive || payloads[0].IsDirectory || + payloads[0].ConsumerActionIds.Count != 1 || payloads[0].ConsumerActionIds[0] != joins[0].Id || + plan.OwnedPayloads.Any(p => p != payloads[0] && (p.ConsumerActionIds.Contains(joins[0].Id) || p.ConsumerActionIds.Contains(checks[0].Id) || + expected.StartsWith(p.RelativePath.Replace('\\', '/').TrimEnd('/') + "/", StringComparison.OrdinalIgnoreCase)))) + throw new InvalidDataException("Domain credential ownership is invalid."); + return new(joins[0], parameters, checks[0]); + } + + public static void ValidateHash(string hash) + { + if (hash.Length != 64 || !hash.All(Uri.IsHexDigit)) throw new InvalidDataException("Plan hash is invalid."); + } + + public static void ValidateBoot(string boot) + { + if (string.IsNullOrWhiteSpace(boot) || boot.Length > 128 || boot.Any(char.IsControl)) + throw new InvalidDataException("Boot identity is invalid."); + } + + public static void RequireRunning(string root, PreOobeExecutionPlan plan, string hash, string boot, string actionId) + { + ValidateHash(hash); + ValidateBoot(boot); + PreOobePlanValidator.ValidatePlan(plan); + var binding = Validate(plan); + JournalState state = new ExecutionJournal(root).Read(); + PreOobePlanValidator.ValidateState(plan, state, hash); + if (state.Status != "Running" || state.BootIdentity != boot || state.Cursor >= plan.Actions.Count || state.Substep != 0 || + plan.Actions[state.Cursor].Id != actionId || state.Actions.GetValueOrDefault(actionId)?.Status != "Running" || + state.UnsafePayloadBootIdentity == boot && !(binding.VerificationAction.Id == actionId && + state.UnsafeActionId == binding.JoinAction.Id)) + throw new InvalidDataException("The worker is not the active action."); + } + + public void RequireAction(PreOobeExecutionAction action, bool verification = false) + { + var expected = verification ? VerificationAction : JoinAction; + if (action.Id != expected.Id || action.BuiltInKind != expected.BuiltInKind || action.CustomAction is not null || + action.Parameters?.GetRawText() != expected.Parameters?.GetRawText()) throw new InvalidDataException("Domain action identity changed."); + } +} diff --git a/src/Foundry.PostInstall/Execution/DomainJoinPhaseStore.cs b/src/Foundry.PostInstall/Execution/DomainJoinPhaseStore.cs new file mode 100644 index 000000000..84ba2c7f0 --- /dev/null +++ b/src/Foundry.PostInstall/Execution/DomainJoinPhaseStore.cs @@ -0,0 +1,116 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using Foundry.Core.Models.PreOobe; +using Foundry.Core.Services.Configuration; + +namespace Foundry.PostInstall.Execution; + +/// Fixed-path durable receipts; callers hold the separate worker lease while binding and advancing them. +internal sealed class DomainJoinPhaseStore(string root, PreOobeExecutionPlan plan, string planHash) +{ + private readonly string path = OwnedPaths.Resolve(root, "State/PreOobe/domain-join-phase.json"); + + /// Serializes domain workers independently of the parent-held runner lease. + public IDisposable AcquireWorkerLease() => new FileStream(OwnedPaths.Resolve(root, "State/PreOobe/domain-worker.lease"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + public DomainJoinPhaseReceipt Read() + { + var value = DomainStateFile.Read(root, path); Validate(value); return value; + } + public void Write(DomainJoinPhaseReceipt value) + { + Validate(value); + var previous = Read(); + bool binding = previous.Phase == DomainJoinReceiptPhase.Prepared && previous.OriginatingBootId is null && + value.Phase == DomainJoinReceiptPhase.Prepared && value.OriginatingBootId is not null; + bool advance = previous.OriginatingBootId is not null && previous.OriginatingBootId == value.OriginatingBootId && + (previous.Phase, value.Phase) is + (DomainJoinReceiptPhase.Prepared, DomainJoinReceiptPhase.JoinStarted or DomainJoinReceiptPhase.Finished) or + (DomainJoinReceiptPhase.JoinStarted, DomainJoinReceiptPhase.JoinReturned) or + (DomainJoinReceiptPhase.JoinReturned, DomainJoinReceiptPhase.PlacementStarted or DomainJoinReceiptPhase.Finished) or + (DomainJoinReceiptPhase.PlacementStarted, DomainJoinReceiptPhase.PlacementReturned) or + (DomainJoinReceiptPhase.PlacementReturned, DomainJoinReceiptPhase.Finished); + if (value.Generation != checked(previous.Generation + 1) || !(binding || advance) || + previous.RestartRequired && !value.RestartRequired || + previous.ComputerObjectGuid is { } computer && computer != value.ComputerObjectGuid || + previous.DestinationObjectGuid is { } destination && destination != value.DestinationObjectGuid || + previous.Join.State != DomainJoinPhaseState.NotStarted && previous.Join != value.Join || + previous.Placement.State != DomainJoinPhaseState.NotStarted && previous.Placement != value.Placement) + throw new InvalidDataException("Domain receipt transition is invalid."); + DomainStateFile.Write(root, path, value); + } + /// Binds one unstarted seed to the validated Running journal before any mutation. + public DomainJoinPhaseReceipt BindOrigin(string boot) + { + DomainJoinBinding.ValidateBoot(boot); + var seed = Read(); + if (seed.OriginatingBootId is not null || seed.Phase != DomainJoinReceiptPhase.Prepared || seed.Generation != 0) + throw new InvalidDataException("The domain worker cannot replay this receipt."); + DomainJoinBinding.RequireRunning(root, plan, planHash, boot, seed.ActionId); + var value = seed with { OriginatingBootId = boot, Generation = 1 }; + Write(value); return value; + } + private void Validate(DomainJoinPhaseReceipt value) + { + var binding = DomainJoinBinding.Validate(plan); DomainJoinBinding.ValidateHash(planHash); + if (value.SchemaVersion != 1 || value.OperationId != plan.OperationId || value.AttemptId != plan.AttemptId || + value.PlanHash != planHash || value.ActionId != binding.JoinAction.Id || value.Generation < 0 || + !Enum.IsDefined(value.Phase) || value.ComputerObjectGuid == Guid.Empty || value.DestinationObjectGuid == Guid.Empty) + throw new InvalidDataException("Domain receipt identity is invalid."); + DomainStateFile.ValidatePhase(value.Join); DomainStateFile.ValidatePhase(value.Placement); + if (value.OriginatingBootId is null) + { + if (value.Generation != 0 || value.Phase != DomainJoinReceiptPhase.Prepared || value.ComputerObjectGuid is not null || value.DestinationObjectGuid is not null) + throw new InvalidDataException("An unassigned receipt cannot contain execution."); + } + else { DomainJoinBinding.ValidateBoot(value.OriginatingBootId); if (value.Generation == 0) throw new InvalidDataException("Domain receipt generation is invalid."); } + bool beforeReturn = value.Phase is DomainJoinReceiptPhase.Prepared or DomainJoinReceiptPhase.JoinStarted; + if (beforeReturn && (value.Join.State != DomainJoinPhaseState.NotStarted || value.Placement.State != DomainJoinPhaseState.NotStarted || value.RestartRequired) || + value.Phase == DomainJoinReceiptPhase.Prepared && (value.ComputerObjectGuid is not null || value.DestinationObjectGuid is not null) || + !beforeReturn && value.Join.State is not (DomainJoinPhaseState.Succeeded or DomainJoinPhaseState.Failed or DomainJoinPhaseState.Unknown) || + value.RestartRequired != (value.Join.State == DomainJoinPhaseState.Succeeded) || + value.Phase is DomainJoinReceiptPhase.PlacementStarted or DomainJoinReceiptPhase.PlacementReturned && + (value.Join.State != DomainJoinPhaseState.Succeeded || value.ComputerObjectGuid is null || value.DestinationObjectGuid is null) || + value.Phase is DomainJoinReceiptPhase.JoinReturned or DomainJoinReceiptPhase.PlacementStarted && value.Placement.State != DomainJoinPhaseState.NotStarted || + value.Phase is DomainJoinReceiptPhase.PlacementReturned or DomainJoinReceiptPhase.Finished && value.Placement.State == DomainJoinPhaseState.NotStarted || + value.Join.State is DomainJoinPhaseState.Failed or DomainJoinPhaseState.Unknown && value.Placement.State is not (DomainJoinPhaseState.NotStarted or DomainJoinPhaseState.Skipped)) + throw new InvalidDataException("Domain receipt phases are inconsistent."); + } + +} + +/// Bounded, durable, atomic replacement shared by the two fixed domain state files. +internal static class DomainStateFile +{ + public static T Read(string root, string path) + { + OwnedPaths.RejectReparsePoints(root, path); + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + if (stream.Length is <= 0 or > DomainJoinConfigurationValidator.MaximumResultBytes) throw new InvalidDataException("Domain state size is invalid."); + byte[] bytes = new byte[(int)stream.Length]; stream.ReadExactly(bytes); + return JsonSerializer.Deserialize(bytes, ExecutionJournal.JsonOptions) ?? throw new InvalidDataException("Domain state is empty."); + } + public static void Write(string root, string path, T value) + { + OwnedPaths.RejectReparsePoints(root, path); + byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(value, ExecutionJournal.JsonOptions); + if (bytes.Length > DomainJoinConfigurationValidator.MaximumResultBytes) throw new InvalidDataException("Domain state size is invalid."); + string temporary = OwnedPaths.Resolve(root, "State/PreOobe/.domain-" + Guid.NewGuid().ToString("N") + ".tmp"); + try + { + using (var file = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { file.Write(bytes); file.Flush(true); } + File.Replace(temporary, path, null); + } + finally { try { File.Delete(temporary); } catch (IOException) { } } + } + public static void ValidatePhase(DomainJoinPhaseResult value) + { + if (value is null || new[] { value.NativeErrorCode, value.LdapErrorCode, value.DirectoryResultCode }.Count(code => code.HasValue) > 1 || !Enum.IsDefined(value.State) || value.FailureCode is { } code && !Enum.IsDefined(code) || + value.State is DomainJoinPhaseState.NotStarted or DomainJoinPhaseState.Succeeded or DomainJoinPhaseState.Skipped && + (value.FailureCode is not null || value.NativeErrorCode is not null || value.LdapErrorCode is not null || value.DirectoryResultCode is not null) || + value.State is DomainJoinPhaseState.Failed or DomainJoinPhaseState.Unknown or DomainJoinPhaseState.Unverified && value.FailureCode is null) + throw new InvalidDataException("Domain phase result is invalid."); + } +} diff --git a/src/Foundry.PostInstall/Execution/DomainJoinResultStore.cs b/src/Foundry.PostInstall/Execution/DomainJoinResultStore.cs new file mode 100644 index 000000000..56879e57b --- /dev/null +++ b/src/Foundry.PostInstall/Execution/DomainJoinResultStore.cs @@ -0,0 +1,98 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Models.PreOobe; +namespace Foundry.PostInstall.Execution; + +/// Parent-owned password-free report. The worker never writes this file. +internal sealed class DomainJoinResultStore(string root, PreOobeExecutionPlan plan, string planHash) +{ + private readonly string path = OwnedPaths.Resolve(root, "State/PreOobe/domain-join-result.json"); + /// Describes the credential-free report Deploy staged, with no assumed installed-Windows boot. + public DomainJoinResult CreateSeed() + { + PreOobePlanValidator.ValidatePlan(plan); + var binding = DomainJoinBinding.Validate(plan); + return DomainJoinResult.CreateSeed(plan.OperationId, plan.AttemptId, planHash, + binding.Parameters.ComputerName, binding.Parameters.DomainName, binding.Parameters.TargetOuDn); + } + public DomainJoinResult Read() + { + var value = DomainStateFile.Read(root, path); Validate(value); return value; + } + public DomainJoinResult Reconcile(DomainJoinPhaseReceipt receipt, string origin, + DomainJoinFailureCode interruption = DomainJoinFailureCode.Interrupted, bool workerUnsettled = false) + { + var report = Read(); + if (receipt.OriginatingBootId is { } recorded && recorded != origin || + report.OriginatingBootId.Length > 0 && report.OriginatingBootId != origin || + report.Join.State == DomainJoinPhaseState.Succeeded && receipt.Join.State != DomainJoinPhaseState.Succeeded) + throw new InvalidDataException("Domain receipt origin does not match the operation."); + var join = receipt.Phase switch + { + DomainJoinReceiptPhase.Prepared => new() { State = workerUnsettled ? DomainJoinPhaseState.Unknown : DomainJoinPhaseState.Failed, FailureCode = interruption }, + DomainJoinReceiptPhase.JoinStarted => new() { State = DomainJoinPhaseState.Unknown, FailureCode = interruption }, + _ => receipt.Join + }; + var placement = receipt.Phase == DomainJoinReceiptPhase.PlacementStarted + ? new() { State = DomainJoinPhaseState.Unknown, FailureCode = interruption } : receipt.Placement; + if (placement.State == DomainJoinPhaseState.NotStarted) + placement = join.State == DomainJoinPhaseState.Succeeded && report.TargetOuDn is not null + ? new() { State = DomainJoinPhaseState.Unverified, FailureCode = interruption } + : new() { State = DomainJoinPhaseState.Skipped }; + // Later observations cannot prove the outcome of an uncertain mutation. + if (report.Join.State != DomainJoinPhaseState.NotStarted) join = report.Join; + if (report.Placement.State != DomainJoinPhaseState.NotStarted) placement = report.Placement; + var value = report with + { + OriginatingBootId = origin, + Join = join, + Placement = placement, + ComputerObjectGuid = report.ComputerObjectGuid ?? receipt.ComputerObjectGuid, + Restart = report.Restart == DomainJoinRestartState.NotRequired && join.State is DomainJoinPhaseState.Succeeded or DomainJoinPhaseState.Unknown + ? DomainJoinRestartState.Required : report.Restart + }; + Write(value); + return value; + } + + public static bool HasWarnings(DomainJoinResult report) => report.Join.State is DomainJoinPhaseState.Failed or DomainJoinPhaseState.Unknown || + report.Placement.State is DomainJoinPhaseState.Failed or DomainJoinPhaseState.Unknown or DomainJoinPhaseState.Unverified || + report.Membership.State is DomainJoinPhaseState.Failed or DomainJoinPhaseState.Unknown or DomainJoinPhaseState.Unverified; + + public void Write(DomainJoinResult value) + { + Validate(value); + var previous = Read(); + if (previous.OriginatingBootId.Length > 0 && previous.OriginatingBootId != value.OriginatingBootId || + previous.ComputerObjectGuid is { } guid && guid != value.ComputerObjectGuid || value.Restart < previous.Restart || + previous.Cleanup == DomainJoinCleanupState.Disposed && value.Cleanup != DomainJoinCleanupState.Disposed || + !CanAdvance(previous.Join, value.Join) || !CanAdvance(previous.Placement, value.Placement) || !CanAdvance(previous.Membership, value.Membership)) + throw new InvalidDataException("Domain report transition is invalid."); + DomainStateFile.Write(root, path, value); + } + private static bool CanAdvance(DomainJoinPhaseResult previous, DomainJoinPhaseResult value) => previous == value || + previous.State == DomainJoinPhaseState.NotStarted || + previous.State == DomainJoinPhaseState.Unverified && + value.State is DomainJoinPhaseState.Succeeded or DomainJoinPhaseState.Failed or DomainJoinPhaseState.Unverified; + private void Validate(DomainJoinResult value) + { + var expected = CreateSeed(); DomainJoinBinding.ValidateHash(planHash); + if (value.SchemaVersion != 1 || value.OperationId != expected.OperationId || value.AttemptId != expected.AttemptId || value.PlanHash != planHash || + value.ExpectedComputerName != expected.ExpectedComputerName || value.ExpectedDomainName != expected.ExpectedDomainName || value.TargetOuDn != expected.TargetOuDn || + value.ComputerObjectGuid == Guid.Empty || !Enum.IsDefined(value.Restart) || !Enum.IsDefined(value.Cleanup)) + throw new InvalidDataException("Domain report identity is invalid."); + DomainStateFile.ValidatePhase(value.Join); DomainStateFile.ValidatePhase(value.Placement); DomainStateFile.ValidatePhase(value.Membership); + if (value.OriginatingBootId.Length == 0) + { + if (value.Join.State != DomainJoinPhaseState.NotStarted || value.Placement.State != DomainJoinPhaseState.NotStarted || + value.Membership.State != DomainJoinPhaseState.NotStarted || value.Restart != DomainJoinRestartState.NotRequired || value.ComputerObjectGuid is not null) + throw new InvalidDataException("An unassigned report cannot contain execution."); + } + else DomainJoinBinding.ValidateBoot(value.OriginatingBootId); + if (value.Join.State == DomainJoinPhaseState.Succeeded && value.Restart == DomainJoinRestartState.NotRequired || + value.Join.State != DomainJoinPhaseState.Succeeded && value.Placement.State == DomainJoinPhaseState.Succeeded) + throw new InvalidDataException("Domain report phases are inconsistent."); + } +} diff --git a/src/Foundry.PostInstall/Execution/ExecutionJournal.cs b/src/Foundry.PostInstall/Execution/ExecutionJournal.cs index a8c3fcc6a..80685e3fa 100644 --- a/src/Foundry.PostInstall/Execution/ExecutionJournal.cs +++ b/src/Foundry.PostInstall/Execution/ExecutionJournal.cs @@ -21,6 +21,8 @@ public sealed class JournalState public string? BootIdentity { get; set; } public int RestartCount { get; set; } public bool DeferredRestart { get; set; } + public string? DomainRestartBootIdentity { get; set; } + public long? DomainReceiptGeneration { get; set; } public Dictionary Actions { get; set; } = new(StringComparer.Ordinal); public Dictionary PayloadDispositions { get; set; } = new(StringComparer.OrdinalIgnoreCase); public string? UnsafePayloadBootIdentity { get; set; } diff --git a/src/Foundry.PostInstall/Execution/OwnedPayloadCleanup.cs b/src/Foundry.PostInstall/Execution/OwnedPayloadCleanup.cs index a3007bedf..7be015417 100644 --- a/src/Foundry.PostInstall/Execution/OwnedPayloadCleanup.cs +++ b/src/Foundry.PostInstall/Execution/OwnedPayloadCleanup.cs @@ -6,43 +6,66 @@ namespace Foundry.PostInstall.Execution; -public sealed class OwnedPayloadCleanup(string root, ExecutionJournal journal) +public sealed record OwnedPayloadCleanupOutcome(bool HasFatalSensitiveFailure, bool HasDomainCleanupPending); + +public sealed class OwnedPayloadCleanup(string root, ExecutionJournal journal, Action? retryDelay = null) { - public bool Dispose(PreOobeExecutionPlan plan, JournalState state, bool terminal, string boot) + public OwnedPayloadCleanupOutcome Dispose(PreOobeExecutionPlan plan, JournalState state, bool terminal, string boot) { - bool sensitiveFailure = false; + PreOobePlanValidator.ValidatePlan(plan); + string? domainPath = plan.Actions.Any(action => action.BuiltInKind == PreOobeBuiltInKind.DomainJoinAndPlacement) + ? DomainJoinBinding.Validate(plan).Parameters.CredentialPayloadPath : null; + bool fatal = false; + bool domainPending = false; foreach (PreOobeOwnedPayload payload in plan.OwnedPayloads) { if (state.PayloadDispositions.GetValueOrDefault(payload.RelativePath) == "Disposed") continue; bool consumed = payload.ConsumerActionIds.Count > 0 && payload.ConsumerActionIds.All(id => state.Actions.TryGetValue(id, out var result) && result.Status is "Succeeded" or "Failed" or "Skipped"); if (!terminal && !(payload.IsSensitive && consumed)) continue; - if (state.UnsafePayloadBootIdentity == boot && - (state.UnsafeActionId is null || payload.ConsumerActionIds.Contains(state.UnsafeActionId))) - { - state.PayloadDispositions[payload.RelativePath] = "CleanupPending"; - sensitiveFailure |= payload.IsSensitive; - continue; - } + bool domain = payload.RelativePath == domainPath; + bool unsafeExecution = state.UnsafePayloadBootIdentity == boot && + (state.UnsafeActionId is null || payload.ConsumerActionIds.Contains(state.UnsafeActionId)); state.PayloadDispositions[payload.RelativePath] = "CleanupPending"; + bool disposed = false; try { + // For a sensitive payload, a journal publication failure must propagate even if best-effort deletion succeeds. try { journal.Write(state); } - finally + finally { if (!unsafeExecution) disposed = TryDelete(payload); } + if (disposed) state.PayloadDispositions[payload.RelativePath] = "Disposed"; + else { - string path = OwnedPaths.Resolve(root, payload.RelativePath); - if (payload.IsDirectory) OwnedPaths.DeleteDirectory(path); - else if (File.Exists(path)) { File.SetAttributes(path, File.GetAttributes(path) & ~FileAttributes.ReadOnly); File.Delete(path); } + fatal |= payload.IsSensitive && !domain; + domainPending |= domain; + state.HasWarnings = true; } - state.PayloadDispositions[payload.RelativePath] = "Disposed"; journal.Write(state); } - catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException) + catch (Exception error) when (!payload.IsSensitive && error is IOException or UnauthorizedAccessException) { - sensitiveFailure |= payload.IsSensitive; state.HasWarnings = true; } } - return !sensitiveFailure; + return new(fatal, domainPending); + } + + private bool TryDelete(PreOobeOwnedPayload payload) + { + for (int attempt = 0; attempt < 3; attempt++) + { + try + { + string path = OwnedPaths.Resolve(root, payload.RelativePath); + if (payload.IsDirectory) OwnedPaths.DeleteDirectory(path); + else if (File.Exists(path)) { File.SetAttributes(path, File.GetAttributes(path) & ~FileAttributes.ReadOnly); File.Delete(path); } + return true; + } + catch (Exception error) when (error is IOException or InvalidDataException or UnauthorizedAccessException) + { + if (attempt < 2) (retryDelay ?? Thread.Sleep)(TimeSpan.FromMilliseconds(250)); + } + } + return false; } } diff --git a/src/Foundry.PostInstall/Execution/PostInstallProgress.cs b/src/Foundry.PostInstall/Execution/PostInstallProgress.cs index eedc9d1cc..b6d3274c7 100644 --- a/src/Foundry.PostInstall/Execution/PostInstallProgress.cs +++ b/src/Foundry.PostInstall/Execution/PostInstallProgress.cs @@ -11,7 +11,7 @@ public sealed record PostInstallActionProgress(string Id, string Name, string St /// Restores completed actions after restart and exposes countdowns without persisting presentation state. public sealed record PostInstallProgress(IReadOnlyList Actions, string Status, - bool IsResuming = false, int? RestartSecondsRemaining = null) + bool IsResuming = false, int? RestartSecondsRemaining = null, DomainJoinResult? DomainResult = null, int WarningCount = 0) { /// Uses English built-in labels while preserving user-authored action names. public static string GetActionName(PreOobeExecutionAction action) => action.BuiltInKind switch @@ -22,6 +22,8 @@ public sealed record PostInstallProgress(IReadOnlyList "Remove AI components", PreOobeBuiltInKind.Activation => "Activate Windows", PreOobeBuiltInKind.Cleanup => "Cleanup", + PreOobeBuiltInKind.DomainJoinAndPlacement => "Join domain and place computer", + PreOobeBuiltInKind.VerifyDomainMembership => "Verify domain membership", _ => action.Name }; } diff --git a/src/Foundry.PostInstall/Execution/PreOobeOrchestrator.cs b/src/Foundry.PostInstall/Execution/PreOobeOrchestrator.cs index ff0663932..114c8c5f2 100644 --- a/src/Foundry.PostInstall/Execution/PreOobeOrchestrator.cs +++ b/src/Foundry.PostInstall/Execution/PreOobeOrchestrator.cs @@ -5,7 +5,6 @@ using System.Security.Cryptography; using Foundry.Core.Models.Configuration; using Foundry.Core.Models.PreOobe; -using Foundry.Core.Services.Configuration; using Serilog; namespace Foundry.PostInstall.Execution; @@ -17,6 +16,9 @@ public sealed class PreOobeOrchestrator(string root, string planHash, ExecutionJ private IProgress? progress = progress; private readonly Func delay = delay ?? Task.Delay; private bool isResuming; + private DomainJoinBinding? domainBinding; + private DomainJoinResultStore? domainResults; + private DomainJoinResult? domainReport; private const int MaximumRestarts = 1024; public async Task RunAsync(PreOobeExecutionPlan plan, CancellationToken cancellationToken) { @@ -32,14 +34,26 @@ public async Task RunAsync(PreOobeExecutionPlan plan, Canc var cleanup = new OwnedPayloadCleanup(root, journal); try { - ValidatePlan(plan); + PreOobePlanValidator.ValidatePlan(plan); state = journal.Read(); - ValidateState(plan, state); + PreOobePlanValidator.ValidateState(plan, state, planHash); boot = bootIdentity(); if (string.IsNullOrWhiteSpace(boot)) throw new InvalidDataException("Boot identity is unavailable."); validated = true; isResuming = state.RestartCount > 0 && state.BootIdentity != boot; + bool domainResume = ReconcileDomain(plan, state, boot); Report(plan, state); + if (domainResume && domainReport?.Restart is DomainJoinRestartState.Required or DomainJoinRestartState.Requested) + { + if (state.DomainRestartBootIdentity == boot) return new("AwaitingRestart", 3); + state.BootIdentity = boot; + return await RequestRestartAsync(plan, state, 0, cancellationToken).ConfigureAwait(false); + } + if (domainResume) + { + state.Status = "Running"; + state.CompletionStatus = null; + } if (state.Status is "Succeeded" or "CompletedWithErrors" or "Failed" or "Interrupted") return await FinishWithBuiltInCleanupAsync(plan, state, boot, cleanup).ConfigureAwait(false); if (state.Status == "Completing") @@ -51,7 +65,7 @@ public async Task RunAsync(PreOobeExecutionPlan plan, Canc { if (state.BootIdentity == boot) return new("AwaitingRestart", 3); } - else if (state.Status != "Pending") + else if (state.Status != "Pending" && !domainResume) { state.Status = "Interrupted"; state.UnsafePayloadBootIdentity = state.BootIdentity; @@ -59,7 +73,7 @@ public async Task RunAsync(PreOobeExecutionPlan plan, Canc journal.Write(state); return await FinishWithBuiltInCleanupAsync(plan, state, boot, cleanup).ConfigureAwait(false); } - else + else if (state.Status == "Pending") { Report(plan, state, "Verifying"); await VerifyPackagesAsync(plan, cancellationToken).ConfigureAwait(false); @@ -100,6 +114,16 @@ public async Task RunAsync(PreOobeExecutionPlan plan, Canc state.UnsafeActionId = action.Id; outcome = outcome with { Succeeded = false, FailureCode = "execution_uncertain" }; } + bool domainAction = action.BuiltInKind is PreOobeBuiltInKind.DomainJoinAndPlacement or PreOobeBuiltInKind.VerifyDomainMembership; + if (domainAction) + { + domainReport = domainResults!.Read(); + state.DomainReceiptGeneration = new DomainJoinPhaseStore(root, plan, planHash).Read().Generation; + state.HasWarnings |= DomainJoinResultStore.HasWarnings(domainReport); + LogDomainReport(domainReport); + if (action.BuiltInKind == PreOobeBuiltInKind.DomainJoinAndPlacement) + outcome = outcome with { RestartRequested = domainReport.Restart == DomainJoinRestartState.Required }; + } if (outcome.Succeeded && outcome.NextSubstep is int next) { if (next <= state.Substep || next > 10000) throw new InvalidDataException("Invalid internal cursor."); @@ -121,10 +145,11 @@ public async Task RunAsync(PreOobeExecutionPlan plan, Canc Report(plan, state); Log.Information("Post-installation action {ActionId} finished with {Status}; exit code {ExitCode}", action.Id, state.Actions[action.Id].Status, outcome.ExitCode); - if (!cleanup.Dispose(plan, state, false, boot)) throw new IOException("Sensitive input disposal failed."); + if (DisposePayloads(plan, state, false, boot, cleanup).HasFatalSensitiveFailure) throw new IOException("Sensitive input disposal failed."); bool mayContinue = action.CustomAction?.Process?.ErrorPolicy == PreOobeErrorPolicy.Continue || - action.BuiltInKind is PreOobeBuiltInKind.Activation; - if (!outcome.Succeeded && (!mayContinue || state.UnsafePayloadBootIdentity == boot)) + action.BuiltInKind is PreOobeBuiltInKind.Activation || domainAction; + bool uncertainNow = state.UnsafePayloadBootIdentity == boot && state.UnsafeActionId == action.Id; + if (!outcome.Succeeded && (!mayContinue || uncertainNow && !domainAction)) { state.Status = "Failed"; journal.Write(state); @@ -147,14 +172,17 @@ public async Task RunAsync(PreOobeExecutionPlan plan, Canc Log.Error("Post-installation stopped; failure type {FailureType}", ex.GetType().Name); if (validated && state is not null) { - if (actionInFlight) + if (actionInFlight || state.Status == "Running" && state.Cursor < plan.Actions.Count && + state.Actions.GetValueOrDefault(plan.Actions[state.Cursor].Id)?.Status == "Running") { state.UnsafePayloadBootIdentity = boot; state.UnsafeActionId = state.Cursor < plan.Actions.Count ? plan.Actions[state.Cursor].Id : null; } state.Status = "Failed"; try { journal.Write(state); } catch (Exception error) when (error is IOException or UnauthorizedAccessException) { } - cleanup.Dispose(plan, state, true, boot); + try { if (DisposePayloads(plan, state, true, boot, cleanup).HasFatalSensitiveFailure) state.Status = "Failed"; } + catch (Exception error) when (error is not OutOfMemoryException and not StackOverflowException) + { Log.Error("Post-installation cleanup could not be published; failure type {FailureType}", error.GetType().Name); } Report(plan, state); } return new("Failed", 3); @@ -162,6 +190,125 @@ public async Task RunAsync(PreOobeExecutionPlan plan, Canc } } + private bool ReconcileDomain(PreOobeExecutionPlan plan, JournalState state, string boot) + { + if (!plan.Actions.Any(action => action.BuiltInKind == PreOobeBuiltInKind.DomainJoinAndPlacement)) return false; + domainBinding = DomainJoinBinding.Validate(plan); + var phases = new DomainJoinPhaseStore(root, plan, planHash); + var join = state.Actions.GetValueOrDefault(domainBinding.JoinAction.Id); + bool inspectWorker = join?.Status == "Running" && state.BootIdentity == boot; + using var workerLease = inspectWorker ? TryAcquireWorkerLease(phases) : null; + var receipt = phases.Read(); + if (state.DomainReceiptGeneration is { } generation && (generation < 0 || receipt.Generation < generation)) + throw new InvalidDataException("Domain receipt generation moved backwards."); + domainResults = new(root, plan, planHash); + domainReport = domainResults.Read(); + if (join is null || join.Status == "Skipped" && domainReport.Join.State == DomainJoinPhaseState.NotStarted) + { + if (receipt.OriginatingBootId is not null || domainReport.Join.State != DomainJoinPhaseState.NotStarted || + state.DomainRestartBootIdentity is not null || state.DomainReceiptGeneration is not null) + throw new InvalidDataException("Domain execution has no journaled owner."); + return false; + } + string origin = domainReport.OriginatingBootId.Length > 0 ? domainReport.OriginatingBootId : + state.BootIdentity ?? throw new InvalidDataException("Domain execution origin is missing."); + DomainJoinBinding.ValidateBoot(origin); + domainReport = domainResults.Reconcile(receipt, origin, workerUnsettled: inspectWorker && workerLease is null); + state.DomainReceiptGeneration = receipt.Generation; + if (state.DomainRestartBootIdentity is { } requested && (requested != origin || state.RestartCount == 0)) + throw new InvalidDataException("Domain restart checkpoint is invalid."); + if (domainReport.Restart is DomainJoinRestartState.Required or DomainJoinRestartState.Requested) + { + var restart = boot != origin ? DomainJoinRestartState.Completed : + state.DomainRestartBootIdentity is not null ? DomainJoinRestartState.Requested : DomainJoinRestartState.Required; + if (domainReport.Restart == DomainJoinRestartState.Requested && state.DomainRestartBootIdentity is null) + throw new InvalidDataException("Domain restart publication is missing."); + domainReport = domainReport with { Restart = restart }; + domainResults.Write(domainReport); + } + bool warning = DomainJoinResultStore.HasWarnings(domainReport); + state.HasWarnings |= warning; + LogDomainReport(domainReport); + int joinIndex = plan.Actions.ToList().FindIndex(action => action.Id == domainBinding.JoinAction.Id); + if (state.Cursor < joinIndex) throw new InvalidDataException("Domain cursor precedes its recorded execution."); + if (join.Status == "Running") + { + bool settledPrepared = workerLease is not null && receipt.Phase == DomainJoinReceiptPhase.Prepared && + domainReport.Join.State == DomainJoinPhaseState.Failed; + if (settledPrepared) + { + if (state.UnsafePayloadBootIdentity == origin && state.UnsafeActionId == domainBinding.JoinAction.Id) + { + state.UnsafePayloadBootIdentity = null; + state.UnsafeActionId = null; + } + else if (state.UnsafePayloadBootIdentity == boot) + { + state.Status = "Failed"; + state.CompletionStatus = null; + journal.Write(state); + return false; + } + } + else if (state.UnsafePayloadBootIdentity != boot || state.UnsafeActionId == domainBinding.JoinAction.Id) + { + state.UnsafePayloadBootIdentity = origin; + state.UnsafeActionId = domainBinding.JoinAction.Id; + } + state.Actions[domainBinding.JoinAction.Id] = join with + { + Status = warning ? "Failed" : "Succeeded", + FailureCode = warning ? "domain_join_warning" : null, + CompletedAtUtc = DateTimeOffset.UtcNow + }; + } + if (state.Cursor == joinIndex) { state.Cursor++; state.Substep = 0; } + bool resume = state.Cursor < plan.Actions.Count && plan.Actions[state.Cursor].Id == domainBinding.VerificationAction.Id; + if (resume && domainReport.Membership.State is DomainJoinPhaseState.Succeeded or DomainJoinPhaseState.Failed or DomainJoinPhaseState.Skipped) + { + state.Actions[domainBinding.VerificationAction.Id] = new() { Status = domainReport.Membership.State == DomainJoinPhaseState.Failed ? "Failed" : "Succeeded" }; + state.Cursor++; + } + journal.Write(state); + return resume; + } + + /// Records the password-free phase outcomes; only states, allowlisted codes and numeric errors are written. + private static void LogDomainReport(DomainJoinResult report) + { + Log.Information( + "Domain join report; join {JoinState} ({JoinFailure}), placement {PlacementState} ({PlacementFailure}), " + + "membership {MembershipState} ({MembershipFailure}), restart {Restart}, cleanup {Cleanup}", + report.Join.State, report.Join.FailureCode, report.Placement.State, report.Placement.FailureCode, + report.Membership.State, report.Membership.FailureCode, report.Restart, report.Cleanup); + foreach (var (phase, result) in new[] { ("join", report.Join), ("placement", report.Placement), ("membership", report.Membership) }) + { + if (result.NativeErrorCode is null && result.LdapErrorCode is null && result.DirectoryResultCode is null) continue; + Log.Warning("Domain {Phase} error codes; native {NativeErrorCode}, LDAP {LdapErrorCode}, directory result {DirectoryResultCode}", + phase, result.NativeErrorCode, result.LdapErrorCode, result.DirectoryResultCode); + } + } + + private static IDisposable? TryAcquireWorkerLease(DomainJoinPhaseStore phases) + { + try { return phases.AcquireWorkerLease(); } + catch (IOException error) when ((error.HResult & 0xffff) is 32 or 33) { return null; } + } + + private OwnedPayloadCleanupOutcome DisposePayloads(PreOobeExecutionPlan plan, JournalState state, bool terminal, string boot, OwnedPayloadCleanup cleanup) + { + var outcome = cleanup.Dispose(plan, state, terminal, boot); + state.HasWarnings |= outcome.HasDomainCleanupPending; + if (domainResults is not null && domainBinding is not null) + { + domainReport = domainResults.Read(); + var disposition = state.PayloadDispositions.GetValueOrDefault(domainBinding.Parameters.CredentialPayloadPath); + var next = disposition == "Disposed" ? DomainJoinCleanupState.Disposed : DomainJoinCleanupState.Pending; + if (domainReport.Cleanup != next) domainResults.Write(domainReport = domainReport with { Cleanup = next }); + } + return outcome; + } + /// Commits the next cursor before waiting; cancelling the display delay must not invalidate a durable restart. private async Task RequestRestartAsync(PreOobeExecutionPlan plan, JournalState state, int seconds, CancellationToken cancellationToken) @@ -169,7 +316,22 @@ private async Task RequestRestartAsync(PreOobeExecutionPla if (++state.RestartCount > MaximumRestarts) throw new InvalidDataException("Restart budget exceeded."); state.DeferredRestart = false; state.Status = "AwaitingRestart"; - journal.Write(state); + bool domainRestart = domainReport?.Restart == DomainJoinRestartState.Required; + if (domainRestart) state.DomainRestartBootIdentity = domainReport!.OriginatingBootId; + try { journal.Write(state); } + catch + { + // Do not publish an uncommitted restart marker through the outer failure handler. + var durable = journal.Read(); + state.RestartCount = durable.RestartCount; + state.DomainRestartBootIdentity = durable.DomainRestartBootIdentity; + throw; + } + if (domainRestart) + { + domainReport = domainReport! with { Restart = DomainJoinRestartState.Requested }; + domainResults!.Write(domainReport); + } Log.Information("Post-installation checkpoint saved for restart {RestartCount}; delay {DelaySeconds} seconds", state.RestartCount, seconds); try @@ -227,7 +389,7 @@ private async Task FinishWithBuiltInCleanupAsync(PreOobeEx private OrchestrationOutcome Finish(PreOobeExecutionPlan plan, JournalState state, string boot, OwnedPayloadCleanup cleanup) { - if (!cleanup.Dispose(plan, state, true, boot)) state.Status = "Failed"; + if (DisposePayloads(plan, state, true, boot, cleanup).HasFatalSensitiveFailure) state.Status = "Failed"; foreach (PreOobeExecutionAction action in plan.Actions) if (!state.Actions.ContainsKey(action.Id)) state.Actions[action.Id] = new() { Status = "Skipped" }; if (state.Status == "Succeeded" && state.HasWarnings) state.Status = "CompletedWithErrors"; @@ -252,7 +414,16 @@ private void Report(PreOobeExecutionPlan plan, JournalState state, string? statu if (elapsed < TimeSpan.Zero) elapsed = TimeSpan.Zero; return new PostInstallActionProgress(action.Id, PostInstallProgress.GetActionName(action), actionStatus, elapsed, result?.ExitCode); }).ToArray(); - progress.Report(new(actions, status ?? state.Status, isResuming, restartSecondsRemaining)); + int warnings = state.Actions.Count(pair => pair.Key != domainBinding?.JoinAction.Id && pair.Key != domainBinding?.VerificationAction.Id && + (pair.Value.Status == "Failed" || pair.Value.FailureCode is not null)); + if (domainReport is not null) + { + warnings += new[] { domainReport.Join, domainReport.Placement, domainReport.Membership }.Count(phase => + phase.State is DomainJoinPhaseState.Failed or DomainJoinPhaseState.Unknown or DomainJoinPhaseState.Unverified); + if (state.PayloadDispositions.GetValueOrDefault(domainBinding!.Parameters.CredentialPayloadPath) == "CleanupPending") warnings++; + } + if (state.HasWarnings) warnings = Math.Max(1, warnings); + progress.Report(new(actions, status ?? state.Status, isResuming, restartSecondsRemaining, domainReport, warnings)); } catch (Exception error) when (error is not OutOfMemoryException and not StackOverflowException) { @@ -261,51 +432,6 @@ private void Report(PreOobeExecutionPlan plan, JournalState state, string? statu } } - private static void ValidatePlan(PreOobeExecutionPlan plan) - { - if (plan.SchemaVersion != 1 || plan.RuntimeContractVersion != 1 || - string.IsNullOrWhiteSpace(plan.OperationId) || string.IsNullOrWhiteSpace(plan.AttemptId) || - plan.Actions.Count > PreOobeConfigurationValidator.MaximumActions + Enum.GetValues().Length || - plan.Actions.Count(action => action.CustomAction is not null) > PreOobeConfigurationValidator.MaximumActions || - plan.Actions.Where(action => action.BuiltInKind.HasValue).Select(action => action.BuiltInKind).Distinct().Count() != plan.Actions.Count(action => action.BuiltInKind.HasValue) || - plan.Actions.Select(action => action.Id).Distinct(StringComparer.Ordinal).Count() != plan.Actions.Count) - throw new InvalidDataException("The execution plan is invalid."); - bool sawCustom = false; - bool sawCleanup = false; - foreach (PreOobeExecutionAction action in plan.Actions) - { - if (string.IsNullOrWhiteSpace(action.Id) || action.Id.Any(character => !char.IsAsciiLetterOrDigit(character) && character is not ('-' or '_')) || - (action.CustomAction is null) == (action.BuiltInKind is null) || - action.BuiltInKind is { } kind && !Enum.IsDefined(kind) || sawCleanup || - sawCustom && action.BuiltInKind is not (null or PreOobeBuiltInKind.Cleanup)) - throw new InvalidDataException("The execution action is invalid."); - sawCustom |= action.CustomAction is not null; - sawCleanup |= action.BuiltInKind == PreOobeBuiltInKind.Cleanup; - if (action.CustomAction is { } custom && - (custom.RestartDelaySeconds is < 0 or > PreOobeConfigurationValidator.MaximumRestartDelaySeconds || - custom.Kind != PreOobeActionKind.Restart && custom.RestartDelaySeconds != 0)) - throw new InvalidDataException("The restart delay is invalid."); - } - foreach (PreOobeOwnedPayload payload in plan.OwnedPayloads) - if (!(payload.RelativePath.Replace('/', '\\').StartsWith("Payloads\\", StringComparison.OrdinalIgnoreCase) || - payload.RelativePath.Replace('/', '\\').Equals("Work\\PreOobe\\" + plan.OperationId, StringComparison.OrdinalIgnoreCase)) || - payload.ConsumerActionIds.Any(id => !plan.Actions.Any(action => action.Id == id))) - throw new InvalidDataException("Payload ownership is invalid."); - } - - private void ValidateState(PreOobeExecutionPlan plan, JournalState state) - { - if (state.SchemaVersion != 1 || state.OperationId != plan.OperationId || state.AttemptId != plan.AttemptId || - !string.Equals(state.PlanHash, planHash, StringComparison.OrdinalIgnoreCase) || state.Cursor < 0 || - state.Cursor > plan.Actions.Count || state.Generation < 0 || state.Substep < 0 || state.Substep > 10000 || - state.RestartCount < 0 || state.RestartCount > MaximumRestarts || - state.Status == "AwaitingRestart" && (state.RestartCount == 0 || string.IsNullOrWhiteSpace(state.BootIdentity)) || - state.Status is not ("Pending" or "Running" or "AwaitingRestart" or "Completing" or "Succeeded" or "CompletedWithErrors" or "Failed" or "Interrupted") || - state.Status == "Completing" && state.CompletionStatus is not ("Succeeded" or "CompletedWithErrors" or "Failed" or "Interrupted") || - state.Status == "Pending" && (state.Cursor != 0 || state.Substep != 0 || state.Actions.Count != 0 || state.RestartCount != 0)) - throw new InvalidDataException("The journal does not match this operation."); - } - private async Task VerifyPackagesAsync(PreOobeExecutionPlan plan, CancellationToken cancellationToken) { foreach (PreOobeStagedPackage package in plan.Packages) diff --git a/src/Foundry.PostInstall/Execution/PreOobePlanLoader.cs b/src/Foundry.PostInstall/Execution/PreOobePlanLoader.cs new file mode 100644 index 000000000..7778dccb7 --- /dev/null +++ b/src/Foundry.PostInstall/Execution/PreOobePlanLoader.cs @@ -0,0 +1,42 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Security.Cryptography; +using System.Text.Json; +using Foundry.Core.Models.PreOobe; + +namespace Foundry.PostInstall.Execution; + +/// A validated plan and the digest of its one owned serialized snapshot. +internal sealed record PreOobePlanSnapshot(PreOobeExecutionPlan Plan, string Hash); + +/// Loads only the fixed installed-Windows plan, with a bounded read under a non-writable file share. +internal static class PreOobePlanLoader +{ + public static async Task LoadAsync(string root, CancellationToken token) + { + byte[] bytes = await ReadBoundedAsync(OwnedPaths.Resolve(root, "State/PreOobe/plan.json"), 8 * 1024 * 1024, token).ConfigureAwait(false); + var plan = JsonSerializer.Deserialize(bytes, ExecutionJournal.JsonOptions) + ?? throw new InvalidDataException("The plan is empty."); + PreOobePlanValidator.ValidatePlan(plan); + return new(plan, Convert.ToHexStringLower(SHA256.HashData(bytes))); + } + + public static void RequireMatching(PreOobePlanSnapshot snapshot, PreOobeExecutionPlan plan, string hash) + { + DomainJoinBinding.ValidateHash(hash); + if (snapshot.Hash != hash || !JsonSerializer.SerializeToUtf8Bytes(snapshot.Plan, ExecutionJournal.JsonOptions).AsSpan() + .SequenceEqual(JsonSerializer.SerializeToUtf8Bytes(plan, ExecutionJournal.JsonOptions))) + throw new InvalidDataException("The fixed plan snapshot does not match this operation."); + } + + internal static async Task ReadBoundedAsync(string path, int maximum, CancellationToken token) + { + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + if (stream.Length is <= 0 || stream.Length > maximum) throw new InvalidDataException("The owned file size is invalid."); + byte[] bytes = new byte[(int)stream.Length]; + try { await stream.ReadExactlyAsync(bytes, token).ConfigureAwait(false); return bytes; } + catch { CryptographicOperations.ZeroMemory(bytes); throw; } + } +} diff --git a/src/Foundry.PostInstall/Execution/PreOobePlanValidator.cs b/src/Foundry.PostInstall/Execution/PreOobePlanValidator.cs new file mode 100644 index 000000000..a27a5feca --- /dev/null +++ b/src/Foundry.PostInstall/Execution/PreOobePlanValidator.cs @@ -0,0 +1,61 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Models.Configuration; +using Foundry.Core.Models.PreOobe; +using Foundry.Core.Services.Configuration; + +namespace Foundry.PostInstall.Execution; + +/// Shares structural and journal identity validation between the parent runner and its workers. +internal static class PreOobePlanValidator +{ + public static void ValidatePlan(PreOobeExecutionPlan plan) + { + if (plan.SchemaVersion != 1 || plan.RuntimeContractVersion != 1 || + string.IsNullOrWhiteSpace(plan.OperationId) || string.IsNullOrWhiteSpace(plan.AttemptId) || + plan.Actions.Count > PreOobeConfigurationValidator.MaximumActions + Enum.GetValues().Length || + plan.Actions.Count(action => action.CustomAction is not null) > PreOobeConfigurationValidator.MaximumActions || + plan.Actions.Where(action => action.BuiltInKind.HasValue).Select(action => action.BuiltInKind).Distinct().Count() != plan.Actions.Count(action => action.BuiltInKind.HasValue) || + plan.Actions.Select(action => action.Id).Distinct(StringComparer.Ordinal).Count() != plan.Actions.Count) + throw new InvalidDataException("The execution plan is invalid."); + if (plan.Actions.Any(action => action.BuiltInKind is PreOobeBuiltInKind.DomainJoinAndPlacement or PreOobeBuiltInKind.VerifyDomainMembership)) + DomainJoinBinding.Validate(plan); + bool sawCustom = false; + bool sawCleanup = false; + foreach (PreOobeExecutionAction action in plan.Actions) + { + if (string.IsNullOrWhiteSpace(action.Id) || action.Id.Any(character => !char.IsAsciiLetterOrDigit(character) && character is not ('-' or '_')) || + (action.CustomAction is null) == (action.BuiltInKind is null) || + action.BuiltInKind is { } kind && !Enum.IsDefined(kind) || sawCleanup || + sawCustom && action.BuiltInKind is not (null or PreOobeBuiltInKind.Cleanup)) + throw new InvalidDataException("The execution action is invalid."); + sawCustom |= action.CustomAction is not null; + sawCleanup |= action.BuiltInKind == PreOobeBuiltInKind.Cleanup; + if (action.CustomAction is { } custom && + (custom.RestartDelaySeconds is < 0 or > PreOobeConfigurationValidator.MaximumRestartDelaySeconds || + custom.Kind != PreOobeActionKind.Restart && custom.RestartDelaySeconds != 0)) + throw new InvalidDataException("The restart delay is invalid."); + } + foreach (PreOobeOwnedPayload payload in plan.OwnedPayloads) + if (!(payload.RelativePath.Replace('/', '\\').StartsWith("Payloads\\", StringComparison.OrdinalIgnoreCase) || + payload.RelativePath.Replace('/', '\\').Equals("Work\\PreOobe\\" + plan.OperationId, StringComparison.OrdinalIgnoreCase)) || + payload.ConsumerActionIds.Any(id => !plan.Actions.Any(action => action.Id == id))) + throw new InvalidDataException("Payload ownership is invalid."); + } + + public static void ValidateState(PreOobeExecutionPlan plan, JournalState state, string planHash) + { + if (state.SchemaVersion != 1 || state.OperationId != plan.OperationId || state.AttemptId != plan.AttemptId || + !string.Equals(state.PlanHash, planHash, StringComparison.OrdinalIgnoreCase) || state.Cursor < 0 || + state.Cursor > plan.Actions.Count || state.Generation < 0 || state.Substep < 0 || state.Substep > 10000 || + state.RestartCount < 0 || state.RestartCount > 1024 || + state.Status == "AwaitingRestart" && (state.RestartCount == 0 || string.IsNullOrWhiteSpace(state.BootIdentity)) || + state.Status is not ("Pending" or "Running" or "AwaitingRestart" or "Completing" or "Succeeded" or "CompletedWithErrors" or "Failed" or "Interrupted") || + state.Status == "Completing" && state.CompletionStatus is not ("Succeeded" or "CompletedWithErrors" or "Failed" or "Interrupted") || + state.Status == "Pending" && (state.Cursor != 0 || state.Substep != 0 || state.Actions.Count != 0 || state.RestartCount != 0)) + throw new InvalidDataException("The journal does not match this operation."); + } + +} diff --git a/src/Foundry.PostInstall/Foundry.PostInstall.csproj b/src/Foundry.PostInstall/Foundry.PostInstall.csproj index 7f8656080..bfefed9c8 100644 --- a/src/Foundry.PostInstall/Foundry.PostInstall.csproj +++ b/src/Foundry.PostInstall/Foundry.PostInstall.csproj @@ -6,6 +6,7 @@ + diff --git a/src/Foundry.PostInstall/Program.cs b/src/Foundry.PostInstall/Program.cs index ef2e6b955..bcdcb018c 100644 --- a/src/Foundry.PostInstall/Program.cs +++ b/src/Foundry.PostInstall/Program.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. // See the LICENSE file in the project root for more information. -using System.Security.Cryptography; using System.Text.Json; using Foundry.Core.Models.PreOobe; using Foundry.PostInstall.Actions; @@ -24,7 +23,7 @@ public static async Task Main(string[] args) System.Console.WriteLine(typeof(Program).Assembly.GetName().Version?.ToString()); return 0; } - if (args is not (["--setup"] or ["--activation-worker"])) + if (args is not (["--setup"] or ["--activation-worker"] or ["--domain-join-worker"])) { WriteError("Foundry.PostInstall --setup | --version"); return 3; } try { @@ -39,22 +38,29 @@ public static async Task Main(string[] args) string windows = Environment.GetFolderPath(Environment.SpecialFolder.Windows); string root = Path.Combine(windows, "Temp", "Foundry"); OwnedPaths.RejectReparsePoints(windows, root); - string path = OwnedPaths.Resolve(root, "State/PreOobe/plan.json"); - if (new FileInfo(path).Length > 8 * 1024 * 1024) return 3; - byte[] bytes = await File.ReadAllBytesAsync(path).ConfigureAwait(false); - var plan = JsonSerializer.Deserialize(bytes, ExecutionJournal.JsonOptions) - ?? throw new InvalidDataException("The execution plan is empty."); + var snapshot = await PreOobePlanLoader.LoadAsync(root, CancellationToken.None).ConfigureAwait(false); + var plan = snapshot.Plan; + string hash = snapshot.Hash; string logs = OwnedPaths.Resolve(root, "Logs/PreOobe"); Directory.CreateDirectory(logs); Log.Logger = FoundryLogConfiguration.CreateFileLogger(Path.Combine(logs, "Foundry.PostInstall.log"), "Foundry.PostInstall", plan.DiagnosticSessionId, LogEventLevel.Verbose, 5); - string hash = Convert.ToHexStringLower(SHA256.HashData(bytes)); + // Read once: every check in this process must compare against the same boot identity. + string boot = BootIdentityProvider.Read(); + if (args[0] == "--domain-join-worker") + { + var binding = DomainJoinBinding.Validate(plan); + DomainJoinBinding.RequireRunning(root, plan, hash, boot, binding.JoinAction.Id); + var workerResult = await new DomainJoinWorker(root, plan, hash, boot, new NativeDomainJoin(), new DomainComputerAccountDirectory()) + .RunAsync(binding.Parameters, CancellationToken.None).ConfigureAwait(false); + return workerResult.Join.State == DomainJoinPhaseState.Succeeded ? 0 : 10; + } Log.Information("Post-installation started with {ActionCount} actions", plan.Actions.Count); using var console = new PostInstallConsole(plan, Path.Combine(logs, "Foundry.PostInstall.log")); var executor = new PreOobeActionExecutor(root, windows, plan, new PreOobeProcessExecutor(), - new CertificateImporter(), Environment.ProcessPath ?? throw new InvalidDataException("Runtime path is unavailable.")); + new CertificateImporter(), Environment.ProcessPath ?? throw new InvalidDataException("Runtime path is unavailable."), hash, boot); OrchestrationOutcome result = await new PreOobeOrchestrator(root, hash, new ExecutionJournal(root), executor, - BootIdentityProvider.Read, console).RunAsync(plan, CancellationToken.None).ConfigureAwait(false); + () => boot, console).RunAsync(plan, CancellationToken.None).ConfigureAwait(false); Log.Information("Post-installation ended with {Status}; host exit code {HostExitCode}", result.Status, result.ExitCode); console.Complete(result); await console.WaitForSetupAsync().ConfigureAwait(false); diff --git a/src/Foundry.PostInstall/Windows/DomainComputerAccountDirectory.cs b/src/Foundry.PostInstall/Windows/DomainComputerAccountDirectory.cs new file mode 100644 index 000000000..311cc432e --- /dev/null +++ b/src/Foundry.PostInstall/Windows/DomainComputerAccountDirectory.cs @@ -0,0 +1,303 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.DirectoryServices.Protocols; +using System.Net; +using System.Runtime.InteropServices; +using System.Security; +using Foundry.Core.Models.Configuration; +using Foundry.Core.Services.Configuration; +namespace Foundry.PostInstall.Windows; + +/// Owns a signed/sealed connection to one writable DC for serialized reads and at most one move. +internal interface IDomainComputerAccountDirectory : IDisposable +{ + Task PrepareAsync(DomainJoinCredentialContext context, ReadOnlyMemory password, + string computerName, string? targetOuDn, CancellationToken token); + Task FindComputerAsync(string computerName, CancellationToken token); + Task ReadComputerAsync(Guid guid, CancellationToken token); + Task ReadDestinationAsync(Guid guid, CancellationToken token); + Task MoveAsync(DomainDirectoryObject computer, DomainDirectoryObject destination, CancellationToken token); +} +/// Directory identity captured on the chosen DC, including the live parent's identity for computers. +internal sealed record DomainDirectoryObject(Guid Guid, string DistinguishedName, Guid? ParentGuid = null); +/// +/// A completed readiness read; null computer means no visible match, never proof of absolute absence. +/// DestinationMissing means the directory answered that the requested OU does not exist or is not an +/// organizational unit, so the join can still proceed in the domain's default location. +/// +internal sealed record DomainDirectoryReady(string Domain, string Controller, DomainDirectoryObject? Destination, DomainDirectoryObject? Computer, + bool DestinationMissing = false); +/// +/// Sanitized numeric diagnostics; Rejected indicates an acknowledged server rejection of a write, and Transient a +/// readiness failure that happened before the supplied identity was evaluated: the controller could not be located +/// or connected to. A bind that timed out is not transient, because the controller may have seen the credentials. +/// +internal sealed class DomainDirectoryException(int? nativeError = null, int? ldapError = null, bool rejected = false, int? directoryResult = null, + bool transient = false) : Exception("Directory operation unavailable.") +{ + public bool Transient { get; } = transient; + public int? NativeErrorCode { get; } = nativeError; + public int? LdapErrorCode { get; } = ldapError; + public bool Rejected { get; } = rejected; + public int? DirectoryResultCode { get; } = directoryResult; +} + +/// Authenticates to one writable DC per readiness attempt and preserves GUID, naming-context, and RDN boundaries for relocation. +internal sealed class DomainComputerAccountDirectory : IDomainComputerAccountDirectory +{ + private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(10); + private const int LdapServerDown = 81; + private const int LdapConnectError = 91; + private const int NoSuchObject = 32; + private const uint ForceRediscovery = 0x1; + private LdapConnection? connection; + private string domain = string.Empty; + private string namingContext = string.Empty; + private string computerName = string.Empty; + private bool moveStarted; + private bool readinessAttempted; + + /// Leaves no connection behind on failure, so a transient readiness failure can be attempted again. + public async Task PrepareAsync(DomainJoinCredentialContext context, ReadOnlyMemory password, + string computerName, string? targetOuDn, CancellationToken token) + { + if (connection is not null) throw new InvalidOperationException("Directory readiness cannot be repeated."); + try + { + return await ConnectAsync(context, password, computerName, targetOuDn, token).ConfigureAwait(false); + } + catch + { + Dispose(); + throw; + } + finally + { + readinessAttempted = true; + } + } + + private async Task ConnectAsync(DomainJoinCredentialContext context, ReadOnlyMemory password, + string computerName, string? targetOuDn, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + domain = DomainJoinCredentialContext.CanonicalizeDomainName(context.DomainName); + this.computerName = computerName; + // A repeated attempt bypasses the locator's cached failure from the previous one. + string controller = LocateController(domain, forceRediscovery: readinessAttempted); + token.ThrowIfCancellationRequested(); + using var secret = new SecureString(); + foreach (char character in password.Span) secret.AppendChar(character); + secret.MakeReadOnly(); + string[] account = context.AccountName.Trim().Split('\\'); + var credential = account.Length == 2 ? new NetworkCredential(account[1], secret, account[0]) : new NetworkCredential(account[0], secret); + try + { + connection = new LdapConnection(new LdapDirectoryIdentifier(controller, 389, true, false), credential, AuthType.Negotiate); + connection.Timeout = RequestTimeout; + connection.SessionOptions.ProtocolVersion = 3; + connection.SessionOptions.Signing = true; + connection.SessionOptions.Sealing = true; + connection.SessionOptions.ReferralChasing = ReferralChasingOptions.None; + connection.SessionOptions.AutoReconnect = false; + connection.SessionOptions.SendTimeout = RequestTimeout; + connection.Bind(); + token.ThrowIfCancellationRequested(); + } + catch (LdapException error) + { + throw new DomainDirectoryException(ldapError: error.ErrorCode, + transient: error.ErrorCode is LdapServerDown or LdapConnectError); + } + finally { credential.Password = null; } + var root = await SearchAsync("", "(objectClass=*)", SearchScope.Base, ["defaultNamingContext", "dnsHostName"], token).ConfigureAwait(false); + if (root.Count != 1 || !string.Equals(Attribute(root[0], "dnsHostName"), controller, StringComparison.OrdinalIgnoreCase)) throw new DomainDirectoryException(); + namingContext = Attribute(root[0], "defaultNamingContext"); + if (!DistinguishedNameRules.TryParse(namingContext, out var parsed) || parsed.Rdns.Count != domain.Split('.').Length || + !DistinguishedNameRules.IsWithinDomain(namingContext, domain)) throw new DomainDirectoryException(); + if (targetOuDn is null) return new(domain, controller, null, null); + if (!DistinguishedNameRules.IsWithinDomain(targetOuDn, domain)) throw new DomainDirectoryException(); + var target = await FindOrganizationalUnitAsync(targetOuDn, token).ConfigureAwait(false); + if (target is null) return new(domain, controller, null, null, DestinationMissing: true); + return new(domain, controller, target, await FindComputerAsync(computerName, token).ConfigureAwait(false)); + } + + /// Returns null only when the directory positively answers that no such organizational unit exists. + private async Task FindOrganizationalUnitAsync(string dn, CancellationToken token) + { + try + { + var results = await SearchAsync(dn, "(objectClass=organizationalUnit)", SearchScope.Base, ["distinguishedName", "objectGUID"], token).ConfigureAwait(false); + if (results.Count > 1) throw new DomainDirectoryException(); + return results.Count == 0 ? null : Parse(results[0]); + } + catch (DomainDirectoryException error) when (error.DirectoryResultCode == NoSuchObject) + { + return null; + } + } + + public async Task FindComputerAsync(string name, CancellationToken token) + { + var results = await SearchAsync(namingContext, "(&(objectCategory=computer)(sAMAccountName=" + EscapeFilter(name + "$") + "))", + SearchScope.Subtree, ["distinguishedName", "objectGUID"], token).ConfigureAwait(false); + if (results.Count == 0) return null; + if (results.Count != 1) throw new DomainDirectoryException(); + return await WithParentAsync(Parse(results[0]), token).ConfigureAwait(false); + } + public async Task ReadComputerAsync(Guid guid, CancellationToken token) + { + var value = await ReadGuidAsync(guid, "computer", token).ConfigureAwait(false); + return await WithParentAsync(value, token).ConfigureAwait(false); + } + public Task ReadDestinationAsync(Guid guid, CancellationToken token) => ReadGuidAsync(guid, "organizationalUnit", token); + + public async Task MoveAsync(DomainDirectoryObject computer, DomainDirectoryObject destination, CancellationToken token) + { + if (moveStarted) throw new InvalidOperationException("A directory mutation cannot be repeated."); + var liveComputer = await ReadComputerAsync(computer.Guid, token).ConfigureAwait(false); + var namedComputer = await FindComputerAsync(computerName, token).ConfigureAwait(false); + var liveTarget = await ReadDestinationAsync(destination.Guid, token).ConfigureAwait(false); + if (namedComputer?.Guid != computer.Guid || liveComputer.Guid != computer.Guid || liveTarget.Guid != destination.Guid || + liveComputer.DistinguishedName != computer.DistinguishedName || liveTarget.DistinguishedName != destination.DistinguishedName) + throw new DomainDirectoryException(rejected: true); + if (liveComputer.ParentGuid == liveTarget.Guid) return; + var request = CreateMoveRequest(liveComputer.DistinguishedName, liveTarget.DistinguishedName); + token.ThrowIfCancellationRequested(); + moveStarted = true; + await SendAsync(request, token).ConfigureAwait(false); + } + + /// Constructs the move after caller identity checks, preserving the complete original escaped RDN. + internal static ModifyDNRequest CreateMoveRequest(string sourceDn, string destinationDn) + { + if (!DistinguishedNameRules.TryParse(sourceDn, out var parsed) || parsed.Parent is null) throw new DomainDirectoryException(rejected: true); + string rdn = sourceDn[..^(parsed.Parent.Length + 1)]; + return new ModifyDNRequest(sourceDn, destinationDn, rdn) { DeleteOldRdn = true }; + } + + private async Task ReadGuidAsync(Guid guid, string objectClass, CancellationToken token) + { + string binary = string.Concat(guid.ToByteArray().Select(value => "\\" + value.ToString("x2"))); + var results = await SearchAsync(namingContext, $"(&(objectClass={objectClass})(objectGUID={binary}))", SearchScope.Subtree, + ["distinguishedName", "objectGUID"], token).ConfigureAwait(false); + if (results.Count != 1) throw new DomainDirectoryException(); + var value = Parse(results[0]); + if (value.Guid != guid) throw new DomainDirectoryException(); + return value; + } + private async Task ReadBaseAsync(string dn, string filter, CancellationToken token) + { + var results = await SearchAsync(dn, filter, SearchScope.Base, ["distinguishedName", "objectGUID"], token).ConfigureAwait(false); + if (results.Count != 1) throw new DomainDirectoryException(); + return Parse(results[0]); + } + private async Task WithParentAsync(DomainDirectoryObject value, CancellationToken token) + { + if (!DistinguishedNameRules.TryParse(value.DistinguishedName, out var parsed) || parsed.Parent is null) throw new DomainDirectoryException(); + var parent = await ReadBaseAsync(parsed.Parent, "(objectClass=*)", token).ConfigureAwait(false); + return value with { ParentGuid = parent.Guid }; + } + private DomainDirectoryObject Parse(SearchResultEntry entry) + { + string dn = Attribute(entry, "distinguishedName"); + if (!DistinguishedNameRules.IsWithinDomain(dn, domain) || entry.Attributes["objectGUID"] is not { Count: 1 } attribute || + attribute[0] is not byte[] { Length: 16 } guid || new Guid(guid) == Guid.Empty) throw new DomainDirectoryException(); + return new(new Guid(guid), dn); + } + private async Task> SearchAsync(string dn, string filter, SearchScope scope, string[] attributes, CancellationToken token) + { + var request = new SearchRequest(dn, filter, scope, attributes) { TimeLimit = RequestTimeout }; + var paging = new PageResultRequestControl(256) { IsCritical = true }; + if (scope == SearchScope.Subtree) + { + request.Controls.Add(new DomainScopeControl { IsCritical = true }); request.Controls.Add(paging); + } + List entries = []; + do + { + var response = (SearchResponse)await SendAsync(request, token).ConfigureAwait(false); + if (response.ResultCode != ResultCode.Success || response.References.Count > 0 || response.Referral.Length > 0) throw new DomainDirectoryException(); + foreach (SearchResultEntry entry in response.Entries) + { + if (entries.Count >= 4096) throw new DomainDirectoryException(); + entries.Add(entry); + } + if (scope != SearchScope.Subtree) return entries; + var controls = response.Controls.OfType().ToArray(); + if (controls.Length != 1) throw new DomainDirectoryException(); + paging.Cookie = controls[0].Cookie; + if (paging.Cookie.Length > 0 && entries.Count >= 4096) throw new DomainDirectoryException(); + } while (paging.Cookie.Length != 0); + return entries; + } + private async Task SendAsync(DirectoryRequest request, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + var owned = connection ?? throw new InvalidOperationException("Directory readiness is required."); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var sync = new object(); bool settled = false; + try + { + // Begin may connect/send synchronously. Retain ownership until it returns and cancellation settles. + IAsyncResult pending = owned.BeginSendRequest(request, RequestTimeout, PartialResultProcessing.NoPartialResultSupport, result => + { + lock (sync) + { + if (settled) return; settled = true; + try { completion.TrySetResult(owned.EndSendRequest(result)); } + catch (Exception error) { completion.TrySetException(error); } + } + }, null); + using var registration = token.Register(() => + { + lock (sync) + { + if (settled) return; settled = true; + try { owned.Abort(pending); } + catch (ArgumentException) { } + finally { completion.TrySetCanceled(token); } + } + }); + return await completion.Task.ConfigureAwait(false); + } + catch (LdapException error) { throw new DomainDirectoryException(ldapError: error.ErrorCode); } + catch (DirectoryOperationException error) + { + throw new DomainDirectoryException(directoryResult: error.Response is null ? null : (int)error.Response.ResultCode, + rejected: error.Response is not null && error.Response.ResultCode != ResultCode.Success); + } + } + internal static string EscapeFilter(string value) => value.Replace("\\", "\\5c", StringComparison.Ordinal).Replace("*", "\\2a", StringComparison.Ordinal) + .Replace("(", "\\28", StringComparison.Ordinal).Replace(")", "\\29", StringComparison.Ordinal).Replace("\0", "\\00", StringComparison.Ordinal); + private static string Attribute(SearchResultEntry entry, string name) => entry.Attributes[name] is { Count: 1 } attribute && attribute[0] is string value ? value : string.Empty; + public void Dispose() { connection?.Dispose(); connection = null; } + private static string LocateController(string domain, bool forceRediscovery) + { + nint buffer = 0; + try + { + uint status = DsGetDcName(null, domain, 0, null, + 0x10 | 0x20000 | 0x40000000 | 0x1000 | (forceRediscovery ? ForceRediscovery : 0), out buffer); + if (status != 0) throw new DomainDirectoryException(nativeError: unchecked((int)status), transient: true); + var info = Marshal.PtrToStructure(buffer); + string foundDomain = Marshal.PtrToStringUni(info.DomainName) ?? string.Empty; + string controller = (Marshal.PtrToStringUni(info.DomainControllerName) ?? string.Empty).TrimStart('\\'); + if (DomainJoinCredentialContext.CanonicalizeDomainName(foundDomain) != domain || !DomainJoinCredentialContext.IsValidDomainName(controller)) throw new DomainDirectoryException(); + return controller; + } + finally { if (buffer != 0) NetApiBufferFree(buffer); } + } + [StructLayout(LayoutKind.Sequential)] + private struct DomainControllerInfo + { + public nint DomainControllerName; public nint DomainControllerAddress; public uint DomainControllerAddressType; + public Guid DomainGuid; public nint DomainName; public nint DnsForestName; public uint Flags; public nint DcSiteName; public nint ClientSiteName; + } + [DllImport("netapi32.dll", EntryPoint = "DsGetDcNameW", ExactSpelling = true, CharSet = CharSet.Unicode)] + private static extern uint DsGetDcName(string? computerName, string? domainName, nint domainGuid, string? siteName, uint flags, out nint buffer); + [DllImport("netapi32.dll", ExactSpelling = true)] + private static extern uint NetApiBufferFree(nint buffer); +} diff --git a/src/Foundry.PostInstall/Windows/DomainJoinWorker.cs b/src/Foundry.PostInstall/Windows/DomainJoinWorker.cs new file mode 100644 index 000000000..636092764 --- /dev/null +++ b/src/Foundry.PostInstall/Windows/DomainJoinWorker.cs @@ -0,0 +1,227 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Security.Cryptography; +using Foundry.Core.Models.Configuration; +using Foundry.Core.Models.PreOobe; +using Foundry.Core.Services.Configuration; +using Foundry.PostInstall.Execution; +using Serilog; + +namespace Foundry.PostInstall.Windows; + +/// Reports independent join and placement observations without credentials. +internal sealed record DomainJoinWorkerResult(DomainJoinPhaseResult Join, DomainJoinPhaseResult Placement, Guid? ComputerObjectGuid, bool RestartRequired); + +/// Runs one operation-bound online join; parent process supervision supplies the hard wall-clock bound. +internal sealed class DomainJoinWorker(string root, PreOobeExecutionPlan plan, string planHash, string originatingBoot, + INativeDomainJoin native, IDomainComputerAccountDirectory directory, Func? delay = null) +{ + /// + /// Cooperative budget checked between native calls, which cannot be interrupted; the supervising parent waits + /// slightly longer before terminating the worker. + /// + internal static readonly TimeSpan Budget = TimeSpan.FromSeconds(300); + private static readonly TimeSpan ReadinessBudget = TimeSpan.FromSeconds(120); + private static readonly TimeSpan ReadinessRetryInterval = TimeSpan.FromSeconds(5); + private readonly Func delay = delay ?? Task.Delay; + private DomainDirectoryException? lastReadinessFailure; + + public async Task RunAsync(DomainJoinActionParameters parameters, CancellationToken token) + { + var snapshot = await PreOobePlanLoader.LoadAsync(root, token).ConfigureAwait(false); + PreOobePlanLoader.RequireMatching(snapshot, plan, planHash); + var binding = DomainJoinBinding.Validate(plan); + if (snapshot.Hash != planHash || DomainJoinBinding.Validate(snapshot.Plan).Parameters != parameters || binding.Parameters != parameters) + throw new InvalidDataException("The worker plan binding changed."); + DomainJoinBinding.RequireRunning(root, plan, planHash, originatingBoot, binding.JoinAction.Id); + var store = new DomainJoinPhaseStore(root, plan, planHash); + using var lease = store.AcquireWorkerLease(); + var receipt = store.BindOrigin(originatingBoot); + Log.Information("Domain join worker started"); + using (directory) + using (var budget = CancellationTokenSource.CreateLinkedTokenSource(token)) + { + budget.CancelAfter(Budget); + DomainJoinCredentialPayload? credentials = null; + DomainDirectoryReady ready; + try + { + byte[] bytes = await PreOobePlanLoader.ReadBoundedAsync(OwnedPaths.Resolve(root, parameters.CredentialPayloadPath), + DomainJoinConfigurationValidator.MaximumCredentialPayloadBytes, budget.Token).ConfigureAwait(false); + try { credentials = DomainJoinCredentialPayloadCodec.Decode(bytes); } + finally { CryptographicOperations.ZeroMemory(bytes); } + if (!credentials.Context.Matches(new(parameters.DomainName, credentials.Context.AccountName))) + { + credentials.Dispose(); + return Finish(Failure(DomainJoinPhaseState.Failed, DomainJoinFailureCode.ContextMismatch), Skipped()); + } + using var readiness = CancellationTokenSource.CreateLinkedTokenSource(budget.Token); + readiness.CancelAfter(ReadinessBudget); + ready = await WaitForDirectoryAsync(credentials, parameters, readiness.Token).ConfigureAwait(false); + readiness.Token.ThrowIfCancellationRequested(); + ValidateReady(ready, parameters); + } + catch (Exception error) when (Recoverable(error)) + { + credentials?.Dispose(); + // A timeout keeps the numeric code of the last unreachable attempt, which is the actual cause. + bool timedOut = error is OperationCanceledException; + return Finish(Failure(DomainJoinPhaseState.Failed, timedOut ? DomainJoinFailureCode.ReadinessTimeout : + credentials is null ? DomainJoinFailureCode.CredentialUnavailable : DomainJoinFailureCode.DomainUnavailable, + timedOut ? lastReadinessFailure ?? error : error), Skipped()); + } + using (credentials) + { + bool pending; + try { budget.Token.ThrowIfCancellationRequested(); pending = !string.Equals(native.GetActiveComputerName(), parameters.ComputerName, StringComparison.OrdinalIgnoreCase); } + catch (Exception error) when (Recoverable(error)) { return Finish(Failure(DomainJoinPhaseState.Failed, DomainJoinFailureCode.ComputerNameMismatch, error), Skipped()); } + receipt = receipt with { ComputerObjectGuid = ready.Computer?.Guid, DestinationObjectGuid = ready.Destination?.Guid }; + Save(DomainJoinReceiptPhase.JoinStarted); + DomainJoinPhaseResult join; + try + { + budget.Token.ThrowIfCancellationRequested(); + int status = pending ? native.SetComputerName(parameters.ComputerName) : 0; + if (status != 0) join = Failure(DomainJoinPhaseState.Failed, DomainJoinFailureCode.ComputerNameMismatch) with { NativeErrorCode = status }; + else + { + budget.Token.ThrowIfCancellationRequested(); + status = native.Join(ready.Domain + "\\" + ready.Controller, ready.Computer is null ? ready.Destination?.DistinguishedName : null, + credentials.Context, credentials.Password.Span, pending); + join = status == 0 ? new() { State = DomainJoinPhaseState.Succeeded } : Failure(DomainJoinPhaseState.Failed, DomainJoinFailureCode.JoinFailed) with { NativeErrorCode = status }; + } + } + catch (Exception error) when (Recoverable(error)) { join = Failure(DomainJoinPhaseState.Unknown, DomainJoinFailureCode.Interrupted, error); } + credentials.Dispose(); + receipt = receipt with { Join = join, RestartRequired = join.State == DomainJoinPhaseState.Succeeded }; + Save(DomainJoinReceiptPhase.JoinReturned); + if (join.State != DomainJoinPhaseState.Succeeded) return Finish(join, Skipped()); + // A missing OU does not prevent the join: the account stays in the default location and placement is reported failed. + if (ready.Destination is null) + return Finish(join, ready.DestinationMissing ? Failure(DomainJoinPhaseState.Failed, DomainJoinFailureCode.OrganizationalUnitNotFound) : Skipped()); + DomainJoinPhaseResult placement; + try + { + budget.Token.ThrowIfCancellationRequested(); + var target = await directory.ReadDestinationAsync(ready.Destination.Guid, budget.Token).ConfigureAwait(false); + ValidateObject(target, parameters.DomainName); + if (target.Guid != ready.Destination.Guid) throw new InvalidDataException("The OU identity changed."); + if (ready.Computer is null) + { + var created = await directory.FindComputerAsync(parameters.ComputerName, budget.Token).ConfigureAwait(false); + if (created is not null) { ValidateObject(created, parameters.DomainName); receipt = receipt with { ComputerObjectGuid = created.Guid }; } + placement = created?.ParentGuid == target.Guid ? new() { State = DomainJoinPhaseState.Succeeded } : Failure(DomainJoinPhaseState.Unverified, DomainJoinFailureCode.PlacementFailed); + } + else + { + var current = await directory.ReadComputerAsync(ready.Computer.Guid, budget.Token).ConfigureAwait(false); + ValidateObject(current, parameters.DomainName); + if (current.Guid != ready.Computer.Guid) throw new InvalidDataException("The computer identity changed."); + if (current.ParentGuid == target.Guid) placement = new() { State = DomainJoinPhaseState.Succeeded }; + else + { + Save(DomainJoinReceiptPhase.PlacementStarted); + bool acknowledged = false; + try + { + await directory.MoveAsync(current, target, budget.Token).ConfigureAwait(false); + acknowledged = true; + placement = new() { State = DomainJoinPhaseState.Succeeded }; + } + catch (Exception error) when (Recoverable(error)) + { + placement = Failure(error is DomainDirectoryException { Rejected: true } ? DomainJoinPhaseState.Failed : DomainJoinPhaseState.Unknown, + DomainJoinFailureCode.PlacementFailed, error); + } + bool verified = await ReadBackAsync(current.Guid, target.Guid, budget.Token).ConfigureAwait(false); + if (acknowledged && !verified) placement = Failure(DomainJoinPhaseState.Unverified, DomainJoinFailureCode.PlacementFailed); + receipt = receipt with { Placement = placement }; Save(DomainJoinReceiptPhase.PlacementReturned); + } + } + } + catch (Exception error) when (Recoverable(error) && receipt.Phase == DomainJoinReceiptPhase.JoinReturned) + { placement = Failure(DomainJoinPhaseState.Failed, DomainJoinFailureCode.PlacementFailed, error); } + return Finish(join, placement); + } + } + + void Save(DomainJoinReceiptPhase phase) + { + var next = receipt with { Phase = phase, Generation = checked(receipt.Generation + 1) }; + store.Write(next); receipt = next; + } + DomainJoinWorkerResult Finish(DomainJoinPhaseResult join, DomainJoinPhaseResult placement) + { + receipt = receipt with { Join = join, Placement = placement }; + Save(DomainJoinReceiptPhase.Finished); + Log.Information("Domain join worker finished; join {JoinState} ({JoinFailure}), placement {PlacementState} ({PlacementFailure})", + join.State, join.FailureCode, placement.State, placement.FailureCode); + return new(receipt.Join, receipt.Placement, receipt.ComputerObjectGuid, receipt.RestartRequired); + } + } + + /// + /// Repeats read-only readiness while the installed network or a domain controller is still becoming reachable. + /// Credential and directory rejections are never repeated, so a wrong password cannot lock the account. + /// + private async Task WaitForDirectoryAsync(DomainJoinCredentialPayload credentials, + DomainJoinActionParameters parameters, CancellationToken token) + { + for (int attempt = 1; ; attempt++) + { + try + { + return await directory.PrepareAsync(credentials.Context, credentials.Password, parameters.ComputerName, + parameters.TargetOuDn, token).ConfigureAwait(false); + } + catch (DomainDirectoryException error) when (error.Transient) + { + lastReadinessFailure = error; + Log.Warning("Domain controller not reachable yet; attempt {Attempt}, native {NativeErrorCode}, LDAP {LdapErrorCode}", + attempt, error.NativeErrorCode, error.LdapErrorCode); + await delay(ReadinessRetryInterval, token).ConfigureAwait(false); + } + } + } + + private async Task ReadBackAsync(Guid computer, Guid target, CancellationToken token) + { + for (int attempt = 0; attempt < 3 && !token.IsCancellationRequested; attempt++) + { + using var request = CancellationTokenSource.CreateLinkedTokenSource(token); request.CancelAfter(TimeSpan.FromSeconds(10)); + try + { + var observed = await directory.ReadComputerAsync(computer, request.Token).ConfigureAwait(false); + if (observed.Guid == computer && observed.ParentGuid == target) return true; + } + catch (Exception error) when (Recoverable(error)) { } + } + return false; + } + private static void ValidateReady(DomainDirectoryReady ready, DomainJoinActionParameters parameters) + { + if (DomainJoinCredentialContext.CanonicalizeDomainName(ready.Domain) != DomainJoinCredentialContext.CanonicalizeDomainName(parameters.DomainName) || + !DomainJoinCredentialContext.IsValidDomainName(ready.Controller) || + ready.DestinationMissing && (parameters.TargetOuDn is null || ready.Destination is not null) || + !ready.DestinationMissing && (parameters.TargetOuDn is null) != (ready.Destination is null)) + throw new InvalidDataException("Directory readiness is invalid."); + if (ready.Destination is not null) ValidateObject(ready.Destination, parameters.DomainName); + if (ready.Computer is not null) ValidateObject(ready.Computer, parameters.DomainName); + } + private static void ValidateObject(DomainDirectoryObject value, string domain) + { + if (value.Guid == Guid.Empty || !DistinguishedNameRules.IsWithinDomain(value.DistinguishedName, domain)) throw new InvalidDataException("Directory identity is invalid."); + } + internal static bool Recoverable(Exception error) => error is not OutOfMemoryException and not StackOverflowException; + internal static DomainJoinPhaseResult Failure(DomainJoinPhaseState state, DomainJoinFailureCode code, Exception? error = null) => new() + { + State = state, + FailureCode = code, + NativeErrorCode = (error as DomainDirectoryException)?.NativeErrorCode ?? (error as System.ComponentModel.Win32Exception)?.NativeErrorCode, + LdapErrorCode = (error as DomainDirectoryException)?.LdapErrorCode, + DirectoryResultCode = (error as DomainDirectoryException)?.DirectoryResultCode + }; + private static DomainJoinPhaseResult Skipped() => new() { State = DomainJoinPhaseState.Skipped }; +} diff --git a/src/Foundry.PostInstall/Windows/NativeDomainJoin.cs b/src/Foundry.PostInstall/Windows/NativeDomainJoin.cs new file mode 100644 index 000000000..1448677fe --- /dev/null +++ b/src/Foundry.PostInstall/Windows/NativeDomainJoin.cs @@ -0,0 +1,112 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.ComponentModel; +using System.Runtime.InteropServices; +using Foundry.Core.Models.Configuration; +using Microsoft.Win32; +namespace Foundry.PostInstall.Windows; + +/// Provides the local native operations; a join is never automatically retried. +internal interface INativeDomainJoin +{ + string GetActiveComputerName(); + int SetComputerName(string name); + int Join(string domainAndDc, string? creationOu, DomainJoinCredentialContext context, ReadOnlySpan password, bool usePendingName); + DomainMembershipSnapshot GetMembership(); + + /// + /// Tells Windows OOBE that no user account has to be created, so setup ends on the sign-in screen. Windows + /// sets the same value itself when an answer file creates an account. + /// + void SkipAccountCreation(); +} +/// Local passwordless membership observation using DNS domain identity and the active startup name. +internal sealed record DomainMembershipSnapshot(int JoinStatus, string? DomainName, string ActiveComputerName, int? NativeErrorCode = null); + +/// +/// Joins through documented installed-Windows APIs without rejoin, unsecured, or hardening-bypass flags, and +/// records for Windows setup that no local account has to be created. +/// +internal sealed class NativeDomainJoin : INativeDomainJoin +{ + public string GetActiveComputerName() + { + uint count = 0; + bool probed = GetComputerNameEx(5, 0, ref count); + int error = Marshal.GetLastPInvokeError(); + if (probed || error != 234 || count is 0 or > 32768) throw new Win32Exception(error); + nint buffer = Marshal.AllocHGlobal(checked((int)count * 2)); + try + { + if (!GetComputerNameEx(5, buffer, ref count)) { error = Marshal.GetLastPInvokeError(); throw new Win32Exception(error); } + return Marshal.PtrToStringUni(buffer, checked((int)count)); + } + finally { Marshal.FreeHGlobal(buffer); } + } + public int SetComputerName(string name) => SetComputerNameEx(5, name) ? 0 : Marshal.GetLastPInvokeError(); + public int Join(string domainAndDc, string? creationOu, DomainJoinCredentialContext context, ReadOnlySpan password, bool usePendingName) + { + if (password.IsEmpty || password.Contains('\0')) throw new InvalidDataException("The password is invalid."); + int characters = checked(password.Length + 1); + nint native = Marshal.AllocHGlobal(checked(characters * 2)); + try + { + for (int index = 0; index < password.Length; index++) Marshal.WriteInt16(native, index * 2, unchecked((short)password[index])); + Marshal.WriteInt16(native, password.Length * 2, 0); + return unchecked((int)NetJoinDomain(null, domainAndDc, creationOu, context.AccountName.Trim(), native, 0x1u | 0x2u | (usePendingName ? 0x400u : 0u))); + } + finally + { + for (int index = 0; index < characters; index++) Marshal.WriteInt16(native, index * 2, 0); + Marshal.FreeHGlobal(native); + } + } + public DomainMembershipSnapshot GetMembership() + { + nint flat = 0; nint dns = 0; string name = string.Empty; + try + { + name = GetActiveComputerName(); + uint status = NetGetJoinInformation(null, out flat, out int joinStatus); + if (status != 0) return new(joinStatus, null, name, unchecked((int)status)); + if (joinStatus != 3) return new(joinStatus, null, name); + status = DsRoleGetPrimaryDomainInformation(null, 1, out dns); + if (status != 0) return new(joinStatus, null, name, unchecked((int)status)); + var info = Marshal.PtrToStructure(dns); + string? domain = info.MachineRole is 1 or 3 ? Marshal.PtrToStringUni(info.DomainNameDns) : null; + return new(joinStatus, domain, name); + } + catch (Win32Exception error) { return new(0, null, name, error.NativeErrorCode); } + finally { if (flat != 0) NetApiBufferFree(flat); if (dns != 0) DsRoleFreeMemory(dns); } + } + public void SkipAccountCreation() + { + using RegistryKey key = Registry.LocalMachine.CreateSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\OOBE", writable: true); + key.SetValue("UnattendCreatedUser", 1, RegistryValueKind.DWord); + } + + [StructLayout(LayoutKind.Sequential)] + private struct PrimaryDomainInfoBasic + { + public int MachineRole; public uint Flags; public nint DomainNameFlat; public nint DomainNameDns; + public nint DomainForestName; public Guid DomainGuid; + } + [DllImport("netapi32.dll", ExactSpelling = true, CharSet = CharSet.Unicode)] + private static extern uint NetJoinDomain(string? server, string domainAndController, string? machineAccountOu, string account, nint password, uint joinOptions); + [DllImport("netapi32.dll", ExactSpelling = true, CharSet = CharSet.Unicode)] + private static extern uint NetGetJoinInformation(string? server, out nint buffer, out int status); + [DllImport("netapi32.dll", ExactSpelling = true)] + private static extern uint NetApiBufferFree(nint buffer); + [DllImport("netapi32.dll", ExactSpelling = true, CharSet = CharSet.Unicode)] + private static extern uint DsRoleGetPrimaryDomainInformation(string? server, int infoLevel, out nint buffer); + [DllImport("netapi32.dll", ExactSpelling = true)] + private static extern void DsRoleFreeMemory(nint buffer); + [DllImport("kernel32.dll", EntryPoint = "SetComputerNameExW", ExactSpelling = true, CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetComputerNameEx(int format, string name); + [DllImport("kernel32.dll", EntryPoint = "GetComputerNameExW", ExactSpelling = true, CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetComputerNameEx(int format, nint buffer, ref uint count); +} diff --git a/src/Foundry.Telemetry.Tests/TelemetryEventPropertyPolicyTests.cs b/src/Foundry.Telemetry.Tests/TelemetryEventPropertyPolicyTests.cs index 80d019b58..7d5e29d31 100644 --- a/src/Foundry.Telemetry.Tests/TelemetryEventPropertyPolicyTests.cs +++ b/src/Foundry.Telemetry.Tests/TelemetryEventPropertyPolicyTests.cs @@ -290,6 +290,12 @@ public void Sanitize_ForBootMediaFinished_DropsPropertiesOutsideEventAllowlist() ["boot_media_deploy_runtime_payload_source"] = "release", ["autopilot_enabled"] = true, ["autopilot_provisioning_mode"] = "hardware_hash_upload", + ["domain_join_enabled"] = true, + ["domain_join_mode"] = "zero_touch", + ["domain_join_domain_count"] = 2, + ["domain_join_shared_account_used"] = true, + ["domain_join_ou_count"] = 3, + ["domain_join_default_ou_set"] = true, ["network_configured"] = true, ["connect_configured"] = true, ["deploy_configured"] = true, @@ -391,6 +397,14 @@ public void Sanitize_ForBootMediaFinished_DropsPropertiesOutsideEventAllowlist() Assert.Equal("release", result["boot_media_deploy_runtime_payload_source"]); Assert.True((bool)result["autopilot_enabled"]!); Assert.Equal("hardware_hash_upload", result["autopilot_provisioning_mode"]); + Assert.True((bool)result["domain_join_enabled"]!); + Assert.Equal("zero_touch", result["domain_join_mode"]); + Assert.Equal(2, result["domain_join_domain_count"]); + + Assert.True((bool)result["domain_join_shared_account_used"]!); + Assert.Equal(3, result["domain_join_ou_count"]); + Assert.True((bool)result["domain_join_default_ou_set"]!); + Assert.False(result.ContainsKey("network_configured")); Assert.False(result.ContainsKey("connect_configured")); Assert.False(result.ContainsKey("deploy_configured")); @@ -506,6 +520,11 @@ public void Sanitize_ForDeploySessionFinished_DropsKnownDeploySensitiveValues() ["deploy_autopilot_provisioning_mode"] = "hardware_hash_upload", ["deploy_autopilot_hash_upload_state"] = "completed", ["deploy_autopilot_hash_group_tag_selected"] = true, + ["deploy_domain_join_enabled"] = true, + ["deploy_domain_join_mode"] = "interactive", + ["deploy_domain_join_ou_source"] = "selected", + ["deploy_domain_join_domain_source"] = "default", + ["deploy_domain_join_status"] = "staged", ["operating_system_url"] = "https://example.invalid/os.wim", ["driver_pack_url"] = "https://example.invalid/driver.cab", ["target_computer_name"] = "PC-001", @@ -542,6 +561,11 @@ public void Sanitize_ForDeploySessionFinished_DropsKnownDeploySensitiveValues() Assert.Equal("hardware_hash_upload", result["deploy_autopilot_provisioning_mode"]); Assert.Equal("completed", result["deploy_autopilot_hash_upload_state"]); Assert.True((bool)result["deploy_autopilot_hash_group_tag_selected"]!); + Assert.True((bool)result["deploy_domain_join_enabled"]!); + Assert.Equal("interactive", result["deploy_domain_join_mode"]); + Assert.Equal("selected", result["deploy_domain_join_ou_source"]); + Assert.Equal("default", result["deploy_domain_join_domain_source"]); + Assert.Equal("staged", result["deploy_domain_join_status"]); Assert.False(result.ContainsKey("operating_system_url")); Assert.False(result.ContainsKey("driver_pack_url")); Assert.False(result.ContainsKey("target_computer_name")); diff --git a/src/Foundry.Telemetry/TelemetryEventPropertyPolicy.cs b/src/Foundry.Telemetry/TelemetryEventPropertyPolicy.cs index 23b10fe43..01d36ecfe 100644 --- a/src/Foundry.Telemetry/TelemetryEventPropertyPolicy.cs +++ b/src/Foundry.Telemetry/TelemetryEventPropertyPolicy.cs @@ -92,6 +92,12 @@ public static class TelemetryEventPropertyPolicy "boot_media_deploy_runtime_payload_source", "autopilot_enabled", "autopilot_provisioning_mode", + "domain_join_enabled", + "domain_join_mode", + "domain_join_domain_count", + "domain_join_shared_account_used", + "domain_join_ou_count", + "domain_join_default_ou_set", "deployment_protection_enabled", "unattend_enabled", "unattend_default_mode", @@ -221,6 +227,11 @@ public static class TelemetryEventPropertyPolicy "deploy_autopilot_provisioning_mode", "deploy_autopilot_hash_upload_state", "deploy_autopilot_hash_group_tag_selected", + "deploy_domain_join_enabled", + "deploy_domain_join_mode", + "deploy_domain_join_domain_source", + "deploy_domain_join_ou_source", + "deploy_domain_join_status", "deploy_completion_reboot_mode", "deploy_completion_reboot_delay_seconds" } diff --git a/src/Foundry.Utilities.Tests/Diagnostics/DiagnosticContentSanitizerTests.cs b/src/Foundry.Utilities.Tests/Diagnostics/DiagnosticContentSanitizerTests.cs index fc0f413e8..6637db088 100644 --- a/src/Foundry.Utilities.Tests/Diagnostics/DiagnosticContentSanitizerTests.cs +++ b/src/Foundry.Utilities.Tests/Diagnostics/DiagnosticContentSanitizerTests.cs @@ -8,6 +8,28 @@ namespace Foundry.Utilities.Tests.Diagnostics; public sealed class DiagnosticContentSanitizerTests { + [Theory] + [InlineData("""{"Password":"prefix-canary-tail","Domain":"corp.example.test"}""")] + [InlineData("""{"dOmAiN_jOiN_pAsSwOrD":"prefix\"canary-tail","Domain":"corp.example.test"}""")] + [InlineData("""{"DomainJoinPassword":"prefix\\\"canary-tail","Domain":"corp.example.test"}""")] + [InlineData("""{\"DomainJoinPassword\":\"prefix\\\"canary-tail\",\"Domain\":\"corp.example.test\"}""")] + [InlineData("""{"Domain Join Password":"prefix\"canary-tail","Domain":"corp.example.test"}""")] + [InlineData("""{\"Domain.JoinPassword\":\"prefix\\\"canary-tail\",\"Domain\":\"corp.example.test\"}""")] + public void Sanitize_QuotedCredentialValuesDoNotLeakEscapedSuffixes(string text) + { + string singleLine = DiagnosticContentSanitizer.Sanitize(text); + string multiline = DiagnosticContentSanitizer.SanitizeMultiline("Before\r\n" + text + "\r\nAfter"); + + Assert.DoesNotContain("prefix", singleLine, StringComparison.Ordinal); + Assert.DoesNotContain("canary-tail", singleLine, StringComparison.Ordinal); + Assert.DoesNotContain("prefix", multiline, StringComparison.Ordinal); + Assert.DoesNotContain("canary-tail", multiline, StringComparison.Ordinal); + Assert.Contains("corp.example.test", singleLine, StringComparison.Ordinal); + Assert.StartsWith("Before\r\n", multiline, StringComparison.Ordinal); + Assert.EndsWith("\r\nAfter", multiline, StringComparison.Ordinal); + Assert.Contains("", singleLine, StringComparison.Ordinal); + } + [Fact] public void Sanitize_RemovesSensitiveDiagnosticValues() { diff --git a/src/Foundry.Utilities.Tests/Diagnostics/LogEventNormalizerTests.cs b/src/Foundry.Utilities.Tests/Diagnostics/LogEventNormalizerTests.cs index ab437b24e..5bbd05327 100644 --- a/src/Foundry.Utilities.Tests/Diagnostics/LogEventNormalizerTests.cs +++ b/src/Foundry.Utilities.Tests/Diagnostics/LogEventNormalizerTests.cs @@ -10,6 +10,28 @@ namespace Foundry.Utilities.Tests.Diagnostics; public sealed class LogEventNormalizerTests { + [Theory] + [InlineData("""JSON "DomainJoinPassword":"prefix\"canary-tail","Domain":"corp.example.test" end""")] + [InlineData("""JSON \"DomainJoinPassword\":\"prefix\\\"canary-tail\",\"Domain\":\"corp.example.test\" end""")] + public void Normalize_QuotedCredentialValuesAreMaskedInEveryTextSurface(string text) + { + var source = new LogEvent(DateTimeOffset.UtcNow, LogEventLevel.Error, + new InvalidOperationException(text), new MessageTemplateParser().Parse(text + " Output: {Output}"), + [new("Output", new ScalarValue(text)), new("DomainJoinPassword", new ScalarValue("structured-canary")), + new("Domain", new ScalarValue("corp.example.test")), new("Placement", new ScalarValue("Succeeded"))]); + + LogEvent result = LogEventNormalizer.Normalize(source); + + Assert.DoesNotContain("canary-tail", result.RenderMessage(), StringComparison.Ordinal); + Assert.DoesNotContain("prefix", result.RenderMessage(), StringComparison.Ordinal); + Assert.DoesNotContain("canary-tail", Assert.IsType(((ScalarValue)result.Properties["Output"]).Value), StringComparison.Ordinal); + Assert.DoesNotContain("canary-tail", result.Exception!.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain("prefix", result.Exception.ToString(), StringComparison.Ordinal); + Assert.Equal("", ((ScalarValue)result.Properties["DomainJoinPassword"]).Value); + Assert.Equal("corp.example.test", ((ScalarValue)result.Properties["Domain"]).Value); + Assert.Equal("Succeeded", ((ScalarValue)result.Properties["Placement"]).Value); + } + [Fact] public void Normalize_MasksSignedUrlsUsedAsDictionaryKeysWithoutDroppingCollidingEntries() { diff --git a/src/Foundry.Utilities.Tests/Diagnostics/SupportBundleExporterTests.cs b/src/Foundry.Utilities.Tests/Diagnostics/SupportBundleExporterTests.cs index af6d6e2c5..62e2431b7 100644 --- a/src/Foundry.Utilities.Tests/Diagnostics/SupportBundleExporterTests.cs +++ b/src/Foundry.Utilities.Tests/Diagnostics/SupportBundleExporterTests.cs @@ -11,6 +11,78 @@ namespace Foundry.Utilities.Tests.Diagnostics; public sealed class SupportBundleExporterTests { + [Theory] + [InlineData(SupportBundlePrivacyMode.Sanitized, "credentials.bin", true, false)] + [InlineData(SupportBundlePrivacyMode.Raw, "CrEdEnTiAlS.BiN", true, false)] + [InlineData(SupportBundlePrivacyMode.Sanitized, "credentials.bin", true, true)] + [InlineData(SupportBundlePrivacyMode.Raw, "CrEdEnTiAlS.BiN", true, true)] + [InlineData(SupportBundlePrivacyMode.Sanitized, "CrEdEnTiAlS.BiN", false, false)] + [InlineData(SupportBundlePrivacyMode.Raw, "credentials.bin", false, false)] + public async Task ExportAsync_ExplicitDomainCredentialCandidateIsOmittedBeforeReading( + SupportBundlePrivacyMode privacyMode, string fileName, bool exists, bool locked) + { + using var tempDirectory = new TemporaryDirectory(); + string payloadDirectory = Path.Combine(tempDirectory.Path, "Payloads", "DomainJoin", "private-operation"); + Directory.CreateDirectory(payloadDirectory); + string credentialPath = Path.Combine(payloadDirectory, fileName); + if (exists) + { + await File.WriteAllTextAsync(credentialPath, "payload-canary", TestContext.Current.CancellationToken); + } + string logPath = Path.Combine(tempDirectory.Path, "Foundry.log"); + await File.WriteAllTextAsync(logPath, "Ordinary log", TestContext.Current.CancellationToken); + using FileStream? lockedPayload = locked ? new FileStream(credentialPath, FileMode.Open, FileAccess.Read, FileShare.None) : null; + + SupportBundleResult result = await new SupportBundleExporter().ExportAsync(new SupportBundleRequest + { + ApplicationName = "Foundry", + ApplicationVersion = "1.0.0", + SessionId = "ABC12345", + DestinationDirectoryPath = Path.Combine(tempDirectory.Path, "export"), + LogFilePaths = [credentialPath, logPath], + PrivacyMode = privacyMode + }, TestContext.Current.CancellationToken); + + using ZipArchive archive = ZipFile.OpenRead(result.ArchivePath); + Assert.Equal("Ordinary log", await ReadEntryAsync(archive, "logs/Foundry.log")); + Assert.Single(archive.Entries, entry => entry.FullName.StartsWith("logs/", StringComparison.Ordinal)); + string manifestJson = await ReadEntryAsync(archive, "manifest.json"); + Assert.DoesNotContain("payload-canary", manifestJson, StringComparison.Ordinal); + Assert.DoesNotContain("private-operation", manifestJson, StringComparison.Ordinal); + using JsonDocument manifest = JsonDocument.Parse(manifestJson); + JsonElement omission = Assert.Single(manifest.RootElement.GetProperty("omittedFiles").EnumerateArray()); + Assert.Equal(1, omission.GetProperty("sourceIndex").GetInt32()); + Assert.Equal(fileName, omission.GetProperty("fileName").GetString()); + Assert.Equal("SensitiveCredentialPayload", omission.GetProperty("reason").GetString()); + Assert.Equal(2, Assert.Single(manifest.RootElement.GetProperty("includedFiles").EnumerateArray()).GetProperty("sourceIndex").GetInt32()); + } + + [Fact] + public async Task ExportAsync_SanitizedQuotedCredentialsNeverReachLogsOrSummary() + { + using var tempDirectory = new TemporaryDirectory(); + string logPath = Path.Combine(tempDirectory.Path, "Foundry.log"); + const string text = """{"DomainJoinPassword":"prefix\"bundle-canary","Domain":"corp.example.test"}"""; + await File.WriteAllTextAsync(logPath, text, TestContext.Current.CancellationToken); + SupportBundleResult result = await new SupportBundleExporter().ExportAsync(new SupportBundleRequest + { + ApplicationName = "Foundry", + ApplicationVersion = "1.0.0", + SessionId = "ABC12345", + DestinationDirectoryPath = Path.Combine(tempDirectory.Path, "export"), + LogFilePaths = [logPath], + Summary = new Dictionary { ["Output"] = text } + }, TestContext.Current.CancellationToken); + + using ZipArchive archive = ZipFile.OpenRead(result.ArchivePath); + foreach (ZipArchiveEntry entry in archive.Entries) + { + string content = await ReadEntryAsync(archive, entry.FullName); + Assert.DoesNotContain("bundle-canary", content, StringComparison.Ordinal); + Assert.DoesNotContain("prefix", content, StringComparison.Ordinal); + } + } + [Fact] public async Task ExportAsync_SanitizedModeRedactsSensitiveLogContent() { diff --git a/src/Foundry.Utilities/Diagnostics/DiagnosticContentSanitizer.cs b/src/Foundry.Utilities/Diagnostics/DiagnosticContentSanitizer.cs index db258b22f..995050905 100644 --- a/src/Foundry.Utilities/Diagnostics/DiagnosticContentSanitizer.cs +++ b/src/Foundry.Utilities/Diagnostics/DiagnosticContentSanitizer.cs @@ -39,7 +39,7 @@ private static string SanitizeCore(string value, int maximumLength) { ArgumentOutOfRangeException.ThrowIfLessThan(maximumLength, TruncationMarker.Length); - string sanitized = value; + string sanitized = LogSecretMasker.MaskJsonCredentials(value); sanitized = UriPattern().Replace(sanitized, static match => SanitizeUriText(match.Value)); sanitized = BearerTokenPattern().Replace(sanitized, "Bearer "); sanitized = SensitivePropertyPattern().Replace(sanitized, "$1="); diff --git a/src/Foundry.Utilities/Diagnostics/LogSecretMasker.cs b/src/Foundry.Utilities/Diagnostics/LogSecretMasker.cs index 3e2fafaa1..99c87d889 100644 --- a/src/Foundry.Utilities/Diagnostics/LogSecretMasker.cs +++ b/src/Foundry.Utilities/Diagnostics/LogSecretMasker.cs @@ -32,7 +32,8 @@ public static bool IsSecretName(string name) /// Removes explicit credentials embedded in free text without truncating diagnostic content. public static string Mask(string text) { - string result = PrivateKey().Replace(text, Redacted); + string result = MaskJsonCredentials(text); + result = PrivateKey().Replace(result, Redacted); result = CookieHeader().Replace(result, "$1" + Redacted); result = CredentialArgument().Replace(result, "$1" + Redacted); result = Authorization().Replace(result, "$1" + Redacted); @@ -40,6 +41,25 @@ public static string Mask(string text) return UrlCredentials().Replace(result, "$1" + Redacted + "@"); } + /// Masks complete credential values in JSON text and a single JSON-escaped representation. + internal static string MaskJsonCredentials(string text) + { + string result = EscapedJsonCredential().Replace(text, static match => IsSecretName(match.Groups["name"].Value) + ? match.Groups["prefix"].Value + Redacted + "\\\"" : match.Value); + return JsonCredential().Replace(result, static match => IsSecretName(match.Groups["name"].Value) + ? match.Groups["prefix"].Value + Redacted + "\"" : match.Value); + } + + [GeneratedRegex(""" + (?"(?[^"\\]+)"\s*:\s*")(?:\\.|[^"\\])*" + """, RegexOptions.CultureInvariant)] + private static partial Regex JsonCredential(); + + [GeneratedRegex(""" + (?\\"(?[^"\\]+)\\"\s*:\s*\\")(?:\\\\(?:\\.|[^\\])|[^"\\])*\\" + """, RegexOptions.CultureInvariant)] + private static partial Regex EscapedJsonCredential(); + /// Recognizes placeholders whose surrounding template labels their value as a credential. internal static HashSet GetSecretTemplateProperties(string template) { diff --git a/src/Foundry.Utilities/Diagnostics/SupportBundleExporter.cs b/src/Foundry.Utilities/Diagnostics/SupportBundleExporter.cs index 46827fa93..2cc5d3384 100644 --- a/src/Foundry.Utilities/Diagnostics/SupportBundleExporter.cs +++ b/src/Foundry.Utilities/Diagnostics/SupportBundleExporter.cs @@ -62,6 +62,11 @@ public async Task ExportAsync( sourceIndex++; cancellationToken.ThrowIfCancellationRequested(); string sourceName = Path.GetFileName(sourcePath); + if (sourceName.Equals("credentials.bin", StringComparison.OrdinalIgnoreCase)) + { + omittedFiles.Add(new SupportBundleOmission(sourceIndex, sourceName, "SensitiveCredentialPayload")); + continue; + } try { string content = await ReadSharedTextAsync(sourcePath, cancellationToken).ConfigureAwait(false); diff --git a/src/Foundry/DependencyInjection/ServiceCollectionExtensions.cs b/src/Foundry/DependencyInjection/ServiceCollectionExtensions.cs index c675bebfe..09dd562b2 100644 --- a/src/Foundry/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Foundry/DependencyInjection/ServiceCollectionExtensions.cs @@ -120,6 +120,7 @@ public static IServiceCollection AddFoundryApplicationServices(this IServiceColl services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -168,6 +169,9 @@ public static IServiceCollection AddFoundryApplicationServices(this IServiceColl services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/src/Foundry/Foundry.csproj b/src/Foundry/Foundry.csproj index 52ae776fd..1eea8d279 100644 --- a/src/Foundry/Foundry.csproj +++ b/src/Foundry/Foundry.csproj @@ -55,6 +55,7 @@ + diff --git a/src/Foundry/FoundryApplicationInfo.cs b/src/Foundry/FoundryApplicationInfo.cs index 22f94346b..7b5f5f031 100644 --- a/src/Foundry/FoundryApplicationInfo.cs +++ b/src/Foundry/FoundryApplicationInfo.cs @@ -48,6 +48,12 @@ public static class FoundryApplicationInfo /// public const string AutopilotInteractiveDocumentationUrl = DocumentationUrl + "/foundry-osd/autopilot/interactive-hardware-hash"; + /// Gets the interactive domain joining documentation URL. + public const string InteractiveDomainJoinDocumentationUrl = DocumentationUrl + "/foundry-osd/domain-join/interactive"; + + /// Gets the automatic domain joining documentation URL. + public const string ZeroTouchDomainJoinDocumentationUrl = DocumentationUrl + "/foundry-osd/domain-join/zero-touch"; + /// /// Gets the general configuration documentation URL. /// diff --git a/src/Foundry/Services/Configuration/ConfigurationOverviewService.cs b/src/Foundry/Services/Configuration/ConfigurationOverviewService.cs index af764d604..37370797e 100644 --- a/src/Foundry/Services/Configuration/ConfigurationOverviewService.cs +++ b/src/Foundry/Services/Configuration/ConfigurationOverviewService.cs @@ -18,6 +18,7 @@ internal sealed class ConfigurationOverviewService : IConfigurationOverviewServi private readonly IDeploymentProtectionSecretStateService deploymentProtectionSecretStateService; private readonly INetworkSecretStateService networkSecretStateService; private readonly IOobeAccountSecretStateService oobeAccountSecretStateService; + private readonly IDomainJoinSecretStateService domainJoinSecretStateService; private readonly IWinPeLanguageDiscoveryService languageDiscoveryService; private ConfigurationOverviewEvaluation? cachedEvaluation; @@ -27,6 +28,7 @@ public ConfigurationOverviewService( IDeploymentProtectionSecretStateService deploymentProtectionSecretStateService, INetworkSecretStateService networkSecretStateService, IOobeAccountSecretStateService oobeAccountSecretStateService, + IDomainJoinSecretStateService domainJoinSecretStateService, IWinPeLanguageDiscoveryService languageDiscoveryService) { this.adkService = adkService; @@ -34,6 +36,7 @@ public ConfigurationOverviewService( this.deploymentProtectionSecretStateService = deploymentProtectionSecretStateService; this.networkSecretStateService = networkSecretStateService; this.oobeAccountSecretStateService = oobeAccountSecretStateService; + this.domainJoinSecretStateService = domainJoinSecretStateService; this.languageDiscoveryService = languageDiscoveryService; adkService.StatusChanged += OnAdkStatusChanged; @@ -41,6 +44,7 @@ public ConfigurationOverviewService( deploymentProtectionSecretStateService.Changed += OnUnderlyingStateChanged; networkSecretStateService.Changed += OnUnderlyingStateChanged; oobeAccountSecretStateService.Changed += OnUnderlyingStateChanged; + domainJoinSecretStateService.Changed += OnUnderlyingStateChanged; } public event EventHandler? Changed; @@ -65,6 +69,7 @@ public ConfigurationOverviewEvaluation Evaluate() deploymentProtectionSecretStateService.IsValid, IsOobeAccountConfigurationReady = oobeAccountSecretStateService.Validate(configuration.Customization.Oobe).IsValid, IsAutopilotConfigurationReady = configurationStateService.IsAutopilotConfigurationReady, + IsDomainJoinConfigurationReady = configurationStateService.IsDomainJoinConfigurationReady, IsCustomImagesReady = configurationStateService.IsCustomImagesReady, IsPostInstallationReady = configurationStateService.IsPostInstallationReady, IsUnattendConfigurationReady = configurationStateService.IsUnattendConfigurationReady diff --git a/src/Foundry/Services/Configuration/DeploymentProfileCoordinator.cs b/src/Foundry/Services/Configuration/DeploymentProfileCoordinator.cs index 262a5135b..405849cfc 100644 --- a/src/Foundry/Services/Configuration/DeploymentProfileCoordinator.cs +++ b/src/Foundry/Services/Configuration/DeploymentProfileCoordinator.cs @@ -18,6 +18,7 @@ public sealed partial class DeploymentProfileCoordinator : IDisposable private readonly IDeploymentProfilePackageService packages; private readonly DeploymentProfileSessionService session; private readonly IFoundryConfigurationStateService configuration; + private readonly IDomainJoinSecretStateService domainSecrets; private readonly IAppDispatcher dispatcher; private readonly IShellNavigationGuardService navigationGuard; private readonly SemaphoreSlim gate = new(1, 1); @@ -61,12 +62,13 @@ public IDisposable SuspendActivation() public DeploymentProfileCoordinator(LocalDeploymentProfileRepository local, IDeploymentProfilePackageService packages, DeploymentProfileSessionService session, IFoundryConfigurationStateService configuration, IAppDispatcher dispatcher, IShellNavigationGuardService navigationGuard, - INetworkSecretStateService network, IDeploymentProtectionSecretStateService deployment, IOobeAccountSecretStateService accounts) + INetworkSecretStateService network, IDeploymentProtectionSecretStateService deployment, IOobeAccountSecretStateService accounts, IDomainJoinSecretStateService domainSecrets) { this.local = local; this.packages = packages; this.session = session; this.configuration = configuration; + this.domainSecrets = domainSecrets; observedConfiguration = configuration.Current; this.dispatcher = dispatcher; this.navigationGuard = navigationGuard; @@ -74,6 +76,7 @@ public DeploymentProfileCoordinator(LocalDeploymentProfileRepository local, IDep network.Changed += OnEdited; deployment.Changed += OnEdited; accounts.Changed += OnEdited; + domainSecrets.Changed += OnEdited; } public event EventHandler? Changed; @@ -500,6 +503,7 @@ private static bool HasSharedConfigurationChanges( !ReferenceEquals(previous.PreOobe, current.PreOobe) || !ReferenceEquals(previous.Unattend, current.Unattend) || !ReferenceEquals(previous.Autopilot, current.Autopilot) || + !ReferenceEquals(previous.DomainJoin, current.DomainJoin) || previous.General with { IsoOutputPath = current.General.IsoOutputPath, @@ -577,11 +581,11 @@ private void ClearSharedKey() private static void EnsureCompleteCheckpoint(DeploymentProfileDocument profile) { - if (profile.Secrets.Entries.Any(secret => secret.State is ProfileValueState.Unavailable or ProfileValueState.Omitted) || + if (profile.Secrets.Entries.Any(secret => secret.Purpose != ProfileSecretPurpose.DomainJoinPassword && (secret.State is ProfileValueState.Unavailable or ProfileValueState.Omitted)) || profile.Assets.Any(asset => asset.State is ProfileValueState.Unavailable or ProfileValueState.Omitted)) { Logger.Warning("Profile checkpoint is incomplete. UnavailableSecrets={UnavailableSecrets}, UnavailableAssets={UnavailableAssets}", - profile.Secrets.Entries.Count(secret => secret.State is ProfileValueState.Unavailable or ProfileValueState.Omitted), + profile.Secrets.Entries.Count(secret => secret.Purpose != ProfileSecretPurpose.DomainJoinPassword && (secret.State is ProfileValueState.Unavailable or ProfileValueState.Omitted)), profile.Assets.Count(asset => asset.State is ProfileValueState.Unavailable or ProfileValueState.Omitted)); throw new IncompleteProfileCheckpointException(); } @@ -615,6 +619,7 @@ public void Dispose() { // A close scope may outlive host disposal; it must not re-enable the destroyed shell. ownsNavigationGuard = false; + domainSecrets.Changed -= OnEdited; lifetime.Cancel(); debounce?.Cancel(); debounce?.Dispose(); diff --git a/src/Foundry/Services/Configuration/DeploymentProfileSessionService.cs b/src/Foundry/Services/Configuration/DeploymentProfileSessionService.cs index 08c0adae5..5df3807b2 100644 --- a/src/Foundry/Services/Configuration/DeploymentProfileSessionService.cs +++ b/src/Foundry/Services/Configuration/DeploymentProfileSessionService.cs @@ -23,6 +23,7 @@ public sealed class DeploymentProfileSessionService : IDisposable private readonly INetworkSecretStateService networkSecrets; private readonly IDeploymentProtectionSecretStateService deploymentSecrets; private readonly IOobeAccountSecretStateService accountSecrets; + private readonly IDomainJoinSecretStateService domainSecrets; private readonly IAutopilotHardwareHashSessionState autopilotSession; private DeploymentProfileDocument? sourceMetadata; private FoundryConfigurationDocument? materializedSourceMetadata; @@ -32,12 +33,14 @@ public DeploymentProfileSessionService( INetworkSecretStateService networkSecrets, IDeploymentProtectionSecretStateService deploymentSecrets, IOobeAccountSecretStateService accountSecrets, + IDomainJoinSecretStateService domainSecrets, IAutopilotHardwareHashSessionState autopilotSession) { this.configurationState = configurationState; this.networkSecrets = networkSecrets; this.deploymentSecrets = deploymentSecrets; this.accountSecrets = accountSecrets; + this.domainSecrets = domainSecrets; this.autopilotSession = autopilotSession; configurationState.StateChanged += OnConfigurationChanged; } @@ -76,6 +79,28 @@ public Task CaptureAsync(Guid profileId, string displ Add(ProfileSecretPurpose.WifiPassphrase, configuration.Network.Wifi.Passphrase); } + // An account the media does not use (Interactive, or Domain Join switched off) only records a password + // it really has, so such a profile never reports a missing or omitted domain secret. + HashSet usedAccounts = configuration.DomainJoin.GetReferencedAccountNames().ToHashSet(StringComparer.Ordinal); + foreach (string account in configuration.DomainJoin.GetStoredAccountNames()) + { + char[]? password = domainSecrets.GetPasswordCopy(account); + try + { + if (!usedAccounts.Contains(account) && (!includeSecrets || password is null)) continue; + entries.Add((new DeploymentProfileSecret + { + Purpose = ProfileSecretPurpose.DomainJoinPassword, + State = !includeSecrets ? ProfileValueState.Omitted : password is null ? ProfileValueState.Unavailable : ProfileValueState.Present, + Value = includeSecrets && password is not null ? EncodePassword(password) : null + }, account)); + } + finally + { + if (password is not null) CryptographicOperations.ZeroMemory(MemoryMarshal.AsBytes(password.AsSpan())); + } + } + OobeSettings oobe = configuration.Customization.Oobe; if (oobe.IsEnabled) { @@ -195,6 +220,13 @@ void AddPair(ProfileSecretPurpose purpose, char[] password, char[] confirmation, } } + private static byte[] EncodePassword(ReadOnlySpan password) + { + byte[] bytes = new byte[Encoding.UTF8.GetByteCount(password)]; + Encoding.UTF8.GetBytes(password, bytes); + return bytes; + } + private static FoundryConfigurationDocument FreezeConfiguration(FoundryConfigurationDocument source) { AutopilotBootMediaCertificateSettings certificate = source.Autopilot.HardwareHashUpload.BootMediaCertificate with { PfxPassword = null }; @@ -224,6 +256,7 @@ public void ClearSensitiveState() networkSecrets.Update(new NetworkSettings()); deploymentSecrets.Clear(); accountSecrets.Update(new OobeSettings()); + domainSecrets.Clear(); autopilotSession.ClearTenantConnection(); autopilotSession.BootMediaCertificate = new(); Logger.Debug("Profile session credentials cleared."); @@ -266,23 +299,47 @@ public void Activate(DeploymentProfileDocument profile, FoundryConfigurationDocu } } - configurationState.Replace(materialized, () => + (string Account, char[] Password)[] domainPasswords = GetDomainPasswords(); + try { - ClearSensitiveState(); - deploymentSecrets.SetPassword(deploymentPassword); - deploymentSecrets.SetConfirmation(deploymentPassword); - accountSecrets.SetAdministratorPassword(administratorPassword); - accountSecrets.SetAdministratorConfirmation(administratorPassword); - foreach ((string id, string? password) in additionalPasswords) + configurationState.Replace(materialized, () => { - accountSecrets.SetAdditionalAccountPassword(id, password.AsSpan()); - accountSecrets.SetAdditionalAccountConfirmation(id, password.AsSpan()); - } - autopilotSession.BootMediaCertificate = boot; - }); + ClearSensitiveState(); + deploymentSecrets.SetPassword(deploymentPassword); + deploymentSecrets.SetConfirmation(deploymentPassword); + accountSecrets.SetAdministratorPassword(administratorPassword); + accountSecrets.SetAdministratorConfirmation(administratorPassword); + foreach ((string id, string? password) in additionalPasswords) + { + accountSecrets.SetAdditionalAccountPassword(id, password.AsSpan()); + accountSecrets.SetAdditionalAccountConfirmation(id, password.AsSpan()); + } + foreach ((string account, char[] password) in domainPasswords) domainSecrets.SetPassword(account, password); + autopilotSession.BootMediaCertificate = boot; + }); + } + finally + { + foreach ((_, char[] password) in domainPasswords) CryptographicOperations.ZeroMemory(MemoryMarshal.AsBytes(password.AsSpan())); + } RememberSourceMetadata(profile, materialized); Logger.Information("Profile session activation completed. ProfileId={ProfileId}", profile.ProfileId); + // Only accounts the materialized configuration still stores, and only when qualified, can own a password. + (string Account, char[] Password)[] GetDomainPasswords() + { + var passwords = new List<(string, char[])>(); + foreach (string account in materialized.DomainJoin.GetStoredAccountNames()) + { + if (!DomainJoinConfigurationValidator.IsQualifiedAccount(account)) continue; + string identity = DeploymentProfileSecretBinding.Identity(ProfileSecretPurpose.DomainJoinPassword, profile, account); + DeploymentProfileSecret? secret = profile.Secrets.Entries.SingleOrDefault(secret => secret.Purpose == ProfileSecretPurpose.DomainJoinPassword && secret.Identity == identity); + if (secret?.State == ProfileValueState.Present) passwords.Add((account, Encoding.UTF8.GetChars(secret.Value!))); + } + + return passwords.ToArray(); + } + string? Get(ProfileSecretPurpose purpose, string? accountId = null) { string identity = DeploymentProfileSecretBinding.Identity(purpose, profile, accountId); diff --git a/src/Foundry/Services/Configuration/DomainJoinSecretStateService.cs b/src/Foundry/Services/Configuration/DomainJoinSecretStateService.cs new file mode 100644 index 000000000..8224a3dcd --- /dev/null +++ b/src/Foundry/Services/Configuration/DomainJoinSecretStateService.cs @@ -0,0 +1,31 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Models.Configuration; +using Foundry.Core.Services.Configuration; + +namespace Foundry.Services.Configuration; + +/// Owns the desktop session's join account passwords and publishes changes to profile persistence. +internal sealed class DomainJoinSecretStateService : IDomainJoinSecretStateService, IDisposable +{ + private readonly DomainJoinSecretState state = new(); + public event EventHandler? Changed; + public void SetPassword(string accountName, ReadOnlySpan value) + { + state.SetPassword(accountName, value); + Changed?.Invoke(this, EventArgs.Empty); + } + public char[]? GetPasswordCopy(string accountName) => state.GetPasswordCopy(accountName); + public bool HasPassword(string accountName) => state.HasPassword(accountName); + public void Update(DomainJoinSettings settings) + { + if (state.Update(settings)) Changed?.Invoke(this, EventArgs.Empty); + } + public void Clear() + { + if (state.Clear()) Changed?.Invoke(this, EventArgs.Empty); + } + public void Dispose() => state.Dispose(); +} diff --git a/src/Foundry/Services/Configuration/FoundryConfigurationStateService.cs b/src/Foundry/Services/Configuration/FoundryConfigurationStateService.cs index a99df3b0c..4bff8f4a8 100644 --- a/src/Foundry/Services/Configuration/FoundryConfigurationStateService.cs +++ b/src/Foundry/Services/Configuration/FoundryConfigurationStateService.cs @@ -31,6 +31,7 @@ internal sealed class FoundryConfigurationStateService : IFoundryConfigurationSt private readonly INetworkSecretStateService networkSecretStateService; private readonly IDeploymentProtectionSecretStateService deploymentProtectionSecretStateService; private readonly IOobeAccountSecretStateService oobeAccountSecretStateService; + private readonly IDomainJoinSecretStateService domainJoinSecretStateService; private readonly IAutopilotHardwareHashSessionState autopilotHardwareHashSessionState; private readonly AppSettingsService appSettingsService; private readonly ILogger logger; @@ -47,6 +48,7 @@ public FoundryConfigurationStateService( INetworkSecretStateService networkSecretStateService, IDeploymentProtectionSecretStateService deploymentProtectionSecretStateService, IOobeAccountSecretStateService oobeAccountSecretStateService, + IDomainJoinSecretStateService domainJoinSecretStateService, IAutopilotHardwareHashSessionState autopilotHardwareHashSessionState, AppSettingsService appSettingsService, ILogger logger, @@ -58,6 +60,7 @@ public FoundryConfigurationStateService( this.networkSecretStateService = networkSecretStateService; this.deploymentProtectionSecretStateService = deploymentProtectionSecretStateService; this.oobeAccountSecretStateService = oobeAccountSecretStateService; + this.domainJoinSecretStateService = domainJoinSecretStateService; this.autopilotHardwareHashSessionState = autopilotHardwareHashSessionState; this.appSettingsService = appSettingsService; this.logger = logger.ForContext(); @@ -89,6 +92,7 @@ public void Replace(FoundryConfigurationDocument document, Action restoreSecrets restoreSecrets(); networkSecretStateService.Update(document.Network); oobeAccountSecretStateService.Update(document.Customization.Oobe); + domainJoinSecretStateService.Update(document.DomainJoin); Current = candidate; validatedUnattendSettings = null; unattendSourceValidations = []; @@ -187,6 +191,12 @@ public bool IsDeployConfigurationReady /// public bool IsAutopilotConfigurationReady => AutopilotConfigurationValidation.IsReady; + /// + public bool IsDomainJoinConfigurationReady => DomainJoinConfigurationValidator.EvaluateReadiness( + Current.DomainJoin, + domainJoinSecretStateService.HasPassword, + Current.General.DeploymentProtection.IsEnabled && deploymentProtectionSecretStateService.IsValid).IsValid; + /// public bool IsUnattendConfigurationReady { @@ -364,9 +374,20 @@ public void UpdateCustomization(CustomizationSettings settings) /// public void UpdateAutopilot(AutopilotSettings settings) { - ArgumentNullException.ThrowIfNull(settings); - Current = Current with { Autopilot = SanitizeAutopilotForPersistence(settings) }; - Save(); + UpdateProvisioningSelection(settings, Current.DomainJoin); + } + + /// + public void UpdateDomainJoin(DomainJoinSettings settings) => UpdateProvisioningSelection(Current.Autopilot, settings); + + /// + public void UpdateProvisioningSelection(AutopilotSettings autopilot, DomainJoinSettings domainJoin) + { + DomainJoinConfigurationValidator.ThrowIfProvisioningModesConflict(autopilot, domainJoin); + FoundryConfigurationDocument candidate = Current with { Autopilot = SanitizeAutopilotForPersistence(autopilot), DomainJoin = domainJoin }; + Save(candidate); + Current = candidate; + domainJoinSecretStateService.Update(domainJoin); StateChanged?.Invoke(this, EventArgs.Empty); } @@ -434,9 +455,10 @@ private string GenerateDeployConfigurationJson( } using OobeAccountSecretState oobeAccountSecretState = CreateOobeAccountSecretStateForDeployGeneration(document.Customization.Oobe); + using DomainJoinSecretState domainSecrets = CreateDomainJoinSecretState(document.DomainJoin); return deployConfigurationGenerator.Serialize( - deployConfigurationGenerator.Generate(document, deploymentSecretsKey, protectionSettings, oobeAccountSecretState)); + deployConfigurationGenerator.Generate(document, deploymentSecretsKey, protectionSettings, oobeAccountSecretState, domainSecrets)); } /// @@ -450,13 +472,14 @@ public Task CaptureBuildSnapshotAsync( Network = networkSecretStateService.ApplyRequiredSecrets(Current.Network) }; using OobeAccountSecretState accountSecrets = CreateOobeAccountSecretStateForDeployGeneration(document.Customization.Oobe); + using DomainJoinSecretState domainSecrets = CreateDomainJoinSecretState(document.DomainJoin); char[] password = document.General.DeploymentProtection.IsEnabled ? deploymentProtectionSecretStateService.GetConfirmedPasswordCopy() : []; try { return DeploymentBuildSnapshot.CaptureAsync( - document, accountSecrets, password, privateRootDirectory, authoringVersion, cancellationToken); + document, accountSecrets, domainSecrets, password, privateRootDirectory, authoringVersion, cancellationToken); } finally { @@ -464,6 +487,28 @@ public Task CaptureBuildSnapshotAsync( } } + private DomainJoinSecretState CreateDomainJoinSecretState(DomainJoinSettings settings) + { + var state = new DomainJoinSecretState(); + try + { + foreach (string account in settings.GetReferencedAccountNames()) + { + char[]? password = domainJoinSecretStateService.GetPasswordCopy(account); + if (password is null) continue; + try { state.SetPassword(account, password); } + finally { CryptographicOperations.ZeroMemory(MemoryMarshal.AsBytes(password.AsSpan())); } + } + + return state; + } + catch + { + state.Dispose(); + throw; + } + } + private FoundryConfigurationDocument Load(out bool isLegacyMigration) { isLegacyMigration = false; @@ -497,7 +542,7 @@ private FoundryConfigurationDocument Load(out bool isLegacyMigration) logger.Information("Foundry configuration state loaded from disk."); return document; } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Text.Json.JsonException or ArgumentException) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Text.Json.JsonException or ArgumentException or InvalidOperationException) { string backupPath = Constants.FoundryConfigurationStatePath + ".invalid"; if (sourcePath == Constants.FoundryConfigurationStatePath) @@ -629,6 +674,7 @@ private void Save(FoundryConfigurationDocument? candidate = null, bool throwOnFa private static FoundryConfigurationDocument SanitizeForPersistence(FoundryConfigurationDocument document) { + DomainJoinConfigurationValidator.ThrowIfProvisioningModesConflict(document.Autopilot, document.DomainJoin); return document with { General = SanitizeGeneralForPersistence(document.General), diff --git a/src/Foundry/Services/Configuration/IDomainJoinSecretStateService.cs b/src/Foundry/Services/Configuration/IDomainJoinSecretStateService.cs new file mode 100644 index 000000000..9248559fc --- /dev/null +++ b/src/Foundry/Services/Configuration/IDomainJoinSecretStateService.cs @@ -0,0 +1,20 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Models.Configuration; + +namespace Foundry.Services.Configuration; + +/// Provides the volatile passwords of the join accounts, one per account, without persisting them in authoring configuration. +public interface IDomainJoinSecretStateService +{ + event EventHandler? Changed; + /// Stores the password of a qualified account; an empty value removes it. + void SetPassword(string accountName, ReadOnlySpan value); + /// Returns an owned copy for the account. The caller must clear the buffer. + char[]? GetPasswordCopy(string accountName); + bool HasPassword(string accountName); + void Update(DomainJoinSettings settings); + void Clear(); +} diff --git a/src/Foundry/Services/Configuration/IFoundryConfigurationStateService.cs b/src/Foundry/Services/Configuration/IFoundryConfigurationStateService.cs index 7158aeb7b..1baa5d0bb 100644 --- a/src/Foundry/Services/Configuration/IFoundryConfigurationStateService.cs +++ b/src/Foundry/Services/Configuration/IFoundryConfigurationStateService.cs @@ -141,6 +141,15 @@ public interface IFoundryConfigurationStateService /// New Autopilot settings. void UpdateAutopilot(AutopilotSettings settings); + /// Persists the Domain Join settings and erases the passwords of accounts they no longer store. + void UpdateDomainJoin(DomainJoinSettings settings); + + /// Publishes one exclusive provisioning change after a successful candidate save. + void UpdateProvisioningSelection(AutopilotSettings autopilot, DomainJoinSettings domainJoin); + + /// Gets domain metadata and automatic credential/protection readiness for the active mode. + bool IsDomainJoinConfigurationReady { get; } + /// /// Persists answer-file metadata and source references without storing XML content. /// diff --git a/src/Foundry/Services/Configuration/ProvisioningModeReplacementConfirmation.cs b/src/Foundry/Services/Configuration/ProvisioningModeReplacementConfirmation.cs new file mode 100644 index 000000000..9197d167c --- /dev/null +++ b/src/Foundry/Services/Configuration/ProvisioningModeReplacementConfirmation.cs @@ -0,0 +1,37 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Services.Application; +using Foundry.Core.Services.Configuration; +using Foundry.Services.Localization; + +namespace Foundry.Services.Configuration; + +/// Asks the same replacement question on every Autopilot and Domain Join page, naming both modes. +internal static class ProvisioningModeReplacementConfirmation +{ + /// Returns whether the user accepted replacing with . + public static Task ConfirmAsync(IDialogService dialogs, IApplicationLocalizationService localization, + ProvisioningSelection current, ProvisioningSelection requested) => + dialogs.ConfirmAsync(new ConfirmationDialogRequest( + localization.GetString("Autopilot.ModeSwitchConfirmationTitle"), + localization.FormatString( + "Autopilot.ModeSwitchConfirmationMessageFormat", + GetDisplayName(localization, current), + GetDisplayName(localization, requested)), + localization.GetString("Autopilot.ModeSwitchConfirmationPrimaryButton"), + localization.GetString("Common.Cancel"), + IsPrimaryButtonAccent: true)); + + private static string GetDisplayName(IApplicationLocalizationService localization, ProvisioningSelection selection) => + localization.GetString(selection switch + { + ProvisioningSelection.AutopilotJsonProfile => "Autopilot.JsonProfileHeader", + ProvisioningSelection.AutopilotHardwareHashUpload => "Autopilot.HardwareHashHeader", + ProvisioningSelection.AutopilotInteractiveHardwareHashUpload => "Autopilot.InteractiveHardwareHashHeader", + ProvisioningSelection.DomainJoinInteractive => "InteractiveDomainJoinPageHeader.Title", + ProvisioningSelection.DomainJoinAutomatic => "ZeroTouchDomainJoinPageHeader.Title", + _ => throw new ArgumentOutOfRangeException(nameof(selection)) + }); +} diff --git a/src/Foundry/Services/DomainJoin/AuthoringDomainOuDiscoveryService.cs b/src/Foundry/Services/DomainJoin/AuthoringDomainOuDiscoveryService.cs new file mode 100644 index 000000000..74a16aa78 --- /dev/null +++ b/src/Foundry/Services/DomainJoin/AuthoringDomainOuDiscoveryService.cs @@ -0,0 +1,214 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.DirectoryServices.Protocols; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Foundry.Core.Models.Configuration; +using Foundry.Core.Services.Configuration; +using Serilog; + +namespace Foundry.Services.DomainJoin; + +/// Owns serialized native locator/bind work until it drains, including after the UI stops waiting. +internal sealed class AuthoringDomainOuDiscoveryService : IAuthoringDomainOuDiscoveryService +{ + private readonly SemaphoreSlim operationGate = new(1, 1); + private readonly ILogger logger; + private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(10); + + public AuthoringDomainOuDiscoveryService(ILogger logger) => this.logger = logger.ForContext(); + + /// + public async Task DiscoverAsync(string domainName, CancellationToken cancellationToken) + { + logger.Information("Domain destination discovery started."); + var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + lifetime.CancelAfter(TimeSpan.FromSeconds(60)); + Task work = Task.Run(async () => + { + DomainOuDiscoveryResult? result = null; + try + { + await operationGate.WaitAsync(lifetime.Token).ConfigureAwait(false); + try + { + result = await DiscoverOwnedAsync(domainName, lifetime.Token).ConfigureAwait(false); + if (result.Status == DomainOuDiscoveryStatus.Canceled && !cancellationToken.IsCancellationRequested) + result = result with { Status = DomainOuDiscoveryStatus.Unavailable, ErrorCode = "Timeout" }; + return result; + } + finally { operationGate.Release(); } + } + catch (OperationCanceledException) + { + return result = new([], cancellationToken.IsCancellationRequested ? DomainOuDiscoveryStatus.Canceled : DomainOuDiscoveryStatus.Unavailable, + cancellationToken.IsCancellationRequested ? "Canceled" : "Timeout"); + } + finally + { + lifetime.Dispose(); + logger.Information("Domain destination discovery ended. Status={Status}, CandidateCount={CandidateCount}, FailureCode={FailureCode}, NativeErrorCode={NativeErrorCode}, LdapErrorCode={LdapErrorCode}, DirectoryResultCode={DirectoryResultCode}.", + result?.Status.ToString() ?? "Failed", result?.Candidates.Count ?? 0, result?.ErrorCode, + result?.NativeErrorCode, result?.LdapErrorCode, result?.DirectoryResultCode); + } + }, CancellationToken.None); + try { return await work.WaitAsync(TimeSpan.FromSeconds(60), cancellationToken).ConfigureAwait(false); } + catch (TimeoutException) { return new([], DomainOuDiscoveryStatus.Unavailable, "Timeout"); } + catch (OperationCanceledException) { return new([], DomainOuDiscoveryStatus.Canceled, "Canceled"); } + } + + private static async Task DiscoverOwnedAsync(string domainName, CancellationToken token) + { + string? domain = null; + string? namingContext = null; + List candidates = []; + try + { + token.ThrowIfCancellationRequested(); + (domain, string controller) = LocateDomain(domainName); + token.ThrowIfCancellationRequested(); + using var connection = new LdapConnection(new LdapDirectoryIdentifier(controller, 389, true, false), null, AuthType.Negotiate); + connection.Timeout = RequestTimeout; + connection.SessionOptions.ProtocolVersion = 3; + connection.SessionOptions.Signing = true; + connection.SessionOptions.Sealing = true; + connection.SessionOptions.ReferralChasing = ReferralChasingOptions.None; + connection.SessionOptions.AutoReconnect = false; + connection.SessionOptions.SendTimeout = RequestTimeout; + connection.Bind(); + token.ThrowIfCancellationRequested(); + var rootRequest = new SearchRequest("", "(objectClass=*)", SearchScope.Base, "defaultNamingContext", "dnsHostName", "supportedControl") + { TimeLimit = RequestTimeout }; + var root = (SearchResponse)await SendOwnedAsync(connection, rootRequest, token).ConfigureAwait(false); + if (root.Entries.Count != 1 || root.References.Count > 0) throw new InvalidDataException(); + namingContext = Attribute(root.Entries[0], "defaultNamingContext"); + if (!DistinguishedNameRules.IsWithinDomain(namingContext, domain)) throw new InvalidDataException(); + var request = new SearchRequest(namingContext, "(objectCategory=organizationalUnit)", SearchScope.Subtree, "distinguishedName", "name", "objectGUID") + { TimeLimit = RequestTimeout }; + var paging = new PageResultRequestControl(256) { IsCritical = true }; + request.Controls.Add(paging); + request.Controls.Add(new DomainScopeControl { IsCritical = true }); + while (true) + { + var response = (SearchResponse)await SendOwnedAsync(connection, request, token).ConfigureAwait(false); + bool valid = AddEntries(response, candidates, domain); + PageResultResponseControl[] pages = response.Controls.OfType().ToArray(); + if (!valid || response.References.Count > 0 || pages.Length != 1) + return new(candidates, DomainOuDiscoveryStatus.Incomplete, "Incomplete"); + if (pages[0].Cookie.Length == 0) + return new(candidates, DomainOuDiscoveryStatus.Complete); + if (candidates.Count >= 4096) + return new(candidates, DomainOuDiscoveryStatus.Incomplete, "Limit"); + paging.Cookie = pages[0].Cookie; + } + } + catch (OperationCanceledException) { return new([], DomainOuDiscoveryStatus.Canceled, "Canceled"); } + catch (DirectoryOperationException ex) + { + if (ex.Response is SearchResponse partial && domain is not null) AddEntries(partial, candidates, domain); + bool incomplete = candidates.Count > 0 || ex.Response?.ResultCode is ResultCode.TimeLimitExceeded or ResultCode.SizeLimitExceeded or ResultCode.Referral; + return new(candidates, incomplete ? DomainOuDiscoveryStatus.Incomplete : DomainOuDiscoveryStatus.Unavailable, + "DirectoryRead", DirectoryResultCode: ex.Response is null ? null : (int)ex.Response.ResultCode); + } + catch (Win32Exception ex) { return new([], DomainOuDiscoveryStatus.Unavailable, "DomainUnavailable", NativeErrorCode: ex.NativeErrorCode); } + catch (Exception ex) when (ex is LdapException or InvalidDataException or InvalidOperationException or ArgumentException or NotSupportedException) + { + return new(candidates, candidates.Count > 0 ? DomainOuDiscoveryStatus.Incomplete : DomainOuDiscoveryStatus.Unavailable, + ex is InvalidOperationException ? "DomainUnavailable" : "DirectoryRead", LdapErrorCode: (ex as LdapException)?.ErrorCode); + } + } + + private static bool AddEntries(SearchResponse response, List candidates, string domain) + { + bool valid = true; + foreach (SearchResultEntry entry in response.Entries) + { + if (candidates.Count >= 4096) return false; + string dn = Attribute(entry, "distinguishedName"); + string name = Attribute(entry, "name"); + DirectoryAttribute? guidAttribute = entry.Attributes["objectGUID"]; + if (!DistinguishedNameRules.IsWithinDomain(dn, domain) || name.Length is 0 or > 120 || name.Contains('\0') || + guidAttribute?.Count != 1 || guidAttribute[0] is not byte[] { Length: 16 } guid) + { valid = false; continue; } + candidates.Add(new() { Id = new Guid(guid).ToString("D"), DisplayName = name, DistinguishedName = dn }); + } + return valid; + } + + private static string Attribute(SearchResultEntry entry, string name) => entry.Attributes[name] is { Count: 1 } attribute && attribute[0] is string value + ? value : string.Empty; + + private static async Task SendOwnedAsync(LdapConnection connection, DirectoryRequest request, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var sync = new object(); + bool settled = false; + // Begin can synchronously connect/send. Cancellation is observed after it returns; the worker retains ownership. + IAsyncResult pending = connection.BeginSendRequest(request, RequestTimeout, PartialResultProcessing.NoPartialResultSupport, result => + { + lock (sync) + { + if (settled) return; + settled = true; + try { completion.TrySetResult(connection.EndSendRequest(result)); } + catch (Exception ex) { completion.TrySetException(ex); } + } + }, null); + using CancellationTokenRegistration registration = token.Register(() => + { + lock (sync) + { + if (settled) return; + settled = true; + try { connection.Abort(pending); } + catch (ArgumentException) { } + finally { completion.TrySetCanceled(token); } + } + }); + return await completion.Task.ConfigureAwait(false); + } + + /// Locates a domain controller of the named DNS domain; the computer need not be a member of it. + private static (string Domain, string Controller) LocateDomain(string domainName) + { + if (!DomainJoinCredentialContext.IsValidDomainName(domainName)) throw new InvalidOperationException(); + nint locator = 0; + try + { + // DS_DIRECTORY_SERVICE_REQUIRED | DS_IS_DNS_NAME | DS_RETURN_DNS_NAME + uint status = DsGetDcName(null, DomainJoinCredentialContext.CanonicalizeDomainName(domainName), 0, null, 0x10 | 0x20000 | 0x40000000, out locator); + if (status != 0) throw new Win32Exception(unchecked((int)status)); + DomainControllerInfo info = Marshal.PtrToStructure(locator); + string domain = Marshal.PtrToStringUni(info.DomainName) ?? string.Empty; + string controller = (Marshal.PtrToStringUni(info.DomainControllerName) ?? string.Empty).TrimStart('\\'); + if (!DomainJoinCredentialContext.IsValidDomainName(domain) || !DomainJoinCredentialContext.IsValidDomainName(controller)) throw new InvalidDataException(); + return (DomainJoinCredentialContext.CanonicalizeDomainName(domain), controller); + } + finally + { + if (locator != 0) NetApiBufferFree(locator); + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct DomainControllerInfo + { + public nint DomainControllerName; + public nint DomainControllerAddress; + public uint DomainControllerAddressType; + public Guid DomainGuid; + public nint DomainName; + public nint DnsForestName; + public uint Flags; + public nint DcSiteName; + public nint ClientSiteName; + } + + [DllImport("netapi32.dll", EntryPoint = "DsGetDcNameW", ExactSpelling = true, CharSet = CharSet.Unicode)] + private static extern uint DsGetDcName(string? computer, string? domain, nint guid, string? site, uint flags, out nint info); + [DllImport("netapi32.dll", ExactSpelling = true)] + private static extern uint NetApiBufferFree(nint buffer); +} diff --git a/src/Foundry/Services/DomainJoin/DomainJoinDialogService.cs b/src/Foundry/Services/DomainJoin/DomainJoinDialogService.cs new file mode 100644 index 000000000..c23c2a6c1 --- /dev/null +++ b/src/Foundry/Services/DomainJoin/DomainJoinDialogService.cs @@ -0,0 +1,61 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Models.Configuration; +using Foundry.Services.Localization; + +namespace Foundry.Services.DomainJoin; + +public sealed class DomainJoinDialogService(IApplicationLocalizationService localization) : IDomainJoinDialogService +{ + public async Task ShowDomainAsync(DomainJoinDomainDialogRequest request, Func trySave) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(trySave); + + var dialog = new DomainJoinDomainDialog(localization, request, trySave) + { + XamlRoot = App.MainWindow.Content.XamlRoot + }; + await dialog.ShowAsync(); + } + + public async Task ShowAddAsync(Func tryAdd) + { + ArgumentNullException.ThrowIfNull(tryAdd); + + var dialog = new DomainJoinOuAddDialog(localization, tryAdd) + { + XamlRoot = App.MainWindow.Content.XamlRoot + }; + await dialog.ShowAsync(); + } + + public async Task ShowRenameAsync(DomainJoinOrganizationalUnitSettings unit, Func tryRename) + { + ArgumentNullException.ThrowIfNull(unit); + ArgumentNullException.ThrowIfNull(tryRename); + + var dialog = new DomainJoinOuAddDialog(localization, (displayName, _) => tryRename(displayName), unit) + { + XamlRoot = App.MainWindow.Content.XamlRoot + }; + await dialog.ShowAsync(); + } + + public async Task?> PickAsync( + IReadOnlyList candidates, bool isIncomplete) + { + ArgumentNullException.ThrowIfNull(candidates); + + var viewModel = new DomainJoinOuSelectionDialogViewModel(localization, candidates, isIncomplete); + var dialog = new DomainJoinOuSelectionDialog(viewModel) + { + XamlRoot = App.MainWindow.Content.XamlRoot + }; + + ContentDialogResult result = await dialog.ShowAsync(); + return result == ContentDialogResult.Primary ? viewModel.GetSelected() : null; + } +} diff --git a/src/Foundry/Services/DomainJoin/IAuthoringDomainOuDiscoveryService.cs b/src/Foundry/Services/DomainJoin/IAuthoringDomainOuDiscoveryService.cs new file mode 100644 index 000000000..a65d95e38 --- /dev/null +++ b/src/Foundry/Services/DomainJoin/IAuthoringDomainOuDiscoveryService.cs @@ -0,0 +1,25 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Models.Configuration; + +namespace Foundry.Services.DomainJoin; + +/// Discovers the OUs of a named AD domain using the author's current Windows identity. +public interface IAuthoringDomainOuDiscoveryService +{ + /// + /// Returns a bounded list without retaining an authenticated connection. It works for the authoring computer's + /// own domain and for domains that trust the author's account and are reachable from this computer. + /// + Task DiscoverAsync(string domainName, CancellationToken cancellationToken); +} + +/// Distinguishes a complete list of OUs from partial results and unavailable discovery. +public enum DomainOuDiscoveryStatus { Complete, Incomplete, Unavailable, Canceled } + +/// Contains only portable directory metadata and an allowlisted presentation code. +public sealed record DomainOuDiscoveryResult( + IReadOnlyList Candidates, DomainOuDiscoveryStatus Status, string? ErrorCode = null, + int? NativeErrorCode = null, int? LdapErrorCode = null, int? DirectoryResultCode = null); diff --git a/src/Foundry/Services/DomainJoin/IDomainJoinDialogService.cs b/src/Foundry/Services/DomainJoin/IDomainJoinDialogService.cs new file mode 100644 index 000000000..89c05ef27 --- /dev/null +++ b/src/Foundry/Services/DomainJoin/IDomainJoinDialogService.cs @@ -0,0 +1,52 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Models.Configuration; + +namespace Foundry.Services.DomainJoin; + +/// Shows the dialogs that add domains and organizational units to the Domain Join lists. +public interface IDomainJoinDialogService +{ + /// + /// Asks for a domain and, in Zero-touch, the account it joins with. The dialog stays open while + /// returns a message, so the entry can be corrected in place. + /// + /// What the dialog starts with and which inputs it offers. + /// Saves the input; returns the refusal reason, or null. + Task ShowDomainAsync(DomainJoinDomainDialogRequest request, Func trySave); + + /// + /// Asks for one OU. The dialog stays open while returns a message, so the entry can + /// be corrected in place. + /// + /// Adds the display name and distinguished name; returns the refusal reason, or null. + Task ShowAddAsync(Func tryAdd); + + /// + /// Asks for a new display name for a listed OU. The dialog stays open while + /// returns a message, so the name can be corrected in place. + /// + /// The OU being renamed; its distinguished name is shown but cannot change. + /// Saves the display name; returns the refusal reason, or null. + Task ShowRenameAsync(DomainJoinOrganizationalUnitSettings unit, Func tryRename); + + /// Lets the user choose which discovered OUs to add. + /// OUs found in the searched domain. + /// Whether the directory returned only part of its OUs. + /// The chosen OUs, or when the dialog is canceled. + Task?> PickAsync( + IReadOnlyList candidates, bool isIncomplete); +} + +/// Describes the domain dialog: a new or existing domain, and whether it asks for a join account. +/// Whether a domain is being added rather than edited. +/// Whether the join account inputs are shown, which only Zero-touch needs. +/// Whether the name can change; it cannot while the domain lists OUs. +/// The current domain name, empty for a new domain. +/// The current dedicated account; means the shared account. +public sealed record DomainJoinDomainDialogRequest(bool IsNew, bool AsksForAccount, bool CanRename, string DomainName, string? AccountName); + +/// Carries what was entered in the domain dialog. The save callback copies the password and must not keep this record. +public sealed record DomainJoinDomainDialogInput(string DomainName, bool UsesSharedAccount, string AccountName, ReadOnlyMemory Password); diff --git a/src/Foundry/Services/Shell/NavigationRouteCatalog.cs b/src/Foundry/Services/Shell/NavigationRouteCatalog.cs index 8f0f09f18..affa39717 100644 --- a/src/Foundry/Services/Shell/NavigationRouteCatalog.cs +++ b/src/Foundry/Services/Shell/NavigationRouteCatalog.cs @@ -11,7 +11,8 @@ public enum NavigationSection General, Network, WindowsAutopilot, - Customization + Customization, + DomainJoin } public sealed record NavigationRoute( @@ -38,6 +39,8 @@ public static class NavigationRouteCatalog CreatePrimary("Nav_AutopilotJsonProfileKey", "E8A5", NavigationSection.WindowsAutopilot), CreatePrimary("Nav_AutopilotZeroTouchKey", "E753", NavigationSection.WindowsAutopilot), CreatePrimary("Nav_AutopilotInteractiveHashUploadKey", "E928", NavigationSection.WindowsAutopilot), + CreatePrimary("Nav_ZeroTouchDomainJoinKey", "F003", NavigationSection.DomainJoin), + CreatePrimary("Nav_InteractiveDomainJoinKey", "E928", NavigationSection.DomainJoin), CreatePrimary("Nav_OsSelectionKey", "EC77", NavigationSection.Customization), CreatePrimary("Nav_CustomImagesKey", "E958", NavigationSection.Customization), CreatePrimary("Nav_UnattendKey", "E8A5", NavigationSection.Customization), @@ -71,6 +74,7 @@ public static class NavigationRouteCatalog NavigationSection.General => "Nav_GeneralSection.Title", NavigationSection.Network => "Nav_NetworkSection.Title", NavigationSection.WindowsAutopilot => "Nav_WindowsAutopilotSection.Title", + NavigationSection.DomainJoin => "Nav_DomainJoinSection.Title", NavigationSection.Customization => "Nav_CustomizationSection.Title", _ => throw new ArgumentOutOfRangeException(nameof(section), section, null) }; diff --git a/src/Foundry/Services/Shell/NavigationStatusService.cs b/src/Foundry/Services/Shell/NavigationStatusService.cs index b97d4fe72..eb979f393 100644 --- a/src/Foundry/Services/Shell/NavigationStatusService.cs +++ b/src/Foundry/Services/Shell/NavigationStatusService.cs @@ -47,14 +47,17 @@ public NavigationStatusService( return null; } - bool isAutopilotTarget = target is ConfigurationNavigationTarget.AutopilotJsonProfile or + bool isProvisioningModeTarget = target is ConfigurationNavigationTarget.AutopilotJsonProfile or ConfigurationNavigationTarget.AutopilotHardwareHashUpload or - ConfigurationNavigationTarget.AutopilotInteractiveHardwareHashUpload; - return ToNavigationStatus(state, isAutopilotTarget); + ConfigurationNavigationTarget.AutopilotInteractiveHardwareHashUpload or + ConfigurationNavigationTarget.DomainJoinInteractive or ConfigurationNavigationTarget.DomainJoinAutomatic; + return ToNavigationStatus(state, isProvisioningModeTarget); } private static ConfigurationNavigationTarget ResolveTarget(Type pageType) { + if (pageType == typeof(InteractiveDomainJoinPage)) return ConfigurationNavigationTarget.DomainJoinInteractive; + if (pageType == typeof(ZeroTouchDomainJoinPage)) return ConfigurationNavigationTarget.DomainJoinAutomatic; if (pageType == typeof(GeneralConfigurationPage)) { return ConfigurationNavigationTarget.General; @@ -132,7 +135,7 @@ private static ConfigurationNavigationTarget ResolveTarget(Type pageType) private static NavigationStatus ToNavigationStatus( ConfigurationOverviewState state, - bool isAutopilotTarget) + bool isProvisioningModeTarget) { return state switch { @@ -140,7 +143,7 @@ private static NavigationStatus ToNavigationStatus( "Common.NeedsAttention", NavigationInfoBadgeSeverity.Critical), ConfigurationOverviewState.Configured or ConfigurationOverviewState.Default => CreateStatus( - isAutopilotTarget ? "NavigationStatus.ActiveProvisioningMode" : "NavigationStatus.Configured", + isProvisioningModeTarget ? "NavigationStatus.ActiveProvisioningMode" : "NavigationStatus.Configured", NavigationInfoBadgeSeverity.Success), _ => new NavigationStatus(null, "NavigationStatus.NotConfigured") }; diff --git a/src/Foundry/Strings/ar-SA/Resources.resw b/src/Foundry/Strings/ar-SA/Resources.resw index 39818d421..b84a59d2b 100644 --- a/src/Foundry/Strings/ar-SA/Resources.resw +++ b/src/Foundry/Strings/ar-SA/Resources.resw @@ -1834,6 +1834,216 @@ لا يمكن فتح الوثائق + + حساب الانضمام + + + الافتراضي + + + النطاق + + + وحدات OU + + + الحالة + + + إضافة + + + مسح الافتراضي + + + تحرير + + + إزالة + + + تعيين كافتراضي + + + أدخل أولاً حساباً صالحاً، ثم تحقق من طول كلمة المرور. + + + نعم + + + جارٍ البحث عن وحدات OU في النطاق... + + + تم إلغاء البحث. + + + لم يتم العثور على أي وحدات OU في هذا النطاق. + + + لم نتمكن من عرض سوى بعض وحدات OU. لا يزال بإمكانك إضافة الوحدات المعروضة. + + + جميع وحدات OU في هذا النطاق موجودة بالفعل في القائمة. + + + انتهت مهلة البحث. يمكنك إضافة وحدات OU يدوياً. + + + تعذّر البحث في النطاق. تحقق من أن هذا الكمبيوتر يمكنه الوصول إليه وأن حساب Windows الخاص بك يمكنه قراءته، أو أضف وحدات OU يدوياً. + + + إضافة نطاق + + + إضافة نطاق + + + تحرير النطاق + + + اترك كلمة المرور فارغة للاحتفاظ بكلمة المرور الحالية. + + + حفظ + + + لم يتم حفظ النطاق. تحقق من اسم النطاق. + + + جاهز + + + أزل وحدات OU الخاصة بهذا النطاق قبل تغيير اسمه. + + + لم تتم إضافة أي نطاقات. + + + لم تتم إضافة أي وحدات OU. + + + لم تتم إضافة وحدات OU. إنها تنتمي إلى نطاق يختلف عن النطاق المحدد. + + + أضف نطاقاً أولاً، ثم أدرج وحدات OU الخاصة به. + + + لم تتم إضافة وحدة OU. تحقق من اسم العرض والاسم المميز. يجب أن تنتمي وحدة OU إلى النطاق المحدد. + + + الوحدات التنظيمية + + + الوحدات التنظيمية في {0} + + + حدد نطاقاً لإدراج وحدات OU الخاصة به. + + + وحدات OU المحددة: {0} من {1} + + + مشترك + + + أضف نطاقاً واحداً على الأقل. + + + هذا النطاق مدرج بالفعل. + + + توجد وحدتا OU لهما المعرّف الداخلي نفسه. أزل إحداهما ثم أضفها من جديد. + + + أدخل اسم نطاق صالحاً، مثل corp.contoso.com. + + + تحتاج كل وحدة OU إلى اسم عرض لا يتجاوز 120 حرفاً. + + + فعّل الحماية بكلمة مرور في صفحة «عام» وعيّن كلمة مرور النشر. + + + أدخل كلمة مرور الحساب. + + + أدخل الحساب بالصيغة DOMAIN\user أو user@domain. + + + أدخل حساب الانضمام المشترك، أو خصّص لهذا النطاق حساباً خاصاً به. + + + يمكنك إدراج ما يصل إلى 32 نطاقاً. + + + يمكنك إدراج ما يصل إلى 1,024 وحدة OU. + + + الحساب (DOMAIN\user أو user@domain) + + + إضافة OU + + + إلغاء البحث + + + اسم النطاق + + + corp.contoso.com + + + استخدام حساب مخصص + + + استخدام حساب الانضمام المشترك + + + استيراد من النطاق + + + يعرض وحدات OU في النطاق المحدد باستخدام حساب Windows الخاص بك. حدد الوحدات المراد إضافتها. + + + استيراد وحدات OU من النطاق + + + النطاقات التي يمكن لهذه الوسائط الانضمام إليها. عند إدراج عدة نطاقات، يختار الفنيون أحدها أثناء النشر، ويكون النطاق الافتراضي محددًا مسبقًا. + + + النطاقات + + + إضافة وحدات OU المحددة + + + أدخل اسم عرض والاسم المميز لوحدة OU. يجب أن تنتمي وحدة OU إلى النطاق المحدد. + + + إضافة OU يدوياً + + + الاسم المميز + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + اسم العرض + + + اختياري. أدرج وحدات OU التي يمكن وضع أجهزة الكمبيوتر فيها ضمن النطاق المحدد. عند إدراج وحدة OU واحدة فقط، تُستخدم دائمًا؛ وعند إدراج عدة وحدات، يختار الفنيون إحداها أثناء النشر. يتم نقل حساب الكمبيوتر الموجود إلى وحدة OU المختارة. + + + كلمة المرور + + + الحساب الذي يستخدمه كل نطاق ليس له حساب خاص به. يحتاج إلى صلاحية ضم أجهزة الكمبيوتر ونقلها إلى وحدات OU التي تختارها. + + + حساب الانضمام المشترك + ملف شهادة CA الجذر الموثوق به @@ -1874,7 +2084,7 @@ كلمة المرور - احتفظ بكلمة المرور هذه في مكان آمن. فهي تحمي كلمات مرور حسابات Windows المحلية وبيانات اعتماد Autopilot وملفات تعريفه وملفات الإجابات المخصصة. + احتفظ بكلمة المرور هذه في مكان آمن. فهي تحمي كلمات مرور حسابات Windows المحلية وبيانات اعتماد الانضمام إلى النطاق وبيانات اعتماد Autopilot وملفات تعريفه وملفات الإجابات المخصصة. كلمة مرور النشر @@ -1987,6 +2197,12 @@ حالة جاهزية الوسائط + + طلب بيانات اعتماد النطاق من الفني أثناء النشر. + + + الانضمام التفاعلي إلى النطاق + العربية (المملكة العربية السعودية) @@ -2191,6 +2407,9 @@ التوثيق + + الانضمام إلى النطاق + يتوفر الاتصال القياسي Ethernet بشكل افتراضي. قم بتكوين هذا القسم فقط إذا كانت شبكتك السلكية تتطلب مصادقة مؤسسة 802.1X. @@ -2212,6 +2431,12 @@ الصفحة الرئيسية + + طلب بيانات اعتماد النطاق من الفني أثناء النشر. + + + تفاعلي + قم بتكوين أسماء الأجهزة المنشورة. @@ -2272,6 +2497,12 @@ Autopilot + + الانضمام إلى النطاق تلقائياً باستخدام بيانات اعتماد تحميها كلمة مرور النشر. + + + Zero-Touch + نمط التهيئة النشط @@ -3917,4 +4148,10 @@ Wi-Fi + + الانضمام إلى النطاق تلقائياً باستخدام بيانات اعتماد تحميها كلمة مرور النشر. + + + الانضمام إلى النطاق بوضع Zero-touch + diff --git a/src/Foundry/Strings/bg-BG/Resources.resw b/src/Foundry/Strings/bg-BG/Resources.resw index 2fdde6403..705fd6b00 100644 --- a/src/Foundry/Strings/bg-BG/Resources.resw +++ b/src/Foundry/Strings/bg-BG/Resources.resw @@ -1834,6 +1834,216 @@ Документацията не можа да бъде отворена + + Акаунт за присъединяване + + + По подразбиране + + + Домейн + + + OU + + + Състояние + + + Добавяне + + + Изчистване на избора по подразбиране + + + Редактиране + + + Премахване + + + Задаване по подразбиране + + + Първо въведете валиден акаунт, след което проверете дължината на паролата. + + + Да + + + Търсене на OU в домейна... + + + Търсенето е отменено. + + + Не бяха намерени OU в този домейн. + + + Само част от OU можаха да бъдат изведени. Показаните все пак могат да бъдат добавени. + + + Всички OU в този домейн вече са в списъка. + + + Времето за търсене изтече. Можете да добавите OU ръчно. + + + Търсенето в домейна не беше възможно. Проверете дали този компютър има връзка с него и дали вашият акаунт за Windows може да го чете, или добавете OU ръчно. + + + Добавяне на домейн + + + Добавяне на домейн + + + Редактиране на домейн + + + Оставете паролата празна, за да запазите текущата. + + + Запазване + + + Домейнът не беше запазен. Проверете името на домейна. + + + Готово + + + Премахнете OU на този домейн, преди да промените името му. + + + Няма добавени домейни. + + + Няма добавени OU. + + + OU не бяха добавени. Те принадлежат към домейн, различен от избрания. + + + Първо добавете домейн, след което избройте неговите OU. + + + OU не беше добавена. Проверете показваното име и отличителното име. OU трябва да принадлежи към избрания домейн. + + + Организационни единици + + + Организационни единици на {0} + + + Изберете домейн, за да изброите неговите OU. + + + Избрани OU: {0} от {1} + + + Споделен + + + Добавете поне един домейн. + + + Този домейн вече е в списъка. + + + Две OU имат един и същ вътрешен идентификатор. Премахнете едната и я добавете отново. + + + Въведете валидно име на домейн, например corp.contoso.com. + + + Всяка OU изисква показвано име с най-много 120 знака. + + + Активирайте защитата с парола на страницата „Общи“ и задайте паролата за разполагане. + + + Въведете паролата на акаунта. + + + Въведете акаунта като DOMAIN\user или user@domain. + + + Въведете споделения акаунт за присъединяване или задайте на този домейн собствен акаунт. + + + Можете да изброите най-много 32 домейна. + + + Можете да изброите най-много 1024 OU. + + + Акаунт (DOMAIN\user или user@domain) + + + Добавяне на OU + + + Отмяна на търсенето + + + Име на домейна + + + corp.contoso.com + + + Използване на отделен акаунт + + + Използване на споделения акаунт за присъединяване + + + Импортиране от домейна + + + Извежда OU от избрания домейн с вашия акаунт за Windows. Изберете кои да добавите. + + + Импортиране на OU от домейна + + + Домейни, към които този носител може да присъединява компютри. Когато са изброени няколко, техниците избират един по време на разполагането; домейнът по подразбиране е предварително избран. + + + Домейни + + + Добавяне на избраните OU + + + Въведете показвано име и отличителното име на OU. OU трябва да принадлежи към избрания домейн. + + + Ръчно добавяне на OU + + + Отличително име + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Показвано име + + + Незадължително. Избройте OU, в които могат да се поставят компютри в избрания домейн. Ако е посочена само една OU, тя се използва винаги; когато са изброени няколко, техниците избират една по време на разполагането. Съществуващ компютърен акаунт се премества в избраната OU. + + + Парола + + + Акаунт, използван от всеки домейн, който няма собствен акаунт. Необходимо му е разрешение да присъединява компютри и да ги премества в избраните от вас OU. + + + Споделен акаунт за присъединяване + Файл със сертификат за доверен основен CA @@ -1874,7 +2084,7 @@ Парола - Пазете тази парола на сигурно място. Тя защитава паролите на локалните акаунти в Windows, идентификационните данни и профилите на Autopilot и потребителските файлове с отговори. + Пазете тази парола на сигурно място. Тя защитава паролите на локалните акаунти в Windows, идентификационните данни за присъединяване към домейн, идентификационните данни и профилите на Autopilot и потребителските файлове с отговори. Парола за разполагане @@ -1907,7 +2117,7 @@ Не бяха открити WinPE езикови пакети за избраната архитектура. - генерал + Общи Прегледайте и започнете @@ -1987,6 +2197,12 @@ Състояние на готовност на носителя + + Изискване на идентификационни данни за домейна от техника по време на разполагането. + + + Интерактивно присъединяване към домейн + арабски (Саудитска Арабия) @@ -2191,6 +2407,9 @@ Документация + + Присъединяване към домейн + Стандартната Ethernet връзка е налична по подразбиране. Конфигурирайте този раздел само ако вашата кабелна мрежа изисква 802.1X корпоративно удостоверяване. @@ -2201,10 +2420,10 @@ Стандартен работен процес за конфигурация. - генерал + Общи - генерал + Общи Foundry OSD дом @@ -2212,6 +2431,12 @@ Начало + + Изискване на идентификационни данни за домейна от техника по време на разполагането. + + + Интерактивно + Конфигурирайте имената на внедрените устройства. @@ -2272,6 +2497,12 @@ Autopilot + + Автоматично присъединяване към домейна с идентификационни данни, защитени с паролата за разполагане. + + + Zero-Touch + Активен режим на зaсигуряване @@ -2974,7 +3205,7 @@ Управлявайте стартирането, езика, диагностиката и поведението на приложението. - генерал + Общи Архивирайте настройките си, прехвърляйте ги на друг компютър или ги поддържайте синхронизирани. @@ -3917,4 +4148,10 @@ Wi-Fi + + Автоматично присъединяване към домейна с идентификационни данни, защитени с паролата за разполагане. + + + Присъединяване към домейн Zero-touch + diff --git a/src/Foundry/Strings/cs-CZ/Resources.resw b/src/Foundry/Strings/cs-CZ/Resources.resw index 9e37e622a..1df55d1b0 100644 --- a/src/Foundry/Strings/cs-CZ/Resources.resw +++ b/src/Foundry/Strings/cs-CZ/Resources.resw @@ -1834,6 +1834,216 @@ Licencováno pod licencí MIT. Dokumentaci nelze otevřít + + Účet pro připojení + + + Výchozí + + + Doména + + + OU + + + Stav + + + Přidat + + + Zrušit výchozí výběr + + + Upravit + + + Odebrat + + + Nastavit jako výchozí + + + Nejprve zadejte platný účet, potom zkontrolujte délku hesla. + + + Ano + + + Vyhledávání jednotek OU v doméně... + + + Vyhledávání bylo zrušeno. + + + V této doméně nebyly nalezeny žádné jednotky OU. + + + Podařilo se vypsat jen některé jednotky OU. Zobrazené jednotky můžete přesto přidat. + + + Všechny jednotky OU v této doméně už v seznamu jsou. + + + Vypršel časový limit vyhledávání. Jednotky OU můžete přidat ručně. + + + Doménu se nepodařilo prohledat. Zkontrolujte, zda k ní má tento počítač přístup a zda ji váš účet Windows může číst, nebo přidejte jednotky OU ručně. + + + Přidat doménu + + + Přidat doménu + + + Upravit doménu + + + Chcete-li zachovat stávající heslo, ponechte heslo prázdné. + + + Uložit + + + Doména nebyla uložena. Zkontrolujte název domény. + + + Připraveno + + + Před změnou názvu této domény odeberte její jednotky OU. + + + Nebyly přidány žádné domény. + + + Nebyly přidány žádné jednotky OU. + + + Jednotky OU nebyly přidány. Patří do jiné domény, než je vybraná doména. + + + Nejprve přidejte doménu, potom uveďte její jednotky OU. + + + Jednotka OU nebyla přidána. Zkontrolujte zobrazovaný název a rozlišující název. Jednotka OU musí patřit do vybrané domény. + + + Organizační jednotky + + + Organizační jednotky domény {0} + + + Vyberte doménu, jejíž jednotky OU chcete uvést. + + + Vybrané jednotky OU: {0} z {1} + + + Sdílený + + + Přidejte alespoň jednu doménu. + + + Tato doména už v seznamu je. + + + Dvě jednotky OU mají stejné interní ID. Jednu odeberte a přidejte ji znovu. + + + Zadejte platný název domény, například corp.contoso.com. + + + Každá jednotka OU musí mít zobrazovaný název o délce nejvýše 120 znaků. + + + Na stránce „Obecné“ povolte ochranu heslem a nastavte heslo pro nasazení. + + + Zadejte heslo účtu. + + + Zadejte účet ve tvaru DOMAIN\user nebo user@domain. + + + Zadejte sdílený účet pro připojení, nebo této doméně přiřaďte vlastní účet. + + + Můžete uvést nejvýše 32 domén. + + + Můžete uvést nejvýše 1 024 jednotek OU. + + + Účet (DOMAIN\user nebo user@domain) + + + Přidat OU + + + Zrušit vyhledávání + + + Název domény + + + corp.contoso.com + + + Použít vyhrazený účet + + + Použít sdílený účet pro připojení + + + Importovat z domény + + + Vypíše jednotky OU vybrané domény pomocí vašeho účtu Windows. Vyberte ty, které chcete přidat. + + + Importovat OU z domény + + + Domény, ke kterým se toto médium může připojit. Pokud je jich uvedeno více, technici jednu vyberou během nasazení; výchozí doména je předvybraná. + + + Domény + + + Přidat vybrané OU + + + Zadejte zobrazovaný název a rozlišující název jednotky OU. Jednotka OU musí patřit do vybrané domény. + + + Přidat OU ručně + + + Rozlišující název + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Zobrazovaný název + + + Volitelné. Uveďte jednotky OU, do kterých lze počítače ve vybrané doméně umístit. Jediná uvedená jednotka OU se použije vždy; pokud je jich uvedeno více, technici jednu vyberou během nasazení. Existující účet počítače se přesune do zvolené jednotky OU. + + + Heslo + + + Účet používaný všemi doménami, které nemají vlastní účet. Potřebuje oprávnění připojovat počítače a přesouvat je do jednotek OU, které zvolíte. + + + Sdílený účet pro připojení + Soubor certifikátu důvěryhodného kořenového CA @@ -1874,7 +2084,7 @@ Licencováno pod licencí MIT. Heslo - Uchovejte toto heslo v bezpečí. Chrání hesla místních účtů Windows, přihlašovací údaje a profily Autopilot a vlastní soubory odpovědí. + Uchovejte toto heslo v bezpečí. Chrání hesla místních účtů Windows, přihlašovací údaje pro připojení k doméně, přihlašovací údaje a profily Autopilot a vlastní soubory odpovědí. Heslo pro nasazení @@ -1907,7 +2117,7 @@ Licencováno pod licencí MIT. Pro vybranou architekturu nebyly zjištěny žádné jazykové balíčky WinPE. - Generál + Obecné Zkontrolujte a začněte @@ -1987,6 +2197,12 @@ Licencováno pod licencí MIT. Stav připravenosti média + + Vyžádat od technika přihlašovací údaje k doméně během nasazení. + + + Interaktivní připojení k doméně + arabština (Saúdská Arábie) @@ -2191,6 +2407,9 @@ Licencováno pod licencí MIT. Dokumentace + + Připojení k doméně + Ve výchozím nastavení je k dispozici standardní připojení Ethernet. Tuto část nakonfigurujte pouze v případě, že vaše kabelová síť vyžaduje podnikové ověřování 802.1X. @@ -2201,10 +2420,10 @@ Licencováno pod licencí MIT. Standardní konfigurační pracovní postup. - Generál + Obecné - Generál + Obecné Foundry OSD domů @@ -2212,6 +2431,12 @@ Licencováno pod licencí MIT. Domů + + Vyžádat od technika přihlašovací údaje k doméně během nasazení. + + + Interaktivní + Konfigurujte názvy nasazených zařízení. @@ -2272,6 +2497,12 @@ Licencováno pod licencí MIT. Autopilot + + Připojit k doméně automaticky pomocí přihlašovacích údajů chráněných heslem pro nasazení. + + + Zero-Touch + Aktivní režim zřizování @@ -2974,7 +3205,7 @@ Zahrnutá hesla a přístupové klíče: {2} Spravujte spouštění, jazyk, diagnostiku a chování aplikací. - Generál + Obecné Zálohujte svá nastavení, přeneste je do jiného počítače nebo je udržujte synchronizovaná. @@ -3917,4 +4148,10 @@ Použití aktualizace restartuje Foundry OSD. Wi-Fi + + Připojit k doméně automaticky pomocí přihlašovacích údajů chráněných heslem pro nasazení. + + + Připojení k doméně Zero-touch + diff --git a/src/Foundry/Strings/da-DK/Resources.resw b/src/Foundry/Strings/da-DK/Resources.resw index aade1b17d..668166fef 100644 --- a/src/Foundry/Strings/da-DK/Resources.resw +++ b/src/Foundry/Strings/da-DK/Resources.resw @@ -1834,6 +1834,216 @@ Licenseret under MIT-licensen. Dokumentation kunne ikke åbnes + + Tilslutningskonto + + + Standard + + + Domæne + + + OU'er + + + Status + + + Tilføj + + + Ryd standardvalg + + + Rediger + + + Fjern + + + Angiv som standard + + + Angiv først en gyldig konto, og kontrollér derefter adgangskodens længde. + + + Ja + + + Søger efter OU'er i domænet... + + + Søgningen blev annulleret. + + + Der blev ikke fundet nogen OU'er i dette domæne. + + + Kun nogle af OU'erne kunne vises. Du kan stadig tilføje dem, der vises. + + + Alle OU'er i dette domæne er allerede på listen. + + + Søgningen fik timeout. Du kan tilføje OU'er manuelt. + + + Der kunne ikke søges i domænet. Kontrollér, at denne computer kan nå det, og at din Windows-konto kan læse det, eller tilføj OU'er manuelt. + + + Tilføj domæne + + + Tilføj et domæne + + + Rediger domæne + + + Lad adgangskoden være tom for at beholde den nuværende. + + + Gem + + + Domænet blev ikke gemt. Kontrollér domænenavnet. + + + Klar + + + Fjern dette domænes OU'er, før du ændrer dets navn. + + + Ingen domæner tilføjet. + + + Ingen OU'er tilføjet. + + + OU'erne blev ikke tilføjet. De tilhører et andet domæne end det valgte. + + + Tilføj først et domæne, og angiv derefter dets OU'er. + + + OU'en blev ikke tilføjet. Kontrollér visningsnavnet og det entydige navn. OU'en skal tilhøre det valgte domæne. + + + Organisationsenheder + + + Organisationsenheder i {0} + + + Vælg et domæne for at angive dets OU'er. + + + Valgte OU'er: {0} af {1} + + + Delt + + + Tilføj mindst ét domæne. + + + Dette domæne er allerede på listen. + + + To OU'er har samme interne id. Fjern den ene, og tilføj den igen. + + + Angiv et gyldigt domænenavn, f.eks. corp.contoso.com. + + + Hver OU skal have et visningsnavn på højst 120 tegn. + + + Aktivér adgangskodebeskyttelse på siden "Generelt", og angiv adgangskoden til installation. + + + Angiv kontoens adgangskode. + + + Angiv kontoen som DOMAIN\user eller user@domain. + + + Angiv den delte tilslutningskonto, eller giv dette domæne sin egen konto. + + + Listen kan indeholde op til 32 domæner. + + + Listen kan indeholde op til 1.024 OU'er. + + + Konto (DOMAIN\user eller user@domain) + + + Tilføj OU + + + Annuller søgning + + + Domænenavn + + + corp.contoso.com + + + Brug en dedikeret konto + + + Brug den delte tilslutningskonto + + + Importér fra domæne + + + Viser OU'erne i det valgte domæne ved hjælp af din Windows-konto. Vælg dem, der skal tilføjes. + + + Importér OU'er fra domænet + + + Domæner, som dette medie kan tilslutte computere til. Når der er angivet flere, vælger teknikere ét under udrulning; standarddomænet er valgt på forhånd. + + + Domæner + + + Tilføj valgte OU'er + + + Angiv et visningsnavn og OU'ens entydige navn. OU'en skal tilhøre det valgte domæne. + + + Tilføj en OU manuelt + + + Entydigt navn + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Visningsnavn + + + Valgfrit. Angiv de OU'er i det valgte domæne, som computere kan placeres i. En enkelt OU bruges altid; når der er angivet flere, vælger teknikere én under udrulning. En eksisterende computerkonto flyttes til den valgte OU. + + + Adgangskode + + + Konto, der bruges af alle domæner, som ikke har deres egen konto. Den skal have tilladelse til at tilslutte computere og flytte dem til de OU'er, du vælger. + + + Delt tilslutningskonto + Betroet rod-CA-certifikatfil @@ -1874,7 +2084,7 @@ Licenseret under MIT-licensen. Adgangskode - Opbevar denne adgangskode sikkert. Den beskytter adgangskoder til lokale Windows-konti, legitimationsoplysninger og profiler til Autopilot samt brugerdefinerede svarfiler. + Opbevar denne adgangskode sikkert. Den beskytter adgangskoder til lokale Windows-konti, legitimationsoplysninger til domænetilslutning, legitimationsoplysninger og profiler til Autopilot samt brugerdefinerede svarfiler. Adgangskode til installation @@ -1987,6 +2197,12 @@ Licenseret under MIT-licensen. Status for medieberedskab + + Bed teknikeren om domænelegitimationsoplysninger under udrulning. + + + Interaktiv domænetilslutning + Arabisk (Saudi-Arabien) @@ -2191,6 +2407,9 @@ Licenseret under MIT-licensen. Dokumentation + + Domænetilslutning + Standard Ethernet-forbindelse er tilgængelig som standard. Konfigurer kun denne sektion, hvis dit kablede netværk kræver 802.1X virksomhedsgodkendelse. @@ -2212,6 +2431,12 @@ Licenseret under MIT-licensen. Hjem + + Bed teknikeren om domænelegitimationsoplysninger under udrulning. + + + Interaktiv + Konfigurer deployerede enhedsnavne. @@ -2272,6 +2497,12 @@ Licenseret under MIT-licensen. Autopilot + + Tilslut automatisk til domænet med legitimationsoplysninger, der er beskyttet af adgangskoden til installation. + + + Zero-Touch + Aktiv provisioning-tilstand @@ -3917,4 +4148,10 @@ Anvendelse af opdateringen genstarter Foundry OSD. Wi-Fi + + Tilslut automatisk til domænet med legitimationsoplysninger, der er beskyttet af adgangskoden til installation. + + + Zero-touch-domænetilslutning + diff --git a/src/Foundry/Strings/de-DE/Resources.resw b/src/Foundry/Strings/de-DE/Resources.resw index c92f6a83d..7685f408a 100644 --- a/src/Foundry/Strings/de-DE/Resources.resw +++ b/src/Foundry/Strings/de-DE/Resources.resw @@ -1834,6 +1834,216 @@ Lizenziert unter der MIT-Lizenz. Dokumentation konnte nicht geöffnet werden + + Beitrittskonto + + + Standard + + + Domäne + + + OUs + + + Status + + + Hinzufügen + + + Standardauswahl löschen + + + Bearbeiten + + + Entfernen + + + Als Standard festlegen + + + Geben Sie zuerst ein gültiges Konto ein und prüfen Sie dann die Kennwortlänge. + + + Ja + + + Die Domäne wird nach OUs durchsucht... + + + Suche abgebrochen. + + + In dieser Domäne wurden keine OUs gefunden. + + + Es konnten nur einige OUs aufgelistet werden. Die angezeigten OUs können Sie trotzdem hinzufügen. + + + Alle OUs in dieser Domäne sind bereits in der Liste. + + + Das Zeitlimit für die Suche wurde überschritten. Sie können OUs manuell hinzufügen. + + + Die Domäne konnte nicht durchsucht werden. Prüfen Sie, ob dieser Computer sie erreichen und Ihr Windows-Konto sie lesen kann, oder fügen Sie OUs manuell hinzu. + + + Domäne hinzufügen + + + Domäne hinzufügen + + + Domäne bearbeiten + + + Lassen Sie das Kennwort leer, um das aktuelle beizubehalten. + + + Speichern + + + Die Domäne wurde nicht gespeichert. Prüfen Sie den Domänennamen. + + + Bereit + + + Entfernen Sie die OUs dieser Domäne, bevor Sie ihren Namen ändern. + + + Keine Domänen hinzugefügt. + + + Keine OUs hinzugefügt. + + + Die OUs wurden nicht hinzugefügt. Sie gehören zu einer anderen Domäne als der ausgewählten. + + + Fügen Sie zuerst eine Domäne hinzu und listen Sie dann ihre OUs auf. + + + Die OU wurde nicht hinzugefügt. Prüfen Sie den Anzeigenamen und den Distinguished Name. Die OU muss zur ausgewählten Domäne gehören. + + + Organisationseinheiten + + + Organisationseinheiten von {0} + + + Wählen Sie eine Domäne aus, um ihre OUs aufzulisten. + + + Ausgewählte OUs: {0} von {1} + + + Gemeinsam + + + Fügen Sie mindestens eine Domäne hinzu. + + + Diese Domäne ist bereits aufgelistet. + + + Zwei OUs haben dieselbe interne ID. Entfernen Sie eine davon und fügen Sie sie erneut hinzu. + + + Geben Sie einen gültigen Domänennamen ein, z. B. corp.contoso.com. + + + Jede OU benötigt einen Anzeigenamen mit höchstens 120 Zeichen. + + + Aktivieren Sie auf der Seite „Allgemein“ den Kennwortschutz und legen Sie das Bereitstellungskennwort fest. + + + Geben Sie das Kontokennwort ein. + + + Geben Sie das Konto als DOMAIN\user oder user@domain ein. + + + Geben Sie das gemeinsame Beitrittskonto ein, oder weisen Sie dieser Domäne ein eigenes Konto zu. + + + Sie können bis zu 32 Domänen auflisten. + + + Sie können bis zu 1.024 OUs auflisten. + + + Konto (DOMAIN\user oder user@domain) + + + OU hinzufügen + + + Suche abbrechen + + + Domänenname + + + corp.contoso.com + + + Eigenes Konto verwenden + + + Gemeinsames Beitrittskonto verwenden + + + Aus Domäne importieren + + + Listet mit Ihrem Windows-Konto die OUs der ausgewählten Domäne auf. Wählen Sie die OUs aus, die hinzugefügt werden sollen. + + + OUs aus der Domäne importieren + + + Domänen, denen dieses Medium beitreten kann. Sind mehrere aufgeführt, wählen Techniker während der Bereitstellung eine aus; die Standarddomäne ist vorausgewählt. + + + Domänen + + + Ausgewählte OUs hinzufügen + + + Geben Sie einen Anzeigenamen und den Distinguished Name der OU ein. Die OU muss zur ausgewählten Domäne gehören. + + + OU manuell hinzufügen + + + Distinguished Name + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Anzeigename + + + Optional. Listen Sie die OUs auf, in denen Computer in der ausgewählten Domäne platziert werden können. Eine einzelne OU wird immer verwendet; sind mehrere aufgeführt, wählen Techniker während der Bereitstellung eine aus. Ein vorhandenes Computerkonto wird in die gewählte OU verschoben. + + + Kennwort + + + Konto, das von jeder Domäne verwendet wird, die kein eigenes Konto hat. Es benötigt die Berechtigung, Computer der Domäne hinzuzufügen und in die von Ihnen gewählten OUs zu verschieben. + + + Gemeinsames Beitrittskonto + Vertrauenswürdige Root-CA-Zertifikatdatei @@ -1874,7 +2084,7 @@ Lizenziert unter der MIT-Lizenz. Kennwort - Bewahren Sie dieses Kennwort sicher auf. Es schützt Kennwörter lokaler Windows-Konten, Autopilot-Anmeldeinformationen und -Profile sowie benutzerdefinierte Antwortdateien. + Bewahren Sie dieses Kennwort sicher auf. Es schützt Kennwörter lokaler Windows-Konten, Anmeldeinformationen für den Domänenbeitritt, Autopilot-Anmeldeinformationen und -Profile sowie benutzerdefinierte Antwortdateien. Bereitstellungskennwort @@ -1987,6 +2197,12 @@ Lizenziert unter der MIT-Lizenz. Status der Medienbereitschaft + + Domänenanmeldeinformationen während der Bereitstellung vom Techniker abfragen. + + + Interaktiver Domänenbeitritt + Arabisch (Saudi-Arabien) @@ -2191,6 +2407,9 @@ Lizenziert unter der MIT-Lizenz. Dokumentation + + Domänenbeitritt + Die standardmäßige Ethernet-Konnektivität ist standardmäßig verfügbar. Konfigurieren Sie diesen Abschnitt nur, wenn Ihr kabelgebundenes Netzwerk eine 802.1X-Unternehmensauthentifizierung erfordert. @@ -2212,6 +2431,12 @@ Lizenziert unter der MIT-Lizenz. Zuhause + + Domänenanmeldeinformationen während der Bereitstellung vom Techniker abfragen. + + + Interaktiv + Konfigurieren Sie die Namen bereitgestellter Geräte. @@ -2272,6 +2497,12 @@ Lizenziert unter der MIT-Lizenz. Autopilot + + Der Domäne automatisch mit Anmeldeinformationen beitreten, die durch das Bereitstellungskennwort geschützt sind. + + + Zero-Touch + Aktiverter Bereitstellungsmodus @@ -3917,4 +4148,10 @@ Das Anwenden des Updates startet Foundry OSD neu. Wi-Fi + + Der Domäne automatisch mit Anmeldeinformationen beitreten, die durch das Bereitstellungskennwort geschützt sind. + + + Zero-Touch-Domänenbeitritt + diff --git a/src/Foundry/Strings/el-GR/Resources.resw b/src/Foundry/Strings/el-GR/Resources.resw index 003e5f9d6..6218c825a 100644 --- a/src/Foundry/Strings/el-GR/Resources.resw +++ b/src/Foundry/Strings/el-GR/Resources.resw @@ -1834,6 +1834,216 @@ Δεν ήταν δυνατό το άνοιγμα της τεκμηρίωσης + + Λογαριασμός σύνδεσης σε τομέα + + + Προεπιλογή + + + Τομέας + + + OU + + + Κατάσταση + + + Προσθήκη + + + Εκκαθάριση προεπιλογής + + + Επεξεργασία + + + Κατάργηση + + + Ορισμός ως προεπιλογής + + + Εισαγάγετε πρώτα έγκυρο λογαριασμό και, στη συνέχεια, ελέγξτε το μήκος του κωδικού πρόσβασης. + + + Ναι + + + Αναζήτηση OU στον τομέα... + + + Η αναζήτηση ακυρώθηκε. + + + Δεν βρέθηκαν OU σε αυτόν τον τομέα. + + + Ήταν δυνατή η εμφάνιση μόνο ορισμένων OU. Μπορείτε ωστόσο να προσθέσετε όσες εμφανίζονται. + + + Όλες οι OU σε αυτόν τον τομέα υπάρχουν ήδη στη λίστα. + + + Έληξε το χρονικό όριο της αναζήτησης. Μπορείτε να προσθέσετε OU χειροκίνητα. + + + Δεν ήταν δυνατή η αναζήτηση στον τομέα. Ελέγξτε ότι αυτός ο υπολογιστής έχει πρόσβαση σε αυτόν και ότι ο λογαριασμός σας Windows μπορεί να τον διαβάσει ή προσθέστε OU χειροκίνητα. + + + Προσθήκη τομέα + + + Προσθήκη τομέα + + + Επεξεργασία τομέα + + + Αφήστε τον κωδικό πρόσβασης κενό για να διατηρήσετε τον τρέχοντα. + + + Αποθήκευση + + + Ο τομέας δεν αποθηκεύτηκε. Ελέγξτε το όνομα τομέα. + + + Έτοιμο + + + Καταργήστε τις OU αυτού του τομέα πριν αλλάξετε το όνομά του. + + + Δεν έχουν προστεθεί τομείς. + + + Δεν έχουν προστεθεί OU. + + + Οι OU δεν προστέθηκαν. Ανήκουν σε διαφορετικό τομέα από τον επιλεγμένο. + + + Προσθέστε πρώτα έναν τομέα και, στη συνέχεια, καταχωρήστε τις OU του. + + + Η OU δεν προστέθηκε. Ελέγξτε το εμφανιζόμενο όνομα και το διακεκριμένο όνομα. Η OU πρέπει να ανήκει στον επιλεγμένο τομέα. + + + Οργανωτικές μονάδες + + + Οργανωτικές μονάδες του τομέα {0} + + + Επιλέξτε έναν τομέα για να καταχωρήσετε τις OU του. + + + Επιλεγμένες OU: {0} από {1} + + + Κοινόχρηστος + + + Προσθέστε τουλάχιστον έναν τομέα. + + + Αυτός ο τομέας υπάρχει ήδη στη λίστα. + + + Δύο OU έχουν το ίδιο εσωτερικό αναγνωριστικό. Καταργήστε τη μία και προσθέστε την ξανά. + + + Εισαγάγετε ένα έγκυρο όνομα τομέα, όπως corp.contoso.com. + + + Κάθε OU χρειάζεται εμφανιζόμενο όνομα με έως 120 χαρακτήρες. + + + Ενεργοποιήστε την προστασία με κωδικό πρόσβασης στη σελίδα «Γενικά» και ορίστε τον κωδικό πρόσβασης ανάπτυξης. + + + Εισαγάγετε τον κωδικό πρόσβασης του λογαριασμού. + + + Εισαγάγετε τον λογαριασμό ως DOMAIN\user ή user@domain. + + + Εισαγάγετε τον κοινόχρηστο λογαριασμό σύνδεσης σε τομέα ή ορίστε για αυτόν τον τομέα δικό του λογαριασμό. + + + Η λίστα μπορεί να περιέχει έως 32 τομείς. + + + Η λίστα μπορεί να περιέχει έως 1.024 OU. + + + Λογαριασμός (DOMAIN\user ή user@domain) + + + Προσθήκη OU + + + Ακύρωση αναζήτησης + + + Όνομα τομέα + + + corp.contoso.com + + + Χρήση αποκλειστικού λογαριασμού + + + Χρήση του κοινόχρηστου λογαριασμού σύνδεσης σε τομέα + + + Εισαγωγή από τομέα + + + Εμφανίζει τις OU του επιλεγμένου τομέα, με χρήση του λογαριασμού σας Windows. Επιλέξτε όσες θέλετε να προσθέσετε. + + + Εισαγωγή OU από τον τομέα + + + Τομείς στους οποίους μπορεί να γίνει σύνδεση με αυτό το μέσο. Όταν έχουν καταχωρηθεί περισσότεροι από ένας, οι τεχνικοί επιλέγουν έναν κατά την ανάπτυξη· ο προεπιλεγμένος τομέας είναι ήδη επιλεγμένος. + + + Τομείς + + + Προσθήκη επιλεγμένων OU + + + Εισαγάγετε ένα εμφανιζόμενο όνομα και το διακεκριμένο όνομα της OU. Η OU πρέπει να ανήκει στον επιλεγμένο τομέα. + + + Χειροκίνητη προσθήκη OU + + + Διακεκριμένο όνομα + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Εμφανιζόμενο όνομα + + + Προαιρετικό. Καταχωρήστε τις OU στις οποίες μπορούν να τοποθετηθούν υπολογιστές στον επιλεγμένο τομέα. Αν έχει καταχωρηθεί μόνο μία OU, χρησιμοποιείται πάντα· όταν έχουν καταχωρηθεί περισσότερες, οι τεχνικοί επιλέγουν μία κατά την ανάπτυξη. Ένας υπάρχων λογαριασμός υπολογιστή μετακινείται στην επιλεγμένη OU. + + + Κωδικός πρόσβασης + + + Λογαριασμός που χρησιμοποιείται από κάθε τομέα που δεν έχει δικό του λογαριασμό. Χρειάζεται δικαίωμα σύνδεσης υπολογιστών και μετακίνησής τους στις OU που επιλέγετε. + + + Κοινόχρηστος λογαριασμός σύνδεσης σε τομέα + Αξιόπιστο αρχείο πιστοποιητικού CA root @@ -1874,7 +2084,7 @@ Κωδικός πρόσβασης - Φυλάξτε αυτόν τον κωδικό πρόσβασης με ασφάλεια. Προστατεύει τους κωδικούς πρόσβασης των τοπικών λογαριασμών Windows, τα διαπιστευτήρια και τα προφίλ Autopilot και τα προσαρμοσμένα αρχεία απαντήσεων. + Φυλάξτε αυτόν τον κωδικό πρόσβασης με ασφάλεια. Προστατεύει τους κωδικούς πρόσβασης των τοπικών λογαριασμών Windows, τα διαπιστευτήρια σύνδεσης σε τομέα, τα διαπιστευτήρια και τα προφίλ Autopilot και τα προσαρμοσμένα αρχεία απαντήσεων. Κωδικός πρόσβασης ανάπτυξης @@ -1907,7 +2117,7 @@ Δεν εντοπίστηκαν πακέτα γλώσσας WinPE για την επιλεγμένη αρχιτεκτονική. - Στρατηγός + Γενικά Ελέγξτε και ξεκινήστε @@ -1987,6 +2197,12 @@ Κατάσταση ετοιμότητας μέσου + + Ζητήστε από τον τεχνικό διαπιστευτήρια τομέα κατά την ανάπτυξη. + + + Διαδραστική σύνδεση σε τομέα + Αραβικά (Σαουδική Αραβία) @@ -2191,6 +2407,9 @@ Τεκμηρίωση + + Σύνδεση σε τομέα + Η τυπική συνδεσιμότητα Ethernet είναι διαθέσιμη από προεπιλογή. Διαμορφώστε αυτήν την ενότητα μόνο εάν το ενσύρματο δίκτυό σας απαιτεί εταιρικό έλεγχο ταυτότητας 802.1X. @@ -2201,10 +2420,10 @@ Τυπική ροή εργασιών διαμόρφωσης. - Στρατηγός + Γενικά - Στρατηγός + Γενικά Foundry OSD σπίτι @@ -2212,6 +2431,12 @@ Σπίτι + + Ζητήστε από τον τεχνικό διαπιστευτήρια τομέα κατά την ανάπτυξη. + + + Διαδραστική + Διαμορφώστε τα ονόματα των αναπτυγμένων συσκευών. @@ -2272,6 +2497,12 @@ Autopilot + + Συνδεθείτε στον τομέα αυτόματα με διαπιστευτήρια που προστατεύονται από τον κωδικό πρόσβασης ανάπτυξης. + + + Zero-Touch + Ενεργή λειτουργία προμήθειας @@ -2974,7 +3205,7 @@ Διαχειριστείτε την εκκίνηση, τη γλώσσα, τα διαγνωστικά και τη συμπεριφορά εφαρμογών. - Στρατηγός + Γενικά Δημιουργήστε αντίγραφα ασφαλείας των ρυθμίσεών σας, μεταφέρετέ τις σε άλλον υπολογιστή ή διατηρήστε τις συγχρονισμένες. @@ -3917,4 +4148,10 @@ Wi-Fi + + Συνδεθείτε στον τομέα αυτόματα με διαπιστευτήρια που προστατεύονται από τον κωδικό πρόσβασης ανάπτυξης. + + + Σύνδεση σε τομέα Zero-touch + diff --git a/src/Foundry/Strings/en-GB/Resources.resw b/src/Foundry/Strings/en-GB/Resources.resw index 98e934a3a..cf1a4f829 100644 --- a/src/Foundry/Strings/en-GB/Resources.resw +++ b/src/Foundry/Strings/en-GB/Resources.resw @@ -1834,6 +1834,216 @@ Licensed under the MIT License. Documentation could not be opened + + Join account + + + Default + + + Domain + + + OUs + + + Status + + + Add + + + Clear default + + + Edit + + + Remove + + + Set as default + + + Enter a valid account first, then check the password length. + + + Yes + + + Searching the domain for OUs... + + + Search cancelled. + + + No OUs were found in this domain. + + + Only some of the OUs could be listed. You can still add the ones shown. + + + Every OU in this domain is already in the list. + + + The search timed out. You can add OUs manually. + + + The domain could not be searched. Check that this computer can reach it and that your Windows account can read it, or add OUs manually. + + + Add domain + + + Add a domain + + + Edit domain + + + Leave the password empty to keep the current one. + + + Save + + + The domain was not saved. Check the domain name. + + + Ready + + + Remove this domain's OUs before changing its name. + + + No domains added. + + + No OUs added. + + + The OUs were not added. They belong to a different domain than the selected one. + + + Add a domain first, then list its OUs. + + + The OU was not added. Check the display name and the distinguished name. The OU must belong to the selected domain. + + + Organisational units + + + Organisational units of {0} + + + Select a domain to list its OUs. + + + Selected OUs: {0} of {1} + + + Shared + + + Add at least one domain. + + + This domain is already listed. + + + Two OUs share the same internal ID. Remove one and add it again. + + + Enter a valid domain name, such as corp.contoso.com. + + + Each OU needs a display name of 120 characters or fewer. + + + Turn on password protection on the General page and set the deployment password. + + + Enter the account password. + + + Enter the account as DOMAIN\user or user@domain. + + + Enter the shared join account, or give this domain its own account. + + + You can list up to 32 domains. + + + You can list up to 1,024 OUs. + + + Account (DOMAIN\user or user@domain) + + + Add OU + + + Cancel search + + + Domain name + + + corp.contoso.com + + + Use a dedicated account + + + Use the shared join account + + + Import from domain + + + Lists the OUs of the selected domain, using your Windows account. Select the ones to add. + + + Import OUs from the domain + + + Domains this media can join. When several are listed, technicians choose one during deployment; the default is preselected. + + + Domains + + + Add selected OUs + + + Enter a display name and the distinguished name of the OU. The OU must belong to the selected domain. + + + Add an OU manually + + + Distinguished name + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Display name + + + Optional. List the OUs where computers can be placed in the selected domain. A single OU is always used; when several are listed, technicians choose one during deployment. An existing computer account is moved to the chosen OU. + + + Password + + + Account used by every domain that has no account of its own. It needs permission to join computers and to move them to the OUs you choose. + + + Shared join account + Trusted root CA certificate file @@ -1874,7 +2084,7 @@ Licensed under the MIT License. Password - Keep this password safe. It protects local Windows account passwords, Autopilot credentials and profiles, and custom answer files. + Keep this password safe. It protects local Windows account passwords, domain join credentials, Autopilot credentials and profiles, and custom answer files. Deployment password @@ -1987,6 +2197,12 @@ Licensed under the MIT License. Media readiness status + + Ask the technician for domain credentials during deployment. + + + Interactive Domain Join + Arabic (Saudi Arabia) @@ -2191,6 +2407,9 @@ Licensed under the MIT License. Documentation + + Domain Join + Standard Ethernet connectivity is available by default. Configure this section only if your wired network requires 802.1X enterprise authentication. @@ -2212,6 +2431,12 @@ Licensed under the MIT License. Home + + Ask the technician for domain credentials during deployment. + + + Interactive + Configure deployed device names. @@ -2272,6 +2497,12 @@ Licensed under the MIT License. Autopilot + + Join the domain automatically using credentials protected by the deployment password. + + + Zero-Touch + Active provisioning mode @@ -3917,4 +4148,10 @@ Applying the update restarts Foundry OSD. Wi-Fi + + Join the domain automatically using credentials protected by the deployment password. + + + Zero-touch Domain Join + diff --git a/src/Foundry/Strings/en-US/Resources.resw b/src/Foundry/Strings/en-US/Resources.resw index ff071a49f..7c7b76e94 100644 --- a/src/Foundry/Strings/en-US/Resources.resw +++ b/src/Foundry/Strings/en-US/Resources.resw @@ -1834,6 +1834,216 @@ Licensed under the MIT License. Documentation could not be opened + + Join account + + + Default + + + Domain + + + OUs + + + Status + + + Add + + + Clear default + + + Edit + + + Remove + + + Set as default + + + Enter a valid account first, then check the password length. + + + Yes + + + Searching the domain for OUs... + + + Search canceled. + + + No OUs were found in this domain. + + + Only some of the OUs could be listed. You can still add the ones shown. + + + Every OU in this domain is already in the list. + + + The search timed out. You can add OUs manually. + + + The domain could not be searched. Check that this computer can reach it and that your Windows account can read it, or add OUs manually. + + + Add domain + + + Add a domain + + + Edit domain + + + Leave the password empty to keep the current one. + + + Save + + + The domain was not saved. Check the domain name. + + + Ready + + + Remove this domain's OUs before changing its name. + + + No domains added. + + + No OUs added. + + + The OUs were not added. They belong to a different domain than the selected one. + + + Add a domain first, then list its OUs. + + + The OU was not added. Check the display name and the distinguished name. The OU must belong to the selected domain. + + + Organizational units + + + Organizational units of {0} + + + Select a domain to list its OUs. + + + Selected OUs: {0} of {1} + + + Shared + + + Add at least one domain. + + + This domain is already listed. + + + Two OUs share the same internal ID. Remove one and add it again. + + + Enter a valid domain name, such as corp.contoso.com. + + + Each OU needs a display name of 120 characters or fewer. + + + Turn on password protection on the General page and set the deployment password. + + + Enter the account password. + + + Enter the account as DOMAIN\user or user@domain. + + + Enter the shared join account, or give this domain its own account. + + + You can list up to 32 domains. + + + You can list up to 1,024 OUs. + + + Account (DOMAIN\user or user@domain) + + + Add OU + + + Cancel search + + + Domain name + + + corp.contoso.com + + + Use a dedicated account + + + Use the shared join account + + + Import from domain + + + Lists the OUs of the selected domain, using your Windows account. Select the ones to add. + + + Import OUs from the domain + + + Domains this media can join. When several are listed, technicians choose one during deployment; the default is preselected. + + + Domains + + + Add selected OUs + + + Enter a display name and the distinguished name of the OU. The OU must belong to the selected domain. + + + Add an OU manually + + + Distinguished name + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Display name + + + Optional. List the OUs where computers can be placed in the selected domain. A single OU is always used; when several are listed, technicians choose one during deployment. An existing computer account is moved to the chosen OU. + + + Password + + + Account used by every domain that has no account of its own. It needs permission to join computers and to move them to the OUs you choose. + + + Shared join account + Trusted root CA certificate file @@ -1874,7 +2084,7 @@ Licensed under the MIT License. Password - Keep this password safe. It protects local Windows account passwords, Autopilot credentials and profiles, and custom answer files. + Keep this password safe. It protects local Windows account passwords, domain join credentials, Autopilot credentials and profiles, and custom answer files. Deployment password @@ -1987,6 +2197,12 @@ Licensed under the MIT License. Media readiness status + + Ask the technician for domain credentials during deployment. + + + Interactive Domain Join + Arabic (Saudi Arabia) @@ -2191,6 +2407,9 @@ Licensed under the MIT License. Documentation + + Domain Join + Configure wired 802.1X authentication. @@ -2212,6 +2431,12 @@ Licensed under the MIT License. Home + + Ask the technician for domain credentials during deployment. + + + Interactive + Configure deployed device names. @@ -2272,6 +2497,12 @@ Licensed under the MIT License. Windows Autopilot + + Join the domain automatically using credentials protected by the deployment password. + + + Zero-Touch + Active provisioning mode @@ -3917,4 +4148,10 @@ Applying the update restarts Foundry OSD. Wi-Fi + + Join the domain automatically using credentials protected by the deployment password. + + + Zero-touch Domain Join + diff --git a/src/Foundry/Strings/es-ES/Resources.resw b/src/Foundry/Strings/es-ES/Resources.resw index d9d621176..e93d3b22f 100644 --- a/src/Foundry/Strings/es-ES/Resources.resw +++ b/src/Foundry/Strings/es-ES/Resources.resw @@ -1834,6 +1834,216 @@ Licenciado bajo la Licencia MIT. No se pudo abrir la documentación + + Cuenta de unión + + + Predeterminado + + + Dominio + + + OU + + + Estado + + + Agregar + + + Borrar valor predeterminado + + + Editar + + + Quitar + + + Establecer como predeterminado + + + Introduzca primero una cuenta válida y, después, compruebe la longitud de la contraseña. + + + Sí + + + Buscando OU en el dominio... + + + Búsqueda cancelada. + + + No se ha encontrado ninguna OU en este dominio. + + + Solo se han podido mostrar algunas OU. Puede agregar igualmente las que aparecen. + + + Todas las OU de este dominio ya están en la lista. + + + Se ha agotado el tiempo de búsqueda. Puede agregar OU manualmente. + + + No se ha podido buscar en el dominio. Compruebe que este equipo puede conectarse a él y que su cuenta de Windows puede leerlo, o agregue las OU manualmente. + + + Agregar dominio + + + Agregar un dominio + + + Editar dominio + + + Deje la contraseña vacía para conservar la actual. + + + Guardar + + + No se ha guardado el dominio. Compruebe el nombre de dominio. + + + Listo + + + Quite las OU de este dominio antes de cambiar su nombre. + + + No se ha agregado ningún dominio. + + + No se ha agregado ninguna OU. + + + No se han agregado las OU. Pertenecen a un dominio distinto del seleccionado. + + + Agregue primero un dominio y, después, indique sus OU. + + + No se ha agregado la OU. Compruebe el nombre para mostrar y el nombre distintivo. La OU debe pertenecer al dominio seleccionado. + + + Unidades organizativas + + + Unidades organizativas de {0} + + + Seleccione un dominio para indicar sus OU. + + + OU seleccionadas: {0} de {1} + + + Compartida + + + Agregue al menos un dominio. + + + Este dominio ya está en la lista. + + + Dos OU comparten el mismo id. interno. Quite una y vuelva a agregarla. + + + Introduzca un nombre de dominio válido, como corp.contoso.com. + + + Cada OU necesita un nombre para mostrar de 120 caracteres como máximo. + + + Active la protección con contraseña en la página «General» y establezca la contraseña de implementación. + + + Introduzca la contraseña de la cuenta. + + + Introduzca la cuenta como DOMAIN\user o user@domain. + + + Introduzca la cuenta de unión compartida o asigne a este dominio una cuenta propia. + + + La lista admite un máximo de 32 dominios. + + + La lista admite un máximo de 1024 OU. + + + Cuenta (DOMAIN\user o user@domain) + + + Agregar OU + + + Cancelar búsqueda + + + Nombre de dominio + + + corp.contoso.com + + + Usar una cuenta dedicada + + + Usar la cuenta de unión compartida + + + Importar del dominio + + + Muestra las OU del dominio seleccionado, con su cuenta de Windows. Seleccione las que quiere agregar. + + + Importar OU del dominio + + + Dominios a los que puede unirse este medio. Cuando se indican varios, los técnicos eligen uno durante la implementación; el predeterminado aparece preseleccionado. + + + Dominios + + + Agregar las OU seleccionadas + + + Introduzca un nombre para mostrar y el nombre distintivo de la OU. La OU debe pertenecer al dominio seleccionado. + + + Agregar una OU manualmente + + + Nombre distintivo + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Nombre para mostrar + + + Opcional. Indique las OU en las que se pueden colocar los equipos en el dominio seleccionado. Si solo se indica una OU, se usa siempre; cuando se indican varias, los técnicos eligen una durante la implementación. Una cuenta de equipo existente se mueve a la OU elegida. + + + Contraseña + + + Cuenta utilizada por todos los dominios que no tienen una cuenta propia. Necesita permiso para unir equipos y moverlos a las OU que elija. + + + Cuenta de unión compartida + Archivo de certificado de CA raíz de confianza @@ -1874,7 +2084,7 @@ Licenciado bajo la Licencia MIT. Contraseña - Guarde esta contraseña en un lugar seguro. Protege las contraseñas de las cuentas locales de Windows, las credenciales y los perfiles de Autopilot y los archivos de respuesta personalizados. + Guarde esta contraseña en un lugar seguro. Protege las contraseñas de las cuentas locales de Windows, las credenciales de unión al dominio, las credenciales y los perfiles de Autopilot y los archivos de respuesta personalizados. Contraseña de implementación @@ -1987,6 +2197,12 @@ Licenciado bajo la Licencia MIT. Estado de preparación del medio + + Solicite al técnico las credenciales de dominio durante la implementación. + + + Unión interactiva al dominio + Árabe (Arabia Saudita) @@ -2191,6 +2407,9 @@ Licenciado bajo la Licencia MIT. Documentación + + Unión al dominio + La conectividad estándar Ethernet está disponible de forma predeterminada. Configure esta sección solo si su red cableada requiere autenticación empresarial 802.1X. @@ -2212,6 +2431,12 @@ Licenciado bajo la Licencia MIT. Hogar + + Solicite al técnico las credenciales de dominio durante la implementación. + + + Interactivo + Configure los nombres de dispositivos implementados. @@ -2272,6 +2497,12 @@ Licenciado bajo la Licencia MIT. Autopilot + + Una el equipo al dominio automáticamente con credenciales protegidas por la contraseña de implementación. + + + Zero-Touch + Modo de aprovisionamiento activo @@ -3917,4 +4148,10 @@ Al aplicar la actualización, Foundry OSD se reiniciará. Wi-Fi + + Una el equipo al dominio automáticamente con credenciales protegidas por la contraseña de implementación. + + + Unión al dominio zero-touch + diff --git a/src/Foundry/Strings/es-MX/Resources.resw b/src/Foundry/Strings/es-MX/Resources.resw index 87dd8bd77..9d8375f39 100644 --- a/src/Foundry/Strings/es-MX/Resources.resw +++ b/src/Foundry/Strings/es-MX/Resources.resw @@ -1834,6 +1834,216 @@ Licenciado bajo la Licencia MIT. No se pudo abrir la documentación + + Cuenta de unión + + + Predeterminado + + + Dominio + + + OU + + + Estado + + + Agregar + + + Borrar valor predeterminado + + + Editar + + + Quitar + + + Establecer como predeterminado + + + Introduzca primero una cuenta válida y, después, compruebe la longitud de la contraseña. + + + Sí + + + Buscando OU en el dominio... + + + Búsqueda cancelada. + + + No se ha encontrado ninguna OU en este dominio. + + + Solo se han podido mostrar algunas OU. Puede agregar igualmente las que aparecen. + + + Todas las OU de este dominio ya están en la lista. + + + Se ha agotado el tiempo de búsqueda. Puede agregar OU manualmente. + + + No se ha podido buscar en el dominio. Compruebe que este equipo puede conectarse a él y que su cuenta de Windows puede leerlo, o agregue las OU manualmente. + + + Agregar dominio + + + Agregar un dominio + + + Editar dominio + + + Deje la contraseña vacía para conservar la actual. + + + Guardar + + + No se ha guardado el dominio. Compruebe el nombre de dominio. + + + Listo + + + Quite las OU de este dominio antes de cambiar su nombre. + + + No se ha agregado ningún dominio. + + + No se ha agregado ninguna OU. + + + No se han agregado las OU. Pertenecen a un dominio distinto del seleccionado. + + + Agregue primero un dominio y, después, indique sus OU. + + + No se ha agregado la OU. Compruebe el nombre para mostrar y el nombre distintivo. La OU debe pertenecer al dominio seleccionado. + + + Unidades organizacionales + + + Unidades organizacionales de {0} + + + Seleccione un dominio para indicar sus OU. + + + OU seleccionadas: {0} de {1} + + + Compartida + + + Agregue al menos un dominio. + + + Este dominio ya está en la lista. + + + Dos OU comparten el mismo id. interno. Quite una y vuelva a agregarla. + + + Introduzca un nombre de dominio válido, como corp.contoso.com. + + + Cada OU necesita un nombre para mostrar de 120 caracteres como máximo. + + + Active la protección con contraseña en la página "General" y establezca la contraseña de implementación. + + + Introduzca la contraseña de la cuenta. + + + Introduzca la cuenta como DOMAIN\user o user@domain. + + + Introduzca la cuenta de unión compartida o asigne a este dominio una cuenta propia. + + + La lista admite un máximo de 32 dominios. + + + La lista admite un máximo de 1024 OU. + + + Cuenta (DOMAIN\user o user@domain) + + + Agregar OU + + + Cancelar búsqueda + + + Nombre de dominio + + + corp.contoso.com + + + Usar una cuenta dedicada + + + Usar la cuenta de unión compartida + + + Importar del dominio + + + Muestra las OU del dominio seleccionado, con su cuenta de Windows. Seleccione las que quiere agregar. + + + Importar OU del dominio + + + Dominios a los que puede unirse este medio. Cuando se indican varios, los técnicos eligen uno durante la implementación; el predeterminado aparece preseleccionado. + + + Dominios + + + Agregar las OU seleccionadas + + + Introduzca un nombre para mostrar y el nombre distintivo de la OU. La OU debe pertenecer al dominio seleccionado. + + + Agregar una OU manualmente + + + Nombre distintivo + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Nombre para mostrar + + + Opcional. Indique las OU en las que se pueden colocar los equipos en el dominio seleccionado. Si solo se indica una OU, se usa siempre; cuando se indican varias, los técnicos eligen una durante la implementación. Una cuenta de equipo existente se mueve a la OU elegida. + + + Contraseña + + + Cuenta utilizada por todos los dominios que no tienen una cuenta propia. Necesita permiso para unir equipos y moverlos a las OU que elija. + + + Cuenta de unión compartida + Archivo de certificado de CA raíz de confianza @@ -1874,7 +2084,7 @@ Licenciado bajo la Licencia MIT. Contraseña - Guarde esta contraseña en un lugar seguro. Protege las contraseñas de las cuentas locales de Windows, las credenciales y los perfiles de Autopilot y los archivos de respuesta personalizados. + Guarde esta contraseña en un lugar seguro. Protege las contraseñas de las cuentas locales de Windows, las credenciales de unión al dominio, las credenciales y los perfiles de Autopilot y los archivos de respuesta personalizados. Contraseña de implementación @@ -1987,6 +2197,12 @@ Licenciado bajo la Licencia MIT. Estado de preparación del medio + + Solicite al técnico las credenciales de dominio durante la implementación. + + + Unión interactiva al dominio + Árabe (Arabia Saudita) @@ -2191,6 +2407,9 @@ Licenciado bajo la Licencia MIT. Documentación + + Unión al dominio + La conectividad estándar Ethernet está disponible de forma predeterminada. Configure esta sección solo si su red cableada requiere autenticación empresarial 802.1X. @@ -2212,6 +2431,12 @@ Licenciado bajo la Licencia MIT. Hogar + + Solicite al técnico las credenciales de dominio durante la implementación. + + + Interactivo + Configure los nombres de dispositivos implementados. @@ -2272,6 +2497,12 @@ Licenciado bajo la Licencia MIT. Autopilot + + Una el equipo al dominio automáticamente con credenciales protegidas por la contraseña de implementación. + + + Zero-Touch + Modo de aprovisionamiento activo @@ -3917,4 +4148,10 @@ Al aplicar la actualización, Foundry OSD se reiniciará. Wi-Fi + + Una el equipo al dominio automáticamente con credenciales protegidas por la contraseña de implementación. + + + Unión al dominio zero-touch + diff --git a/src/Foundry/Strings/et-EE/Resources.resw b/src/Foundry/Strings/et-EE/Resources.resw index ad60497a9..1c90663f9 100644 --- a/src/Foundry/Strings/et-EE/Resources.resw +++ b/src/Foundry/Strings/et-EE/Resources.resw @@ -1834,6 +1834,216 @@ Litsentsitud MIT-i litsentsi alusel. Dokumentatsiooni ei saanud avada + + Liitmiskonto + + + Vaikimisi + + + Domeen + + + OU-d + + + Olek + + + Lisa + + + Tühjenda vaikevalik + + + Muuda + + + Eemalda + + + Määra vaikevalikuks + + + Sisestage esmalt kehtiv konto ning kontrollige seejärel parooli pikkust. + + + Jah + + + OU-de otsimine domeenist... + + + Otsing tühistati. + + + Sellest domeenist ei leitud ühtegi OU-d. + + + Loetleda õnnestus ainult osa OU-sid. Kuvatud OU-sid saate siiski lisada. + + + Kõik selle domeeni OU-d on juba loendis. + + + Otsingu ajalimiit ületati. Saate OU-sid käsitsi lisada. + + + Domeenist ei saanud otsida. Kontrollige, kas see arvuti saab sellega ühendust ja kas teie Windowsi konto saab seda lugeda, või lisage OU-d käsitsi. + + + Lisa domeen + + + Lisa domeen + + + Muuda domeeni + + + Praeguse parooli säilitamiseks jätke parool tühjaks. + + + Salvesta + + + Domeeni ei salvestatud. Kontrollige domeeninime. + + + Valmis + + + Enne domeeni nime muutmist eemaldage selle domeeni OU-d. + + + Domeene pole lisatud. + + + OU-sid pole lisatud. + + + OU-sid ei lisatud. Need kuuluvad teise domeeni kui valitud domeen. + + + Lisage esmalt domeen ja loetlege seejärel selle OU-d. + + + OU-d ei lisatud. Kontrollige kuvatavat nime ja eristusnime. OU peab kuuluma valitud domeeni. + + + Organisatsiooniüksused + + + Domeeni {0} organisatsiooniüksused + + + Valige domeen, et loetleda selle OU-d. + + + Valitud OU-d: {0}/{1} + + + Ühine + + + Lisage vähemalt üks domeen. + + + See domeen on juba loendis. + + + Kahel OU-l on sama sisemine ID. Eemaldage üks neist ja lisage see uuesti. + + + Sisestage kehtiv domeeninimi, näiteks corp.contoso.com. + + + Igal OU-l peab olema kuni 120 märgi pikkune kuvatav nimi. + + + Lubage lehel „Üldine“ paroolikaitse ja määrake juurutusparool. + + + Sisestage konto parool. + + + Sisestage konto kujul DOMAIN\user või user@domain. + + + Sisestage ühine liitmiskonto või määrake sellele domeenile oma konto. + + + Loendisse saab lisada kuni 32 domeeni. + + + Loendisse saab lisada kuni 1024 OU-d. + + + Konto (DOMAIN\user või user@domain) + + + Lisa OU + + + Tühista otsing + + + Domeeninimi + + + corp.contoso.com + + + Kasuta eraldi kontot + + + Kasuta ühist liitmiskontot + + + Impordi domeenist + + + Loetleb teie Windowsi konto abil valitud domeeni OU-d. Valige lisatavad OU-d. + + + Impordi OU-d domeenist + + + Domeenid, millega see andmekandja saab arvuteid liita. Kui loetletud on mitu, valivad tehnikud juurutamise ajal ühe; vaikedomeen on eelvalitud. + + + Domeenid + + + Lisa valitud OU-d + + + Sisestage OU kuvatav nimi ja eristusnimi. OU peab kuuluma valitud domeeni. + + + Lisa OU käsitsi + + + Eristusnimi + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Kuvatav nimi + + + Valikuline. Loetlege OU-d, kuhu arvuteid saab valitud domeenis paigutada. Kui loetletud on ainult üks OU, kasutatakse alati seda; kui loetletud on mitu, valivad tehnikud juurutamise ajal ühe. Olemasolev arvutikonto teisaldatakse valitud OU-sse. + + + Parool + + + Konto, mida kasutavad kõik domeenid, millel pole oma kontot. Sellel peab olema õigus arvuteid domeeniga liita ja teie valitud OU-desse teisaldada. + + + Ühine liitmiskonto + Usaldusväärne juur-CA sertifikaadi fail @@ -1874,7 +2084,7 @@ Litsentsitud MIT-i litsentsi alusel. Parool - Hoidke seda parooli turvaliselt. See kaitseb kohalike Windowsi kontode paroole, Autopiloti identimisteavet ja profiile ning kohandatud vastusefaile. + Hoidke seda parooli turvaliselt. See kaitseb kohalike Windowsi kontode paroole, domeeniga liitumise identimisteavet, Autopiloti identimisteavet ja profiile ning kohandatud vastusefaile. Juurutusparool @@ -1907,7 +2117,7 @@ Litsentsitud MIT-i litsentsi alusel. Valitud arhitektuuri jaoks WinPE keelepakette ei tuvastatud. - Kindral + Üldine Vaata üle ja alusta @@ -1987,6 +2197,12 @@ Litsentsitud MIT-i litsentsi alusel. Andmekandja valmisoleku olek + + Küsige tehnikult juurutamise ajal domeeni identimisteavet. + + + Interaktiivne domeeniga liitumine + araabia (Saudi Araabia) @@ -2191,6 +2407,9 @@ Litsentsitud MIT-i litsentsi alusel. Dokumentatsioon + + Domeeniga liitumine + Standardne Ethernet ühenduvus on vaikimisi saadaval. Seadistage see jaotis ainult siis, kui teie traadiga võrk nõuab 802.1X ettevõtte autentimist. @@ -2201,10 +2420,10 @@ Litsentsitud MIT-i litsentsi alusel. Standardse konfiguratsiooni töövoog. - Kindral + Üldine - Kindral + Üldine Foundry OSD koju @@ -2212,6 +2431,12 @@ Litsentsitud MIT-i litsentsi alusel. Kodu + + Küsige tehnikult juurutamise ajal domeeni identimisteavet. + + + Interaktiivne + Seadistage juurutatud seadmete nimed. @@ -2272,6 +2497,12 @@ Litsentsitud MIT-i litsentsi alusel. Autopilot + + Liituge domeeniga automaatselt identimisteabe abil, mida kaitseb juurutusparool. + + + Zero-Touch + Aktiivne juurutusrežiim @@ -2974,7 +3205,7 @@ Kaasatud paroolid ja juurdepääsuvõtmed: {2} Käivitamise, keele, diagnostika ja rakenduse käitumise haldamine. - Kindral + Üldine Varundage oma sätted, teisaldage need teise arvutisse või hoidke neid sünkroonis. @@ -3917,4 +4148,10 @@ Värskenduse rakendamine taaskäivitab Foundry OSD. Wi-Fi + + Liituge domeeniga automaatselt identimisteabe abil, mida kaitseb juurutusparool. + + + Zero-touchi domeeniga liitumine + diff --git a/src/Foundry/Strings/fi-FI/Resources.resw b/src/Foundry/Strings/fi-FI/Resources.resw index 8da3a547a..df007a150 100644 --- a/src/Foundry/Strings/fi-FI/Resources.resw +++ b/src/Foundry/Strings/fi-FI/Resources.resw @@ -1834,6 +1834,216 @@ Lisensoitu MIT-lisenssillä. Asiakirjoja ei voitu avata + + Liittämistili + + + Oletus + + + Toimialue + + + OU:t + + + Tila + + + Lisää + + + Tyhjennä oletusvalinta + + + Muokkaa + + + Poista + + + Aseta oletukseksi + + + Anna ensin kelvollinen tili ja tarkista sitten salasanan pituus. + + + Kyllä + + + Etsitään OU:ita toimialueesta... + + + Haku peruutettiin. + + + Tästä toimialueesta ei löytynyt OU:ita. + + + Vain osa OU:ista voitiin luetella. Voit silti lisätä näkyvissä olevat. + + + Kaikki tämän toimialueen OU:t ovat jo luettelossa. + + + Haku aikakatkaistiin. Voit lisätä OU:ita manuaalisesti. + + + Toimialueesta ei voitu hakea. Tarkista, että tämä tietokone saa siihen yhteyden ja että Windows-tililläsi on oikeus lukea sitä, tai lisää OU:t manuaalisesti. + + + Lisää toimialue + + + Lisää toimialue + + + Muokkaa toimialuetta + + + Jätä salasana tyhjäksi, jos haluat säilyttää nykyisen. + + + Tallenna + + + Toimialuetta ei tallennettu. Tarkista toimialueen nimi. + + + Valmis + + + Poista tämän toimialueen OU:t ennen sen nimen muuttamista. + + + Toimialueita ei ole lisätty. + + + OU:ita ei ole lisätty. + + + OU:ita ei lisätty. Ne kuuluvat eri toimialueeseen kuin valittu toimialue. + + + Lisää ensin toimialue ja luettele sitten sen OU:t. + + + OU:ta ei lisätty. Tarkista näyttönimi ja yksilöivä nimi. OU:n on kuuluttava valittuun toimialueeseen. + + + Organisaatioyksiköt + + + Toimialueen {0} organisaatioyksiköt + + + Valitse toimialue, niin voit luetella sen OU:t. + + + Valitut OU:t: {0} / {1} + + + Jaettu + + + Lisää vähintään yksi toimialue. + + + Tämä toimialue on jo luettelossa. + + + Kahdella OU:lla on sama sisäinen tunnus. Poista toinen ja lisää se uudelleen. + + + Anna kelvollinen toimialueen nimi, esimerkiksi corp.contoso.com. + + + Jokaisella OU:lla on oltava enintään 120 merkin pituinen näyttönimi. + + + Ota salasanasuojaus käyttöön sivulla ”Yleiset” ja määritä käyttöönoton salasana. + + + Anna tilin salasana. + + + Anna tili muodossa DOMAIN\user tai user@domain. + + + Anna jaettu liittämistili tai määritä tälle toimialueelle oma tili. + + + Luettelossa voi olla enintään 32 toimialuetta. + + + Luettelossa voi olla enintään 1 024 OU:ta. + + + Tili (DOMAIN\user tai user@domain) + + + Lisää OU + + + Peruuta haku + + + Toimialueen nimi + + + corp.contoso.com + + + Käytä erillistä tiliä + + + Käytä jaettua liittämistiliä + + + Tuo toimialueesta + + + Luettelee Windows-tilisi avulla valitun toimialueen OU:t. Valitse lisättävät. + + + Tuo OU:t toimialueesta + + + Toimialueet, joihin tämä media voi liittyä. Jos niitä on luettelossa useita, teknikot valitsevat yhden käyttöönoton aikana; oletustoimialue on esivalittuna. + + + Toimialueet + + + Lisää valitut OU:t + + + Anna näyttönimi ja OU:n yksilöivä nimi. OU:n on kuuluttava valittuun toimialueeseen. + + + Lisää OU manuaalisesti + + + Yksilöivä nimi + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Näyttönimi + + + Valinnainen. Luettele OU:t, joihin tietokoneita voidaan sijoittaa valitussa toimialueessa. Jos luettelossa on vain yksi OU, sitä käytetään aina; jos niitä on useita, teknikot valitsevat yhden käyttöönoton aikana. Olemassa oleva tietokonetili siirretään valittuun OU:hun. + + + Salasana + + + Tili, jota käyttävät kaikki toimialueet, joilla ei ole omaa tiliä. Sillä on oltava oikeus liittää tietokoneita ja siirtää ne valitsemiisi OU:ihin. + + + Jaettu liittämistili + Luotettu juurivarmennetiedosto @@ -1874,7 +2084,7 @@ Lisensoitu MIT-lisenssillä. Salasana - Säilytä tämä salasana turvallisesti. Se suojaa paikallisten Windows-tilien salasanat, Autopilot-tunnistetiedot ja -profiilit sekä mukautetut vastaustiedostot. + Säilytä tämä salasana turvallisesti. Se suojaa paikallisten Windows-tilien salasanat, toimialueeseen liittymisen tunnistetiedot, Autopilot-tunnistetiedot ja -profiilit sekä mukautetut vastaustiedostot. Käyttöönoton salasana @@ -1907,7 +2117,7 @@ Lisensoitu MIT-lisenssillä. Valitulle arkkitehtuurille ei havaittu WinPE-kielipaketteja. - Kenraali + Yleiset Tarkista ja aloita @@ -1987,6 +2197,12 @@ Lisensoitu MIT-lisenssillä. Tietovälineen valmiustila + + Pyydä teknikolta toimialueen tunnistetiedot käyttöönoton aikana. + + + Vuorovaikutteinen toimialueeseen liittyminen + arabia (Saudi-Arabia) @@ -2191,6 +2407,9 @@ Lisensoitu MIT-lisenssillä. Dokumentaatio + + Toimialueeseen liittyminen + Normaali Ethernet-yhteys on käytettävissä oletuksena. Määritä tämä osio vain, jos kiinteä verkkosi vaatii 802.1X yritystodennusta. @@ -2201,10 +2420,10 @@ Lisensoitu MIT-lisenssillä. Vakiokonfigurointityönkulku. - Kenraali + Yleiset - Kenraali + Yleiset Foundry OSD kotiin @@ -2212,6 +2431,12 @@ Lisensoitu MIT-lisenssillä. Kotiin + + Pyydä teknikolta toimialueen tunnistetiedot käyttöönoton aikana. + + + Interaktiivinen + Määritä käyttöön otettujen laitteiden nimet. @@ -2272,6 +2497,12 @@ Lisensoitu MIT-lisenssillä. Autopilot + + Liity toimialueeseen automaattisesti käyttöönoton salasanalla suojatuilla tunnistetiedoilla. + + + Zero-Touch + Aktiivinen käyttöönotto-tila @@ -2974,7 +3205,7 @@ Mukana olevat salasanat ja käyttöavaimet: {2} Hallitse käynnistystä, kieltä, diagnostiikkaa ja sovellusten toimintaa. - Kenraali + Yleiset Varmuuskopioi asetuksesi, siirrä ne toiselle tietokoneelle tai pidä ne synkronoituina. @@ -3917,4 +4148,10 @@ Päivityksen asentaminen käynnistää Foundry OSD:n uudelleen. Wi-Fi + + Liity toimialueeseen automaattisesti käyttöönoton salasanalla suojatuilla tunnistetiedoilla. + + + Zero-touch-liittyminen toimialueeseen + diff --git a/src/Foundry/Strings/fr-CA/Resources.resw b/src/Foundry/Strings/fr-CA/Resources.resw index 019fa7d24..85d4c8a16 100644 --- a/src/Foundry/Strings/fr-CA/Resources.resw +++ b/src/Foundry/Strings/fr-CA/Resources.resw @@ -1834,6 +1834,216 @@ Sous licence MIT. La documentation n'a pas pu être ouverte + + Compte de jonction + + + Par défaut + + + Domaine + + + OU + + + État + + + Ajouter + + + Effacer le choix par défaut + + + Modifier + + + Supprimer + + + Définir par défaut + + + Saisissez d’abord un compte valide, puis vérifiez la longueur du mot de passe. + + + Oui + + + Recherche des OU dans le domaine... + + + Recherche annulée. + + + Aucune OU n’a été trouvée dans ce domaine. + + + Seule une partie des OU a pu être répertoriée. Vous pouvez tout de même ajouter celles qui sont affichées. + + + Toutes les OU de ce domaine figurent déjà dans la liste. + + + Le délai de recherche a expiré. Vous pouvez ajouter des OU manuellement. + + + La recherche dans le domaine a échoué. Vérifiez que cet ordinateur peut le contacter et que votre compte Windows peut le lire, ou ajoutez des OU manuellement. + + + Ajouter le domaine + + + Ajouter un domaine + + + Modifier le domaine + + + Laissez le mot de passe vide pour conserver le mot de passe actuel. + + + Enregistrer + + + Le domaine n’a pas été enregistré. Vérifiez le nom de domaine. + + + Prêt + + + Supprimez les OU de ce domaine avant de modifier son nom. + + + Aucun domaine ajouté. + + + Aucune OU ajoutée. + + + Les OU n’ont pas été ajoutées. Elles appartiennent à un domaine différent de celui sélectionné. + + + Ajoutez d’abord un domaine, puis répertoriez ses OU. + + + L’OU n’a pas été ajoutée. Vérifiez le nom d’affichage et le nom distinctif. L’OU doit appartenir au domaine sélectionné. + + + Unités d’organisation + + + Unités d’organisation de {0} + + + Sélectionnez un domaine pour répertorier ses OU. + + + OU sélectionnées: {0} de {1} + + + Partagé + + + Ajoutez au moins un domaine. + + + Ce domaine figure déjà dans la liste. + + + Deux OU ont le même identifiant interne. Supprimez-en une et ajoutez-la de nouveau. + + + Saisissez un nom de domaine valide, par exemple corp.contoso.com. + + + Chaque OU doit avoir un nom d’affichage de 120 caractères maximum. + + + Activez la protection par mot de passe sur la page « Général » et définissez le mot de passe de déploiement. + + + Saisissez le mot de passe du compte. + + + Saisissez le compte au format DOMAIN\user ou user@domain. + + + Saisissez le compte de jonction partagé, ou attribuez à ce domaine son propre compte. + + + Vous pouvez répertorier jusqu’à 32 domaines. + + + Vous pouvez répertorier jusqu’à 1 024 OU. + + + Compte (DOMAIN\user ou user@domain) + + + Ajouter une OU + + + Annuler la recherche + + + Nom de domaine + + + corp.contoso.com + + + Utiliser un compte dédié + + + Utiliser le compte de jonction partagé + + + Importer depuis le domaine + + + Répertorie les OU du domaine sélectionné, à l’aide de votre compte Windows. Sélectionnez celles à ajouter. + + + Importer des OU depuis le domaine + + + Domaines que ce média peut joindre. Lorsque plusieurs sont répertoriés, les techniciens en choisissent un pendant le déploiement; le domaine par défaut est présélectionné. + + + Domaines + + + Ajouter les OU sélectionnées + + + Saisissez un nom d’affichage et le nom distinctif de l’OU. L’OU doit appartenir au domaine sélectionné. + + + Ajouter une OU manuellement + + + Nom distinctif + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Nom d’affichage + + + Facultatif. Répertoriez les OU dans lesquelles les ordinateurs peuvent être placés dans le domaine sélectionné. Une OU unique est toujours utilisée; lorsque plusieurs sont répertoriées, les techniciens en choisissent une pendant le déploiement. Un compte d’ordinateur existant est déplacé vers l’OU choisie. + + + Mot de passe + + + Compte utilisé par tous les domaines qui n’ont pas leur propre compte. Il doit être autorisé à joindre des ordinateurs et à les déplacer vers les OU que vous choisissez. + + + Compte de jonction partagé + Fichier de certificat d'autorité de certification racine de confiance @@ -1874,7 +2084,7 @@ Sous licence MIT. Mot de passe - Conservez ce mot de passe en lieu sûr. Il protège les mots de passe des comptes Windows locaux, les informations d’identification et les profils Autopilot, ainsi que les fichiers de réponses personnalisés. + Conservez ce mot de passe en lieu sûr. Il protège les mots de passe des comptes Windows locaux, les informations d’identification de jonction au domaine, les informations d’identification et les profils Autopilot, ainsi que les fichiers de réponses personnalisés. Mot de passe de déploiement @@ -1987,6 +2197,12 @@ Sous licence MIT. État de préparation du média + + Demandez au technicien les informations d’identification du domaine pendant le déploiement. + + + Jonction au domaine interactive + Arabe (Arabie Saoudite) @@ -2191,6 +2407,9 @@ Sous licence MIT. Documentation + + Jonction au domaine + La connectivité standard Ethernet est disponible par défaut. Configurez cette section uniquement si votre réseau câblé nécessite une authentification d'entreprise 802.1X. @@ -2212,6 +2431,12 @@ Sous licence MIT. Accueil + + Demandez au technicien les informations d’identification du domaine pendant le déploiement. + + + Interactif + Configurer les noms d'appareil déployés. @@ -2272,6 +2497,12 @@ Sous licence MIT. Autopilot + + Rejoignez le domaine automatiquement avec des informations d’identification protégées par le mot de passe de déploiement. + + + Zero-Touch + Mode de provisionnement actif @@ -3917,4 +4148,10 @@ L’application de la mise à jour redémarre Foundry OSD. Wi-Fi + + Rejoignez le domaine automatiquement avec des informations d’identification protégées par le mot de passe de déploiement. + + + Jonction au domaine zero-touch + diff --git a/src/Foundry/Strings/fr-FR/Resources.resw b/src/Foundry/Strings/fr-FR/Resources.resw index 1668756cd..47996581f 100644 --- a/src/Foundry/Strings/fr-FR/Resources.resw +++ b/src/Foundry/Strings/fr-FR/Resources.resw @@ -1834,6 +1834,216 @@ Sous licence MIT. Impossible d’ouvrir la documentation + + Compte de jonction + + + Par défaut + + + Domaine + + + OU + + + État + + + Ajouter + + + Effacer le choix par défaut + + + Modifier + + + Supprimer + + + Définir par défaut + + + Saisissez d’abord un compte valide, puis vérifiez la longueur du mot de passe. + + + Oui + + + Recherche des OU dans le domaine... + + + Recherche annulée. + + + Aucune OU n’a été trouvée dans ce domaine. + + + Seule une partie des OU a pu être répertoriée. Vous pouvez tout de même ajouter celles qui sont affichées. + + + Toutes les OU de ce domaine figurent déjà dans la liste. + + + Le délai de recherche a expiré. Vous pouvez ajouter des OU manuellement. + + + La recherche dans le domaine a échoué. Vérifiez que cet ordinateur peut le contacter et que votre compte Windows peut le lire, ou ajoutez des OU manuellement. + + + Ajouter le domaine + + + Ajouter un domaine + + + Modifier le domaine + + + Laissez le mot de passe vide pour conserver le mot de passe actuel. + + + Enregistrer + + + Le domaine n’a pas été enregistré. Vérifiez le nom de domaine. + + + Prêt + + + Supprimez les OU de ce domaine avant de modifier son nom. + + + Aucun domaine ajouté. + + + Aucune OU ajoutée. + + + Les OU n’ont pas été ajoutées. Elles appartiennent à un domaine différent de celui sélectionné. + + + Ajoutez d’abord un domaine, puis répertoriez ses OU. + + + L’OU n’a pas été ajoutée. Vérifiez le nom d’affichage et le nom distinctif. L’OU doit appartenir au domaine sélectionné. + + + Unités d’organisation + + + Unités d’organisation de {0} + + + Sélectionnez un domaine pour répertorier ses OU. + + + OU sélectionnées : {0} sur {1} + + + Partagé + + + Ajoutez au moins un domaine. + + + Ce domaine figure déjà dans la liste. + + + Deux OU ont le même identifiant interne. Supprimez-en une et ajoutez-la de nouveau. + + + Saisissez un nom de domaine valide, par exemple corp.contoso.com. + + + Chaque OU doit avoir un nom d’affichage de 120 caractères maximum. + + + Activez la protection par mot de passe sur la page « Général » et définissez le mot de passe de déploiement. + + + Saisissez le mot de passe du compte. + + + Saisissez le compte au format DOMAIN\user ou user@domain. + + + Saisissez le compte de jonction partagé, ou attribuez à ce domaine son propre compte. + + + Vous pouvez répertorier jusqu’à 32 domaines. + + + Vous pouvez répertorier jusqu’à 1 024 OU. + + + Compte (DOMAIN\user ou user@domain) + + + Ajouter une OU + + + Annuler la recherche + + + Nom de domaine + + + corp.contoso.com + + + Utiliser un compte dédié + + + Utiliser le compte de jonction partagé + + + Importer depuis le domaine + + + Répertorie les OU du domaine sélectionné, à l’aide de votre compte Windows. Sélectionnez celles à ajouter. + + + Importer des OU depuis le domaine + + + Domaines que ce support peut joindre. Lorsque plusieurs sont répertoriés, les techniciens en choisissent un pendant le déploiement ; le domaine par défaut est présélectionné. + + + Domaines + + + Ajouter les OU sélectionnées + + + Saisissez un nom d’affichage et le nom distinctif de l’OU. L’OU doit appartenir au domaine sélectionné. + + + Ajouter une OU manuellement + + + Nom distinctif + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Nom d’affichage + + + Facultatif. Répertoriez les OU dans lesquelles les ordinateurs peuvent être placés dans le domaine sélectionné. Une OU unique est toujours utilisée ; lorsque plusieurs sont répertoriées, les techniciens en choisissent une pendant le déploiement. Un compte d’ordinateur existant est déplacé vers l’OU choisie. + + + Mot de passe + + + Compte utilisé par tous les domaines qui n’ont pas leur propre compte. Il doit être autorisé à joindre des ordinateurs et à les déplacer vers les OU que vous choisissez. + + + Compte de jonction partagé + Fichier de certificat d'autorité racine de confiance @@ -1874,7 +2084,7 @@ Sous licence MIT. Mot de passe - Conservez ce mot de passe en lieu sûr. Il protège les mots de passe des comptes Windows locaux, les identifiants et profils Autopilot, ainsi que les fichiers de réponses personnalisés. + Conservez ce mot de passe en lieu sûr. Il protège les mots de passe des comptes Windows locaux, les identifiants de jonction au domaine, les identifiants et profils Autopilot, ainsi que les fichiers de réponses personnalisés. Mot de passe de déploiement @@ -1987,6 +2197,12 @@ Sous licence MIT. État de préparation du média + + Demandez au technicien les informations d’identification du domaine pendant le déploiement. + + + Jonction au domaine interactive + Arabe (Arabie Saoudite) @@ -2191,6 +2407,9 @@ Sous licence MIT. Documentation + + Jonction au domaine + La connectivité Ethernet standard est disponible par défaut. Configurez cette section uniquement si votre réseau filaire nécessite une authentification 802.1X. @@ -2212,6 +2431,12 @@ Sous licence MIT. Accueil + + Demandez au technicien les informations d’identification du domaine pendant le déploiement. + + + Interactif + Configurer les noms d’appareil déployés. @@ -2272,6 +2497,12 @@ Sous licence MIT. Autopilot + + Rejoignez le domaine automatiquement avec des informations d’identification protégées par le mot de passe de déploiement. + + + Zero-Touch + Mode de provisionnement actif @@ -3917,4 +4148,10 @@ L’application de la mise à jour redémarre Foundry OSD. Wi-Fi + + Rejoignez le domaine automatiquement avec des informations d’identification protégées par le mot de passe de déploiement. + + + Jonction au domaine zero-touch + diff --git a/src/Foundry/Strings/he-IL/Resources.resw b/src/Foundry/Strings/he-IL/Resources.resw index 958dbe396..bb0792db5 100644 --- a/src/Foundry/Strings/he-IL/Resources.resw +++ b/src/Foundry/Strings/he-IL/Resources.resw @@ -1834,6 +1834,216 @@ לא ניתן היה לפתוח את התיעוד + + חשבון צירוף + + + ברירת מחדל + + + תחום + + + יחידות OU + + + מצב + + + הוספה + + + ניקוי ברירת המחדל + + + עריכה + + + הסרה + + + הגדרה כברירת מחדל + + + הזן תחילה חשבון תקין, ולאחר מכן בדוק את אורך הסיסמה. + + + כן + + + מחפש יחידות OU בתחום... + + + החיפוש בוטל. + + + לא נמצאו יחידות OU בתחום זה. + + + ניתן היה להציג רק חלק מיחידות ה-OU. עדיין ניתן להוסיף את אלה שמוצגות. + + + כל יחידות ה-OU בתחום זה כבר נמצאות ברשימה. + + + תם הזמן המוקצב לחיפוש. ניתן להוסיף יחידות OU ידנית. + + + לא ניתן היה לחפש בתחום. ודא שמחשב זה יכול להגיע אליו ושחשבון ה-Windows שלך יכול לקרוא אותו, או הוסף יחידות OU ידנית. + + + הוספת תחום + + + הוספת תחום + + + עריכת תחום + + + השאר את הסיסמה ריקה כדי לשמור על הסיסמה הנוכחית. + + + שמירה + + + התחום לא נשמר. בדוק את שם התחום. + + + מוכן + + + הסר את יחידות ה-OU של תחום זה לפני שינוי שמו. + + + לא נוספו תחומים. + + + לא נוספו יחידות OU. + + + יחידות ה-OU לא נוספו. הן שייכות לתחום שונה מהתחום שנבחר. + + + הוסף תחילה תחום, ולאחר מכן פרט את יחידות ה-OU שלו. + + + ה-OU לא נוספה. בדוק את שם התצוגה ואת השם המובחן. ה-OU חייבת להשתייך לתחום שנבחר. + + + יחידות ארגוניות + + + יחידות ארגוניות של {0} + + + בחר תחום כדי להציג את יחידות ה-OU שלו. + + + יחידות OU שנבחרו: {0} מתוך {1} + + + משותף + + + הוסף לפחות תחום אחד. + + + תחום זה כבר נמצא ברשימה. + + + לשתי יחידות OU יש אותו מזהה פנימי. הסר אחת מהן והוסף אותה שוב. + + + הזן שם תחום תקין, כגון corp.contoso.com. + + + כל OU דורשת שם תצוגה באורך של עד 120 תווים. + + + הפעל הגנה באמצעות סיסמה בעמוד "כללי" והגדר את סיסמת הפריסה. + + + הזן את סיסמת החשבון. + + + הזן את החשבון בתבנית DOMAIN\user או user@domain. + + + הזן את חשבון הצירוף המשותף, או הגדר לתחום זה חשבון משלו. + + + ניתן לכלול ברשימה עד 32 תחומים. + + + ניתן לכלול ברשימה עד 1,024 יחידות OU. + + + חשבון (DOMAIN\user או user@domain) + + + הוספת OU + + + ביטול החיפוש + + + שם התחום + + + corp.contoso.com + + + שימוש בחשבון ייעודי + + + שימוש בחשבון הצירוף המשותף + + + ייבוא מהתחום + + + מציג את יחידות ה-OU של התחום שנבחר, באמצעות חשבון ה-Windows שלך. בחר את אלה שברצונך להוסיף. + + + ייבוא יחידות OU מהתחום + + + התחומים שאליהם מדיה זו יכולה להצטרף. כאשר מפורטים כמה תחומים, הטכנאים בוחרים אחד מהם במהלך הפריסה; תחום ברירת המחדל נבחר מראש. + + + תחומים + + + הוספת יחידות ה-OU שנבחרו + + + הזן שם תצוגה ואת השם המובחן של ה-OU. ה-OU חייבת להשתייך לתחום שנבחר. + + + הוספת OU ידנית + + + שם מובחן + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + שם תצוגה + + + אופציונלי. פרט את יחידות ה-OU שבהן ניתן למקם מחשבים בתחום שנבחר. כאשר מפורטת יחידת OU אחת בלבד, נעשה בה שימוש תמיד; כאשר מפורטות כמה יחידות, הטכנאים בוחרים אחת מהן במהלך הפריסה. חשבון מחשב קיים מועבר ל-OU שנבחרה. + + + סיסמה + + + החשבון המשמש את כל התחומים שאין להם חשבון משלהם. נדרשת לו הרשאה לצרף מחשבים ולהעביר אותם ליחידות ה-OU שתבחר. + + + חשבון צירוף משותף + קובץ אישור CA שורש מהימן @@ -1874,7 +2084,7 @@ סיסמה - שמור על סיסמה זו במקום בטוח. היא מגנה על סיסמאות של חשבונות Windows מקומיים, על פרטי הכניסה והפרופילים של Autopilot ועל קובצי תשובות מותאמים אישית. + שמור על סיסמה זו במקום בטוח. היא מגנה על סיסמאות של חשבונות Windows מקומיים, על פרטי הכניסה להצטרפות לתחום, על פרטי הכניסה והפרופילים של Autopilot ועל קובצי תשובות מותאמים אישית. סיסמת פריסה @@ -1987,6 +2197,12 @@ מצב מוכנות המדיה + + בקש מהטכנאי פרטי כניסה לתחום במהלך הפריסה. + + + הצטרפות אינטראקטיבית לתחום + ערבית (ערב הסעודית) @@ -2191,6 +2407,9 @@ תיעוד + + הצטרפות לתחום + קישוריות Ethernet סטנדרטית זמינה כברירת מחדל. הגדר את הסעיף הזה רק אם הרשת הקווית שלך דורשת אימות ארגוני 802.1X. @@ -2212,6 +2431,12 @@ בית + + בקש מהטכנאי פרטי כניסה לתחום במהלך הפריסה. + + + אינטראקטיבי + הגדר שמות למכשירים שנפרסו. @@ -2272,6 +2497,12 @@ Autopilot + + הצטרף לתחום באופן אוטומטי באמצעות פרטי כניסה המוגנים בסיסמת הפריסה. + + + Zero-Touch + מצב הקצאה פעיל @@ -3917,4 +4148,10 @@ Wi-Fi + + הצטרף לתחום באופן אוטומטי באמצעות פרטי כניסה המוגנים בסיסמת הפריסה. + + + הצטרפות לתחום ב-Zero-touch + diff --git a/src/Foundry/Strings/hr-HR/Resources.resw b/src/Foundry/Strings/hr-HR/Resources.resw index 2399a7ba5..487b1788e 100644 --- a/src/Foundry/Strings/hr-HR/Resources.resw +++ b/src/Foundry/Strings/hr-HR/Resources.resw @@ -1834,6 +1834,216 @@ Licenciran prema MIT licenci. Dokumentaciju nije bilo moguće otvoriti + + Račun za pridruživanje + + + Zadano + + + Domena + + + OU-ovi + + + Status + + + Dodaj + + + Ukloni zadani odabir + + + Uredi + + + Ukloni + + + Postavi kao zadano + + + Najprije unesite valjan račun, a zatim provjerite duljinu lozinke. + + + Da + + + Pretraživanje OU-ova u domeni... + + + Pretraživanje je otkazano. + + + U ovoj domeni nije pronađen nijedan OU. + + + Mogao se prikazati samo dio OU-ova. Prikazane i dalje možete dodati. + + + Svi OU-ovi u ovoj domeni već su na popisu. + + + Isteklo je vrijeme pretraživanja. OU-ove možete dodati ručno. + + + Domenu nije bilo moguće pretražiti. Provjerite može li joj ovo računalo pristupiti i može li je vaš Windows račun čitati ili ručno dodajte OU-ove. + + + Dodaj domenu + + + Dodaj domenu + + + Uredi domenu + + + Ostavite lozinku praznom kako biste zadržali trenutačnu. + + + Spremi + + + Domena nije spremljena. Provjerite naziv domene. + + + Spremno + + + Uklonite OU-ove ove domene prije promjene njezina naziva. + + + Nema dodanih domena. + + + Nema dodanih OU-ova. + + + OU-ovi nisu dodani. Pripadaju domeni koja se razlikuje od odabrane. + + + Najprije dodajte domenu, a zatim navedite njezine OU-ove. + + + OU nije dodan. Provjerite naziv za prikaz i razlikovni naziv. OU mora pripadati odabranoj domeni. + + + Organizacijske jedinice + + + Organizacijske jedinice domene {0} + + + Odaberite domenu da biste naveli njezine OU-ove. + + + Odabrani OU-ovi: {0} od {1} + + + Zajednički + + + Dodajte barem jednu domenu. + + + Ova je domena već na popisu. + + + Dva OU-a imaju isti interni ID. Uklonite jedan i ponovno ga dodajte. + + + Unesite valjan naziv domene, na primjer corp.contoso.com. + + + Svaki OU treba naziv za prikaz s najviše 120 znakova. + + + Omogućite zaštitu lozinkom na stranici „Općenito” i postavite lozinku za implementaciju. + + + Unesite lozinku računa. + + + Unesite račun u obliku DOMAIN\user ili user@domain. + + + Unesite zajednički račun za pridruživanje ili ovoj domeni dodijelite vlastiti račun. + + + Možete navesti najviše 32 domene. + + + Možete navesti najviše 1.024 OU-a. + + + Račun (DOMAIN\user ili user@domain) + + + Dodaj OU + + + Otkaži pretraživanje + + + Naziv domene + + + corp.contoso.com + + + Koristi namjenski račun + + + Koristi zajednički račun za pridruživanje + + + Uvoz iz domene + + + Navodi OU-ove odabrane domene, koristeći vaš Windows račun. Odaberite one koje želite dodati. + + + Uvoz OU-ova iz domene + + + Domene kojima se ovaj medij može pridružiti. Ako ih je navedeno više, tehničari odabiru jednu tijekom implementacije; zadana je domena unaprijed odabrana. + + + Domene + + + Dodaj odabrane OU-ove + + + Unesite naziv za prikaz i razlikovni naziv OU-a. OU mora pripadati odabranoj domeni. + + + Ručno dodaj OU + + + Razlikovni naziv + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Naziv za prikaz + + + Neobavezno. Navedite OU-ove u koje se računala mogu smjestiti u odabranoj domeni. Ako je naveden samo jedan OU, on se uvijek koristi; ako ih je navedeno više, tehničari odabiru jedan tijekom implementacije. Postojeći račun računala premješta se u odabrani OU. + + + Lozinka + + + Račun koji koriste sve domene koje nemaju vlastiti račun. Mora imati dopuštenje za pridruživanje računala i njihovo premještanje u OU-ove koje odaberete. + + + Zajednički račun za pridruživanje + Datoteka certifikata pouzdanog korijenskog CA @@ -1874,7 +2084,7 @@ Licenciran prema MIT licenci. Lozinka - Čuvajte ovu lozinku na sigurnom. Ona štiti lozinke lokalnih računa sustava Windows, vjerodajnice i profile za Autopilot te prilagođene datoteke odgovora. + Čuvajte ovu lozinku na sigurnom. Ona štiti lozinke lokalnih računa sustava Windows, vjerodajnice za pridruživanje domeni, vjerodajnice i profile za Autopilot te prilagođene datoteke odgovora. Lozinka za implementaciju @@ -1907,7 +2117,7 @@ Licenciran prema MIT licenci. Nijedan WinPE jezični paket nije otkriven za odabranu arhitekturu. - General + Općenito Pregledajte i počnite @@ -1987,6 +2197,12 @@ Licenciran prema MIT licenci. Stanje spremnosti medija + + Zatražite od tehničara vjerodajnice domene tijekom implementacije. + + + Interaktivno pridruživanje domeni + arapski (Saudijska Arabija) @@ -2191,6 +2407,9 @@ Licenciran prema MIT licenci. Dokumentacija + + Pridruživanje domeni + Standardno Ethernet povezivanje dostupno je prema zadanim postavkama. Konfigurirajte ovaj odjeljak samo ako vaša žična mreža zahtijeva 802.1X provjeru autentičnosti poduzeća. @@ -2201,10 +2420,10 @@ Licenciran prema MIT licenci. Standardni tijek konfiguracije. - General + Općenito - General + Općenito Foundry OSD kući @@ -2212,6 +2431,12 @@ Licenciran prema MIT licenci. Dom + + Zatražite od tehničara vjerodajnice domene tijekom implementacije. + + + Interaktivno + Konfigurirajte nazive implementiranih uređaja. @@ -2272,6 +2497,12 @@ Licenciran prema MIT licenci. Autopilot + + Automatski se pridružite domeni pomoću vjerodajnica zaštićenih lozinkom za implementaciju. + + + Zero-Touch + Aktivni način dodjele resursa @@ -2974,7 +3205,7 @@ Uključene lozinke i pristupni ključevi: {2} Upravljajte pokretanjem, jezikom, dijagnostikom i ponašanjem aplikacije. - General + Općenito Sigurnosno kopirajte svoje postavke, prenesite ih na drugo računalo ili ih održavajte sinkroniziranima. @@ -3917,4 +4148,10 @@ Primjena ažuriranja ponovno pokreće Foundry OSD. Wi-Fi + + Automatski se pridružite domeni pomoću vjerodajnica zaštićenih lozinkom za implementaciju. + + + Pridruživanje domeni Zero-touch + diff --git a/src/Foundry/Strings/hu-HU/Resources.resw b/src/Foundry/Strings/hu-HU/Resources.resw index 5d599163c..f549f34e4 100644 --- a/src/Foundry/Strings/hu-HU/Resources.resw +++ b/src/Foundry/Strings/hu-HU/Resources.resw @@ -1834,6 +1834,216 @@ Licenc az MIT licenc alatt. A dokumentációt nem lehetett megnyitni + + Csatlakoztatási fiók + + + Alapértelmezett + + + Tartomány + + + OU-k + + + Állapot + + + Hozzáadás + + + Alapértelmezés törlése + + + Szerkesztés + + + Eltávolítás + + + Beállítás alapértelmezettként + + + Először adjon meg érvényes fiókot, majd ellenőrizze a jelszó hosszát. + + + Igen + + + OU-k keresése a tartományban... + + + A keresés megszakítva. + + + Nem található OU ebben a tartományban. + + + Csak az OU-k egy részét sikerült listázni. A megjelenítetteket így is hozzáadhatja. + + + Ennek a tartománynak az összes OU-ja már szerepel a listában. + + + A keresés túllépte az időkorlátot. Az OU-kat kézzel is hozzáadhatja. + + + Nem sikerült keresni a tartományban. Ellenőrizze, hogy a számítógép eléri-e a tartományt, és hogy az Ön Windows-fiókja olvashatja-e, vagy adja hozzá kézzel az OU-kat. + + + Tartomány hozzáadása + + + Tartomány hozzáadása + + + Tartomány szerkesztése + + + A jelenlegi jelszó megtartásához hagyja üresen a jelszót. + + + Mentés + + + A tartomány nem lett mentve. Ellenőrizze a tartománynevet. + + + Kész + + + A tartomány nevének módosítása előtt távolítsa el a tartomány OU-it. + + + Nincs hozzáadott tartomány. + + + Nincs hozzáadott OU. + + + Az OU-k nem lettek hozzáadva. Más tartományhoz tartoznak, mint a kiválasztott. + + + Először adjon hozzá egy tartományt, majd sorolja fel az OU-it. + + + Az OU nem lett hozzáadva. Ellenőrizze a megjelenítendő nevet és a megkülönböztető nevet. Az OU-nak a kiválasztott tartományhoz kell tartoznia. + + + Szervezeti egységek + + + A(z) {0} tartomány szervezeti egységei + + + Válasszon ki egy tartományt az OU-i listázásához. + + + Kijelölt OU-k: {0}/{1} + + + Közös + + + Adjon hozzá legalább egy tartományt. + + + Ez a tartomány már szerepel a listában. + + + Két OU belső azonosítója megegyezik. Távolítsa el az egyiket, majd adja hozzá újra. + + + Adjon meg érvényes tartománynevet, például: corp.contoso.com. + + + Minden OU-hoz legfeljebb 120 karakteres megjelenítendő név szükséges. + + + Engedélyezze a jelszavas védelmet az „Általános” oldalon, és állítsa be a telepítési jelszót. + + + Adja meg a fiók jelszavát. + + + A fiókot DOMAIN\user vagy user@domain formában adja meg. + + + Adja meg a közös csatlakoztatási fiókot, vagy adjon ennek a tartománynak saját fiókot. + + + Legfeljebb 32 tartomány sorolható fel. + + + Legfeljebb 1024 OU sorolható fel. + + + Fiók (DOMAIN\user vagy user@domain) + + + OU hozzáadása + + + Keresés megszakítása + + + Tartománynév + + + corp.contoso.com + + + Külön fiók használata + + + A közös csatlakoztatási fiók használata + + + Importálás a tartományból + + + Az Ön Windows-fiókjával listázza a kiválasztott tartomány OU-it. Jelölje ki a hozzáadni kívántakat. + + + OU-k importálása a tartományból + + + Azok a tartományok, amelyekhez ez az adathordozó csatlakozhat. Ha több is szerepel a listán, a technikusok telepítés közben választanak közülük egyet; az alapértelmezett tartomány előre ki van választva. + + + Tartományok + + + Kijelölt OU-k hozzáadása + + + Adja meg az OU megjelenítendő nevét és megkülönböztető nevét. Az OU-nak a kiválasztott tartományhoz kell tartoznia. + + + OU kézi hozzáadása + + + Megkülönböztető név + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Megjelenítendő név + + + Nem kötelező. Sorolja fel azokat az OU-kat, amelyekbe a számítógépek a kiválasztott tartományban elhelyezhetők. Ha csak egy OU szerepel a listán, a rendszer mindig azt használja; ha több, a technikusok telepítés közben választanak közülük egyet. A meglévő számítógépfiók a kiválasztott OU-ba kerül. + + + Jelszó + + + Minden olyan tartomány által használt fiók, amelynek nincs saját fiókja. Jogosultnak kell lennie számítógépek csatlakoztatására és a kiválasztott OU-kba való áthelyezésükre. + + + Közös csatlakoztatási fiók + Megbízható root CA tanúsítványfájl @@ -1874,7 +2084,7 @@ Licenc az MIT licenc alatt. Jelszó - Őrizze biztonságosan ezt a jelszót. Védi a helyi Windows-fiókok jelszavait, az Autopilot hitelesítő adatait és profiljait, valamint az egyéni válaszfájlokat. + Őrizze biztonságosan ezt a jelszót. Védi a helyi Windows-fiókok jelszavait, a tartományhoz csatlakozás hitelesítő adatait, az Autopilot hitelesítő adatait és profiljait, valamint az egyéni válaszfájlokat. Telepítési jelszó @@ -1907,7 +2117,7 @@ Licenc az MIT licenc alatt. Nem észleltünk WinPE nyelvi csomagot a kiválasztott architektúrához. - tábornok + Általános Tekintse át és kezdje el @@ -1987,6 +2197,12 @@ Licenc az MIT licenc alatt. Az adathordozó készenléti állapota + + A telepítés során kérje be a technikustól a tartományi hitelesítő adatokat. + + + Interaktív tartományhoz csatlakozás + arab (Szaúd-Arábia) @@ -2191,6 +2407,9 @@ Licenc az MIT licenc alatt. Dokumentáció + + Tartományhoz csatlakozás + A szabványos Ethernet kapcsolat alapértelmezés szerint elérhető. Ezt a szakaszt csak akkor konfigurálja, ha vezetékes hálózata 802.1X vállalati hitelesítést igényel. @@ -2201,10 +2420,10 @@ Licenc az MIT licenc alatt. Szabványos konfigurációs munkafolyamat. - tábornok + Általános - tábornok + Általános Foundry OSD otthon @@ -2212,6 +2431,12 @@ Licenc az MIT licenc alatt. Otthon + + A telepítés során kérje be a technikustól a tartományi hitelesítő adatokat. + + + Interaktív + Konfigurálja a telepített eszközök nevét. @@ -2272,6 +2497,12 @@ Licenc az MIT licenc alatt. Autopilot + + Automatikus csatlakozás a tartományhoz a telepítési jelszóval védett hitelesítő adatokkal. + + + Zero-Touch + Aktív kiépítési mód @@ -2974,7 +3205,7 @@ Mellékelt jelszavak és hozzáférési kulcsok: {2} Kezelje az indítást, a nyelvet, a diagnosztikát és az alkalmazás viselkedését. - tábornok + Általános Készítsen biztonsági mentést a beállításairól, vigye át őket egy másik számítógépre, vagy tartsa őket szinkronban. @@ -3917,4 +4148,10 @@ A frissítés alkalmazása újraindítja a Foundry OSD-t. Wi-Fi + + Automatikus csatlakozás a tartományhoz a telepítési jelszóval védett hitelesítő adatokkal. + + + Zero-touch tartományhoz csatlakozás + diff --git a/src/Foundry/Strings/it-IT/Resources.resw b/src/Foundry/Strings/it-IT/Resources.resw index b23927d2f..59170e7d0 100644 --- a/src/Foundry/Strings/it-IT/Resources.resw +++ b/src/Foundry/Strings/it-IT/Resources.resw @@ -1834,6 +1834,216 @@ Concesso in licenza con la licenza MIT. Impossibile aprire la documentazione + + Account di aggiunta + + + Predefinito + + + Dominio + + + OU + + + Stato + + + Aggiungi + + + Cancella scelta predefinita + + + Modifica + + + Rimuovi + + + Imposta come predefinito + + + Inserisci prima un account valido, quindi controlla la lunghezza della password. + + + Sì + + + Ricerca delle OU nel dominio... + + + Ricerca annullata. + + + Nessuna OU trovata in questo dominio. + + + È stato possibile elencare solo alcune OU. Puoi comunque aggiungere quelle mostrate. + + + Tutte le OU in questo dominio sono già nell'elenco. + + + La ricerca ha superato il tempo limite. Puoi aggiungere le OU manualmente. + + + Impossibile eseguire la ricerca nel dominio. Verifica che questo computer possa raggiungerlo e che il tuo account Windows possa leggerlo, oppure aggiungi le OU manualmente. + + + Aggiungi dominio + + + Aggiungi un dominio + + + Modifica dominio + + + Lascia vuota la password per mantenere quella attuale. + + + Salva + + + Il dominio non è stato salvato. Controlla il nome di dominio. + + + Pronto + + + Rimuovi le OU di questo dominio prima di modificarne il nome. + + + Nessun dominio aggiunto. + + + Nessuna OU aggiunta. + + + Le OU non sono state aggiunte. Appartengono a un dominio diverso da quello selezionato. + + + Aggiungi prima un dominio, quindi elenca le sue OU. + + + La OU non è stata aggiunta. Controlla il nome visualizzato e il nome distinto. La OU deve appartenere al dominio selezionato. + + + Unità organizzative + + + Unità organizzative di {0} + + + Seleziona un dominio per elencarne le OU. + + + OU selezionate: {0} di {1} + + + Condiviso + + + Aggiungi almeno un dominio. + + + Questo dominio è già nell'elenco. + + + Due OU hanno lo stesso ID interno. Rimuovine una e aggiungila di nuovo. + + + Inserisci un nome di dominio valido, ad esempio corp.contoso.com. + + + Ogni OU richiede un nome visualizzato di massimo 120 caratteri. + + + Abilita la protezione con password nella pagina "Generale" e imposta la password di distribuzione. + + + Inserisci la password dell'account. + + + Inserisci l'account come DOMAIN\user o user@domain. + + + Inserisci l'account di aggiunta condiviso oppure assegna a questo dominio un account proprio. + + + Puoi elencare fino a 32 domini. + + + Puoi elencare fino a 1.024 OU. + + + Account (DOMAIN\user o user@domain) + + + Aggiungi OU + + + Annulla ricerca + + + Nome di dominio + + + corp.contoso.com + + + Usa un account dedicato + + + Usa l'account di aggiunta condiviso + + + Importa dal dominio + + + Elenca le OU del dominio selezionato, usando il tuo account Windows. Seleziona quelle da aggiungere. + + + Importa OU dal dominio + + + Domini a cui questo supporto può aggiungere i computer. Se ne sono elencati diversi, i tecnici ne scelgono uno durante la distribuzione; il dominio predefinito è preselezionato. + + + Domini + + + Aggiungi OU selezionate + + + Inserisci un nome visualizzato e il nome distinto della OU. La OU deve appartenere al dominio selezionato. + + + Aggiungi una OU manualmente + + + Nome distinto + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Nome visualizzato + + + Facoltativo. Elenca le OU in cui è possibile collocare i computer nel dominio selezionato. Se è elencata una sola OU, viene sempre usata; se ne sono elencate diverse, i tecnici ne scelgono una durante la distribuzione. Un account computer esistente viene spostato nella OU scelta. + + + Password + + + Account usato da tutti i domini che non hanno un account proprio. Deve avere l'autorizzazione per aggiungere computer e spostarli nelle OU scelte. + + + Account di aggiunta condiviso + File del certificato della CA radice attendibile @@ -1874,7 +2084,7 @@ Concesso in licenza con la licenza MIT. Password - Conserva questa password al sicuro. Protegge le password degli account locali di Windows, le credenziali e i profili Autopilot e i file di risposte personalizzati. + Conserva questa password al sicuro. Protegge le password degli account locali di Windows, le credenziali di aggiunta al dominio, le credenziali e i profili Autopilot e i file di risposte personalizzati. Password di distribuzione @@ -1987,6 +2197,12 @@ Concesso in licenza con la licenza MIT. Stato di preparazione del supporto + + Richiedi al tecnico le credenziali di dominio durante la distribuzione. + + + Aggiunta interattiva al dominio + Arabo (Arabia Saudita) @@ -2191,6 +2407,9 @@ Concesso in licenza con la licenza MIT. Documentazione + + Aggiunta al dominio + La connettività standard Ethernet è disponibile per impostazione predefinita. Configura questa sezione solo se la tua rete cablata richiede l'autenticazione aziendale 802.1X. @@ -2212,6 +2431,12 @@ Concesso in licenza con la licenza MIT. Casa + + Richiedi al tecnico le credenziali di dominio durante la distribuzione. + + + Interattivo + Configura i nomi dei dispositivi distribuiti. @@ -2272,6 +2497,12 @@ Concesso in licenza con la licenza MIT. Autopilot + + Esegui l'aggiunta al dominio automaticamente usando credenziali protette dalla password di distribuzione. + + + Zero-Touch + Modalità di provisioning attiva @@ -3917,4 +4148,10 @@ L’applicazione dell’aggiornamento riavvia Foundry OSD. Wi-Fi + + Esegui l'aggiunta al dominio automaticamente usando credenziali protette dalla password di distribuzione. + + + Aggiunta al dominio zero-touch + diff --git a/src/Foundry/Strings/ja-JP/Resources.resw b/src/Foundry/Strings/ja-JP/Resources.resw index 434860b8a..d74d07c0a 100644 --- a/src/Foundry/Strings/ja-JP/Resources.resw +++ b/src/Foundry/Strings/ja-JP/Resources.resw @@ -1834,6 +1834,216 @@ MITライセンスに基づいてライセンスされています。 ドキュメントを開けませんでした + + 参加用アカウント + + + 既定 + + + ドメイン + + + OU数 + + + 状態 + + + 追加 + + + 既定の選択を解除 + + + 編集 + + + 削除 + + + 既定に設定 + + + 先に有効なアカウントを入力してから、パスワードの長さを確認してください。 + + + はい + + + ドメインでOUを検索しています... + + + 検索をキャンセルしました。 + + + このドメインにOUが見つかりませんでした。 + + + 一部のOUのみ一覧表示できました。表示されているOUは追加できます。 + + + このドメインのOUは、すべて既に一覧にあります。 + + + 検索がタイムアウトしました。OUは手動で追加できます。 + + + ドメインを検索できませんでした。このコンピューターがドメインに接続でき、Windowsアカウントでドメインを読み取れることを確認するか、OUを手動で追加してください。 + + + ドメインを追加 + + + ドメインの追加 + + + ドメインの編集 + + + 現在のパスワードを保持するには、パスワードを空欄のままにしてください。 + + + 保存 + + + ドメインは保存されませんでした。ドメイン名を確認してください。 + + + 準備完了 + + + ドメイン名を変更する前に、このドメインのOUを削除してください。 + + + ドメインは追加されていません。 + + + OUは追加されていません。 + + + OUは追加されませんでした。選択したドメインとは異なるドメインに属しています。 + + + 先にドメインを追加してから、そのOUを登録してください。 + + + OUは追加されませんでした。表示名と識別名を確認してください。OUは選択したドメインに属している必要があります。 + + + 組織単位 + + + {0}の組織単位 + + + OUを一覧表示するドメインを選択してください。 + + + 選択されたOU: {0} / {1} + + + 共有 + + + ドメインを1つ以上追加してください。 + + + このドメインは既に一覧にあります。 + + + 2つのOUの内部IDが同じです。一方を削除して追加し直してください。 + + + corp.contoso.comなどの有効なドメイン名を入力してください。 + + + 各OUには120文字以内の表示名が必要です。 + + + 「一般」ページでパスワード保護を有効にし、展開パスワードを設定してください。 + + + アカウントのパスワードを入力してください。 + + + アカウントはDOMAIN\userまたはuser@domainの形式で入力してください。 + + + 共有の参加用アカウントを入力するか、このドメインに専用のアカウントを設定してください。 + + + ドメインは最大32件まで登録できます。 + + + OUは最大1,024件まで登録できます。 + + + アカウント (DOMAIN\userまたはuser@domain) + + + OUを追加 + + + 検索をキャンセル + + + ドメイン名 + + + corp.contoso.com + + + 専用アカウントを使用 + + + 共有の参加用アカウントを使用 + + + ドメインからインポート + + + Windowsアカウントを使用して、選択したドメインのOUを一覧表示します。追加するOUを選択してください。 + + + ドメインからOUをインポート + + + このメディアが参加できるドメインです。複数登録されている場合は、技術者が展開中に1つを選択します。既定のドメインがあらかじめ選択されています。 + + + ドメイン + + + 選択したOUを追加 + + + OUの表示名と識別名を入力してください。OUは選択したドメインに属している必要があります。 + + + OUを手動で追加 + + + 識別名 + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + 表示名 + + + 省略可能です。選択したドメインでコンピューターを配置できるOUを登録します。OUが1つだけの場合は常にそのOUが使用され、複数登録されている場合は技術者が展開中に1つを選択します。既存のコンピューターアカウントは選択したOUに移動されます。 + + + パスワード + + + 専用のアカウントを持たないすべてのドメインで使用されるアカウントです。コンピューターを参加させ、選択したOUに移動する権限が必要です。 + + + 共有の参加用アカウント + 信頼されたルート CA 証明書ファイル @@ -1874,7 +2084,7 @@ MITライセンスに基づいてライセンスされています。 パスワード - このパスワードは安全に保管してください。Windows のローカル アカウントのパスワード、Autopilot の資格情報とプロファイル、およびカスタム応答ファイルを保護します。 + このパスワードは安全に保管してください。Windows のローカル アカウントのパスワード、ドメイン参加の資格情報、Autopilot の資格情報とプロファイル、およびカスタム応答ファイルを保護します。 展開パスワード @@ -1987,6 +2197,12 @@ MITライセンスに基づいてライセンスされています。 メディアの準備状況 + + 展開時に技術者にドメインの資格情報の入力を求めます。 + + + 対話型ドメイン参加 + アラビア語 (サウジアラビア) @@ -2191,6 +2407,9 @@ MITライセンスに基づいてライセンスされています。 ドキュメント + + ドメイン参加 + 標準の Ethernet 接続はデフォルトで利用可能です。このセクションは、有線ネットワークで 802.1X エンタープライズ認証が必要な場合にのみ設定します。 @@ -2212,6 +2431,12 @@ MITライセンスに基づいてライセンスされています。 ホーム + + 展開時に技術者にドメインの資格情報の入力を求めます。 + + + 対話型 + 展開されたデバイス名を構成します。 @@ -2272,6 +2497,12 @@ MITライセンスに基づいてライセンスされています。 Autopilot + + 展開パスワードで保護された資格情報を使用して、ドメインに自動的に参加します。 + + + ゼロタッチ + アクティブなプロビジョニング モード @@ -3917,4 +4148,10 @@ MITライセンスに基づいてライセンスされています。 Wi-Fi + + 展開パスワードで保護された資格情報を使用して、ドメインに自動的に参加します。 + + + ゼロタッチドメイン参加 + diff --git a/src/Foundry/Strings/ko-KR/Resources.resw b/src/Foundry/Strings/ko-KR/Resources.resw index 018931fe0..a5e27331c 100644 --- a/src/Foundry/Strings/ko-KR/Resources.resw +++ b/src/Foundry/Strings/ko-KR/Resources.resw @@ -1834,6 +1834,216 @@ MIT 라이선스에 따라 라이선스가 부여됩니다. 문서를 열 수 없습니다. + + 가입 계정 + + + 기본값 + + + 도메인 + + + OU 수 + + + 상태 + + + 추가 + + + 기본 선택 지우기 + + + 편집 + + + 제거 + + + 기본값으로 설정 + + + 먼저 유효한 계정을 입력한 다음 암호 길이를 확인하세요. + + + 예 + + + 도메인에서 OU를 검색하는 중... + + + 검색이 취소되었습니다. + + + 이 도메인에서 OU를 찾을 수 없습니다. + + + 일부 OU만 표시할 수 있었습니다. 표시된 OU는 추가할 수 있습니다. + + + 이 도메인에 있는 모든 OU가 이미 목록에 있습니다. + + + 검색 시간이 초과되었습니다. OU를 수동으로 추가할 수 있습니다. + + + 도메인을 검색할 수 없습니다. 이 컴퓨터가 도메인에 연결할 수 있고 Windows 계정으로 도메인을 읽을 수 있는지 확인하거나 OU를 수동으로 추가하세요. + + + 도메인 추가 + + + 도메인 추가 + + + 도메인 편집 + + + 현재 암호를 유지하려면 암호를 비워 두세요. + + + 저장 + + + 도메인이 저장되지 않았습니다. 도메인 이름을 확인하세요. + + + 준비됨 + + + 이름을 변경하기 전에 이 도메인의 OU를 제거하세요. + + + 추가된 도메인이 없습니다. + + + 추가된 OU가 없습니다. + + + OU가 추가되지 않았습니다. 선택한 도메인과 다른 도메인에 속해 있습니다. + + + 먼저 도메인을 추가한 다음 해당 도메인의 OU를 등록하세요. + + + OU가 추가되지 않았습니다. 표시 이름과 고유 이름을 확인하세요. OU는 선택한 도메인에 속해야 합니다. + + + 조직 구성 단위 + + + {0}의 조직 구성 단위 + + + OU를 표시할 도메인을 선택하세요. + + + 선택된 OU: {0}/{1} + + + 공유 + + + 도메인을 하나 이상 추가하세요. + + + 이 도메인은 이미 목록에 있습니다. + + + 두 OU의 내부 ID가 같습니다. 하나를 제거한 후 다시 추가하세요. + + + corp.contoso.com과 같은 유효한 도메인 이름을 입력하세요. + + + 각 OU에는 120자 이하의 표시 이름이 필요합니다. + + + "일반" 페이지에서 암호 보호를 활성화하고 배포 암호를 설정하세요. + + + 계정 암호를 입력하세요. + + + 계정을 DOMAIN\user 또는 user@domain 형식으로 입력하세요. + + + 공유 가입 계정을 입력하거나 이 도메인에 전용 계정을 지정하세요. + + + 도메인은 최대 32개까지 등록할 수 있습니다. + + + OU는 최대 1,024개까지 등록할 수 있습니다. + + + 계정 (DOMAIN\user 또는 user@domain) + + + OU 추가 + + + 검색 취소 + + + 도메인 이름 + + + corp.contoso.com + + + 전용 계정 사용 + + + 공유 가입 계정 사용 + + + 도메인에서 가져오기 + + + Windows 계정을 사용하여 선택한 도메인의 OU를 표시합니다. 추가할 OU를 선택하세요. + + + 도메인에서 OU 가져오기 + + + 이 미디어가 가입할 수 있는 도메인입니다. 여러 개가 등록된 경우 기술자가 배포 중에 하나를 선택하며, 기본 도메인이 미리 선택되어 있습니다. + + + 도메인 + + + 선택한 OU 추가 + + + OU의 표시 이름과 고유 이름을 입력하세요. OU는 선택한 도메인에 속해야 합니다. + + + OU 수동 추가 + + + 고유 이름 + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + 표시 이름 + + + 선택 사항입니다. 선택한 도메인에서 컴퓨터를 배치할 수 있는 OU를 등록하세요. OU가 하나만 등록된 경우 항상 해당 OU가 사용되며, 여러 개가 등록된 경우 기술자가 배포 중에 하나를 선택합니다. 기존 컴퓨터 계정은 선택한 OU로 이동됩니다. + + + 암호 + + + 자체 계정이 없는 모든 도메인에서 사용하는 계정입니다. 컴퓨터를 가입시키고 선택한 OU로 이동할 권한이 필요합니다. + + + 공유 가입 계정 + 신뢰할 수 있는 루트 CA 인증서 파일 @@ -1874,7 +2084,7 @@ MIT 라이선스에 따라 라이선스가 부여됩니다. 암호 - 이 암호를 안전하게 보관하세요. 로컬 Windows 계정 암호, Autopilot 자격 증명 및 프로필, 사용자 지정 응답 파일을 보호합니다. + 이 암호를 안전하게 보관하세요. 로컬 Windows 계정 암호, 도메인 가입 자격 증명, Autopilot 자격 증명 및 프로필, 사용자 지정 응답 파일을 보호합니다. 배포 암호 @@ -1987,6 +2197,12 @@ MIT 라이선스에 따라 라이선스가 부여됩니다. 미디어 준비 상태 + + 배포 중 기술자에게 도메인 자격 증명을 요청합니다. + + + 대화형 도메인 가입 + 아랍어(사우디아라비아) @@ -2191,6 +2407,9 @@ MIT 라이선스에 따라 라이선스가 부여됩니다. 문서 + + 도메인 가입 + 기본적으로 표준 Ethernet 연결을 사용할 수 있습니다. 유선 네트워크에 802.1X 기업 인증이 필요한 경우에만 이 섹션을 구성하십시오. @@ -2212,6 +2431,12 @@ MIT 라이선스에 따라 라이선스가 부여됩니다. 홈 + + 배포 중 기술자에게 도메인 자격 증명을 요청합니다. + + + 대화형 + 배포된 장치 이름을 구성합니다. @@ -2272,6 +2497,12 @@ MIT 라이선스에 따라 라이선스가 부여됩니다. Autopilot + + 배포 암호로 보호되는 자격 증명을 사용하여 도메인에 자동으로 가입합니다. + + + 제로 터치 + 활성 프로비저닝 모드 @@ -3917,4 +4148,10 @@ MIT 라이선스에 따라 라이선스가 부여됩니다. Wi-Fi + + 배포 암호로 보호되는 자격 증명을 사용하여 도메인에 자동으로 가입합니다. + + + 제로 터치 도메인 가입 + diff --git a/src/Foundry/Strings/lt-LT/Resources.resw b/src/Foundry/Strings/lt-LT/Resources.resw index d6c87aece..563ea2060 100644 --- a/src/Foundry/Strings/lt-LT/Resources.resw +++ b/src/Foundry/Strings/lt-LT/Resources.resw @@ -1834,6 +1834,216 @@ Licencijuota pagal MIT licenciją. Nepavyko atidaryti dokumentų + + Prijungimo prie domeno paskyra + + + Numatytasis + + + Domenas + + + OU + + + Būsena + + + Pridėti + + + Išvalyti numatytąjį pasirinkimą + + + Redaguoti + + + Pašalinti + + + Nustatyti kaip numatytąjį + + + Pirmiausia įveskite tinkamą paskyrą, tada patikrinkite slaptažodžio ilgį. + + + Taip + + + Ieškoma OU domene... + + + Paieška atšaukta. + + + Šiame domene OU nerasta. + + + Pavyko pateikti tik dalį OU. Rodomus vis tiek galite pridėti. + + + Visi šio domeno OU jau yra sąraše. + + + Baigėsi paieškai skirtas laikas. OU galite pridėti rankiniu būdu. + + + Nepavyko atlikti paieškos domene. Patikrinkite, ar šis kompiuteris gali jį pasiekti ir ar jūsų Windows paskyra gali jį skaityti, arba pridėkite OU rankiniu būdu. + + + Pridėti domeną + + + Pridėti domeną + + + Redaguoti domeną + + + Palikite slaptažodį tuščią, kad būtų išsaugotas dabartinis. + + + Įrašyti + + + Domenas neįrašytas. Patikrinkite domeno vardą. + + + Paruošta + + + Prieš keisdami šio domeno vardą, pašalinkite jo OU. + + + Domenų nepridėta. + + + OU nepridėta. + + + OU nepridėti. Jie priklauso kitam domenui nei pasirinktasis. + + + Pirmiausia pridėkite domeną, tada išvardykite jo OU. + + + OU nepridėtas. Patikrinkite rodomą pavadinimą ir skiriamąjį vardą. OU turi priklausyti pasirinktam domenui. + + + Organizaciniai vienetai + + + Domeno {0} organizaciniai vienetai + + + Pasirinkite domeną, kad būtų pateiktas jo OU sąrašas. + + + Pasirinkta OU: {0} iš {1} + + + Bendra + + + Pridėkite bent vieną domeną. + + + Šis domenas jau yra sąraše. + + + Du OU turi tą patį vidinį ID. Pašalinkite vieną ir pridėkite jį iš naujo. + + + Įveskite tinkamą domeno vardą, pvz., corp.contoso.com. + + + Kiekvienam OU reikia rodomo pavadinimo, ne ilgesnio kaip 120 simbolių. + + + Puslapyje „Bendra“ įjunkite apsaugą slaptažodžiu ir nustatykite diegimo slaptažodį. + + + Įveskite paskyros slaptažodį. + + + Įveskite paskyrą formatu DOMAIN\user arba user@domain. + + + Įveskite bendrą prijungimo prie domeno paskyrą arba priskirkite šiam domenui atskirą paskyrą. + + + Sąraše gali būti ne daugiau kaip 32 domenai. + + + Sąraše gali būti ne daugiau kaip 1 024 OU. + + + Paskyra (DOMAIN\user arba user@domain) + + + Pridėti OU + + + Atšaukti paiešką + + + Domeno vardas + + + corp.contoso.com + + + Naudoti atskirą paskyrą + + + Naudoti bendrą prijungimo prie domeno paskyrą + + + Importuoti iš domeno + + + Naudojant jūsų Windows paskyrą pateikiamas pasirinkto domeno OU sąrašas. Pasirinkite, kuriuos pridėti. + + + Importuoti OU iš domeno + + + Domenai, prie kurių gali prisijungti ši laikmena. Jei išvardyti keli, technikai diegimo metu pasirenka vieną; numatytasis domenas būna parinktas iš anksto. + + + Domenai + + + Pridėti pasirinktus OU + + + Įveskite OU rodomą pavadinimą ir skiriamąjį vardą. OU turi priklausyti pasirinktam domenui. + + + Pridėti OU rankiniu būdu + + + Skiriamasis vardas + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Rodomas pavadinimas + + + Nebūtina. Išvardykite OU, kuriuose galima talpinti kompiuterius pasirinktame domene. Jei išvardytas tik vienas OU, jis naudojamas visada; jei išvardyti keli, technikai diegimo metu pasirenka vieną. Esama kompiuterio paskyra perkeliama į pasirinktą OU. + + + Slaptažodis + + + Paskyra, kurią naudoja visi domenai, neturintys savo paskyros. Jai reikia teisių prijungti kompiuterius ir perkelti juos į jūsų pasirinktus OU. + + + Bendra prijungimo prie domeno paskyra + Patikimas šakninio CA sertifikato failas @@ -1874,7 +2084,7 @@ Licencijuota pagal MIT licenciją. Slaptažodis - Saugiai laikykite šį slaptažodį. Jis apsaugo vietinių „Windows“ paskyrų slaptažodžius, „Autopilot“ prisijungimo duomenis ir profilius bei pasirinktinius atsakymų failus. + Saugiai laikykite šį slaptažodį. Jis apsaugo vietinių „Windows“ paskyrų slaptažodžius, prisijungimo prie domeno duomenis, „Autopilot“ prisijungimo duomenis ir profilius bei pasirinktinius atsakymų failus. Diegimo slaptažodis @@ -1907,7 +2117,7 @@ Licencijuota pagal MIT licenciją. Pasirinktai architektūrai WinPE kalbos paketų neaptikta. - Generolas + Bendra Peržiūrėkite ir pradėkite @@ -1987,6 +2197,12 @@ Licencijuota pagal MIT licenciją. Laikmenos parengties būsena + + Diegimo metu paprašyti techniko domeno prisijungimo duomenų. + + + Interaktyvus prisijungimas prie domeno + arabų (Saudo Arabija) @@ -2191,6 +2407,9 @@ Licencijuota pagal MIT licenciją. Dokumentacija + + Prisijungimas prie domeno + Standartinis Ethernet ryšys galimas pagal numatytuosius nustatymus. Konfigūruokite šį skyrių, tik jei jūsų laidiniam tinklui reikalingas 802.1X įmonės autentifikavimas. @@ -2201,10 +2420,10 @@ Licencijuota pagal MIT licenciją. Standartinė konfigūravimo darbo eiga. - Generolas + Bendra - Generolas + Bendra Foundry OSD namuose @@ -2212,6 +2431,12 @@ Licencijuota pagal MIT licenciją. Pradžia + + Diegimo metu paprašyti techniko domeno prisijungimo duomenų. + + + Interaktyvus + Konfigūruokite įdiegtų įrenginių pavadinimus. @@ -2272,6 +2497,12 @@ Licencijuota pagal MIT licenciją. Autopilot + + Prisijungti prie domeno automatiškai naudojant prisijungimo duomenis, apsaugotus diegimo slaptažodžiu. + + + Zero-Touch + Aktyvus parengimo režimas @@ -2974,7 +3205,7 @@ Licencijuota pagal MIT licenciją. Tvarkykite paleidimą, kalbą, diagnostiką ir programos elgseną. - Generolas + Bendra Kurkite atsargines parametrų kopijas, perkelkite juos į kitą kompiuterį arba palaikykite jų sinchronizavimą. @@ -3917,4 +4148,10 @@ Pritaikius naujinimą Foundry OSD paleidžiama iš naujo. Wi-Fi + + Prisijungti prie domeno automatiškai naudojant prisijungimo duomenis, apsaugotus diegimo slaptažodžiu. + + + Zero-touch prisijungimas prie domeno + diff --git a/src/Foundry/Strings/lv-LV/Resources.resw b/src/Foundry/Strings/lv-LV/Resources.resw index e30ab0d16..81d1dc2b2 100644 --- a/src/Foundry/Strings/lv-LV/Resources.resw +++ b/src/Foundry/Strings/lv-LV/Resources.resw @@ -1834,6 +1834,216 @@ Licencēts saskaņā ar MIT licenci. Dokumentāciju nevarēja atvērt + + Pievienošanas konts + + + Noklusējums + + + Domēns + + + OU + + + Statuss + + + Pievienot + + + Notīrīt noklusējuma izvēli + + + Rediģēt + + + Noņemt + + + Iestatīt kā noklusējumu + + + Vispirms ievadiet derīgu kontu, pēc tam pārbaudiet paroles garumu. + + + Jā + + + Tiek meklētas OU domēnā... + + + Meklēšana atcelta. + + + Šajā domēnā netika atrasta neviena OU. + + + Izdevās parādīt tikai daļu OU. Parādītās joprojām varat pievienot. + + + Visas šī domēna OU jau ir sarakstā. + + + Meklēšanai iestājās taimauts. OU varat pievienot manuāli. + + + Domēnā neizdevās veikt meklēšanu. Pārbaudiet, vai šis dators var to sasniegt un vai jūsu Windows konts var to lasīt, vai pievienojiet OU manuāli. + + + Pievienot domēnu + + + Pievienot domēnu + + + Rediģēt domēnu + + + Atstājiet paroli tukšu, lai paturētu pašreizējo. + + + Saglabāt + + + Domēns netika saglabāts. Pārbaudiet domēna nosaukumu. + + + Gatavs + + + Pirms domēna nosaukuma maiņas noņemiet šī domēna OU. + + + Nav pievienots neviens domēns. + + + Nav pievienota neviena OU. + + + OU netika pievienotas. Tās pieder citam domēnam, nevis atlasītajam. + + + Vispirms pievienojiet domēnu, pēc tam norādiet tā OU. + + + OU netika pievienota. Pārbaudiet parādāmo nosaukumu un atšķirīgo nosaukumu. OU jāpieder atlasītajam domēnam. + + + Organizatoriskās vienības + + + Domēna {0} organizatoriskās vienības + + + Atlasiet domēnu, lai norādītu tā OU. + + + Atlasītās OU: {0} no {1} + + + Kopīgs + + + Pievienojiet vismaz vienu domēnu. + + + Šis domēns jau ir sarakstā. + + + Divām OU ir vienāds iekšējais ID. Noņemiet vienu un pievienojiet to vēlreiz. + + + Ievadiet derīgu domēna nosaukumu, piemēram, corp.contoso.com. + + + Katrai OU nepieciešams parādāmais nosaukums, kas nav garāks par 120 rakstzīmēm. + + + Lapā „Vispārīgi” ieslēdziet aizsardzību ar paroli un iestatiet izvietošanas paroli. + + + Ievadiet konta paroli. + + + Ievadiet kontu formātā DOMAIN\user vai user@domain. + + + Ievadiet kopīgo pievienošanas kontu vai piešķiriet šim domēnam savu kontu. + + + Sarakstā var būt ne vairāk kā 32 domēni. + + + Sarakstā var būt ne vairāk kā 1024 OU. + + + Konts (DOMAIN\user vai user@domain) + + + Pievienot OU + + + Atcelt meklēšanu + + + Domēna nosaukums + + + corp.contoso.com + + + Izmantot atsevišķu kontu + + + Izmantot kopīgo pievienošanas kontu + + + Importēt no domēna + + + Parāda atlasītā domēna OU, izmantojot jūsu Windows kontu. Atlasiet pievienojamās. + + + Importēt OU no domēna + + + Domēni, kuriem var pievienoties, izmantojot šo datu nesēju. Ja norādīti vairāki, tehniķi izvietošanas laikā izvēlas vienu; noklusējuma domēns ir atlasīts iepriekš. + + + Domēni + + + Pievienot atlasītās OU + + + Ievadiet OU parādāmo nosaukumu un atšķirīgo nosaukumu. OU jāpieder atlasītajam domēnam. + + + Pievienot OU manuāli + + + Atšķirīgais nosaukums + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Parādāmais nosaukums + + + Neobligāti. Norādiet OU, kurās atlasītajā domēnā var ievietot datorus. Ja norādīta viena OU, tā tiek izmantota vienmēr; ja norādītas vairākas, tehniķi izvietošanas laikā izvēlas vienu. Esošs datora konts tiek pārvietots uz izvēlēto OU. + + + Parole + + + Konts, ko izmanto visi domēni, kuriem nav sava konta. Tam nepieciešamas atļaujas pievienot datorus un pārvietot tos uz jūsu izvēlētajām OU. + + + Kopīgais pievienošanas konts + Uzticams saknes CA sertifikāta fails @@ -1874,7 +2084,7 @@ Licencēts saskaņā ar MIT licenci. Parole - Glabājiet šo paroli drošībā. Tā aizsargā lokālo Windows kontu paroles, Autopilot akreditācijas datus un profilus, kā arī pielāgotos atbilžu failus. + Glabājiet šo paroli drošībā. Tā aizsargā lokālo Windows kontu paroles, pievienošanās domēnam akreditācijas datus, Autopilot akreditācijas datus un profilus, kā arī pielāgotos atbilžu failus. Izvietošanas parole @@ -1907,7 +2117,7 @@ Licencēts saskaņā ar MIT licenci. Atlasītajai arhitektūrai netika konstatētas WinPE valodu pakotnes. - Ģenerālis + Vispārīgi Pārskatiet un sāciet @@ -1987,6 +2197,12 @@ Licencēts saskaņā ar MIT licenci. Datu nesēja gatavības statuss + + Izvietošanas laikā pieprasīt tehniķim domēna akreditācijas datus. + + + Interaktīva pievienošanās domēnam + arābu (Saūda Arābija) @@ -2191,6 +2407,9 @@ Licencēts saskaņā ar MIT licenci. Dokumentācija + + Pievienošanās domēnam + Standarta Ethernet savienojums ir pieejams pēc noklusējuma. Konfigurējiet šo sadaļu tikai tad, ja jūsu vadu tīklam ir nepieciešama 802.1X uzņēmuma autentifikācija. @@ -2201,10 +2420,10 @@ Licencēts saskaņā ar MIT licenci. Standarta konfigurācijas darbplūsma. - Ģenerālis + Vispārīgi - Ģenerālis + Vispārīgi Foundry OSD mājās @@ -2212,6 +2431,12 @@ Licencēts saskaņā ar MIT licenci. Sākums + + Izvietošanas laikā pieprasīt tehniķim domēna akreditācijas datus. + + + Interaktīvi + Konfigurējiet izvietoto ierīču nosaukumus. @@ -2272,6 +2497,12 @@ Licencēts saskaņā ar MIT licenci. Autopilot + + Automātiski pievienoties domēnam, izmantojot akreditācijas datus, ko aizsargā izvietošanas parole. + + + Zero-Touch + Aktīvais nodrošināšanas režīms @@ -2974,7 +3205,7 @@ Iekļautās paroles un piekļuves atslēgas: {2} Pārvaldiet startēšanu, valodu, diagnostiku un lietojumprogrammu darbību. - Ģenerālis + Vispārīgi Dublējiet savus iestatījumus, pārsūtiet tos uz citu datoru vai uzturiet tos sinhronizētus. @@ -3917,4 +4148,10 @@ Atjauninājuma lietošana restartē Foundry OSD. Wi-Fi + + Automātiski pievienoties domēnam, izmantojot akreditācijas datus, ko aizsargā izvietošanas parole. + + + Pievienošanās domēnam Zero-touch režīmā + diff --git a/src/Foundry/Strings/nb-NO/Resources.resw b/src/Foundry/Strings/nb-NO/Resources.resw index 182464730..043e66eda 100644 --- a/src/Foundry/Strings/nb-NO/Resources.resw +++ b/src/Foundry/Strings/nb-NO/Resources.resw @@ -1834,6 +1834,216 @@ Lisensiert under MIT-lisensen. Dokumentasjonen kunne ikke åpnes + + Tilknytningskonto + + + Standard + + + Domene + + + OU-er + + + Status + + + Legg til + + + Fjern standardvalg + + + Rediger + + + Fjern + + + Angi som standard + + + Angi en gyldig konto først, og kontroller deretter passordlengden. + + + Ja + + + Søker etter OU-er i domenet... + + + Søket ble avbrutt. + + + Fant ingen OU-er i dette domenet. + + + Bare noen av OU-ene kunne vises. Du kan fortsatt legge til de som vises. + + + Alle OU-er i dette domenet finnes allerede i listen. + + + Tidsavbrudd under søket. Du kan legge til OU-er manuelt. + + + Kunne ikke søke i domenet. Kontroller at denne datamaskinen kan nå det, og at Windows-kontoen din kan lese det, eller legg til OU-er manuelt. + + + Legg til domene + + + Legg til et domene + + + Rediger domene + + + La passordet stå tomt for å beholde det gjeldende. + + + Lagre + + + Domenet ble ikke lagret. Kontroller domenenavnet. + + + Klar + + + Fjern OU-ene til dette domenet før du endrer navnet. + + + Ingen domener er lagt til. + + + Ingen OU-er er lagt til. + + + OU-ene ble ikke lagt til. De tilhører et annet domene enn det valgte. + + + Legg til et domene først, og før deretter opp OU-ene for det. + + + OU-en ble ikke lagt til. Kontroller visningsnavnet og det unike navnet. OU-en må tilhøre det valgte domenet. + + + Organisasjonsenheter + + + Organisasjonsenheter i {0} + + + Velg et domene for å føre opp OU-ene for det. + + + Valgte OU-er: {0} av {1} + + + Felles + + + Legg til minst ett domene. + + + Dette domenet står allerede i listen. + + + To OU-er har samme interne ID. Fjern den ene og legg den til på nytt. + + + Angi et gyldig domenenavn, for eksempel corp.contoso.com. + + + Hver OU trenger et visningsnavn på maksimalt 120 tegn. + + + Slå på passordbeskyttelse på siden Generelt, og angi distribusjonspassordet. + + + Angi kontopassordet. + + + Angi kontoen som DOMAIN\user eller user@domain. + + + Angi den felles tilknytningskontoen, eller gi dette domenet en egen konto. + + + Listen kan inneholde opptil 32 domener. + + + Listen kan inneholde opptil 1 024 OU-er. + + + Konto (DOMAIN\user eller user@domain) + + + Legg til OU + + + Avbryt søk + + + Domenenavn + + + corp.contoso.com + + + Bruk en dedikert konto + + + Bruk den felles tilknytningskontoen + + + Importer fra domene + + + Viser OU-ene i det valgte domenet ved hjelp av Windows-kontoen din. Velg de du vil legge til. + + + Importer OU-er fra domenet + + + Domener dette mediet kan knytte datamaskiner til. Når flere er oppført, velger teknikere ett under distribusjon; standarddomenet er forhåndsvalgt. + + + Domener + + + Legg til valgte OU-er + + + Angi et visningsnavn og det unike navnet til OU-en. OU-en må tilhøre det valgte domenet. + + + Legg til en OU manuelt + + + Unikt navn + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Visningsnavn + + + Valgfritt. Før opp OU-ene der datamaskiner kan plasseres i det valgte domenet. Én enkelt OU brukes alltid; når flere er oppført, velger teknikere én under distribusjon. En eksisterende datamaskinkonto flyttes til den valgte OU-en. + + + Passord + + + Konto som brukes av alle domener som ikke har en egen konto. Den må ha tillatelse til å knytte til datamaskiner og flytte dem til OU-ene du velger. + + + Felles tilknytningskonto + Klarert rot-CA-sertifikatfil @@ -1874,7 +2084,7 @@ Lisensiert under MIT-lisensen. Passord - Oppbevar dette passordet trygt. Det beskytter passord for lokale Windows-kontoer, påloggingsinformasjon og profiler for Autopilot og egendefinerte svarfiler. + Oppbevar dette passordet trygt. Det beskytter passord for lokale Windows-kontoer, legitimasjon for domenetilknytning, påloggingsinformasjon og profiler for Autopilot og egendefinerte svarfiler. Distribusjonspassord @@ -1987,6 +2197,12 @@ Lisensiert under MIT-lisensen. Status for medieberedskap + + Be teknikeren om domenelegitimasjon under distribusjon. + + + Interaktiv domenetilknytning + Arabisk (Saudi-Arabia) @@ -2191,6 +2407,9 @@ Lisensiert under MIT-lisensen. Dokumentasjon + + Domenetilknytning + Standard Ethernet-tilkobling er tilgjengelig som standard. Konfigurer denne delen bare hvis det kablede nettverket ditt krever 802.1X enterprise-autentisering. @@ -2212,6 +2431,12 @@ Lisensiert under MIT-lisensen. Hjem + + Be teknikeren om domenelegitimasjon under distribusjon. + + + Interaktiv + Konfigurer navn på distribuerte enheter. @@ -2272,6 +2497,12 @@ Lisensiert under MIT-lisensen. Autopilot + + Knytt til domenet automatisk med legitimasjon beskyttet av distribusjonspassordet. + + + Zero-Touch + Aktiv klargjøringsmodus @@ -3917,4 +4148,10 @@ Oppdateringen starter Foundry OSD på nytt. Wi-Fi + + Knytt til domenet automatisk med legitimasjon beskyttet av distribusjonspassordet. + + + Zero-touch-domenetilknytning + diff --git a/src/Foundry/Strings/nl-NL/Resources.resw b/src/Foundry/Strings/nl-NL/Resources.resw index e823e3b26..104ab3e00 100644 --- a/src/Foundry/Strings/nl-NL/Resources.resw +++ b/src/Foundry/Strings/nl-NL/Resources.resw @@ -1834,6 +1834,216 @@ Gelicentieerd onder de MIT-licentie. Documentatie kon niet worden geopend + + Toetredingsaccount + + + Standaard + + + Domein + + + OU's + + + Status + + + Toevoegen + + + Standaardselectie wissen + + + Bewerken + + + Verwijderen + + + Als standaard instellen + + + Voer eerst een geldig account in en controleer daarna de lengte van het wachtwoord. + + + Ja + + + OU's zoeken in het domein... + + + Zoeken geannuleerd. + + + Er zijn geen OU's gevonden in dit domein. + + + Slechts een deel van de OU's kon worden weergegeven. U kunt de weergegeven OU's nog steeds toevoegen. + + + Alle OU's in dit domein staan al in de lijst. + + + De tijdslimiet voor het zoeken is verstreken. U kunt OU's handmatig toevoegen. + + + Het domein kon niet worden doorzocht. Controleer of deze computer het domein kan bereiken en of uw Windows-account het kan lezen, of voeg OU's handmatig toe. + + + Domein toevoegen + + + Een domein toevoegen + + + Domein bewerken + + + Laat het wachtwoord leeg om het huidige wachtwoord te behouden. + + + Opslaan + + + Het domein is niet opgeslagen. Controleer de domeinnaam. + + + Gereed + + + Verwijder de OU's van dit domein voordat u de naam wijzigt. + + + Geen domeinen toegevoegd. + + + Geen OU's toegevoegd. + + + De OU's zijn niet toegevoegd. Ze behoren tot een ander domein dan het geselecteerde domein. + + + Voeg eerst een domein toe en geef daarna de OU's ervan op. + + + De OU is niet toegevoegd. Controleer de weergavenaam en de DN-naam. De OU moet tot het geselecteerde domein behoren. + + + Organisatie-eenheden + + + Organisatie-eenheden van {0} + + + Selecteer een domein om de OU's ervan op te geven. + + + Geselecteerde OU's: {0} van {1} + + + Gedeeld + + + Voeg ten minste één domein toe. + + + Dit domein staat al in de lijst. + + + Twee OU's hebben dezelfde interne id. Verwijder er één en voeg deze opnieuw toe. + + + Voer een geldige domeinnaam in, zoals corp.contoso.com. + + + Elke OU heeft een weergavenaam van maximaal 120 tekens nodig. + + + Schakel wachtwoordbeveiliging in op de pagina Algemeen en stel het implementatiewachtwoord in. + + + Voer het accountwachtwoord in. + + + Voer het account in als DOMAIN\user of user@domain. + + + Voer het gedeelde toetredingsaccount in of geef dit domein een eigen account. + + + De lijst mag maximaal 32 domeinen bevatten. + + + De lijst mag maximaal 1.024 OU's bevatten. + + + Account (DOMAIN\user of user@domain) + + + OU toevoegen + + + Zoeken annuleren + + + Domeinnaam + + + corp.contoso.com + + + Een afzonderlijk account gebruiken + + + Het gedeelde toetredingsaccount gebruiken + + + Importeren uit domein + + + Geeft met uw Windows-account de OU's van het geselecteerde domein weer. Selecteer de OU's die u wilt toevoegen. + + + OU's importeren uit het domein + + + Domeinen waarvan computers met dit medium lid kunnen worden. Als er meerdere zijn opgegeven, kiezen technici er één tijdens de implementatie; het standaarddomein is vooraf geselecteerd. + + + Domeinen + + + Geselecteerde OU's toevoegen + + + Voer een weergavenaam en de DN-naam van de OU in. De OU moet tot het geselecteerde domein behoren. + + + Handmatig een OU toevoegen + + + DN-naam + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Weergavenaam + + + Optioneel. Geef de OU's op waarin computers in het geselecteerde domein kunnen worden geplaatst. Eén enkele OU wordt altijd gebruikt; als er meerdere zijn opgegeven, kiezen technici er één tijdens de implementatie. Een bestaand computeraccount wordt naar de gekozen OU verplaatst. + + + Wachtwoord + + + Account dat wordt gebruikt door elk domein zonder eigen account. Het heeft rechten nodig om computers toe te voegen en ze te verplaatsen naar de OU's die u kiest. + + + Gedeeld toetredingsaccount + Vertrouwd root-CA-certificaatbestand @@ -1874,7 +2084,7 @@ Gelicentieerd onder de MIT-licentie. Wachtwoord - Bewaar dit wachtwoord veilig. Het beschermt wachtwoorden voor lokale Windows-accounts, Autopilot-referenties en -profielen en aangepaste antwoordbestanden. + Bewaar dit wachtwoord veilig. Het beschermt wachtwoorden voor lokale Windows-accounts, referenties voor domeintoetreding, Autopilot-referenties en -profielen en aangepaste antwoordbestanden. Implementatiewachtwoord @@ -1987,6 +2197,12 @@ Gelicentieerd onder de MIT-licentie. Status van mediagereedheid + + Vraag de technicus tijdens de implementatie om domeinreferenties. + + + Interactieve domeintoetreding + Arabisch (Saoedi-Arabië) @@ -2191,6 +2407,9 @@ Gelicentieerd onder de MIT-licentie. Documentatie + + Domeintoetreding + Standaard Ethernet-connectiviteit is standaard beschikbaar. Configureer deze sectie alleen als uw bekabelde netwerk 802.1X bedrijfsverificatie vereist. @@ -2212,6 +2431,12 @@ Gelicentieerd onder de MIT-licentie. Thuis + + Vraag de technicus tijdens de implementatie om domeinreferenties. + + + Interactief + Configureer namen van geïmplementeerde apparaten. @@ -2272,6 +2497,12 @@ Gelicentieerd onder de MIT-licentie. Autopilot + + Treed automatisch toe tot het domein met referenties die zijn beveiligd met het implementatiewachtwoord. + + + Zero-Touch + Actieve provisioningsmodus @@ -3917,4 +4148,10 @@ Het toepassen van de update start Foundry OSD opnieuw. Wi-Fi + + Treed automatisch toe tot het domein met referenties die zijn beveiligd met het implementatiewachtwoord. + + + Zero-touch-domeintoetreding + diff --git a/src/Foundry/Strings/pl-PL/Resources.resw b/src/Foundry/Strings/pl-PL/Resources.resw index c89e887a7..29342bdd0 100644 --- a/src/Foundry/Strings/pl-PL/Resources.resw +++ b/src/Foundry/Strings/pl-PL/Resources.resw @@ -1834,6 +1834,216 @@ Licencjonowany na licencji MIT. Nie można otworzyć dokumentacji + + Konto dołączania + + + Domyślna + + + Domena + + + Jednostki OU + + + Stan + + + Dodaj + + + Wyczyść domyślny wybór + + + Edytuj + + + Usuń + + + Ustaw jako domyślną + + + Najpierw wprowadź prawidłowe konto, a następnie sprawdź długość hasła. + + + Tak + + + Wyszukiwanie jednostek OU w domenie... + + + Wyszukiwanie anulowano. + + + Nie znaleziono żadnych jednostek OU w tej domenie. + + + Udało się wyświetlić tylko część jednostek OU. Wyświetlone jednostki nadal można dodać. + + + Wszystkie jednostki OU w tej domenie są już na liście. + + + Upłynął limit czasu wyszukiwania. Jednostki OU możesz dodać ręcznie. + + + Nie można przeszukać domeny. Sprawdź, czy ten komputer ma z nią połączenie i czy Twoje konto Windows może ją odczytać, albo dodaj jednostki OU ręcznie. + + + Dodaj domenę + + + Dodaj domenę + + + Edytuj domenę + + + Pozostaw hasło puste, aby zachować bieżące. + + + Zapisz + + + Nie zapisano domeny. Sprawdź nazwę domeny. + + + Gotowe + + + Usuń jednostki OU tej domeny przed zmianą jej nazwy. + + + Nie dodano żadnych domen. + + + Nie dodano żadnych jednostek OU. + + + Nie dodano jednostek OU. Należą one do innej domeny niż wybrana. + + + Najpierw dodaj domenę, a następnie podaj jej jednostki OU. + + + Nie dodano jednostki OU. Sprawdź nazwę wyświetlaną i nazwę wyróżniającą. Jednostka OU musi należeć do wybranej domeny. + + + Jednostki organizacyjne + + + Jednostki organizacyjne domeny {0} + + + Wybierz domenę, aby podać jej jednostki OU. + + + Wybrane jednostki OU: {0} z {1} + + + Wspólne + + + Dodaj co najmniej jedną domenę. + + + Ta domena jest już na liście. + + + Dwie jednostki OU mają ten sam wewnętrzny identyfikator. Usuń jedną z nich i dodaj ją ponownie. + + + Wprowadź prawidłową nazwę domeny, na przykład corp.contoso.com. + + + Każda jednostka OU wymaga nazwy wyświetlanej o długości do 120 znaków. + + + Włącz ochronę hasłem na stronie Ogólne i ustaw hasło wdrożenia. + + + Wprowadź hasło konta. + + + Wprowadź konto w formacie DOMAIN\user lub user@domain. + + + Wprowadź wspólne konto dołączania albo przypisz tej domenie własne konto. + + + Lista może zawierać maksymalnie 32 domeny. + + + Lista może zawierać maksymalnie 1024 jednostki OU. + + + Konto (DOMAIN\user lub user@domain) + + + Dodaj OU + + + Anuluj wyszukiwanie + + + Nazwa domeny + + + corp.contoso.com + + + Użyj dedykowanego konta + + + Użyj wspólnego konta dołączania + + + Importuj z domeny + + + Wyświetla jednostki OU wybranej domeny przy użyciu Twojego konta Windows. Wybierz te, które chcesz dodać. + + + Importuj jednostki OU z domeny + + + Domeny, do których ten nośnik może dołączać komputery. Jeśli podano kilka, technicy wybierają jedną podczas wdrażania; domena domyślna jest wstępnie wybrana. + + + Domeny + + + Dodaj wybrane jednostki OU + + + Wprowadź nazwę wyświetlaną i nazwę wyróżniającą jednostki OU. Jednostka OU musi należeć do wybranej domeny. + + + Dodaj jednostkę OU ręcznie + + + Nazwa wyróżniająca + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Nazwa wyświetlana + + + Opcjonalne. Podaj jednostki OU, w których można umieszczać komputery w wybranej domenie. Pojedyncza jednostka OU jest używana zawsze; jeśli podano kilka, technicy wybierają jedną podczas wdrażania. Istniejące konto komputera jest przenoszone do wybranej jednostki OU. + + + Hasło + + + Konto używane przez każdą domenę, która nie ma własnego konta. Wymaga uprawnień do dołączania komputerów i przenoszenia ich do wybranych jednostek OU. + + + Wspólne konto dołączania + Plik certyfikatu zaufanego głównego urzędu certyfikacji @@ -1874,7 +2084,7 @@ Licencjonowany na licencji MIT. Hasło - Przechowuj to hasło w bezpiecznym miejscu. Chroni ono hasła lokalnych kont systemu Windows, dane uwierzytelniające i profile Autopilot oraz niestandardowe pliki odpowiedzi. + Przechowuj to hasło w bezpiecznym miejscu. Chroni ono hasła lokalnych kont systemu Windows, poświadczenia dołączania do domeny, dane uwierzytelniające i profile Autopilot oraz niestandardowe pliki odpowiedzi. Hasło wdrożenia @@ -1907,7 +2117,7 @@ Licencjonowany na licencji MIT. Nie wykryto pakietów językowych WinPE dla wybranej architektury. - Generał + Ogólne Przejrzyj i zacznij @@ -1987,6 +2197,12 @@ Licencjonowany na licencji MIT. Stan gotowości nośnika + + Poproś technika o poświadczenia domeny podczas wdrażania. + + + Interaktywne dołączanie do domeny + arabski (Arabia Saudyjska) @@ -2191,6 +2407,9 @@ Licencjonowany na licencji MIT. Dokumentacja + + Dołączanie do domeny + Domyślnie dostępna jest standardowa łączność Ethernet. Skonfiguruj tę sekcję tylko wtedy, gdy Twoja sieć przewodowa wymaga uwierzytelniania korporacyjnego 802.1X. @@ -2201,10 +2420,10 @@ Licencjonowany na licencji MIT. Standardowy przebieg konfiguracji. - Generał + Ogólne - Generał + Ogólne Foundry OSD w domu @@ -2212,6 +2431,12 @@ Licencjonowany na licencji MIT. Dom + + Poproś technika o poświadczenia domeny podczas wdrażania. + + + Interaktywnie + Skonfiguruj nazwy wdrożonych urządzeń. @@ -2272,6 +2497,12 @@ Licencjonowany na licencji MIT. Autopilot + + Dołączaj do domeny automatycznie przy użyciu poświadczeń chronionych hasłem wdrożenia. + + + Zero-Touch + Aktywny tryb aprowizacji @@ -2974,7 +3205,7 @@ Dołączone hasła i klucze dostępu: {2} Zarządzaj uruchamianiem, językiem, diagnostyką i zachowaniem aplikacji. - Generał + Ogólne Twórz kopie zapasowe ustawień, przenoś je na inny komputer lub dbaj o ich synchronizację. @@ -3917,4 +4148,10 @@ Zastosowanie aktualizacji uruchomi Foundry OSD ponownie. Wi-Fi + + Dołączaj do domeny automatycznie przy użyciu poświadczeń chronionych hasłem wdrożenia. + + + Bezobsługowe dołączanie do domeny + diff --git a/src/Foundry/Strings/pt-BR/Resources.resw b/src/Foundry/Strings/pt-BR/Resources.resw index c0711dfdc..82ce0c14e 100644 --- a/src/Foundry/Strings/pt-BR/Resources.resw +++ b/src/Foundry/Strings/pt-BR/Resources.resw @@ -1834,6 +1834,216 @@ Licenciado sob a licença MIT. Não foi possível abrir a documentação + + Conta de ingresso + + + Padrão + + + Domínio + + + OUs + + + Status + + + Adicionar + + + Limpar padrão + + + Editar + + + Remover + + + Definir como padrão + + + Primeiro insira uma conta válida; depois verifique o tamanho da senha. + + + Sim + + + Buscando OUs no domínio... + + + Busca cancelada. + + + Nenhuma OU foi encontrada neste domínio. + + + Só foi possível listar algumas das OUs. Você ainda pode adicionar as que aparecem. + + + Todas as OUs deste domínio já estão na lista. + + + A busca excedeu o tempo limite. Você pode adicionar OUs manualmente. + + + Não foi possível buscar no domínio. Verifique se este computador consegue acessá-lo e se sua conta do Windows pode lê-lo, ou adicione OUs manualmente. + + + Adicionar domínio + + + Adicionar um domínio + + + Editar domínio + + + Deixe a senha em branco para manter a atual. + + + Salvar + + + O domínio não foi salvo. Verifique o nome do domínio. + + + Pronto + + + Remova as OUs deste domínio antes de alterar o nome dele. + + + Nenhum domínio adicionado. + + + Nenhuma OU adicionada. + + + As OUs não foram adicionadas. Elas pertencem a um domínio diferente do selecionado. + + + Primeiro adicione um domínio; depois liste as OUs dele. + + + A OU não foi adicionada. Verifique o nome de exibição e o nome diferenciado. A OU deve pertencer ao domínio selecionado. + + + Unidades organizacionais + + + Unidades organizacionais de {0} + + + Selecione um domínio para listar as OUs dele. + + + OUs selecionadas: {0} de {1} + + + Compartilhada + + + Adicione pelo menos um domínio. + + + Este domínio já está na lista. + + + Duas OUs têm o mesmo ID interno. Remova uma e adicione-a novamente. + + + Insira um nome de domínio válido, como corp.contoso.com. + + + Cada OU precisa de um nome de exibição com no máximo 120 caracteres. + + + Ative a proteção por senha na página Geral e defina a senha de implantação. + + + Insira a senha da conta. + + + Insira a conta como DOMAIN\user ou user@domain. + + + Insira a conta de ingresso compartilhada ou atribua a este domínio uma conta própria. + + + É possível listar até 32 domínios. + + + É possível listar até 1.024 OUs. + + + Conta (DOMAIN\user ou user@domain) + + + Adicionar OU + + + Cancelar busca + + + Nome do domínio + + + corp.contoso.com + + + Usar uma conta dedicada + + + Usar a conta de ingresso compartilhada + + + Importar do domínio + + + Lista as OUs do domínio selecionado, usando sua conta do Windows. Selecione as que deseja adicionar. + + + Importar OUs do domínio + + + Domínios nos quais esta mídia pode ingressar computadores. Quando há vários listados, os técnicos escolhem um durante a implantação; o padrão vem pré-selecionado. + + + Domínios + + + Adicionar OUs selecionadas + + + Insira um nome de exibição e o nome diferenciado da OU. A OU deve pertencer ao domínio selecionado. + + + Adicionar uma OU manualmente + + + Nome diferenciado + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Nome de exibição + + + Opcional. Liste as OUs em que os computadores podem ser colocados no domínio selecionado. Uma única OU é sempre usada; quando há várias listadas, os técnicos escolhem uma durante a implantação. Uma conta de computador existente é movida para a OU escolhida. + + + Senha + + + Conta usada por todos os domínios que não têm uma conta própria. Ela precisa de permissão para ingressar computadores no domínio e movê-los para as OUs que você escolher. + + + Conta de ingresso compartilhada + Arquivo de certificado de CA raiz confiável @@ -1874,7 +2084,7 @@ Licenciado sob a licença MIT. Senha - Mantenha essa senha em segurança. Ela protege as senhas de contas locais do Windows, as credenciais e os perfis do Autopilot e os arquivos de resposta personalizados. + Mantenha essa senha em segurança. Ela protege as senhas de contas locais do Windows, as credenciais de ingresso no domínio, as credenciais e os perfis do Autopilot e os arquivos de resposta personalizados. Senha de implantação @@ -1987,6 +2197,12 @@ Licenciado sob a licença MIT. Status de preparação da mídia + + Solicite ao técnico as credenciais de domínio durante a implantação. + + + Ingresso interativo no domínio + Árabe (Arábia Saudita) @@ -2191,6 +2407,9 @@ Licenciado sob a licença MIT. Documentação + + Ingresso no domínio + A conectividade padrão Ethernet está disponível por padrão. Configure esta seção somente se sua rede com fio exigir autenticação empresarial 802.1X. @@ -2212,6 +2431,12 @@ Licenciado sob a licença MIT. Página inicial + + Solicite ao técnico as credenciais de domínio durante a implantação. + + + Interativo + Configure os nomes dos dispositivos implantados. @@ -2272,6 +2497,12 @@ Licenciado sob a licença MIT. Autopilot + + Ingresse no domínio automaticamente usando credenciais protegidas pela senha de implantação. + + + Zero-Touch + Modo de provisionamento ativo @@ -3917,4 +4148,10 @@ A aplicação da atualização reinicia o Foundry OSD. Wi-Fi + + Ingresse no domínio automaticamente usando credenciais protegidas pela senha de implantação. + + + Ingresso zero-touch no domínio + diff --git a/src/Foundry/Strings/pt-PT/Resources.resw b/src/Foundry/Strings/pt-PT/Resources.resw index 8e892aa7f..bf4813173 100644 --- a/src/Foundry/Strings/pt-PT/Resources.resw +++ b/src/Foundry/Strings/pt-PT/Resources.resw @@ -1834,6 +1834,216 @@ Licenciado sob a licença MIT. Não foi possível abrir a documentação + + Conta de associação + + + Predefinição + + + Domínio + + + OU + + + Estado + + + Adicionar + + + Limpar predefinição + + + Editar + + + Remover + + + Definir como predefinição + + + Introduza primeiro uma conta válida e, em seguida, verifique o comprimento da palavra-passe. + + + Sim + + + A procurar OU no domínio... + + + Procura cancelada. + + + Não foi encontrada nenhuma OU neste domínio. + + + Só foi possível listar algumas das OU. Pode adicionar na mesma as que são apresentadas. + + + Todas as OU deste domínio já estão na lista. + + + O tempo limite da procura foi excedido. Pode adicionar OU manualmente. + + + Não foi possível procurar no domínio. Verifique se este computador consegue contactá-lo e se a sua conta Windows o consegue ler, ou adicione OU manualmente. + + + Adicionar domínio + + + Adicionar um domínio + + + Editar domínio + + + Deixe a palavra-passe em branco para manter a atual. + + + Guardar + + + O domínio não foi guardado. Verifique o nome do domínio. + + + Pronto + + + Remova as OU deste domínio antes de alterar o respetivo nome. + + + Nenhum domínio adicionado. + + + Nenhuma OU adicionada. + + + As OU não foram adicionadas. Pertencem a um domínio diferente do selecionado. + + + Adicione primeiro um domínio e, em seguida, liste as respetivas OU. + + + A OU não foi adicionada. Verifique o nome a apresentar e o nome distinto. A OU tem de pertencer ao domínio selecionado. + + + Unidades organizacionais + + + Unidades organizacionais de {0} + + + Selecione um domínio para listar as respetivas OU. + + + OU selecionadas: {0} de {1} + + + Partilhada + + + Adicione pelo menos um domínio. + + + Este domínio já está na lista. + + + Duas OU têm o mesmo ID interno. Remova uma e adicione-a novamente. + + + Introduza um nome de domínio válido, como corp.contoso.com. + + + Cada OU necessita de um nome a apresentar com um máximo de 120 carateres. + + + Ative a proteção por palavra-passe na página Geral e defina a palavra-passe de implementação. + + + Introduza a palavra-passe da conta. + + + Introduza a conta como DOMAIN\user ou user@domain. + + + Introduza a conta de associação partilhada ou atribua a este domínio uma conta própria. + + + Pode listar até 32 domínios. + + + Pode listar até 1 024 OU. + + + Conta (DOMAIN\user ou user@domain) + + + Adicionar OU + + + Cancelar procura + + + Nome do domínio + + + corp.contoso.com + + + Utilizar uma conta dedicada + + + Utilizar a conta de associação partilhada + + + Importar do domínio + + + Lista as OU do domínio selecionado, utilizando a sua conta Windows. Selecione as que pretende adicionar. + + + Importar OU do domínio + + + Domínios aos quais este suporte pode associar computadores. Quando são listados vários, os técnicos escolhem um durante a implementação; o predefinido fica pré-selecionado. + + + Domínios + + + Adicionar OU selecionadas + + + Introduza um nome a apresentar e o nome distinto da OU. A OU tem de pertencer ao domínio selecionado. + + + Adicionar uma OU manualmente + + + Nome distinto + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Nome a apresentar + + + Opcional. Liste as OU onde os computadores podem ser colocados no domínio selecionado. Uma única OU é sempre utilizada; quando são listadas várias, os técnicos escolhem uma durante a implementação. Uma conta de computador existente é movida para a OU escolhida. + + + Palavra-passe + + + Conta utilizada por todos os domínios que não têm uma conta própria. Necessita de permissão para associar computadores e para os mover para as OU que escolher. + + + Conta de associação partilhada + Arquivo de certificado de CA raiz confiável @@ -1874,7 +2084,7 @@ Licenciado sob a licença MIT. Palavra-passe - Guarde esta palavra-passe em segurança. Protege as palavras-passe das contas locais do Windows, as credenciais e os perfis do Autopilot e os ficheiros de resposta personalizados. + Guarde esta palavra-passe em segurança. Protege as palavras-passe das contas locais do Windows, as credenciais de associação ao domínio, as credenciais e os perfis do Autopilot e os ficheiros de resposta personalizados. Palavra-passe de implementação @@ -1987,6 +2197,12 @@ Licenciado sob a licença MIT. Estado de preparação do suporte de dados + + Solicite ao técnico as credenciais do domínio durante a implementação. + + + Associação interativa ao domínio + Árabe (Arábia Saudita) @@ -2191,6 +2407,9 @@ Licenciado sob a licença MIT. Documentação + + Associação ao domínio + A conectividade padrão Ethernet está disponível por padrão. Configure esta seção somente se sua rede com fio exigir autenticação empresarial 802.1X. @@ -2212,6 +2431,12 @@ Licenciado sob a licença MIT. Página inicial + + Solicite ao técnico as credenciais do domínio durante a implementação. + + + Interativo + Configure os nomes dos dispositivos implementados. @@ -2272,6 +2497,12 @@ Licenciado sob a licença MIT. Autopilot + + Associe ao domínio automaticamente com credenciais protegidas pela palavra-passe de implementação. + + + Zero-Touch + Modo de aprovisionamento ativo @@ -3917,4 +4148,10 @@ A aplicação da atualização reinicia o Foundry OSD. Wi-Fi + + Associe ao domínio automaticamente com credenciais protegidas pela palavra-passe de implementação. + + + Associação zero-touch ao domínio + diff --git a/src/Foundry/Strings/ro-RO/Resources.resw b/src/Foundry/Strings/ro-RO/Resources.resw index ff216e4fb..4c4a11f79 100644 --- a/src/Foundry/Strings/ro-RO/Resources.resw +++ b/src/Foundry/Strings/ro-RO/Resources.resw @@ -1834,6 +1834,216 @@ Licențiat sub licența MIT. Documentația nu a putut fi deschisă + + Cont de asociere + + + Implicit + + + Domeniu + + + OU-uri + + + Stare + + + Adaugă + + + Șterge selecția implicită + + + Editează + + + Elimină + + + Setează ca implicit + + + Introduceți mai întâi un cont valid, apoi verificați lungimea parolei. + + + Da + + + Se caută OU-uri în domeniu... + + + Căutare anulată. + + + Nu s-a găsit niciun OU în acest domeniu. + + + Doar o parte dintre OU-uri au putut fi listate. Le puteți adăuga în continuare pe cele afișate. + + + Toate OU-urile din acest domeniu sunt deja în listă. + + + Căutarea a depășit timpul alocat. Puteți adăuga OU-uri manual. + + + Nu s-a putut căuta în domeniu. Verificați dacă acest computer îl poate contacta și dacă contul dvs. Windows îl poate citi sau adăugați OU-uri manual. + + + Adăugare domeniu + + + Adăugarea unui domeniu + + + Editare domeniu + + + Lăsați parola necompletată pentru a o păstra pe cea actuală. + + + Salvează + + + Domeniul nu a fost salvat. Verificați numele domeniului. + + + Pregătit + + + Eliminați OU-urile acestui domeniu înainte de a-i schimba numele. + + + Niciun domeniu adăugat. + + + Niciun OU adăugat. + + + OU-urile nu au fost adăugate. Aparțin altui domeniu decât cel selectat. + + + Adăugați mai întâi un domeniu, apoi listați OU-urile acestuia. + + + OU-ul nu a fost adăugat. Verificați numele afișat și numele distinctiv. OU-ul trebuie să aparțină domeniului selectat. + + + Unități organizaționale + + + Unități organizaționale din {0} + + + Selectați un domeniu pentru a lista OU-urile acestuia. + + + OU-uri selectate: {0} din {1} + + + Partajat + + + Adăugați cel puțin un domeniu. + + + Acest domeniu este deja în listă. + + + Două OU-uri au același ID intern. Eliminați unul și adăugați-l din nou. + + + Introduceți un nume de domeniu valid, de exemplu corp.contoso.com. + + + Fiecare OU necesită un nume afișat de cel mult 120 de caractere. + + + Activați protecția prin parolă pe pagina General și setați parola de implementare. + + + Introduceți parola contului. + + + Introduceți contul ca DOMAIN\user sau user@domain. + + + Introduceți contul de asociere partajat sau atribuiți acestui domeniu un cont propriu. + + + Puteți lista cel mult 32 de domenii. + + + Puteți lista cel mult 1.024 de OU-uri. + + + Cont (DOMAIN\user sau user@domain) + + + Adăugare OU + + + Anulare căutare + + + Nume de domeniu + + + corp.contoso.com + + + Folosiți un cont dedicat + + + Folosiți contul de asociere partajat + + + Import din domeniu + + + Listează OU-urile domeniului selectat, folosind contul dvs. Windows. Selectați-le pe cele de adăugat. + + + Import de OU-uri din domeniu + + + Domeniile la care acest suport poate asocia computere. Când sunt listate mai multe, tehnicienii aleg unul în timpul implementării; cel implicit este preselectat. + + + Domenii + + + Adăugare OU-uri selectate + + + Introduceți un nume afișat și numele distinctiv al OU-ului. OU-ul trebuie să aparțină domeniului selectat. + + + Adăugarea manuală a unui OU + + + Nume distinctiv + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Nume afișat + + + Opțional. Listați OU-urile în care pot fi plasate computerele în domeniul selectat. Un singur OU este folosit întotdeauna; când sunt listate mai multe, tehnicienii aleg unul în timpul implementării. Un cont de computer existent este mutat în OU-ul ales. + + + Parolă + + + Contul folosit de fiecare domeniu care nu are un cont propriu. Are nevoie de permisiunea de a asocia computere și de a le muta în OU-urile pe care le alegeți. + + + Cont de asociere partajat + Fișier de certificat CA rădăcină de încredere @@ -1874,7 +2084,7 @@ Licențiat sub licența MIT. Parolă - Păstrați această parolă în siguranță. Ea protejează parolele conturilor locale Windows, datele de autentificare și profilurile Autopilot, precum și fișierele de răspuns personalizate. + Păstrați această parolă în siguranță. Ea protejează parolele conturilor locale Windows, acreditările de asociere la domeniu, datele de autentificare și profilurile Autopilot, precum și fișierele de răspuns personalizate. Parolă de implementare @@ -1907,7 +2117,7 @@ Licențiat sub licența MIT. Nu au fost detectate pachete de limbă WinPE pentru arhitectura selectată. - general + General Examinați și începeți @@ -1987,6 +2197,12 @@ Licențiat sub licența MIT. Starea de pregătire a suportului media + + Solicitați tehnicianului acreditările de domeniu în timpul implementării. + + + Asociere interactivă la domeniu + arabă (Arabia Saudită) @@ -2191,6 +2407,9 @@ Licențiat sub licența MIT. Documentare + + Asociere la domeniu + Conectivitatea standard Ethernet este disponibilă implicit. Configurați această secțiune numai dacă rețeaua dvs. cu fir necesită autentificare de întreprindere 802.1X. @@ -2201,10 +2420,10 @@ Licențiat sub licența MIT. Flux de lucru de configurare standard. - general + General - general + General Foundry OSD acasă @@ -2212,6 +2431,12 @@ Licențiat sub licența MIT. Acasă + + Solicitați tehnicianului acreditările de domeniu în timpul implementării. + + + Interactiv + Configurați numele dispozitivelor implementate. @@ -2272,6 +2497,12 @@ Licențiat sub licența MIT. Autopilot + + Asociați automat la domeniu folosind acreditări protejate de parola de implementare. + + + Zero-Touch + Mod de furnizare activ @@ -2974,7 +3205,7 @@ Parole și chei de acces incluse: {2} Gestionați pornirea, limbajul, diagnosticarea și comportamentul aplicației. - general + General Creați copii de rezervă ale setărilor, transferați-le pe alt PC sau mențineți-le sincronizate. @@ -3917,4 +4148,10 @@ Aplicarea actualizării repornește Foundry OSD. Wi-Fi + + Asociați automat la domeniu folosind acreditări protejate de parola de implementare. + + + Asociere zero-touch la domeniu + diff --git a/src/Foundry/Strings/ru-RU/Resources.resw b/src/Foundry/Strings/ru-RU/Resources.resw index 3ca78e525..0fea22d91 100644 --- a/src/Foundry/Strings/ru-RU/Resources.resw +++ b/src/Foundry/Strings/ru-RU/Resources.resw @@ -1834,6 +1834,216 @@ Документацию невозможно открыть + + Учетная запись для присоединения + + + По умолчанию + + + Домен + + + OU + + + Состояние + + + Добавить + + + Сбросить выбор по умолчанию + + + Изменить + + + Удалить + + + Задать по умолчанию + + + Сначала укажите допустимую учетную запись, затем проверьте длину пароля. + + + Да + + + Поиск OU в домене... + + + Поиск отменен. + + + В этом домене не найдено ни одного OU. + + + Удалось получить только часть OU. Показанные OU все равно можно добавить. + + + Все OU в этом домене уже есть в списке. + + + Время ожидания поиска истекло. OU можно добавить вручную. + + + Не удалось выполнить поиск в домене. Убедитесь, что домен доступен с этого компьютера и что ваша учетная запись Windows может его читать, или добавьте OU вручную. + + + Добавить домен + + + Добавить домен + + + Изменить домен + + + Оставьте пароль пустым, чтобы сохранить текущий. + + + Сохранить + + + Домен не сохранен. Проверьте имя домена. + + + Готово + + + Удалите OU этого домена, прежде чем менять его имя. + + + Домены не добавлены. + + + OU не добавлены. + + + OU не добавлены. Они принадлежат домену, отличному от выбранного. + + + Сначала добавьте домен, затем перечислите его OU. + + + OU не добавлено. Проверьте отображаемое имя и различающееся имя. OU должно принадлежать выбранному домену. + + + Подразделения + + + Подразделения домена {0} + + + Выберите домен, чтобы перечислить его OU. + + + Выбранные OU: {0} из {1} + + + Общая + + + Добавьте хотя бы один домен. + + + Этот домен уже есть в списке. + + + У двух OU одинаковый внутренний идентификатор. Удалите одно из них и добавьте снова. + + + Укажите допустимое имя домена, например corp.contoso.com. + + + Каждому OU требуется отображаемое имя длиной не более 120 символов. + + + Включите защиту паролем на странице «Общие» и задайте пароль развертывания. + + + Укажите пароль учетной записи. + + + Укажите учетную запись в формате DOMAIN\user или user@domain. + + + Укажите общую учетную запись для присоединения или задайте для этого домена отдельную учетную запись. + + + В список можно добавить не более 32 доменов. + + + В список можно добавить не более 1 024 OU. + + + Учетная запись (DOMAIN\user или user@domain) + + + Добавить OU + + + Отменить поиск + + + Имя домена + + + corp.contoso.com + + + Использовать отдельную учетную запись + + + Использовать общую учетную запись для присоединения + + + Импорт из домена + + + Выводит список OU выбранного домена, используя вашу учетную запись Windows. Выберите OU для добавления. + + + Импорт OU из домена + + + Домены, к которым этот носитель может присоединять компьютеры. Если указано несколько, технические специалисты выбирают один во время развертывания; домен по умолчанию выбран заранее. + + + Домены + + + Добавить выбранные OU + + + Укажите отображаемое имя и различающееся имя OU. OU должно принадлежать выбранному домену. + + + Добавить OU вручную + + + Различающееся имя + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Отображаемое имя + + + Необязательно. Перечислите OU выбранного домена, в которые можно помещать компьютеры. Единственное OU используется всегда; если указано несколько, технические специалисты выбирают одно во время развертывания. Существующая учетная запись компьютера перемещается в выбранное OU. + + + Пароль + + + Учетная запись, которую используют все домены, не имеющие собственной учетной записи. Ей нужны разрешения на присоединение компьютеров и их перемещение в выбранные вами OU. + + + Общая учетная запись для присоединения + Файл сертификата доверенного корневого центра сертификации @@ -1874,7 +2084,7 @@ Пароль - Храните этот пароль в надежном месте. Он защищает пароли локальных учетных записей Windows, учетные данные и профили Autopilot, а также пользовательские файлы ответов. + Храните этот пароль в надежном месте. Он защищает пароли локальных учетных записей Windows, учетные данные для присоединения к домену, учетные данные и профили Autopilot, а также пользовательские файлы ответов. Пароль развертывания @@ -1907,7 +2117,7 @@ Языковые пакеты WinPE для выбранной архитектуры не обнаружены. - Общий + Общие Просмотрите и начните @@ -1987,6 +2197,12 @@ Состояние готовности носителя + + Запрашивать у технического специалиста учетные данные домена во время развертывания. + + + Интерактивное присоединение к домену + Арабский (Саудовская Аравия) @@ -2191,6 +2407,9 @@ Документация + + Присоединение к домену + Стандартное подключение Ethernet доступно по умолчанию. Настройте этот раздел только в том случае, если ваша проводная сеть требует корпоративной аутентификации 802.1X. @@ -2201,10 +2420,10 @@ Стандартный рабочий процесс настройки. - Общий + Общие - Общий + Общие Foundry OSD дома @@ -2212,6 +2431,12 @@ Главная + + Запрашивать у технического специалиста учетные данные домена во время развертывания. + + + Интерактивно + Настройте имена развернутых устройств. @@ -2272,6 +2497,12 @@ Autopilot + + Присоединять к домену автоматически с использованием учетных данных, защищенных паролем развертывания. + + + Zero-Touch + Активный режим подготовки @@ -2974,7 +3205,7 @@ Управляйте запуском, языком, диагностикой и поведением приложений. - Общий + Общие Создавайте резервные копии настроек, переносите их на другой компьютер или поддерживайте их синхронизацию. @@ -3917,4 +4148,10 @@ Wi-Fi + + Присоединять к домену автоматически с использованием учетных данных, защищенных паролем развертывания. + + + Zero-touch присоединение к домену + diff --git a/src/Foundry/Strings/sk-SK/Resources.resw b/src/Foundry/Strings/sk-SK/Resources.resw index 6fae6656e..f749d4729 100644 --- a/src/Foundry/Strings/sk-SK/Resources.resw +++ b/src/Foundry/Strings/sk-SK/Resources.resw @@ -1834,6 +1834,216 @@ Licencované podľa licencie MIT. Dokumentáciu nebolo možné otvoriť + + Účet na pripojenie + + + Predvolená + + + Doména + + + OU + + + Stav + + + Pridať + + + Zrušiť predvolený výber + + + Upraviť + + + Odstrániť + + + Nastaviť ako predvolenú + + + Najprv zadajte platný účet, potom skontrolujte dĺžku hesla. + + + Áno + + + Vyhľadávajú sa OU v doméne... + + + Vyhľadávanie bolo zrušené. + + + V tejto doméne sa nenašli žiadne OU. + + + Podarilo sa načítať len niektoré OU. Zobrazené OU môžete napriek tomu pridať. + + + Všetky OU v tejto doméne sa už v zozname nachádzajú. + + + Časový limit vyhľadávania uplynul. OU môžete pridať ručne. + + + Doménu sa nepodarilo prehľadať. Skontrolujte, či má tento počítač k doméne prístup a či ju váš účet Windows môže čítať, alebo pridajte OU ručne. + + + Pridať doménu + + + Pridať doménu + + + Upraviť doménu + + + Ak chcete ponechať aktuálne heslo, nechajte heslo prázdne. + + + Uložiť + + + Doména nebola uložená. Skontrolujte názov domény. + + + Pripravené + + + Pred zmenou názvu tejto domény odstráňte jej OU. + + + Neboli pridané žiadne domény. + + + Neboli pridané žiadne OU. + + + OU neboli pridané. Patria do inej domény, než je vybraná doména. + + + Najprv pridajte doménu, potom uveďte jej OU. + + + OU nebola pridaná. Skontrolujte zobrazovaný názov a rozlišujúci názov. OU musí patriť do vybranej domény. + + + Organizačné jednotky + + + Organizačné jednotky domény {0} + + + Vyberte doménu, ak chcete uviesť jej OU. + + + Vybrané OU: {0} z {1} + + + Zdieľaný + + + Pridajte aspoň jednu doménu. + + + Táto doména sa už v zozname nachádza. + + + Dve OU majú rovnaký interný identifikátor. Jednu odstráňte a pridajte ju znova. + + + Zadajte platný názov domény, napríklad corp.contoso.com. + + + Každá OU musí mať zobrazovaný názov s najviac 120 znakmi. + + + Na stránke „Všeobecné“ zapnite ochranu heslom a nastavte heslo nasadenia. + + + Zadajte heslo účtu. + + + Zadajte účet v tvare DOMAIN\user alebo user@domain. + + + Zadajte zdieľaný účet na pripojenie alebo priraďte tejto doméne vlastný účet. + + + Zoznam môže obsahovať najviac 32 domén. + + + Zoznam môže obsahovať najviac 1 024 OU. + + + Účet (DOMAIN\user alebo user@domain) + + + Pridať OU + + + Zrušiť vyhľadávanie + + + Názov domény + + + corp.contoso.com + + + Použiť vyhradený účet + + + Použiť zdieľaný účet na pripojenie + + + Importovať z domény + + + Zobrazí OU vybranej domény pomocou vášho účtu Windows. Vyberte tie, ktoré chcete pridať. + + + Importovať OU z domény + + + Domény, ku ktorým toto médium môže pripájať počítače. Ak je uvedených viacero, technici si počas nasadenia vyberú jednu; predvolená je vopred vybraná. + + + Domény + + + Pridať vybrané OU + + + Zadajte zobrazovaný názov a rozlišujúci názov OU. OU musí patriť do vybranej domény. + + + Pridať OU ručne + + + Rozlišujúci názov + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Zobrazovaný názov + + + Voliteľné. Uveďte OU, do ktorých možno vo vybranej doméne umiestniť počítače. Jediná OU sa použije vždy; ak je ich uvedených viacero, technici si počas nasadenia vyberú jednu. Existujúci účet počítača sa presunie do vybranej OU. + + + Heslo + + + Účet, ktorý používa každá doména bez vlastného účtu. Potrebuje oprávnenie pripájať počítače a presúvať ich do vybraných OU. + + + Zdieľaný účet na pripojenie + Súbor certifikátu dôveryhodnej koreňovej CA @@ -1874,7 +2084,7 @@ Licencované podľa licencie MIT. Heslo - Uchovávajte toto heslo v bezpečí. Chráni heslá lokálnych kont systému Windows, prihlasovacie údaje a profily Autopilot a vlastné súbory odpovedí. + Uchovávajte toto heslo v bezpečí. Chráni heslá lokálnych kont systému Windows, poverenia na pripojenie k doméne, prihlasovacie údaje a profily Autopilot a vlastné súbory odpovedí. Heslo nasadenia @@ -1907,7 +2117,7 @@ Licencované podľa licencie MIT. Pre zvolenú architektúru neboli zistené žiadne jazykové balíky WinPE. - generál + Všeobecné Skontrolujte a začnite @@ -1987,6 +2197,12 @@ Licencované podľa licencie MIT. Stav pripravenosti média + + Počas nasadenia si vyžiadajte od technika poverenia domény. + + + Interaktívne pripojenie k doméne + arabčina (Saudská Arábia) @@ -2191,6 +2407,9 @@ Licencované podľa licencie MIT. Dokumentácia + + Pripojenie k doméne + Štandardne je k dispozícii štandardné pripojenie Ethernet. Túto časť nakonfigurujte len vtedy, ak vaša káblová sieť vyžaduje podnikové overenie 802.1X. @@ -2201,10 +2420,10 @@ Licencované podľa licencie MIT. Štandardný pracovný postup konfigurácie. - generál + Všeobecné - generál + Všeobecné Foundry OSD domov @@ -2212,6 +2431,12 @@ Licencované podľa licencie MIT. Domov + + Počas nasadenia si vyžiadajte od technika poverenia domény. + + + Interaktívne + Nakonfigurujte názvy nasadených zariadení. @@ -2272,6 +2497,12 @@ Licencované podľa licencie MIT. Autopilot + + Pripojte sa k doméne automaticky pomocou poverení chránených heslom nasadenia. + + + Zero-Touch + Aktívny režim poskytovania @@ -2974,7 +3205,7 @@ Zahrnuté heslá a prístupové kľúče: {2} Manage startup, language, diagnostics, and application behavior. - generál + Všeobecné Zálohujte svoje nastavenia, preneste ich do iného počítača alebo ich udržiavajte synchronizované. @@ -3917,4 +4148,10 @@ Použitie aktualizácie reštartuje Foundry OSD. Wi-Fi + + Pripojte sa k doméne automaticky pomocou poverení chránených heslom nasadenia. + + + Zero-touch pripojenie k doméne + diff --git a/src/Foundry/Strings/sl-SI/Resources.resw b/src/Foundry/Strings/sl-SI/Resources.resw index 43bea72b8..d55db4040 100644 --- a/src/Foundry/Strings/sl-SI/Resources.resw +++ b/src/Foundry/Strings/sl-SI/Resources.resw @@ -1834,6 +1834,216 @@ Licencirano pod licenco MIT. Dokumentacije ni bilo mogoče odpreti + + Račun za pridružitev + + + Privzeta + + + Domena + + + OU + + + Stanje + + + Dodaj + + + Počisti privzeti izbor + + + Uredi + + + Odstrani + + + Nastavi kot privzeto + + + Najprej vnesite veljaven račun, nato preverite dolžino gesla. + + + Da + + + Iskanje OU v domeni... + + + Iskanje je preklicano. + + + V tej domeni ni bilo najdenih OU. + + + Prikazati je bilo mogoče le nekatere OU. Prikazane lahko vseeno dodate. + + + Vse OU v tej domeni so že na seznamu. + + + Časovna omejitev iskanja je potekla. OU lahko dodate ročno. + + + Domene ni bilo mogoče preiskati. Preverite, ali jo ta računalnik lahko doseže in ali jo vaš račun Windows lahko bere, ali pa dodajte OU ročno. + + + Dodaj domeno + + + Dodaj domeno + + + Uredi domeno + + + Geslo pustite prazno, če želite ohraniti trenutno. + + + Shrani + + + Domena ni bila shranjena. Preverite ime domene. + + + Pripravljeno + + + Pred spreminjanjem imena te domene odstranite njene OU. + + + Ni dodanih domen. + + + Ni dodanih OU. + + + OU niso bile dodane. Pripadajo drugi domeni, ne izbrani. + + + Najprej dodajte domeno, nato navedite njene OU. + + + OU ni bila dodana. Preverite prikazno ime in razločevalno ime. OU mora pripadati izbrani domeni. + + + Organizacijske enote + + + Organizacijske enote domene {0} + + + Izberite domeno, da navedete njene OU. + + + Izbrane OU: {0} od {1} + + + Skupni + + + Dodajte vsaj eno domeno. + + + Ta domena je že na seznamu. + + + Dve OU imata enak notranji ID. Eno odstranite in jo znova dodajte. + + + Vnesite veljavno ime domene, na primer corp.contoso.com. + + + Vsaka OU potrebuje prikazno ime z največ 120 znaki. + + + Na strani »Splošno« omogočite zaščito z geslom in nastavite geslo za uvajanje. + + + Vnesite geslo računa. + + + Račun vnesite v obliki DOMAIN\user ali user@domain. + + + Vnesite skupni račun za pridružitev ali pa tej domeni dodelite lasten račun. + + + Navedete lahko največ 32 domen. + + + Navedete lahko največ 1.024 OU. + + + Račun (DOMAIN\user ali user@domain) + + + Dodaj OU + + + Prekliči iskanje + + + Ime domene + + + corp.contoso.com + + + Uporabi namenski račun + + + Uporabi skupni račun za pridružitev + + + Uvozi iz domene + + + Z vašim računom Windows prikaže OU izbrane domene. Izberite tiste, ki jih želite dodati. + + + Uvoz OU iz domene + + + Domene, ki jim ta medij lahko pridruži računalnike. Če jih je navedenih več, tehniki med uvajanjem izberejo eno; privzeta je vnaprej izbrana. + + + Domene + + + Dodaj izbrane OU + + + Vnesite prikazno ime in razločevalno ime OU. OU mora pripadati izbrani domeni. + + + Ročno dodajanje OU + + + Razločevalno ime + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Prikazno ime + + + Izbirno. Navedite OU, v katere je mogoče umestiti računalnike v izbrani domeni. Ena sama OU se vedno uporabi; če jih je navedenih več, tehniki med uvajanjem izberejo eno. Obstoječi račun računalnika se premakne v izbrano OU. + + + Geslo + + + Račun, ki ga uporablja vsaka domena brez lastnega računa. Potrebuje dovoljenje za pridruževanje računalnikov in njihovo premikanje v izbrane OU. + + + Skupni račun za pridružitev + Datoteka potrdila zaupanja vrednega korenskega CA @@ -1874,7 +2084,7 @@ Licencirano pod licenco MIT. Geslo - To geslo hranite na varnem. Ščiti gesla lokalnih računov Windows, poverilnice in profile Autopilot ter datoteke odgovorov po meri. + To geslo hranite na varnem. Ščiti gesla lokalnih računov Windows, poverilnice za pridružitev domeni, poverilnice in profile Autopilot ter datoteke odgovorov po meri. Geslo za uvajanje @@ -1987,6 +2197,12 @@ Licencirano pod licenco MIT. Stanje pripravljenosti medija + + Med uvajanjem zahtevajte od tehnika poverilnice domene. + + + Interaktivna pridružitev domeni + arabščina (Saudova Arabija) @@ -2191,6 +2407,9 @@ Licencirano pod licenco MIT. Dokumentacija + + Pridružitev domeni + Standardna Ethernet povezljivost je privzeto na voljo. Ta razdelek konfigurirajte samo, če vaše žično omrežje zahteva preverjanje pristnosti podjetja 802.1X. @@ -2212,6 +2431,12 @@ Licencirano pod licenco MIT. domov + + Med uvajanjem zahtevajte od tehnika poverilnice domene. + + + Interaktivno + Konfigurirajte imena nameščenih naprav. @@ -2272,6 +2497,12 @@ Licencirano pod licenco MIT. Autopilot + + Samodejno se pridružite domeni s poverilnicami, zaščitenimi z geslom za uvajanje. + + + Zero-Touch + Aktivni način zagotavljanja @@ -3917,4 +4148,10 @@ Uporaba posodobitve znova zažene Foundry OSD. Wi-Fi + + Samodejno se pridružite domeni s poverilnicami, zaščitenimi z geslom za uvajanje. + + + Zero-touch pridružitev domeni + diff --git a/src/Foundry/Strings/sr-Latn-RS/Resources.resw b/src/Foundry/Strings/sr-Latn-RS/Resources.resw index 633f5c4de..a16e2b357 100644 --- a/src/Foundry/Strings/sr-Latn-RS/Resources.resw +++ b/src/Foundry/Strings/sr-Latn-RS/Resources.resw @@ -1834,6 +1834,216 @@ Licencirano pod MIT licencom. Dokumentacija nije mogla da se otvori + + Nalog za pridruživanje + + + Podrazumevano + + + Domen + + + OU + + + Status + + + Dodaj + + + Obriši podrazumevani izbor + + + Uredi + + + Ukloni + + + Postavi kao podrazumevano + + + Prvo unesite važeći nalog, a zatim proverite dužinu lozinke. + + + Da + + + Pretraga OU u domenu... + + + Pretraga je otkazana. + + + Nijedna OU nije pronađena u ovom domenu. + + + Samo neke OU su mogle da budu prikazane. Prikazane i dalje možete da dodate. + + + Sve OU u ovom domenu već su na listi. + + + Vreme za pretragu je isteklo. OU možete da dodate ručno. + + + Pretraga domena nije uspela. Proverite da li ovaj računar može da mu pristupi i da li vaš Windows nalog može da ga čita ili dodajte OU ručno. + + + Dodaj domen + + + Dodavanje domena + + + Uređivanje domena + + + Ostavite lozinku praznu da biste zadržali trenutnu. + + + Sačuvaj + + + Domen nije sačuvan. Proverite ime domena. + + + Spreman + + + Uklonite OU ovog domena pre promene njegovog imena. + + + Nema dodatih domena. + + + Nema dodatih OU. + + + OU nisu dodate. Pripadaju drugom domenu, a ne izabranom. + + + Prvo dodajte domen, a zatim navedite njegove OU. + + + OU nije dodata. Proverite ime za prikaz i jedinstveno ime. OU mora pripadati izabranom domenu. + + + Organizacione jedinice + + + Organizacione jedinice domena {0} + + + Izaberite domen da biste prikazali njegove OU. + + + Izabrane OU: {0} od {1} + + + Deljeni + + + Dodajte bar jedan domen. + + + Ovaj domen je već na listi. + + + Dve OU imaju isti interni ID. Uklonite jednu i dodajte je ponovo. + + + Unesite važeće ime domena, na primer corp.contoso.com. + + + Svaka OU mora imati ime za prikaz sa najviše 120 znakova. + + + Omogućite zaštitu lozinkom na stranici „Opšte” i postavite lozinku za primenu. + + + Unesite lozinku naloga. + + + Unesite nalog u obliku DOMAIN\user ili user@domain. + + + Unesite deljeni nalog za pridruživanje ili dodelite ovom domenu sopstveni nalog. + + + Možete navesti najviše 32 domena. + + + Možete navesti najviše 1.024 OU. + + + Nalog (DOMAIN\user ili user@domain) + + + Dodaj OU + + + Otkaži pretragu + + + Ime domena + + + corp.contoso.com + + + Koristi namenski nalog + + + Koristi deljeni nalog za pridruživanje + + + Uvezi iz domena + + + Prikazuje OU izabranog domena, koristeći vaš Windows nalog. Izaberite one koje želite da dodate. + + + Uvoz OU iz domena + + + Domeni kojima ovaj medijum može da se pridruži. Kada ih je navedeno više, tehničari biraju jedan tokom primene; podrazumevani je unapred izabran. + + + Domeni + + + Dodaj izabrane OU + + + Unesite ime za prikaz i jedinstveno ime OU. OU mora pripadati izabranom domenu. + + + Ručno dodavanje OU + + + Jedinstveno ime + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Ime za prikaz + + + Opciono. Navedite OU u koje računari mogu da se smeste u izabranom domenu. Ako je navedena samo jedna OU, ona se uvek koristi; kada ih je navedeno više, tehničari biraju jednu tokom primene. Postojeći nalog računara premešta se u izabranu OU. + + + Lozinka + + + Nalog koji koristi svaki domen koji nema sopstveni nalog. Potrebna mu je dozvola da pridružuje računare i da ih premešta u OU koje izaberete. + + + Deljeni nalog za pridruživanje + Datoteka sertifikata pouzdanog root CA @@ -1874,7 +2084,7 @@ Licencirano pod MIT licencom. Lozinka - Čuvajte ovu lozinku na sigurnom. Ona štiti lozinke lokalnih Windows naloga, Autopilot akreditive i profile, kao i prilagođene datoteke odgovora. + Čuvajte ovu lozinku na sigurnom. Ona štiti lozinke lokalnih Windows naloga, akreditive za pridruživanje domenu, Autopilot akreditive i profile, kao i prilagođene datoteke odgovora. Lozinka za primenu @@ -1907,7 +2117,7 @@ Licencirano pod MIT licencom. Za izabranu arhitekturu nisu otkriveni WinPE jezički paketi. - general + Opšte Pregledajte i počnite @@ -1987,6 +2197,12 @@ Licencirano pod MIT licencom. Status spremnosti medijuma + + Zatražite od tehničara akreditive domena tokom primene. + + + Interaktivno pridruživanje domenu + arapski (Saudijska Arabija) @@ -2191,6 +2407,9 @@ Licencirano pod MIT licencom. Dokumentacija + + Pridruživanje domenu + Standardno Ethernet povezivanje je podrazumevano dostupno. Konfigurišite ovaj odeljak samo ako vaša žičana mreža zahteva 802.1X autentifikaciju preduzeća. @@ -2201,10 +2420,10 @@ Licencirano pod MIT licencom. Standardni tok rada konfiguracije. - general + Opšte - general + Opšte Foundry OSD kući @@ -2212,6 +2431,12 @@ Licencirano pod MIT licencom. Home + + Zatražite od tehničara akreditive domena tokom primene. + + + Interaktivno + Konfigurišite nazive raspoređenih uređaja. @@ -2272,6 +2497,12 @@ Licencirano pod MIT licencom. Autopilot + + Pridružite se domenu automatski pomoću akreditiva zaštićenih lozinkom za primenu. + + + Zero-Touch + Aktivan režim obezbeđivanja @@ -2974,7 +3205,7 @@ Uključene lozinke i pristupni ključevi: {2} Upravljajte pokretanjem, jezikom, dijagnostikom i ponašanjem aplikacije. - general + Opšte Napravite rezervne kopije svojih podešavanja, prenesite ih na drugi računar ili ih održavajte sinhronizovanim. @@ -3917,4 +4148,10 @@ Primena ažuriranja ponovo pokreće Foundry OSD. Wi-Fi + + Pridružite se domenu automatski pomoću akreditiva zaštićenih lozinkom za primenu. + + + Zero-touch pridruživanje domenu + diff --git a/src/Foundry/Strings/sv-SE/Resources.resw b/src/Foundry/Strings/sv-SE/Resources.resw index 734932947..2f29db479 100644 --- a/src/Foundry/Strings/sv-SE/Resources.resw +++ b/src/Foundry/Strings/sv-SE/Resources.resw @@ -1834,6 +1834,216 @@ Licensierad under MIT-licensen. Dokumentationen kunde inte öppnas + + Anslutningskonto + + + Standard + + + Domän + + + OU:er + + + Status + + + Lägg till + + + Rensa standardval + + + Redigera + + + Ta bort + + + Ange som standard + + + Ange först ett giltigt konto, och kontrollera sedan lösenordets längd. + + + Ja + + + Söker efter OU:er i domänen... + + + Sökningen avbröts. + + + Inga OU:er hittades i den här domänen. + + + Endast vissa OU:er kunde visas. Du kan fortfarande lägga till de som visas. + + + Alla OU:er i den här domänen finns redan i listan. + + + Tidsgränsen för sökningen överskreds. Du kan lägga till OU:er manuellt. + + + Det gick inte att söka i domänen. Kontrollera att den här datorn kan nå den och att ditt Windows-konto kan läsa den, eller lägg till OU:er manuellt. + + + Lägg till domän + + + Lägg till en domän + + + Redigera domän + + + Lämna lösenordet tomt om du vill behålla det nuvarande. + + + Spara + + + Domänen sparades inte. Kontrollera domännamnet. + + + Klar + + + Ta bort den här domänens OU:er innan du ändrar dess namn. + + + Inga domäner har lagts till. + + + Inga OU:er har lagts till. + + + OU:erna lades inte till. De tillhör en annan domän än den valda. + + + Lägg först till en domän och ange sedan dess OU:er. + + + OU:n lades inte till. Kontrollera visningsnamnet och det unika namnet. OU:n måste tillhöra den valda domänen. + + + Organisationsenheter + + + Organisationsenheter i {0} + + + Välj en domän för att visa dess OU:er. + + + Valda OU:er: {0} av {1} + + + Delat + + + Lägg till minst en domän. + + + Den här domänen finns redan i listan. + + + Två OU:er har samma interna ID. Ta bort den ena och lägg till den igen. + + + Ange ett giltigt domännamn, till exempel corp.contoso.com. + + + Varje OU behöver ett visningsnamn med högst 120 tecken. + + + Aktivera lösenordsskydd på sidan ”Allmänt” och ange distributionslösenordet. + + + Ange kontots lösenord. + + + Ange kontot som DOMAIN\user eller user@domain. + + + Ange det delade anslutningskontot, eller ge den här domänen ett eget konto. + + + Listan kan innehålla högst 32 domäner. + + + Listan kan innehålla högst 1 024 OU:er. + + + Konto (DOMAIN\user eller user@domain) + + + Lägg till OU + + + Avbryt sökning + + + Domännamn + + + corp.contoso.com + + + Använd ett dedikerat konto + + + Använd det delade anslutningskontot + + + Importera från domän + + + Visar OU:erna i den valda domänen, med ditt Windows-konto. Välj de som ska läggas till. + + + Importera OU:er från domänen + + + Domäner som det här mediet kan ansluta till. När flera anges väljer tekniker en under distributionen; standarddomänen är förvald. + + + Domäner + + + Lägg till valda OU:er + + + Ange ett visningsnamn och OU:ns unika namn. OU:n måste tillhöra den valda domänen. + + + Lägg till en OU manuellt + + + Unikt namn + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Visningsnamn + + + Valfritt. Ange de OU:er där datorer kan placeras i den valda domänen. Om bara en OU anges används den alltid; när flera anges väljer tekniker en under distributionen. Ett befintligt datorkonto flyttas till den valda OU:n. + + + Lösenord + + + Konto som används av alla domäner som inte har ett eget konto. Det behöver behörighet att ansluta datorer och flytta dem till de OU:er du väljer. + + + Delat anslutningskonto + Betrodd rot-CA-certifikatfil @@ -1874,7 +2084,7 @@ Licensierad under MIT-licensen. Lösenord - Förvara detta lösenord säkert. Det skyddar lösenord för lokala Windows-konton, autentiseringsuppgifter och profiler för Autopilot samt egna svarsfiler. + Förvara detta lösenord säkert. Det skyddar lösenord för lokala Windows-konton, autentiseringsuppgifter för domänanslutning, autentiseringsuppgifter och profiler för Autopilot samt egna svarsfiler. Distributionslösenord @@ -1987,6 +2197,12 @@ Licensierad under MIT-licensen. Status för medieberedskap + + Be teknikern om domänens autentiseringsuppgifter under distributionen. + + + Interaktiv domänanslutning + Arabiska (Saudiarabien) @@ -2191,6 +2407,9 @@ Licensierad under MIT-licensen. Dokumentation + + Domänanslutning + Standard Ethernet-anslutning är tillgänglig som standard. Konfigurera detta avsnitt endast om ditt trådbundna nätverk kräver 802.1X företagsautentisering. @@ -2212,6 +2431,12 @@ Licensierad under MIT-licensen. Hem + + Be teknikern om domänens autentiseringsuppgifter under distributionen. + + + Interaktiv + Konfigurera namn på utrullade enheter. @@ -2272,6 +2497,12 @@ Licensierad under MIT-licensen. Autopilot + + Anslut till domänen automatiskt med autentiseringsuppgifter som skyddas av distributionslösenordet. + + + Zero-Touch + Aktivt provisioneringsläge @@ -3917,4 +4148,10 @@ Uppdateringen startar om Foundry OSD. Wi-Fi + + Anslut till domänen automatiskt med autentiseringsuppgifter som skyddas av distributionslösenordet. + + + Zero-touch-domänanslutning + diff --git a/src/Foundry/Strings/th-TH/Resources.resw b/src/Foundry/Strings/th-TH/Resources.resw index c241951f2..1a4a58159 100644 --- a/src/Foundry/Strings/th-TH/Resources.resw +++ b/src/Foundry/Strings/th-TH/Resources.resw @@ -1834,6 +1834,216 @@ ไม่สามารถเปิดเอกสารได้ + + บัญชีสำหรับเข้าร่วมโดเมน + + + ค่าเริ่มต้น + + + โดเมน + + + OU + + + สถานะ + + + เพิ่ม + + + ล้างค่าเริ่มต้น + + + แก้ไข + + + ลบออก + + + ตั้งเป็นค่าเริ่มต้น + + + ป้อนบัญชีที่ถูกต้องก่อน แล้วตรวจสอบความยาวของรหัสผ่าน + + + ใช่ + + + กำลังค้นหา OU ในโดเมน... + + + ยกเลิกการค้นหาแล้ว + + + ไม่พบ OU ในโดเมนนี้ + + + แสดงรายการ OU ได้เพียงบางส่วน คุณยังคงเพิ่ม OU ที่แสดงอยู่ได้ + + + OU ทั้งหมดในโดเมนนี้อยู่ในรายการแล้ว + + + หมดเวลาค้นหาแล้ว คุณสามารถเพิ่ม OU ด้วยตนเองได้ + + + ไม่สามารถค้นหาในโดเมนได้ ตรวจสอบว่าคอมพิวเตอร์เครื่องนี้ติดต่อโดเมนได้และบัญชี Windows ของคุณอ่านโดเมนได้ หรือเพิ่ม OU ด้วยตนเอง + + + เพิ่มโดเมน + + + เพิ่มโดเมน + + + แก้ไขโดเมน + + + เว้นรหัสผ่านว่างไว้เพื่อใช้รหัสผ่านปัจจุบันต่อไป + + + บันทึก + + + ไม่ได้บันทึกโดเมน ตรวจสอบชื่อโดเมน + + + พร้อม + + + ลบ OU ของโดเมนนี้ออกก่อนเปลี่ยนชื่อโดเมน + + + ยังไม่ได้เพิ่มโดเมน + + + ยังไม่ได้เพิ่ม OU + + + ไม่ได้เพิ่ม OU เนื่องจาก OU เหล่านี้อยู่ในโดเมนที่ต่างจากโดเมนที่เลือก + + + เพิ่มโดเมนก่อน แล้วจึงระบุ OU ของโดเมนนั้น + + + ไม่ได้เพิ่ม OU ตรวจสอบชื่อที่แสดงและชื่อเฉพาะ OU ต้องอยู่ในโดเมนที่เลือก + + + หน่วยองค์กร + + + หน่วยองค์กรของ {0} + + + เลือกโดเมนเพื่อแสดงรายการ OU ของโดเมนนั้น + + + OU ที่เลือก: {0} จาก {1} + + + ใช้ร่วมกัน + + + เพิ่มโดเมนอย่างน้อยหนึ่งรายการ + + + โดเมนนี้อยู่ในรายการแล้ว + + + มี OU สองรายการที่ใช้ ID ภายในเดียวกัน ลบออกหนึ่งรายการแล้วเพิ่มใหม่อีกครั้ง + + + ป้อนชื่อโดเมนที่ถูกต้อง เช่น corp.contoso.com + + + แต่ละ OU ต้องมีชื่อที่แสดงยาวไม่เกิน 120 อักขระ + + + เปิดการป้องกันด้วยรหัสผ่านในหน้า "ทั่วไป" และตั้งรหัสผ่านการปรับใช้ + + + ป้อนรหัสผ่านของบัญชี + + + ป้อนบัญชีในรูปแบบ DOMAIN\user หรือ user@domain + + + ป้อนบัญชีที่ใช้ร่วมกันสำหรับเข้าร่วมโดเมน หรือกำหนดบัญชีเฉพาะให้โดเมนนี้ + + + เพิ่มโดเมนในรายการได้ไม่เกิน 32 รายการ + + + เพิ่ม OU ในรายการได้ไม่เกิน 1,024 รายการ + + + บัญชี (DOMAIN\user หรือ user@domain) + + + เพิ่ม OU + + + ยกเลิกการค้นหา + + + ชื่อโดเมน + + + corp.contoso.com + + + ใช้บัญชีเฉพาะ + + + ใช้บัญชีที่ใช้ร่วมกันสำหรับเข้าร่วมโดเมน + + + นำเข้าจากโดเมน + + + แสดงรายการ OU ของโดเมนที่เลือก โดยใช้บัญชี Windows ของคุณ เลือกรายการที่จะเพิ่ม + + + นำเข้า OU จากโดเมน + + + โดเมนที่สื่อนี้เข้าร่วมได้ เมื่อระบุไว้หลายโดเมน ช่างเทคนิคจะเลือกหนึ่งโดเมนระหว่างการปรับใช้ โดยโดเมนเริ่มต้นจะถูกเลือกไว้ล่วงหน้า + + + โดเมน + + + เพิ่ม OU ที่เลือก + + + ป้อนชื่อที่แสดงและชื่อเฉพาะของ OU โดย OU ต้องอยู่ในโดเมนที่เลือก + + + เพิ่ม OU ด้วยตนเอง + + + ชื่อเฉพาะ + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + ชื่อที่แสดง + + + ไม่บังคับ ระบุ OU ที่ใช้จัดวางคอมพิวเตอร์ได้ในโดเมนที่เลือก หากระบุ OU เดียว ระบบจะใช้ OU นั้นเสมอ เมื่อระบุไว้หลาย OU ช่างเทคนิคจะเลือกหนึ่ง OU ระหว่างการปรับใช้ บัญชีคอมพิวเตอร์ที่มีอยู่จะถูกย้ายไปยัง OU ที่เลือก + + + รหัสผ่าน + + + บัญชีที่ใช้กับทุกโดเมนที่ไม่มีบัญชีของตนเอง บัญชีนี้ต้องมีสิทธิ์นำคอมพิวเตอร์เข้าร่วมโดเมนและย้ายไปยัง OU ที่คุณเลือก + + + บัญชีที่ใช้ร่วมกันสำหรับเข้าร่วมโดเมน + ไฟล์ใบรับรอง root CA ที่เชื่อถือได้ @@ -1874,7 +2084,7 @@ รหัสผ่าน - เก็บรักษารหัสผ่านนี้ให้ปลอดภัย รหัสผ่านนี้ปกป้องรหัสผ่านของบัญชี Windows ภายในเครื่อง ข้อมูลประจำตัวและโปรไฟล์ Autopilot และไฟล์คำตอบที่กำหนดเอง + เก็บรักษารหัสผ่านนี้ให้ปลอดภัย รหัสผ่านนี้ปกป้องรหัสผ่านของบัญชี Windows ภายในเครื่อง ข้อมูลประจำตัวสำหรับการเข้าร่วมโดเมน ข้อมูลประจำตัวและโปรไฟล์ Autopilot และไฟล์คำตอบที่กำหนดเอง รหัสผ่านการปรับใช้ @@ -1987,6 +2197,12 @@ สถานะความพร้อมของสื่อ + + ขอข้อมูลประจำตัวของโดเมนจากช่างเทคนิคระหว่างการปรับใช้ + + + การเข้าร่วมโดเมนแบบโต้ตอบ + อาหรับ (ซาอุดีอาระเบีย) @@ -2191,6 +2407,9 @@ เอกสารประกอบ + + การเข้าร่วมโดเมน + การเชื่อมต่อมาตรฐาน Ethernet พร้อมใช้งานตามค่าเริ่มต้น กำหนดค่าส่วนนี้เฉพาะในกรณีที่เครือข่ายแบบใช้สายของคุณต้องมีการรับรองความถูกต้องระดับองค์กร 802.1X @@ -2212,6 +2431,12 @@ บ้าน + + ขอข้อมูลประจำตัวของโดเมนจากช่างเทคนิคระหว่างการปรับใช้ + + + แบบโต้ตอบ + กำหนดค่าชื่ออุปกรณ์ที่ปรับใช้ @@ -2272,6 +2497,12 @@ Autopilot + + เข้าร่วมโดเมนโดยอัตโนมัติโดยใช้ข้อมูลประจำตัวที่ป้องกันด้วยรหัสผ่านการปรับใช้ + + + Zero-Touch + โหมดการจัดสรรที่ใช้งานอยู่ @@ -3917,4 +4148,10 @@ Wi-Fi + + เข้าร่วมโดเมนโดยอัตโนมัติโดยใช้ข้อมูลประจำตัวที่ป้องกันด้วยรหัสผ่านการปรับใช้ + + + การเข้าร่วมโดเมนแบบ Zero-touch + diff --git a/src/Foundry/Strings/tr-TR/Resources.resw b/src/Foundry/Strings/tr-TR/Resources.resw index 8fe50dada..932f8e0d2 100644 --- a/src/Foundry/Strings/tr-TR/Resources.resw +++ b/src/Foundry/Strings/tr-TR/Resources.resw @@ -1834,6 +1834,216 @@ MIT Lisansı kapsamında lisanslanmıştır. Belgeler açılamadı + + Katılma hesabı + + + Varsayılan + + + Etki alanı + + + OU’lar + + + Durum + + + Ekle + + + Varsayılan seçimi temizle + + + Düzenle + + + Kaldır + + + Varsayılan olarak ayarla + + + Önce geçerli bir hesap girin, ardından parola uzunluğunu denetleyin. + + + Evet + + + Etki alanında OU aranıyor... + + + Arama iptal edildi. + + + Bu etki alanında OU bulunamadı. + + + OU’ların yalnızca bir kısmı listelenebildi. Gösterilenleri yine de ekleyebilirsiniz. + + + Bu etki alanındaki tüm OU’lar zaten listede. + + + Arama zaman aşımına uğradı. OU’ları el ile ekleyebilirsiniz. + + + Etki alanında arama yapılamadı. Bu bilgisayarın etki alanına erişebildiğini ve Windows hesabınızın etki alanını okuyabildiğini denetleyin veya OU’ları el ile ekleyin. + + + Etki alanı ekle + + + Etki alanı ekle + + + Etki alanını düzenle + + + Geçerli parolayı korumak için parolayı boş bırakın. + + + Kaydet + + + Etki alanı kaydedilmedi. Etki alanı adını denetleyin. + + + Hazır + + + Adını değiştirmeden önce bu etki alanının OU’larını kaldırın. + + + Etki alanı eklenmedi. + + + OU eklenmedi. + + + OU’lar eklenmedi. Seçilen etki alanından farklı bir etki alanına aitler. + + + Önce bir etki alanı ekleyin, ardından OU’larını listeleyin. + + + OU eklenmedi. Görünen adı ve ayırt edici adı denetleyin. OU seçilen etki alanına ait olmalıdır. + + + Kuruluş birimleri + + + {0} kuruluş birimleri + + + OU’larını listelemek için bir etki alanı seçin. + + + Seçilen OU’lar: {0} / {1} + + + Paylaşılan + + + En az bir etki alanı ekleyin. + + + Bu etki alanı zaten listede. + + + İki OU aynı iç kimliği paylaşıyor. Birini kaldırıp yeniden ekleyin. + + + corp.contoso.com gibi geçerli bir etki alanı adı girin. + + + Her OU’nun en fazla 120 karakterlik bir görünen adı olmalıdır. + + + "Genel" sayfasında parola korumasını etkinleştirin ve dağıtım parolasını belirleyin. + + + Hesap parolasını girin. + + + Hesabı DOMAIN\user veya user@domain biçiminde girin. + + + Paylaşılan katılma hesabını girin veya bu etki alanına kendi hesabını verin. + + + En fazla 32 etki alanı listeleyebilirsiniz. + + + En fazla 1.024 OU listeleyebilirsiniz. + + + Hesap (DOMAIN\user veya user@domain) + + + OU ekle + + + Aramayı iptal et + + + Etki alanı adı + + + corp.contoso.com + + + Ayrılmış bir hesap kullan + + + Paylaşılan katılma hesabını kullan + + + Etki alanından içe aktar + + + Windows hesabınızı kullanarak seçilen etki alanındaki OU’ları listeler. Eklenecek olanları seçin. + + + OU’ları etki alanından içe aktar + + + Bu medyanın katılabileceği etki alanları. Birden fazla etki alanı listelendiğinde teknisyenler dağıtım sırasında birini seçer; varsayılan etki alanı önceden seçilidir. + + + Etki alanları + + + Seçilen OU’ları ekle + + + OU için bir görünen ad ve ayırt edici adı girin. OU seçilen etki alanına ait olmalıdır. + + + El ile OU ekle + + + Ayırt edici ad + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Görünen ad + + + İsteğe bağlı. Seçilen etki alanında bilgisayarların yerleştirilebileceği OU’ları listeleyin. Tek bir OU listelenmişse her zaman o kullanılır; birden fazla listelendiğinde teknisyenler dağıtım sırasında birini seçer. Mevcut bir bilgisayar hesabı seçilen OU’ya taşınır. + + + Parola + + + Kendi hesabı olmayan her etki alanının kullandığı hesap. Bilgisayarları etki alanına katma ve seçtiğiniz OU’lara taşıma izni olmalıdır. + + + Paylaşılan katılma hesabı + Güvenilen kök CA sertifika dosyası @@ -1874,7 +2084,7 @@ MIT Lisansı kapsamında lisanslanmıştır. Parola - Bu parolayı güvende tutun. Yerel Windows hesaplarının parolalarını, Autopilot kimlik bilgilerini ve profillerini ve özel yanıt dosyalarını korur. + Bu parolayı güvende tutun. Yerel Windows hesaplarının parolalarını, etki alanına katılma kimlik bilgilerini, Autopilot kimlik bilgilerini ve profillerini ve özel yanıt dosyalarını korur. Dağıtım parolası @@ -1987,6 +2197,12 @@ MIT Lisansı kapsamında lisanslanmıştır. Medya hazırlık durumu + + Dağıtım sırasında teknisyenden etki alanı kimlik bilgilerini isteyin. + + + Etkileşimli etki alanına katılma + Arapça (Suudi Arabistan) @@ -2191,6 +2407,9 @@ MIT Lisansı kapsamında lisanslanmıştır. Dokümantasyon + + Etki alanına katılma + Standart Ethernet bağlantısı varsayılan olarak mevcuttur. Bu bölümü yalnızca kablolu ağınız 802.1X kurumsal kimlik doğrulaması gerektiriyorsa yapılandırın. @@ -2212,6 +2431,12 @@ MIT Lisansı kapsamında lisanslanmıştır. Ana sayfa + + Dağıtım sırasında teknisyenden etki alanı kimlik bilgilerini isteyin. + + + Etkileşimli + Dağıtılan cihaz adlarını yapılandırın. @@ -2272,6 +2497,12 @@ MIT Lisansı kapsamında lisanslanmıştır. Autopilot + + Dağıtım parolasıyla korunan kimlik bilgilerini kullanarak etki alanına otomatik olarak katılın. + + + Zero-Touch + Etkin temel hazırlık modu @@ -3917,4 +4148,10 @@ Güncellemeyi uygulamak Foundry OSD’yi yeniden başlatır. Wi-Fi + + Dağıtım parolasıyla korunan kimlik bilgilerini kullanarak etki alanına otomatik olarak katılın. + + + Zero-touch etki alanına katılma + diff --git a/src/Foundry/Strings/uk-UA/Resources.resw b/src/Foundry/Strings/uk-UA/Resources.resw index a06226be2..f7a1b5411 100644 --- a/src/Foundry/Strings/uk-UA/Resources.resw +++ b/src/Foundry/Strings/uk-UA/Resources.resw @@ -1834,6 +1834,216 @@ Не вдалося відкрити документацію + + Обліковий запис для приєднання + + + За замовчуванням + + + Домен + + + OU + + + Стан + + + Додати + + + Скинути вибір за замовчуванням + + + Редагувати + + + Видалити + + + Установити за замовчуванням + + + Спочатку введіть дійсний обліковий запис, а потім перевірте довжину пароля. + + + Так + + + Пошук OU в домені... + + + Пошук скасовано. + + + У цьому домені не знайдено жодної OU. + + + Вдалося отримати лише частину OU. Показані OU все одно можна додати. + + + Усі OU в цьому домені вже є в списку. + + + Час очікування пошуку минув. OU можна додати вручну. + + + Не вдалося виконати пошук у домені. Перевірте, чи цей комп’ютер має до нього доступ і чи ваш обліковий запис Windows може його читати, або додайте OU вручну. + + + Додати домен + + + Додати домен + + + Редагувати домен + + + Залиште пароль порожнім, щоб зберегти поточний. + + + Зберегти + + + Домен не збережено. Перевірте ім’я домену. + + + Готовий + + + Видаліть OU цього домену, перш ніж змінювати його ім’я. + + + Домени не додано. + + + OU не додано. + + + OU не додано. Вони належать до іншого домену, ніж вибраний. + + + Спочатку додайте домен, а потім укажіть його OU. + + + OU не додано. Перевірте відображуване ім’я та розрізнювальне ім’я. OU має належати до вибраного домену. + + + Організаційні одиниці + + + Організаційні одиниці домену {0} + + + Виберіть домен, щоб показати його OU. + + + Вибрані OU: {0} з {1} + + + Спільний + + + Додайте принаймні один домен. + + + Цей домен уже є в списку. + + + Дві OU мають однаковий внутрішній ідентифікатор. Видаліть одну з них і додайте її знову. + + + Введіть дійсне ім’я домену, наприклад corp.contoso.com. + + + Кожна OU потребує відображуваного імені завдовжки щонайбільше 120 символів. + + + Увімкніть захист паролем на сторінці «Загальні» і встановіть пароль розгортання. + + + Введіть пароль облікового запису. + + + Введіть обліковий запис у форматі DOMAIN\user або user@domain. + + + Введіть спільний обліковий запис для приєднання або надайте цьому домену власний обліковий запис. + + + До списку можна додати щонайбільше 32 домени. + + + До списку можна додати щонайбільше 1 024 OU. + + + Обліковий запис (DOMAIN\user або user@domain) + + + Додати OU + + + Скасувати пошук + + + Ім’я домену + + + corp.contoso.com + + + Використовувати окремий обліковий запис + + + Використовувати спільний обліковий запис для приєднання + + + Імпортувати з домену + + + Показує OU вибраного домену, використовуючи ваш обліковий запис Windows. Виберіть ті, які потрібно додати. + + + Імпорт OU з домену + + + Домени, до яких може приєднуватися цей носій. Якщо вказано кілька, технічні спеціалісти вибирають один під час розгортання; домен за замовчуванням попередньо вибрано. + + + Домени + + + Додати вибрані OU + + + Введіть відображуване ім’я та розрізнювальне ім’я OU. OU має належати до вибраного домену. + + + Додати OU вручну + + + Розрізнювальне ім’я + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + Відображуване ім’я + + + Необов’язково. Укажіть OU, у яких можна розміщувати комп’ютери у вибраному домені. Якщо вказано одну OU, вона використовується завжди; якщо кілька, технічні спеціалісти вибирають одну під час розгортання. Наявний обліковий запис комп’ютера переміщується до вибраної OU. + + + Пароль + + + Обліковий запис, який використовують усі домени, що не мають власного облікового запису. Він повинен мати дозвіл приєднувати комп’ютери та переміщувати їх до вибраних OU. + + + Спільний обліковий запис для приєднання + Файл сертифіката довіреного кореневого ЦС @@ -1874,7 +2084,7 @@ Пароль - Зберігайте цей пароль у безпечному місці. Він захищає паролі локальних облікових записів Windows, облікові дані та профілі Autopilot, а також власні файли відповідей. + Зберігайте цей пароль у безпечному місці. Він захищає паролі локальних облікових записів Windows, облікові дані для приєднання до домену, облікові дані та профілі Autopilot, а також власні файли відповідей. Пароль розгортання @@ -1907,7 +2117,7 @@ Для вибраної архітектури не виявлено мовних пакетів WinPE. - Загальний + Загальні Перегляд і початок @@ -1987,6 +2197,12 @@ Стан готовності носія + + Запитувати в технічного спеціаліста облікові дані домену під час розгортання. + + + Інтерактивне приєднання до домену + арабська (Саудівська Аравія) @@ -2191,6 +2407,9 @@ Документація + + Приєднання до домену + Стандартне підключення Ethernet доступне за замовчуванням. Налаштуйте цей розділ, лише якщо ваша дротова мережа потребує корпоративної автентифікації 802.1X. @@ -2201,10 +2420,10 @@ Робочий процес стандартної конфігурації. - Загальний + Загальні - Загальний + Загальні Foundry OSD додому @@ -2212,6 +2431,12 @@ додому + + Запитувати в технічного спеціаліста облікові дані домену під час розгортання. + + + Інтерактивно + Налаштуйте імена розгорнутих пристроїв. @@ -2272,6 +2497,12 @@ Autopilot + + Автоматично приєднувати до домену за допомогою облікових даних, захищених паролем розгортання. + + + Zero-Touch + Активний режим ініціалізації @@ -2974,7 +3205,7 @@ Керуйте запуском, мовою, діагностикою та поведінкою програми. - Загальний + Загальні Створюйте резервні копії налаштувань, переносьте їх на інший комп’ютер або підтримуйте їх синхронізацію. @@ -3917,4 +4148,10 @@ Wi-Fi + + Автоматично приєднувати до домену за допомогою облікових даних, захищених паролем розгортання. + + + Приєднання до домену в режимі Zero-touch + diff --git a/src/Foundry/Strings/zh-CN/Resources.resw b/src/Foundry/Strings/zh-CN/Resources.resw index 057ae90ae..c2d2b344d 100644 --- a/src/Foundry/Strings/zh-CN/Resources.resw +++ b/src/Foundry/Strings/zh-CN/Resources.resw @@ -1834,6 +1834,216 @@ 文档无法打开 + + 域加入账户 + + + 默认 + + + 域 + + + OU + + + 状态 + + + 添加 + + + 清除默认选择 + + + 编辑 + + + 删除 + + + 设为默认 + + + 请先输入有效的账户,然后检查密码长度。 + + + 是 + + + 正在域中搜索 OU... + + + 搜索已取消。 + + + 在此域中未找到任何 OU。 + + + 只能列出部分 OU。您仍可添加已显示的 OU。 + + + 此域中的所有 OU 均已在列表中。 + + + 搜索已超时。您可以手动添加 OU。 + + + 无法搜索域。请检查此计算机是否能访问该域,以及您的 Windows 账户是否能读取该域,或手动添加 OU。 + + + 添加域 + + + 添加域 + + + 编辑域 + + + 将密码留空可保留当前密码。 + + + 保存 + + + 未保存域。请检查域名。 + + + 就绪 + + + 请先删除此域的 OU,再更改其名称。 + + + 尚未添加域。 + + + 尚未添加 OU。 + + + 未添加这些 OU。它们所属的域与所选域不一致。 + + + 请先添加域,再列出其 OU。 + + + 未添加 OU。请检查显示名称和可分辨名称。OU 必须属于所选域。 + + + 组织单位 + + + {0} 的组织单位 + + + 选择一个域以列出其 OU。 + + + 已选择的 OU:{0} / {1} + + + 共享 + + + 请至少添加一个域。 + + + 此域已在列表中。 + + + 两个 OU 的内部 ID 相同。请删除其中一个,然后重新添加。 + + + 请输入有效的域名,例如 corp.contoso.com。 + + + 每个 OU 都需要一个不超过 120 个字符的显示名称。 + + + 请在“常规”页面启用密码保护,并设置部署密码。 + + + 请输入账户密码。 + + + 请以 DOMAIN\user 或 user@domain 格式输入账户。 + + + 请输入共享的域加入账户,或为此域指定自己的账户。 + + + 最多可列出 32 个域。 + + + 最多可列出 1,024 个 OU。 + + + 账户(DOMAIN\user 或 user@domain) + + + 添加 OU + + + 取消搜索 + + + 域名 + + + corp.contoso.com + + + 使用专用账户 + + + 使用共享的域加入账户 + + + 从域导入 + + + 使用您的 Windows 账户列出所选域中的 OU。请选择要添加的 OU。 + + + 从域导入 OU + + + 此介质可加入的域。列出多个域时,技术人员在部署期间选择其中一个;默认域已预选。 + + + 域 + + + 添加所选 OU + + + 请输入显示名称和 OU 的可分辨名称。OU 必须属于所选域。 + + + 手动添加 OU + + + 可分辨名称 + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + 显示名称 + + + 可选。列出所选域中可放置计算机的 OU。仅列出一个 OU 时始终使用该 OU;列出多个时,技术人员在部署期间选择其中一个。现有计算机账户将移至所选 OU。 + + + 密码 + + + 供所有没有自己账户的域使用的账户。该账户需要有权将计算机加入域,并将其移至您选择的 OU。 + + + 共享的域加入账户 + 受信任的根 CA 证书文件 @@ -1874,7 +2084,7 @@ 密码 - 请妥善保管此密码。它用于保护本地 Windows 帐户密码、Autopilot 凭据和配置文件,以及自定义应答文件。 + 请妥善保管此密码。它用于保护本地 Windows 帐户密码、域加入凭据、Autopilot 凭据和配置文件,以及自定义应答文件。 部署密码 @@ -1907,7 +2117,7 @@ 未检测到所选架构的 WinPE 语言包。 - 一般 + 常规 回顾并开始 @@ -1987,6 +2197,12 @@ 媒体准备状态 + + 在部署期间要求技术人员提供域凭据。 + + + 交互式域加入 + 阿拉伯语(沙特阿拉伯) @@ -2191,6 +2407,9 @@ 文档 + + 域加入 + 默认情况下,标准 Ethernet 连接可用。仅当您的有线网络需要 802.1X 企业身份验证时才配置此部分。 @@ -2201,10 +2420,10 @@ 标准配置工作流程。 - 一般 + 常规 - 一般 + 常规 Foundry OSD家 @@ -2212,6 +2431,12 @@ 首页 + + 在部署期间要求技术人员提供域凭据。 + + + 交互式 + 配置已部署设备的名称。 @@ -2272,6 +2497,12 @@ Autopilot + + 使用受部署密码保护的凭据自动加入域。 + + + Zero-Touch + 活动配置模式 @@ -2974,7 +3205,7 @@ 管理启动、语言、诊断和应用程序行为。 - 一般 + 常规 备份设置、将其传输到另一台电脑,或让设置保持同步。 @@ -3735,10 +3966,10 @@ Foundry 会保留原始文件,并将其必需的安装后命令添加到部署副本中。您仍需对自定义设置和命令负责。 - 自定义文件要求在“一般”配置中启用部署介质密码保护。XML 可能包含凭据或脚本。目录仅保存源文件引用;启动映像中包含加密副本。 + 自定义文件要求在“常规”配置中启用部署介质密码保护。XML 可能包含凭据或脚本。目录仅保存源文件引用;启动映像中包含加密副本。 - 请在“一般”配置中启用部署介质密码保护,以使用自定义应答文件。 + 请在“常规”配置中启用部署介质密码保护,以使用自定义应答文件。 此源文件现在与另一个已导入文件重复。请移除此条目并选择已有文件。 @@ -3917,4 +4148,10 @@ Wi-Fi + + 使用受部署密码保护的凭据自动加入域。 + + + Zero-touch 域加入 + diff --git a/src/Foundry/Strings/zh-TW/Resources.resw b/src/Foundry/Strings/zh-TW/Resources.resw index b87b14a38..23b7494de 100644 --- a/src/Foundry/Strings/zh-TW/Resources.resw +++ b/src/Foundry/Strings/zh-TW/Resources.resw @@ -1834,6 +1834,216 @@ 文件無法開啟 + + 網域加入帳戶 + + + 預設 + + + 網域 + + + OU + + + 狀態 + + + 新增 + + + 清除預設選取 + + + 編輯 + + + 移除 + + + 設為預設 + + + 請先輸入有效的帳戶,然後檢查密碼長度。 + + + 是 + + + 正在網域中搜尋 OU... + + + 搜尋已取消。 + + + 在此網域中找不到任何 OU。 + + + 只能列出部分 OU。您仍可新增已顯示的 OU。 + + + 此網域中的所有 OU 都已在清單中。 + + + 搜尋已逾時。您可以手動新增 OU。 + + + 無法搜尋網域。請檢查此電腦是否可連線到該網域,以及您的 Windows 帳戶是否可讀取該網域,或手動新增 OU。 + + + 新增網域 + + + 新增網域 + + + 編輯網域 + + + 將密碼留空可保留目前的密碼。 + + + 儲存 + + + 未儲存網域。請檢查網域名稱。 + + + 就緒 + + + 請先移除此網域的 OU,再變更其名稱。 + + + 尚未新增網域。 + + + 尚未新增 OU。 + + + 未新增這些 OU。它們所屬的網域與選取的網域不一致。 + + + 請先新增網域,再列出其 OU。 + + + 未新增 OU。請檢查顯示名稱和辨別名稱。OU 必須屬於選取的網域。 + + + 組織單位 + + + {0} 的組織單位 + + + 選取一個網域以列出其 OU。 + + + 已選取的 OU:{0} / {1} + + + 共用 + + + 請至少新增一個網域。 + + + 此網域已在清單中。 + + + 兩個 OU 的內部識別碼相同。請移除其中一個,然後重新新增。 + + + 請輸入有效的網域名稱,例如 corp.contoso.com。 + + + 每個 OU 都需要一個不超過 120 個字元的顯示名稱。 + + + 請在「一般」頁面啟用密碼保護,並設定部署密碼。 + + + 請輸入帳戶密碼。 + + + 請以 DOMAIN\user 或 user@domain 格式輸入帳戶。 + + + 請輸入共用的網域加入帳戶,或為此網域指定自己的帳戶。 + + + 最多可列出 32 個網域。 + + + 最多可列出 1,024 個 OU。 + + + 帳戶(DOMAIN\user 或 user@domain) + + + 新增 OU + + + 取消搜尋 + + + 網域名稱 + + + corp.contoso.com + + + 使用專用帳戶 + + + 使用共用的網域加入帳戶 + + + 從網域匯入 + + + 使用您的 Windows 帳戶列出所選網域中的 OU。請選取要新增的 OU。 + + + 從網域匯入 OU + + + 此媒體可加入的網域。列出多個網域時,技術人員會在部署期間選取其中一個;預設網域會預先選取。 + + + 網域 + + + 新增選取的 OU + + + 請輸入顯示名稱和 OU 的辨別名稱。OU 必須屬於選取的網域。 + + + 手動新增 OU + + + 辨別名稱 + + + OU=Workstations,DC=corp,DC=contoso,DC=com + + + 顯示名稱 + + + 選用。列出所選網域中可放置電腦的 OU。僅列出一個 OU 時一律使用該 OU;列出多個時,技術人員會在部署期間選取其中一個。現有電腦帳戶將移至選取的 OU。 + + + 密碼 + + + 供所有沒有自己帳戶的網域使用的帳戶。該帳戶需要有權將電腦加入網域,並將其移至您選取的 OU。 + + + 共用的網域加入帳戶 + 受信任的根 CA 憑證文件 @@ -1874,7 +2084,7 @@ 密碼 - 請妥善保管此密碼。它用於保護本機 Windows 帳戶密碼、Autopilot 認證和設定檔,以及自訂回應檔案。 + 請妥善保管此密碼。它用於保護本機 Windows 帳戶密碼、網域加入認證、Autopilot 認證和設定檔,以及自訂回應檔案。 部署密碼 @@ -1987,6 +2197,12 @@ 媒體準備狀態 + + 在部署期間要求技術人員提供網域認證。 + + + 互動式網域加入 + 阿拉伯語(沙烏地阿拉伯) @@ -2191,6 +2407,9 @@ 文件 + + 網域加入 + 預設情況下,標準 Ethernet 連線可用。僅當您的有線網路需要 802.1X 企業身份驗證時才配置此部分。 @@ -2212,6 +2431,12 @@ 首頁 + + 在部署期間要求技術人員提供網域認證。 + + + 互動式 + 設定已部署裝置的名稱。 @@ -2272,6 +2497,12 @@ Autopilot + + 使用受部署密碼保護的認證自動加入網域。 + + + Zero-Touch + 使用中的配置模式 @@ -3917,4 +4148,10 @@ Wi-Fi + + 使用受部署密碼保護的認證自動加入網域。 + + + Zero-touch 網域加入 + diff --git a/src/Foundry/ViewModels/AutopilotConfigurationViewModel.cs b/src/Foundry/ViewModels/AutopilotConfigurationViewModel.cs index 4e45ce1b6..36f536d0b 100644 --- a/src/Foundry/ViewModels/AutopilotConfigurationViewModel.cs +++ b/src/Foundry/ViewModels/AutopilotConfigurationViewModel.cs @@ -38,6 +38,7 @@ public sealed partial class AutopilotConfigurationViewModel : ObservableObject, private readonly ILogger logger; private bool isApplyingState = true; private bool isSavingState; + private bool isDisposed; private AutopilotProvisioningMode provisioningMode = AutopilotProvisioningMode.JsonProfile; private AutopilotHardwareHashUploadSettings hardwareHashUploadSettings = new(); private AutopilotTenantOnboardingStatus? tenantOnboardingStatus; @@ -447,6 +448,7 @@ private bool HasConnectedTenantInCurrentSession /// public void Dispose() { + isDisposed = true; localizationService.LanguageChanged -= OnLanguageChanged; configurationStateService.StateChanged -= OnConfigurationStateChanged; Profiles.CollectionChanged -= OnProfilesCollectionChanged; @@ -460,17 +462,27 @@ public void Dispose() /// Provisioning mode represented by the current page. public async Task ToggleProvisioningModeAsync(AutopilotProvisioningMode mode) { - AutopilotProvisioningModeToggleResult result = AutopilotProvisioningModeToggleEvaluator.Evaluate( - IsAutopilotEnabled, - provisioningMode, - mode); + FoundryConfigurationDocument baseline = configurationStateService.Current; + ProvisioningSelection requested = mode switch + { + AutopilotProvisioningMode.JsonProfile => ProvisioningSelection.AutopilotJsonProfile, + AutopilotProvisioningMode.HardwareHashUpload => ProvisioningSelection.AutopilotHardwareHashUpload, + AutopilotProvisioningMode.InteractiveHardwareHashUpload => ProvisioningSelection.AutopilotInteractiveHardwareHashUpload, + _ => throw new ArgumentOutOfRangeException(nameof(mode)) + }; + ProvisioningSelectionDecision decision = ProvisioningModeSelectionEvaluator.Evaluate(baseline.Autopilot, baseline.DomainJoin, requested); - if (result.RequiresConfirmation && !await ConfirmProvisioningModeReplacementAsync(mode)) + if (decision.RequiresReplacementConfirmation && + !await ProvisioningModeReplacementConfirmation.ConfirmAsync(dialogService, localizationService, + ProvisioningModeSelectionEvaluator.GetCurrent(baseline.Autopilot, baseline.DomainJoin), requested)) { return; } - ApplyProvisioningModeState(result.Mode, result.IsEnabled); + if (isDisposed || !ReferenceEquals(baseline, configurationStateService.Current)) return; + configurationStateService.UpdateProvisioningSelection( + baseline.Autopilot with { ProvisioningMode = mode, IsEnabled = decision.Next == requested }, + baseline.DomainJoin with { IsEnabled = false }); } [RelayCommand(CanExecute = nameof(CanImportProfile))] @@ -1031,51 +1043,11 @@ private void RefreshProvisioningModeState() RetireActiveCertificateCommand.NotifyCanExecuteChanged(); } - private void ApplyProvisioningModeState(AutopilotProvisioningMode mode, bool isEnabled) - { - isApplyingState = true; - try - { - provisioningMode = mode; - IsAutopilotEnabled = isEnabled; - } - finally - { - isApplyingState = false; - } - - RefreshProvisioningModeState(); - SaveState(); - } - - private async Task ConfirmProvisioningModeReplacementAsync(AutopilotProvisioningMode requestedMode) - { - string currentMode = GetProvisioningModeDisplayName(provisioningMode); - string requestedModeName = GetProvisioningModeDisplayName(requestedMode); - return await dialogService.ConfirmAsync(new ConfirmationDialogRequest( - localizationService.GetString("Autopilot.ModeSwitchConfirmationTitle"), - localizationService.FormatString( - "Autopilot.ModeSwitchConfirmationMessageFormat", - currentMode, - requestedModeName), - localizationService.GetString("Autopilot.ModeSwitchConfirmationPrimaryButton"), - localizationService.GetString("Common.Cancel"), - IsPrimaryButtonAccent: true)); - } - private bool IsModeActive(AutopilotProvisioningMode mode) => IsAutopilotEnabled && provisioningMode == mode; private string GetProvisioningModeActionText(AutopilotProvisioningMode mode) => localizationService.GetString(IsModeActive(mode) ? "Common.Disable" : "Common.Enable"); - private string GetProvisioningModeDisplayName(AutopilotProvisioningMode mode) => mode switch - { - AutopilotProvisioningMode.JsonProfile => JsonProfileHeader, - AutopilotProvisioningMode.HardwareHashUpload => HardwareHashHeader, - AutopilotProvisioningMode.InteractiveHardwareHashUpload => InteractiveHardwareHashHeader, - _ => mode.ToString() - }; - private void NotifyProvisioningPageStateChanged() { OnPropertyChanged(nameof(IsJsonProfileActive)); diff --git a/src/Foundry/ViewModels/DomainJoinConfigurationViewModel.cs b/src/Foundry/ViewModels/DomainJoinConfigurationViewModel.cs new file mode 100644 index 000000000..1dce285d5 --- /dev/null +++ b/src/Foundry/ViewModels/DomainJoinConfigurationViewModel.cs @@ -0,0 +1,686 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Collections.ObjectModel; +using Foundry.Core.Models.Configuration; +using Foundry.Core.Services.Application; +using Foundry.Core.Services.Configuration; +using Foundry.Services.Configuration; +using Foundry.Services.DomainJoin; +using Foundry.Services.Localization; + +namespace Foundry.ViewModels; + +/// +/// Shares the list of joinable domains, their OUs and the volatile join account passwords across both authoring +/// pages. The OU list always shows the domain selected in the domain list. +/// +public sealed partial class DomainJoinConfigurationViewModel : ObservableObject, IDisposable +{ + private readonly IFoundryConfigurationStateService configuration; + private readonly IDomainJoinSecretStateService secrets; + private readonly IDeploymentProtectionSecretStateService protectionSecrets; + private readonly IAuthoringDomainOuDiscoveryService discovery; + private readonly IDomainJoinDialogService domainDialogs; + private readonly IDialogService dialogs; + private readonly IApplicationLocalizationService localization; + private readonly List selectedOuRows = []; + private IReadOnlyList issues = []; + private CancellationTokenSource? discoveryCancellation; + private DomainJoinSettings? appliedSettings; + private DomainJoinMode pageMode; + private bool applying; + private bool disposed; + private bool sharedCredentialInputInvalid; + private long discoveryRevision; + private string? statusKey; + + public DomainJoinConfigurationViewModel(IFoundryConfigurationStateService configuration, + IDomainJoinSecretStateService secrets, IDeploymentProtectionSecretStateService protectionSecrets, IAuthoringDomainOuDiscoveryService discovery, + IDomainJoinDialogService domainDialogs, IDialogService dialogs, IApplicationLocalizationService localization) + { + this.configuration = configuration; + this.secrets = secrets; + this.protectionSecrets = protectionSecrets; + this.discovery = discovery; + this.domainDialogs = domainDialogs; + this.dialogs = dialogs; + this.localization = localization; + configuration.StateChanged += OnStateChanged; + secrets.Changed += OnSecretsChanged; + protectionSecrets.Changed += OnProtectionSecretsChanged; + localization.LanguageChanged += OnLanguageChanged; + ApplyState(); + } + + /// + /// Raised before listed rows are removed. A table must drop its selection first: removing selected rows one + /// by one makes it scroll to a row that no longer exists. + /// + public event EventHandler? DomainRowsRemoving; + + /// + public event EventHandler? OrganizationalUnitRowsRemoving; + + public ObservableCollection Domains { get; } = []; + + /// Gets the OUs of ; a domain search changes them only through the import dialog. + public ObservableCollection OrganizationalUnits { get; } = []; + + public bool IsActive => configuration.Current.DomainJoin.IsEnabled && configuration.Current.DomainJoin.Mode == pageMode; + public bool IsZeroTouch => pageMode == DomainJoinMode.Automatic; + public string ActionText => localization.GetString(IsActive ? "Common.Disable" : "Common.Enable"); + public string DocumentationUrl => pageMode == DomainJoinMode.Interactive + ? FoundryApplicationInfo.InteractiveDomainJoinDocumentationUrl : FoundryApplicationInfo.ZeroTouchDomainJoinDocumentationUrl; + + public string DomainColumnHeader => Text("ColumnDomain"); + public string AccountColumnHeader => Text("ColumnAccount"); + public string OuCountColumnHeader => Text("ColumnOuCount"); + public string DefaultColumnHeader => Text("ColumnDefault"); + public string StatusColumnHeader => Text("ColumnStatus"); + public string LabelColumnHeader => localization.GetString("DomainJoinManualLabel.Header"); + public string DistinguishedNameColumnHeader => localization.GetString("DomainJoinManualDn.Header"); + public string EmptyDomainsText => Text("EmptyDomains"); + public string EmptyOrganizationalUnitsText => Text(HasSelectedDomain ? "EmptyOrganizationalUnits" : Domains.Count == 0 ? "NoDomainYet" : "SelectDomain"); + public string AddLabel => Text("CommandAdd"); + public string EditLabel => Text("CommandEdit"); + public string RemoveLabel => Text("CommandRemove"); + public string SetDefaultLabel => Text("CommandSetDefault"); + public string ClearDefaultLabel => Text("CommandClearDefault"); + + /// Gets the import button label; the same button cancels a running domain search. + public string ImportButtonText => localization.GetString(IsDiscovering ? "DomainJoinCancel.Content" : "DomainJoinDiscover.Content"); + + public bool HasSelectedDomain => SelectedDomain is not null; + public string OrganizationalUnitsHeader => SelectedDomain is { } domain + ? localization.FormatString("DomainJoin.OrganizationalUnitsOfFormat", domain.DomainName) + : Text("OrganizationalUnitsHeader"); + public Visibility DomainListVisibility => ToVisibility(Domains.Count > 0); + public Visibility EmptyDomainsVisibility => ToVisibility(Domains.Count == 0); + public Visibility OrganizationalUnitListVisibility => ToVisibility(OrganizationalUnits.Count > 0); + public Visibility EmptyOrganizationalUnitsVisibility => ToVisibility(OrganizationalUnits.Count == 0); + + public string SharedAccountValidationMessage => sharedCredentialInputInvalid ? Text("CredentialInputInvalid") : GetSharedAccountIssueText(); + public Visibility SharedAccountValidationVisibility => ToVisibility(SharedAccountValidationMessage.Length > 0); + /// + /// Gets what the domain list still needs. Only a missing domain can be reported: every edit goes through the + /// catalogs, so the saved lists themselves are always valid. + /// + public string DomainsValidationMessage => issues.Any(issue => issue.Code == DomainJoinValidationCode.DomainsRequired) + ? Text("Validation." + DomainJoinValidationCode.DomainsRequired) : string.Empty; + public Visibility DomainsValidationVisibility => ToVisibility(DomainsValidationMessage.Length > 0); + + /// Gets the progress or outcome of the last domain search, shown beside the OU commands. + public string StatusText => statusKey is null ? string.Empty : Text(statusKey); + public Visibility StatusVisibility => ToVisibility(statusKey is not null); + public Visibility DiscoveringVisibility => ToVisibility(IsDiscovering); + + private bool CanEditDomain => SelectedDomain is not null; + private bool CanSetDefaultDomain => SelectedDomain is { } domain && + !string.Equals(configuration.Current.DomainJoin.DefaultDomainId, domain.Id, StringComparison.OrdinalIgnoreCase); + private bool CanEditOrganizationalUnit => SelectedDomain is not null && selectedOuRows.Count == 1; + private bool CanRemoveSelectedOrganizationalUnits => selectedOuRows.Count > 0; + // A domain's only OU is always used, so the default only matters, and can only change, among several. + private bool CanSetDefaultOrganizationalUnit => SelectedDomain is { } domain && selectedOuRows.Count == 1 && + !string.Equals(GetDefaultOrganizationalUnitId(domain.Settings), selectedOuRows[0].Settings.Id, StringComparison.OrdinalIgnoreCase); + private bool CanClearDefaultOrganizationalUnit => SelectedDomain?.Settings is { DefaultOuId: not null, OrganizationalUnits.Count: > 1 }; + + [ObservableProperty] + public partial DomainJoinDomainEntryViewModel? SelectedDomain { get; set; } + + /// Gets or sets the account used by every domain that has no account of its own. + [ObservableProperty] + public partial string SharedAccountName { get; set; } = string.Empty; + + [ObservableProperty] + public partial bool IsDiscovering { get; set; } + + /// Invalidates PasswordBox content when volatile secret ownership or profile state changes. + [ObservableProperty] + public partial int SecretStateVersion { get; set; } + + /// Selects the page presentation without changing the configured provisioning mode. + public void SetPageMode(DomainJoinMode mode) + { + pageMode = mode; + OnPropertyChanged(nameof(IsZeroTouch)); + RefreshPresentation(); + } + + [RelayCommand] + private async Task ToggleModeAsync() + { + FoundryConfigurationDocument baseline = configuration.Current; + ProvisioningSelection requested = pageMode switch + { + DomainJoinMode.Interactive => ProvisioningSelection.DomainJoinInteractive, + DomainJoinMode.Automatic => ProvisioningSelection.DomainJoinAutomatic, + _ => throw new ArgumentOutOfRangeException(nameof(pageMode)) + }; + ProvisioningSelectionDecision decision = ProvisioningModeSelectionEvaluator.Evaluate(baseline.Autopilot, baseline.DomainJoin, requested); + if (decision.RequiresReplacementConfirmation && + !await ProvisioningModeReplacementConfirmation.ConfirmAsync(dialogs, localization, + ProvisioningModeSelectionEvaluator.GetCurrent(baseline.Autopilot, baseline.DomainJoin), requested)) + { + return; + } + + if (disposed || !ReferenceEquals(baseline, configuration.Current)) return; + configuration.UpdateProvisioningSelection(baseline.Autopilot with { IsEnabled = false }, + baseline.DomainJoin with { IsEnabled = decision.Next == requested, Mode = pageMode }); + } + + /// Copies the shared account's password for PasswordBox synchronization; the caller clears the buffer. + public char[]? GetSharedPasswordCopy() => IsActive && IsZeroTouch && SharedAccountName.Trim() is { Length: > 0 } account + ? secrets.GetPasswordCopy(account) : null; + + /// Stores the shared account's password; an account that is not qualified cannot own one. + public void SetSharedPassword(ReadOnlySpan password) + { + if (!IsActive || !IsZeroTouch) return; + try + { + secrets.SetPassword(SharedAccountName.Trim(), password); + SetSharedCredentialInputInvalid(false); + } + catch (Exception exception) when (exception is ArgumentException or InvalidDataException) + { + // An empty box on an account that cannot own a password is not an error worth showing. + SetSharedCredentialInputInvalid(!password.IsEmpty); + } + } + + [RelayCommand] + private Task AddDomainAsync() => domainDialogs.ShowDomainAsync( + new(IsNew: true, AsksForAccount: IsZeroTouch, CanRename: true, DomainName: string.Empty, AccountName: null), + input => TrySaveDomain(null, input)); + + [RelayCommand(CanExecute = nameof(CanEditDomain))] + private Task EditDomainAsync() + { + if (SelectedDomain is not { } row) return Task.CompletedTask; + DomainJoinDomainSettings domain = row.Settings; + return domainDialogs.ShowDomainAsync( + new(IsNew: false, AsksForAccount: IsZeroTouch, CanRename: domain.OrganizationalUnits.Count == 0, domain.DomainName, domain.AccountName), + input => TrySaveDomain(domain.Id, input)); + } + + /// Adds or updates a domain; returns the reason shown in the dialog when the entry is refused. + private string? TrySaveDomain(string? domainId, DomainJoinDomainDialogInput input) + { + if (disposed) return null; + DomainJoinSettings current = configuration.Current.DomainJoin; + string? account = IsZeroTouch && !input.UsesSharedAccount ? input.AccountName.Trim() : null; + if (account is not null && !DomainJoinConfigurationValidator.IsQualifiedAccount(account)) + return Text("Validation." + DomainJoinValidationCode.QualifiedAccountRequired); + if (account is not null && !input.Password.IsEmpty) + { + try { DomainJoinCredentialPayloadCodec.ValidatePassword(input.Password.Span); } + catch (InvalidDataException) { return Text("CredentialInputInvalid"); } + } + + try + { + // Interactive hides the account inputs, so an edit there keeps the account Zero-touch stored on the domain. + Save(domainId is null ? DomainJoinDomainCatalog.Add(current, input.DomainName, account) + : IsZeroTouch ? DomainJoinDomainCatalog.Update(current, domainId, input.DomainName, account) + : DomainJoinDomainCatalog.Rename(current, domainId, input.DomainName)); + } + catch (ArgumentException) { return Text(GetDomainFailureKey(current, domainId, input.DomainName)); } + + // An empty password box keeps the password the account already owns. + if (account is not null && !input.Password.IsEmpty) secrets.SetPassword(account, input.Password.Span); + if (domainId is null) SelectedDomain = Domains.LastOrDefault(); + return null; + } + + private static string GetDomainFailureKey(DomainJoinSettings current, string? domainId, string domainName) + { + string name = (domainName ?? string.Empty).Trim(); + if (!DomainJoinCredentialContext.IsValidDomainName(name)) return "Validation." + DomainJoinValidationCode.InvalidDomainName; + string canonical = DomainJoinCredentialContext.CanonicalizeDomainName(name); + if (current.Domains.Any(domain => !string.Equals(domain.Id, domainId, StringComparison.OrdinalIgnoreCase) && + string.Equals(DomainJoinCredentialContext.CanonicalizeDomainName(domain.DomainName), canonical, StringComparison.Ordinal))) + return "Validation." + DomainJoinValidationCode.DuplicateDomainName; + if (domainId is null && current.Domains.Count >= DomainJoinConfigurationValidator.MaximumDomains) + return "Validation." + DomainJoinValidationCode.TooManyDomains; + return domainId is not null && current.FindDomain(domainId) is { OrganizationalUnits.Count: > 0 } ? "DomainRenameBlocked" : "DomainInputInvalid"; + } + + /// Removes the selected domain together with its OUs. + [RelayCommand(CanExecute = nameof(CanEditDomain))] + private void RemoveDomain() + { + if (SelectedDomain is { } row) Save(DomainJoinDomainCatalog.Remove(configuration.Current.DomainJoin, row.Id)); + } + + [RelayCommand(CanExecute = nameof(CanSetDefaultDomain))] + private void SetDefaultDomain() + { + if (SelectedDomain is { } row) Save(DomainJoinDomainCatalog.SetDefault(configuration.Current.DomainJoin, row.Id)); + } + + [RelayCommand(CanExecute = nameof(CanEditDomain))] + private Task AddOrganizationalUnitAsync() => domainDialogs.ShowAddAsync(TryAddOrganizationalUnit); + + /// Adds one typed OU to the selected domain; returns the reason shown in the dialog when it is refused. + private string? TryAddOrganizationalUnit(string displayName, string distinguishedName) + { + if (disposed || SelectedDomain is not { } row) return null; + DomainJoinSettings current = configuration.Current.DomainJoin; + var unit = new DomainJoinOrganizationalUnitSettings + { Id = Guid.NewGuid().ToString("D"), DisplayName = displayName.Trim(), DistinguishedName = distinguishedName.Trim() }; + DomainJoinOrganizationalUnitSettings[] added = [unit]; + try + { + Save(DomainJoinOrganizationalUnitCatalog.Merge(current, row.Id, added)); + return null; + } + catch (ArgumentException) { return Text(GetMergeFailureKey(row.Settings, added, "OrganizationalUnitInputInvalid")); } + } + + [RelayCommand(CanExecute = nameof(CanEditOrganizationalUnit))] + private Task EditOrganizationalUnitAsync() + { + if (SelectedDomain is not { } row || selectedOuRows.Count != 1) return Task.CompletedTask; + DomainJoinOrganizationalUnitSettings unit = selectedOuRows[0].Settings; + return domainDialogs.ShowRenameAsync(unit, displayName => TryRenameOrganizationalUnit(row.Id, unit.Id, displayName)); + } + + /// Renames one listed OU; returns the reason shown in the dialog when the name is refused. + private string? TryRenameOrganizationalUnit(string domainId, string ouId, string displayName) + { + if (disposed) return null; + try + { + Save(DomainJoinOrganizationalUnitCatalog.Rename(configuration.Current.DomainJoin, domainId, ouId, displayName)); + return null; + } + catch (ArgumentException) { return Text("Validation." + DomainJoinValidationCode.InvalidOuDisplayName); } + } + + /// Tracks the OU table selection that the OU commands act on. + public void ReplaceSelectedOrganizationalUnits(IEnumerable rows) + { + selectedOuRows.Clear(); + selectedOuRows.AddRange(rows); + NotifyOrganizationalUnitCommands(); + } + + /// Removes the selected OUs of the selected domain, and its default when it is among them. + [RelayCommand(CanExecute = nameof(CanRemoveSelectedOrganizationalUnits))] + private void RemoveSelectedOrganizationalUnits() + { + if (SelectedDomain is not { } row) return; + DomainJoinSettings settings = configuration.Current.DomainJoin; + foreach (DomainJoinOrganizationalUnitEntryViewModel unit in selectedOuRows) + { + settings = DomainJoinOrganizationalUnitCatalog.Remove(settings, row.Id, unit.Settings.Id); + } + + Save(settings); + } + + [RelayCommand(CanExecute = nameof(CanSetDefaultOrganizationalUnit))] + private void SetDefaultOrganizationalUnit() + { + if (SelectedDomain is { } row && selectedOuRows.Count == 1) + Save(DomainJoinOrganizationalUnitCatalog.SetDefault(configuration.Current.DomainJoin, row.Id, selectedOuRows[0].Settings.Id)); + } + + [RelayCommand(CanExecute = nameof(CanClearDefaultOrganizationalUnit))] + private void ClearDefaultOrganizationalUnit() + { + if (SelectedDomain is { } row) Save(DomainJoinOrganizationalUnitCatalog.SetDefault(configuration.Current.DomainJoin, row.Id, null)); + } + + /// + /// Searches the selected domain, then lets the user pick the OUs to add. Invoked again while the search runs, + /// it cancels that search instead. + /// + [RelayCommand(AllowConcurrentExecutions = true)] + private async Task ImportFromDomainAsync() + { + if (IsDiscovering) + { + StopDiscovery(); + SetStatus("DiscoveryCanceled"); + return; + } + + if (SelectedDomain is not { } row) return; + string domainId = row.Id; + DomainJoinSettings baseline = configuration.Current.DomainJoin; + DomainOuDiscoveryResult? result = await SearchDomainAsync(baseline, row.DomainName); + if (result is null) return; + string? failureKey = result.Status switch + { + DomainOuDiscoveryStatus.Canceled => "DiscoveryCanceled", + DomainOuDiscoveryStatus.Unavailable => result.ErrorCode == "Timeout" ? "DiscoveryTimeout" : "DiscoveryUnavailable", + _ => result.Candidates.Count == 0 ? "DiscoveryEmpty" : null + }; + // OUs already listed are left out of the picker; selecting them again would add nothing. + IReadOnlyList candidates = failureKey is null + ? DomainJoinOrganizationalUnitCatalog.ExcludeListed(row.Settings, result.Candidates) + : []; + if (failureKey is null && candidates.Count == 0) failureKey = "DiscoveryNothingNew"; + SetStatus(failureKey); + if (failureKey is not null) return; + + IReadOnlyList? selected = + await domainDialogs.PickAsync(candidates, result.Status == DomainOuDiscoveryStatus.Incomplete); + // The settings can change while the dialog is open, for example through profile synchronization. + if (selected is null || selected.Count == 0 || disposed || !ReferenceEquals(baseline, configuration.Current.DomainJoin)) return; + try { Save(DomainJoinOrganizationalUnitCatalog.Merge(baseline, domainId, selected)); } + catch (ArgumentException) { SetStatus(GetMergeFailureKey(row.Settings, selected, "ImportDomainMismatch")); } + } + + /// Runs one search; returns null when it was canceled, superseded or made stale by a settings change. + private async Task SearchDomainAsync(DomainJoinSettings baseline, string domainName) + { + long revision = ++discoveryRevision; + var cancellation = new CancellationTokenSource(); + discoveryCancellation = cancellation; + IsDiscovering = true; + SetStatus("Discovering"); + try + { + DomainOuDiscoveryResult result = await discovery.DiscoverAsync(domainName, cancellation.Token); + return !disposed && revision == discoveryRevision && ReferenceEquals(baseline, configuration.Current.DomainJoin) ? result : null; + } + finally + { + if (revision == discoveryRevision) + { + IsDiscovering = false; + discoveryCancellation = null; + } + cancellation.Dispose(); + } + } + + /// Stops a running search and clears its status, because both describe an earlier state. + private void StopDiscovery() + { + discoveryRevision++; + discoveryCancellation?.Cancel(); + discoveryCancellation = null; + IsDiscovering = false; + SetStatus(null); + } + + /// Names the specific reason a merge was refused, falling back to the caller's general message. + private static string GetMergeFailureKey(DomainJoinDomainSettings domain, IReadOnlyList added, string fallbackKey) + { + if (domain.OrganizationalUnits.Count + added.Count > DomainJoinConfigurationValidator.MaximumOrganizationalUnits) + return "Validation." + DomainJoinValidationCode.TooManyOrganizationalUnits; + bool reusesListedId = added.Any(unit => domain.OrganizationalUnits.Any(listed => + string.Equals(listed.Id, unit.Id, StringComparison.OrdinalIgnoreCase) && + !string.Equals(listed.DistinguishedName, unit.DistinguishedName, StringComparison.OrdinalIgnoreCase))); + return reusesListedId ? "Validation." + DomainJoinValidationCode.DuplicateOuId : fallbackKey; + } + + partial void OnIsDiscoveringChanged(bool value) + { + OnPropertyChanged(nameof(ImportButtonText)); + OnPropertyChanged(nameof(DiscoveringVisibility)); + } + + partial void OnSelectedDomainChanged(DomainJoinDomainEntryViewModel? value) + { + if (applying) return; + // A search and its outcome describe the domain that was selected when it started. + StopDiscovery(); + SynchronizeOrganizationalUnits(); + RefreshPresentation(); + } + + partial void OnSharedAccountNameChanged(string value) + { + if (applying) return; + string trimmed = value.Trim(); + Save(configuration.Current.DomainJoin with { SharedAccountName = trimmed.Length == 0 ? null : trimmed }); + } + + private void Save(DomainJoinSettings settings) => configuration.UpdateDomainJoin(settings); + private string Text(string key) => localization.GetString("DomainJoin." + key); + private static Visibility ToVisibility(bool isVisible) => isVisible ? Visibility.Visible : Visibility.Collapsed; + + private string GetIssueText(Func ownsIssue) + { + foreach (DomainJoinValidationIssue issue in issues) + { + if (ownsIssue(issue)) return Text("Validation." + issue.Code); + } + + return string.Empty; + } + + /// + /// Returns what the shared account card must fix: media protection, or an account problem reported by a domain + /// that relies on the shared account. Problems of dedicated accounts stay in that domain's row. + /// + private string GetSharedAccountIssueText() + { + DomainJoinSettings current = configuration.Current.DomainJoin; + foreach (DomainJoinValidationIssue issue in issues) + { + if (issue.DomainId is null && issue.Code is DomainJoinValidationCode.MediaProtectionRequired or DomainJoinValidationCode.InvalidAccountName) + return Text("Validation." + issue.Code); + bool usesSharedAccount = current.FindDomain(issue.DomainId) is { } domain && string.IsNullOrWhiteSpace(domain.AccountName); + if (usesSharedAccount && issue.Code is DomainJoinValidationCode.SharedAccountRequired or DomainJoinValidationCode.QualifiedAccountRequired or + DomainJoinValidationCode.PasswordRequired) + return Text("Validation." + issue.Code); + } + + return string.Empty; + } + + private void SetSharedCredentialInputInvalid(bool value) + { + sharedCredentialInputInvalid = value; + OnPropertyChanged(nameof(SharedAccountValidationMessage)); + OnPropertyChanged(nameof(SharedAccountValidationVisibility)); + } + + private void SetStatus(string? key) + { + statusKey = key; + OnPropertyChanged(nameof(StatusText)); + OnPropertyChanged(nameof(StatusVisibility)); + } + + /// + /// Rebuilds the page only when the Domain Join settings themselves changed. Unrelated configuration changes + /// reuse the same settings instance and must not discard a running search or the table selections. + /// + private void OnStateChanged(object? sender, EventArgs args) + { + if (ReferenceEquals(appliedSettings, configuration.Current.DomainJoin)) + { + RefreshPresentation(); + return; + } + + StopDiscovery(); + ApplyState(); + } + + private void OnSecretsChanged(object? sender, EventArgs args) + { + sharedCredentialInputInvalid = false; + SecretStateVersion++; + RefreshPresentation(); + } + + private void OnProtectionSecretsChanged(object? sender, EventArgs args) => RefreshPresentation(); + + private void OnLanguageChanged(object? sender, ApplicationLanguageChangedEventArgs args) + { + foreach (string property in new[] + { + nameof(DomainColumnHeader), nameof(AccountColumnHeader), nameof(OuCountColumnHeader), nameof(DefaultColumnHeader), nameof(StatusColumnHeader), + nameof(LabelColumnHeader), nameof(DistinguishedNameColumnHeader), nameof(EmptyDomainsText), nameof(ImportButtonText), nameof(StatusText), + nameof(AddLabel), nameof(EditLabel), nameof(RemoveLabel), nameof(SetDefaultLabel), nameof(ClearDefaultLabel) + }) + { + OnPropertyChanged(property); + } + + RefreshPresentation(); + } + + private void ApplyState() + { + applying = true; + try + { + DomainJoinSettings settings = configuration.Current.DomainJoin; + appliedSettings = settings; + // The password box is refilled below, so an earlier rejected entry no longer describes what is shown. + sharedCredentialInputInvalid = false; + SharedAccountName = settings.SharedAccountName ?? string.Empty; + SynchronizeDomains(settings); + if (SelectedDomain is null || !Domains.Contains(SelectedDomain)) + SelectedDomain = Domains.FirstOrDefault(row => string.Equals(row.Id, settings.DefaultDomainId, StringComparison.OrdinalIgnoreCase)) ?? Domains.FirstOrDefault(); + SynchronizeOrganizationalUnits(); + } + finally { applying = false; } + SecretStateVersion++; + RefreshPresentation(); + } + + /// + /// Aligns the domain rows with the saved domains while keeping the row of every domain that still exists, so + /// the selection survives a save. + /// + private void SynchronizeDomains(DomainJoinSettings settings) + { + IReadOnlyList domains = settings.Domains; + if (Domains.Any(row => settings.FindDomain(row.Id) is null)) DomainRowsRemoving?.Invoke(this, EventArgs.Empty); + for (int index = Domains.Count - 1; index >= 0; index--) + { + if (settings.FindDomain(Domains[index].Id) is null) Domains.RemoveAt(index); + } + + for (int index = 0; index < domains.Count; index++) + { + if (index < Domains.Count && SameId(Domains[index], domains[index])) continue; + int existing = -1; + for (int candidate = index + 1; candidate < Domains.Count && existing < 0; candidate++) + { + if (SameId(Domains[candidate], domains[index])) existing = candidate; + } + + if (existing >= 0) Domains.Move(existing, index); + else Domains.Insert(index, new(domains[index])); + } + + for (int index = 0; index < domains.Count; index++) Domains[index].Settings = domains[index]; + + static bool SameId(DomainJoinDomainEntryViewModel row, DomainJoinDomainSettings domain) => + string.Equals(row.Id, domain.Id, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Aligns the OU rows with the selected domain's OUs while keeping the row of every unchanged OU, so their + /// selection survives a save. A renamed OU gets a new row, which the sorted table then places by its new name. + /// + private void SynchronizeOrganizationalUnits() + { + IReadOnlyList units = SelectedDomain?.Settings.OrganizationalUnits ?? []; + if (OrganizationalUnits.Any(row => !units.Contains(row.Settings))) OrganizationalUnitRowsRemoving?.Invoke(this, EventArgs.Empty); + for (int index = OrganizationalUnits.Count - 1; index >= 0; index--) + { + if (!units.Contains(OrganizationalUnits[index].Settings)) OrganizationalUnits.RemoveAt(index); + } + + for (int index = 0; index < units.Count; index++) + { + if (index < OrganizationalUnits.Count && Equals(OrganizationalUnits[index].Settings, units[index])) continue; + int existing = -1; + for (int candidate = index + 1; candidate < OrganizationalUnits.Count && existing < 0; candidate++) + { + if (Equals(OrganizationalUnits[candidate].Settings, units[index])) existing = candidate; + } + + if (existing >= 0) OrganizationalUnits.Move(existing, index); + else OrganizationalUnits.Insert(index, new(units[index])); + } + + selectedOuRows.RemoveAll(row => !OrganizationalUnits.Contains(row)); + } + + /// Recomputes readiness issues; an inactive mode reports none because its inputs are disabled. + private void RefreshValidation() + { + DomainJoinSettings current = configuration.Current.DomainJoin; + issues = IsActive + ? DomainJoinConfigurationValidator.EvaluateReadiness(current, secrets.HasPassword, + configuration.Current.General.DeploymentProtection.IsEnabled && protectionSecrets.IsValid).Issues + : []; + string sharedText = Text("SharedAccount"); + string defaultText = Text("DefaultMarker"); + string readyText = Text("DomainReady"); + foreach (DomainJoinDomainEntryViewModel row in Domains) + { + DomainJoinValidationIssue? issue = issues.FirstOrDefault(candidate => string.Equals(candidate.DomainId, row.Id, StringComparison.OrdinalIgnoreCase)); + row.AccountText = string.IsNullOrWhiteSpace(row.Settings.AccountName) ? sharedText : row.Settings.AccountName; + row.DefaultText = string.Equals(current.DefaultDomainId, row.Id, StringComparison.OrdinalIgnoreCase) ? defaultText : string.Empty; + row.IsReady = issue is null; + row.StatusText = issue is null ? readyText : Text("Validation." + issue.Code); + } + + string? defaultOuId = SelectedDomain is { } selected ? GetDefaultOrganizationalUnitId(selected.Settings) : null; + foreach (DomainJoinOrganizationalUnitEntryViewModel row in OrganizationalUnits) + { + row.DefaultText = string.Equals(defaultOuId, row.Settings.Id, StringComparison.OrdinalIgnoreCase) ? defaultText : string.Empty; + } + } + + private void RefreshPresentation() + { + // Disposal can follow application shutdown, when the shared secret services are already disposed. + if (disposed) return; + RefreshValidation(); + foreach (string property in new[] + { + nameof(IsActive), nameof(ActionText), nameof(DocumentationUrl), nameof(HasSelectedDomain), nameof(OrganizationalUnitsHeader), + nameof(EmptyOrganizationalUnitsText), nameof(DomainListVisibility), nameof(EmptyDomainsVisibility), nameof(OrganizationalUnitListVisibility), + nameof(EmptyOrganizationalUnitsVisibility), nameof(SharedAccountValidationMessage), nameof(SharedAccountValidationVisibility), + nameof(DomainsValidationMessage), nameof(DomainsValidationVisibility) + }) + { + OnPropertyChanged(property); + } + + EditDomainCommand.NotifyCanExecuteChanged(); + RemoveDomainCommand.NotifyCanExecuteChanged(); + SetDefaultDomainCommand.NotifyCanExecuteChanged(); + AddOrganizationalUnitCommand.NotifyCanExecuteChanged(); + NotifyOrganizationalUnitCommands(); + } + + private static string? GetDefaultOrganizationalUnitId(DomainJoinDomainSettings domain) => + DomainJoinOrganizationalUnitCatalog.ResolveDefault(domain.OrganizationalUnits, domain.DefaultOuId)?.Id; + + private void NotifyOrganizationalUnitCommands() + { + EditOrganizationalUnitCommand.NotifyCanExecuteChanged(); + RemoveSelectedOrganizationalUnitsCommand.NotifyCanExecuteChanged(); + SetDefaultOrganizationalUnitCommand.NotifyCanExecuteChanged(); + ClearDefaultOrganizationalUnitCommand.NotifyCanExecuteChanged(); + } + + /// Discards search work and subscriptions without clearing credentials owned by the active profile session. + public void Dispose() + { + disposed = true; + StopDiscovery(); + configuration.StateChanged -= OnStateChanged; + secrets.Changed -= OnSecretsChanged; + protectionSecrets.Changed -= OnProtectionSecretsChanged; + localization.LanguageChanged -= OnLanguageChanged; + } +} diff --git a/src/Foundry/ViewModels/DomainJoinDomainEntryViewModel.cs b/src/Foundry/ViewModels/DomainJoinDomainEntryViewModel.cs new file mode 100644 index 000000000..70b504f34 --- /dev/null +++ b/src/Foundry/ViewModels/DomainJoinDomainEntryViewModel.cs @@ -0,0 +1,44 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Models.Configuration; + +namespace Foundry.ViewModels; + +/// +/// Displays one joinable domain in the domain table. The row outlives saves of its domain so the table keeps its +/// selection; the page view model refreshes the texts that depend on the whole configuration. +/// +public sealed partial class DomainJoinDomainEntryViewModel(DomainJoinDomainSettings settings) : ObservableObject +{ + public string Id => Settings.Id; + public string DomainName => Settings.DomainName; + public int OrganizationalUnitCount => Settings.OrganizationalUnits.Count; + public Style StatusStyle => (Style)Application.Current.Resources[IsReady ? "FoundrySuccessTextBlockStyle" : "FoundryCriticalTextBlockStyle"]; + + [ObservableProperty] + public partial DomainJoinDomainSettings Settings { get; set; } = settings; + + /// Gets or sets the dedicated account, or the word that stands for the shared account. + [ObservableProperty] + public partial string AccountText { get; set; } = string.Empty; + + [ObservableProperty] + public partial string DefaultText { get; set; } = string.Empty; + + /// Gets or sets what this domain still needs before media can be created, or that it is ready. + [ObservableProperty] + public partial string StatusText { get; set; } = string.Empty; + + [ObservableProperty] + public partial bool IsReady { get; set; } + + partial void OnSettingsChanged(DomainJoinDomainSettings value) + { + OnPropertyChanged(nameof(DomainName)); + OnPropertyChanged(nameof(OrganizationalUnitCount)); + } + + partial void OnIsReadyChanged(bool value) => OnPropertyChanged(nameof(StatusStyle)); +} diff --git a/src/Foundry/ViewModels/DomainJoinOrganizationalUnitEntryViewModel.cs b/src/Foundry/ViewModels/DomainJoinOrganizationalUnitEntryViewModel.cs new file mode 100644 index 000000000..d8f52e9f0 --- /dev/null +++ b/src/Foundry/ViewModels/DomainJoinOrganizationalUnitEntryViewModel.cs @@ -0,0 +1,19 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Models.Configuration; + +namespace Foundry.ViewModels; + +/// Displays a saved or discovered OU in a table row. +public sealed partial class DomainJoinOrganizationalUnitEntryViewModel(DomainJoinOrganizationalUnitSettings settings) : ObservableObject +{ + public DomainJoinOrganizationalUnitSettings Settings { get; } = settings; + public string DisplayName => Settings.DisplayName; + public string DistinguishedName => Settings.DistinguishedName; + + /// Gets or sets the marker shown when this OU is its domain's default. + [ObservableProperty] + public partial string DefaultText { get; set; } = string.Empty; +} diff --git a/src/Foundry/ViewModels/DomainJoinOuSelectionDialogViewModel.cs b/src/Foundry/ViewModels/DomainJoinOuSelectionDialogViewModel.cs new file mode 100644 index 000000000..8af331178 --- /dev/null +++ b/src/Foundry/ViewModels/DomainJoinOuSelectionDialogViewModel.cs @@ -0,0 +1,72 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Models.Configuration; +using Foundry.Services.Localization; + +namespace Foundry.ViewModels; + +/// +/// Backs the picker shown after searching the selected domain for organizational units. Nothing is +/// selected initially because a domain lists OUs, such as Domain Controllers, that must not receive deployed +/// computers by default. +/// +public sealed partial class DomainJoinOuSelectionDialogViewModel : ObservableObject +{ + private readonly IApplicationLocalizationService localization; + private IReadOnlyList selected = []; + + public DomainJoinOuSelectionDialogViewModel(IApplicationLocalizationService localization, + IReadOnlyList candidates, bool isIncomplete) + { + this.localization = localization; + ArgumentNullException.ThrowIfNull(candidates); + + Title = localization.GetString("DomainJoinDiscovery.Header"); + Description = localization.GetString("DomainJoinDiscovery.Description"); + IncompleteText = isIncomplete ? localization.GetString("DomainJoin.DiscoveryIncomplete") : string.Empty; + SelectAllText = localization.GetString("Autopilot.TenantPickerSelectAll"); + ClearText = localization.GetString("Autopilot.TenantPickerClear"); + AddText = localization.GetString("DomainJoinImportSelected.Content"); + CancelText = localization.GetString("Common.Cancel"); + DisplayNameColumnHeader = localization.GetString("DomainJoinManualLabel.Header"); + DistinguishedNameColumnHeader = localization.GetString("DomainJoinManualDn.Header"); + OrganizationalUnits = candidates + .OrderBy(unit => unit.DistinguishedName, StringComparer.OrdinalIgnoreCase) + .Select(unit => new DomainJoinOrganizationalUnitEntryViewModel(unit)) + .ToArray(); + ReplaceSelection([]); + } + + /// Gets the discovered OUs in distinguished-name order. + public IReadOnlyList OrganizationalUnits { get; } + public string Title { get; } + public string Description { get; } + /// Gets the notice shown when the directory returned only part of its OUs; empty otherwise. + public string IncompleteText { get; } + public Visibility IncompleteVisibility => IncompleteText.Length > 0 ? Visibility.Visible : Visibility.Collapsed; + public string SelectAllText { get; } + public string ClearText { get; } + public string AddText { get; } + public string CancelText { get; } + public string DisplayNameColumnHeader { get; } + public string DistinguishedNameColumnHeader { get; } + + [ObservableProperty] + public partial string SelectedCountDisplay { get; set; } = string.Empty; + + [ObservableProperty] + public partial bool HasSelection { get; set; } + + /// Tracks the rows selected in the table. + public void ReplaceSelection(IEnumerable rows) + { + selected = rows.Select(row => row.Settings).ToArray(); + HasSelection = selected.Count > 0; + SelectedCountDisplay = localization.FormatString("DomainJoin.SelectedCountFormat", selected.Count, OrganizationalUnits.Count); + } + + /// Gets the OUs selected by the user. + public IReadOnlyList GetSelected() => selected; +} diff --git a/src/Foundry/ViewModels/StartMediaViewModel.cs b/src/Foundry/ViewModels/StartMediaViewModel.cs index 41d384769..085b813d5 100644 --- a/src/Foundry/ViewModels/StartMediaViewModel.cs +++ b/src/Foundry/ViewModels/StartMediaViewModel.cs @@ -194,6 +194,9 @@ public StartMediaViewModel( /// public ObservableCollection AutopilotOverviewItems { get; } = []; + /// Gets both exclusive domain joining mode overview rows. + public ObservableCollection DomainJoinOverviewItems { get; } = []; + /// /// Gets the customization overview rows. /// @@ -283,6 +286,9 @@ public StartMediaViewModel( [ObservableProperty] public partial bool IsAutopilotOverviewExpanded { get; set; } + [ObservableProperty] + public partial bool IsDomainJoinOverviewExpanded { get; set; } + [ObservableProperty] public partial bool IsCustomizationOverviewExpanded { get; set; } @@ -295,6 +301,9 @@ public StartMediaViewModel( [ObservableProperty] public partial string AutopilotOverviewSummary { get; set; } = string.Empty; + [ObservableProperty] + public partial string DomainJoinOverviewSummary { get; set; } = string.Empty; + [ObservableProperty] public partial string CustomizationOverviewSummary { get; set; } = string.Empty; @@ -1159,6 +1168,7 @@ private async Task PrepareMediaWorkspaceAsync( Constants.WorkspacesDirectoryPath, Constants.WorkspacesDirectoryPath); runtimePayloadProvisioning = AddReleaseRuntimeProvisioning(runtimePayloadProvisioning); + runtimePayloadProvisioning = runtimePayloadProvisioning.WithDomainJoinRuntime(snapshot.Configuration.DomainJoin.IsEnabled); TelemetrySettings connectTelemetrySettings = snapshot.Configuration.Telemetry with { RuntimePayloadSource = ResolveRuntimePayloadSource(runtimePayloadProvisioning.Connect) }; TelemetrySettings deployTelemetrySettings = snapshot.Configuration.Telemetry with { RuntimePayloadSource = ResolveRuntimePayloadSource(runtimePayloadProvisioning.Deploy) }; @@ -2035,6 +2045,13 @@ private void RebuildConfigurationOverview( IsAutopilotOverviewExpanded = ReplaceOverviewItems( AutopilotOverviewItems, BuildAutopilotOverview(options, overview)); + IsDomainJoinOverviewExpanded = ReplaceOverviewItems(DomainJoinOverviewItems, + (StartConfigurationOverviewItemViewModel[])[ + CreateOverviewItem(ConfigurationOverviewItem.DomainJoinAutomatic, overview, "Nav_ZeroTouchDomainJoinKey.Title", + localizationService.GetString("Nav_ZeroTouchDomainJoinKey.Description"), ConfigurationNavigationTarget.DomainJoinAutomatic), + CreateOverviewItem(ConfigurationOverviewItem.DomainJoinInteractive, overview, "Nav_InteractiveDomainJoinKey.Title", + localizationService.GetString("Nav_InteractiveDomainJoinKey.Description"), ConfigurationNavigationTarget.DomainJoinInteractive) + ]); IsCustomizationOverviewExpanded = ReplaceOverviewItems( CustomizationOverviewItems, BuildCustomizationOverview(configuration, overview)); @@ -2042,6 +2059,7 @@ private void RebuildConfigurationOverview( GeneralConfigurationOverviewSummary = GetOverviewSummary(GeneralConfigurationOverviewItems); NetworkOverviewSummary = GetOverviewSummary(NetworkOverviewItems); AutopilotOverviewSummary = GetOverviewSummary(AutopilotOverviewItems); + DomainJoinOverviewSummary = GetOverviewSummary(DomainJoinOverviewItems); CustomizationOverviewSummary = GetOverviewSummary(CustomizationOverviewItems); } diff --git a/src/Foundry/Views/AutopilotJsonProfilePage.xaml b/src/Foundry/Views/AutopilotJsonProfilePage.xaml index cbdfb319e..1995dfae0 100644 --- a/src/Foundry/Views/AutopilotJsonProfilePage.xaml +++ b/src/Foundry/Views/AutopilotJsonProfilePage.xaml @@ -87,6 +87,7 @@ @@ -100,7 +101,7 @@ CanFilterColumns="False" CanReorderColumns="False" CanResizeColumns="False" - CanSortColumns="False" + CanSortColumns="True" CornerButtonMode="None" HeadersVisibility="Columns" IsReadOnly="True" @@ -112,15 +113,20 @@ Visibility="{x:Bind ViewModel.ProfilesVisibility, Mode=OneWay}"> + Header="{x:Bind ViewModel.ProfileImportedColumnHeader, Mode=OneWay}" + SortMemberPath="ImportedAtUtc" /> diff --git a/src/Foundry/Views/AutopilotJsonProfilePage.xaml.cs b/src/Foundry/Views/AutopilotJsonProfilePage.xaml.cs index 94ac6c409..648da6fd8 100644 --- a/src/Foundry/Views/AutopilotJsonProfilePage.xaml.cs +++ b/src/Foundry/Views/AutopilotJsonProfilePage.xaml.cs @@ -14,6 +14,7 @@ public AutopilotJsonProfilePage() { ViewModel = App.GetService(); InitializeComponent(); + TableViewDefaultSort.Attach(ProfilesTable, nameof(AutopilotProfileEntryViewModel.DisplayName)); Unloaded += OnUnloaded; } diff --git a/src/Foundry/Views/AutopilotZeroTouchPage.xaml b/src/Foundry/Views/AutopilotZeroTouchPage.xaml index 1ca204d8c..bdc93a17f 100644 --- a/src/Foundry/Views/AutopilotZeroTouchPage.xaml +++ b/src/Foundry/Views/AutopilotZeroTouchPage.xaml @@ -52,18 +52,17 @@ - - + - + @@ -136,7 +137,7 @@ CanFilterColumns="False" CanReorderColumns="False" CanResizeColumns="False" - CanSortColumns="False" + CanSortColumns="True" CornerButtonMode="None" HeadersVisibility="Columns" IsReadOnly="True" @@ -148,12 +149,18 @@ Visibility="{x:Bind ViewModel.CertificatesVisibility, Mode=OneWay}"> - + Header="{x:Bind ViewModel.CertificateCreatedColumnHeader, Mode=OneWay}" + SortMemberPath="StartsOnUtc" /> + diff --git a/src/Foundry/Views/AutopilotZeroTouchPage.xaml.cs b/src/Foundry/Views/AutopilotZeroTouchPage.xaml.cs index 23813fa0c..e943aa875 100644 --- a/src/Foundry/Views/AutopilotZeroTouchPage.xaml.cs +++ b/src/Foundry/Views/AutopilotZeroTouchPage.xaml.cs @@ -14,6 +14,9 @@ public AutopilotZeroTouchPage() { ViewModel = App.GetService(); InitializeComponent(); + // Oldest certificate first; the creation date is the second column. + TableViewDefaultSort.Attach(CertificatesTable, nameof(AutopilotCertificateEntryViewModel.StartsOnUtc), columnIndex: 1); + TableViewFullWidth.Attach(TenantReadinessTable); ViewModel.PropertyChanged += OnViewModelPropertyChanged; Unloaded += OnUnloaded; } diff --git a/src/Foundry/Views/CustomImagesPage.xaml b/src/Foundry/Views/CustomImagesPage.xaml index 5d7692098..424380389 100644 --- a/src/Foundry/Views/CustomImagesPage.xaml +++ b/src/Foundry/Views/CustomImagesPage.xaml @@ -118,7 +118,7 @@ CanFilterColumns="False" CanReorderColumns="False" CanResizeColumns="False" - CanSortColumns="False" + CanSortColumns="True" CornerButtonMode="None" HeadersVisibility="Columns" IsEnabled="{x:Bind ViewModel.CanAct, Mode=OneWay}" @@ -131,18 +131,26 @@ Visibility="{x:Bind ViewModel.HasImages, Mode=OneWay}"> - + Header="{x:Bind ViewModel.SizeLabel, Mode=OneWay}" + SortMemberPath="Reference.Length" /> + - + - + - - + (); InitializeComponent(); + TableViewDefaultSort.Attach(ImagesTable, nameof(CustomImageRow.Name)); + TableViewDefaultSort.Attach(IndexesTable, "Index.Index"); + TableViewFullWidth.Attach(ImagesTable); + TableViewFullWidth.Attach(IndexesTable); Loaded += OnLoaded; Unloaded += OnUnloaded; } diff --git a/src/Foundry/Views/DomainJoinDomainDialog.xaml b/src/Foundry/Views/DomainJoinDomainDialog.xaml new file mode 100644 index 000000000..0352d4bbd --- /dev/null +++ b/src/Foundry/Views/DomainJoinDomainDialog.xaml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/Foundry/Views/DomainJoinDomainDialog.xaml.cs b/src/Foundry/Views/DomainJoinDomainDialog.xaml.cs new file mode 100644 index 000000000..a3f916802 --- /dev/null +++ b/src/Foundry/Views/DomainJoinDomainDialog.xaml.cs @@ -0,0 +1,60 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Services.DomainJoin; +using Foundry.Services.Localization; + +namespace Foundry.Views; + +/// Collects a domain and, in Zero-touch, its join account, and stays open until the owning page accepts it. +public sealed partial class DomainJoinDomainDialog : ContentDialog +{ + private const int SharedAccountIndex = 0; + private const int DedicatedAccountIndex = 1; + private readonly DomainJoinDomainDialogRequest request; + private readonly Func trySave; + + public DomainJoinDomainDialog(IApplicationLocalizationService localization, DomainJoinDomainDialogRequest request, + Func trySave) + { + ArgumentNullException.ThrowIfNull(localization); + this.request = request ?? throw new ArgumentNullException(nameof(request)); + this.trySave = trySave ?? throw new ArgumentNullException(nameof(trySave)); + InitializeComponent(); + Title = localization.GetString(request.IsNew ? "DomainJoin.DomainDialogAddTitle" : "DomainJoin.DomainDialogEditTitle"); + PrimaryButtonText = localization.GetString(request.IsNew ? "DomainJoin.DomainDialogAdd" : "DomainJoin.DomainDialogSave"); + CloseButtonText = localization.GetString("Common.Cancel"); + DomainNameBox.Text = request.DomainName; + DomainNameBox.IsEnabled = request.CanRename; + RenameHintTextBlock.Text = localization.GetString("DomainJoin.DomainRenameBlocked"); + RenameHintTextBlock.Visibility = request.CanRename ? Visibility.Collapsed : Visibility.Visible; + AccountPanel.Visibility = request.AsksForAccount ? Visibility.Visible : Visibility.Collapsed; + AccountBox.Text = request.AccountName ?? string.Empty; + // Editing keeps the stored password unless a new one is typed; a new dedicated account needs one. + PasswordHintTextBlock.Text = localization.GetString("DomainJoin.DomainDialogPasswordHint"); + PasswordHintTextBlock.Visibility = request.IsNew || request.AccountName is null ? Visibility.Collapsed : Visibility.Visible; + AccountChoice.SelectedIndex = request.AccountName is null ? SharedAccountIndex : DedicatedAccountIndex; + DedicatedPanel.Visibility = request.AccountName is null ? Visibility.Collapsed : Visibility.Visible; + Closed += OnClosed; + } + + private void OnAccountChoiceChanged(object sender, SelectionChangedEventArgs e) => + DedicatedPanel.Visibility = AccountChoice.SelectedIndex == DedicatedAccountIndex ? Visibility.Visible : Visibility.Collapsed; + + private void OnPrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) + { + bool usesSharedAccount = !request.AsksForAccount || AccountChoice.SelectedIndex != DedicatedAccountIndex; + string? refusal = trySave(new(DomainNameBox.Text, usesSharedAccount, AccountBox.Text, + usesSharedAccount ? ReadOnlyMemory.Empty : PasswordBox.Password.AsMemory())); + ValidationTextBlock.Text = refusal ?? string.Empty; + ValidationTextBlock.Visibility = refusal is null ? Visibility.Collapsed : Visibility.Visible; + args.Cancel = refusal is not null; + } + + private void OnClosed(ContentDialog sender, ContentDialogClosedEventArgs args) + { + Closed -= OnClosed; + PasswordBox.Password = string.Empty; + } +} diff --git a/src/Foundry/Views/DomainJoinOuAddDialog.xaml b/src/Foundry/Views/DomainJoinOuAddDialog.xaml new file mode 100644 index 000000000..99f7a4587 --- /dev/null +++ b/src/Foundry/Views/DomainJoinOuAddDialog.xaml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/src/Foundry/Views/DomainJoinOuAddDialog.xaml.cs b/src/Foundry/Views/DomainJoinOuAddDialog.xaml.cs new file mode 100644 index 000000000..9940cecf2 --- /dev/null +++ b/src/Foundry/Views/DomainJoinOuAddDialog.xaml.cs @@ -0,0 +1,53 @@ +// Copyright (c) Foundry Project contributors. +// Licensed under the MIT License. +// See the LICENSE file in the project root for more information. + +using Foundry.Core.Models.Configuration; +using Foundry.Services.Localization; + +namespace Foundry.Views; + +/// +/// Collects one organizational unit, or a new display name for a listed one, and stays open until the owning page +/// accepts it. +/// +public sealed partial class DomainJoinOuAddDialog : ContentDialog +{ + private readonly Func tryAdd; + + /// Supplies the dialog texts. + /// Saves the display name and distinguished name; returns the refusal reason, or null. + /// The listed OU being renamed; adds a new OU. + public DomainJoinOuAddDialog(IApplicationLocalizationService localization, Func tryAdd, + DomainJoinOrganizationalUnitSettings? existing = null) + { + ArgumentNullException.ThrowIfNull(localization); + this.tryAdd = tryAdd ?? throw new ArgumentNullException(nameof(tryAdd)); + InitializeComponent(); + CloseButtonText = localization.GetString("Common.Cancel"); + if (existing is null) + { + Title = localization.GetString("DomainJoinManualDestination.Header"); + DescriptionTextBlock.Text = localization.GetString("DomainJoinManualDestination.Description"); + PrimaryButtonText = localization.GetString("DomainJoinAdd.Content"); + return; + } + + // Only the display name changes; the distinguished name identifies the OU and is shown for reference. + Title = localization.GetString("DomainJoin.CommandEdit"); + DescriptionTextBlock.Visibility = Visibility.Collapsed; + PrimaryButtonText = localization.GetString("DomainJoin.DomainDialogSave"); + DisplayNameBox.Text = existing.DisplayName; + DisplayNameBox.SelectAll(); + DistinguishedNameBox.Text = existing.DistinguishedName; + DistinguishedNameBox.IsReadOnly = true; + } + + private void OnPrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) + { + string? refusal = tryAdd(DisplayNameBox.Text, DistinguishedNameBox.Text); + ValidationTextBlock.Text = refusal ?? string.Empty; + ValidationTextBlock.Visibility = refusal is null ? Visibility.Collapsed : Visibility.Visible; + args.Cancel = refusal is not null; + } +} diff --git a/src/Foundry/Views/DomainJoinOuSelectionDialog.xaml b/src/Foundry/Views/DomainJoinOuSelectionDialog.xaml new file mode 100644 index 000000000..fbb7d1409 --- /dev/null +++ b/src/Foundry/Views/DomainJoinOuSelectionDialog.xaml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + +