diff --git a/CHECKSUMS-SHA256.txt b/CHECKSUMS-SHA256.txt index ede9433..20f08b4 100644 --- a/CHECKSUMS-SHA256.txt +++ b/CHECKSUMS-SHA256.txt @@ -1,4 +1,4 @@ -779282028f0a75f3906c2c7ac0cb4d914141fc00434abda1f8860db9a1544b10 gpo-agent-windows-amd64.exe -4be4abc096141fa575fab85ec2b5e84b0b5ecf0209bfeaa816bbc943a078a184 gpo-server -614af8ded1c092425fe39d6310518c11e6324d4eaba227ad923cbbc3df8421e0 gpoctl -0865f3bc109bffc535378abdb899dc006ff4b8585538653d6ae9c6e06af413bc gpoctl-windows-amd64.exe +71175431664b4dd8887cb88c51f348994f7ddd7628009a254fe6b47ec6c98fca bin/gpo-server +52ab7f3dfb3bbe6700f7e728662154d58f5a7e2a3059e86c8b2d75018fe4b28a bin/gpoctl +f197120c5fb41dc67501024ead1237b0066f9d3513bfb138270e8258d3450cac bin/gpo-agent-windows-amd64.exe +7994c7baffb5a63b90b5d3816035cfe4d0e35f5795d4fc673dac9837fc65d5dd bin/gpoctl-windows-amd64.exe diff --git a/Dockerfile b/Dockerfile index 72d5038..86e3a99 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 -ARG GO_VERSION=1.26 -ARG ALPINE_VERSION=3.24 +ARG GO_VERSION=1.23 +ARG ALPINE_VERSION=3.21 FROM golang:${GO_VERSION}-alpine AS build WORKDIR /src diff --git a/README.md b/README.md index 4705e81..79f90f6 100644 --- a/README.md +++ b/README.md @@ -103,13 +103,13 @@ Linux/macOS: ```bash make test -make build VERSION=0.2.1 +make build VERSION=0.2.2 ``` Windows PowerShell: ```powershell -.\scripts\Build.ps1 -Version 0.2.1 +.\scripts\Build.ps1 -Version 0.2.2 ``` Erzeugte Dateien: @@ -280,6 +280,19 @@ Optionaler Sonderfall: trotz identischem semantischem Hash eine Version erzwinge Das Skript verwendet `Backup-GPO`, erstellt ein ZIP mit korrektem Sicherungswurzelverzeichnis und lädt es hoch. Bei unverändertem Richtlinieninhalt wird keine neue Version erzeugt. +`GpoName` ist der exakte Anzeigename im Active Directory. `PolicyName` ist dagegen der technische Name im Repository und muss `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` entsprechen. Seit Version 0.2.2 ist `PolicyName` optional: Fehlt er oder enthält er Leerzeichen, Umlaute oder andere unzulässige Zeichen, erzeugt das Skript automatisch einen sicheren Namen und zeigt ihn vor dem Upload an. Mit `-StrictPolicyName` kann stattdessen das frühere strikte Verhalten erzwungen werden. + +Beispiel: + +```powershell +# Wird automatisch als "Server-Windows-Firewall" veröffentlicht. +.\scripts\Export-And-Publish.ps1 ` + -GpoName 'Server - Windows Firewall' ` + -ServerUrl 'https://gpo.example.org:8443' ` + -AdminToken $env:GPO_ADMIN_TOKEN ` + -GpoCtl '.\bin\gpoctl-windows-amd64.exe' +``` + ## Profil erstellen Das folgende Profil verwendet jeweils die aktuelle Version. Die Reihenfolge ist relevant: diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index bdcb4ba..2377eff 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,15 @@ +# Release 0.2.2 + +Export and naming robustness update. + +Included: + +- `Export-And-Publish.ps1` now treats `GpoName` and repository `PolicyName` as separate concepts. +- `PolicyName` is optional and is automatically normalized when it contains spaces, umlauts, path separators or other unsupported characters. +- The resolved repository name is printed before the AD backup and upload start. +- `-StrictPolicyName` retains fail-fast validation when automatic normalization is not desired. +- `gpoctl` validates policy and profile names locally and reports the offending value before sending an HTTP request. + # Release 0.2.1 Container deployment update. diff --git a/bin/gpo-agent-windows-amd64.exe b/bin/gpo-agent-windows-amd64.exe index c2edf74..d6eaec6 100755 Binary files a/bin/gpo-agent-windows-amd64.exe and b/bin/gpo-agent-windows-amd64.exe differ diff --git a/bin/gpo-server b/bin/gpo-server index 10c10cb..2605117 100755 Binary files a/bin/gpo-server and b/bin/gpo-server differ diff --git a/bin/gpoctl b/bin/gpoctl index d25e11d..9f61150 100755 Binary files a/bin/gpoctl and b/bin/gpoctl differ diff --git a/bin/gpoctl-windows-amd64.exe b/bin/gpoctl-windows-amd64.exe index 8fc64c5..f14718b 100755 Binary files a/bin/gpoctl-windows-amd64.exe and b/bin/gpoctl-windows-amd64.exe differ diff --git a/cmd/gpoctl/main.go b/cmd/gpoctl/main.go index 210462c..6d12e96 100644 --- a/cmd/gpoctl/main.go +++ b/cmd/gpoctl/main.go @@ -13,6 +13,7 @@ import ( "net/url" "os" "path/filepath" + "regexp" "strings" "time" @@ -21,6 +22,8 @@ import ( var version = "dev" +var validRepositoryName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) + type clientConfig struct { server string token string @@ -81,6 +84,9 @@ func upload(args []string) error { if *policy == "" || *file == "" { return errors.New("-policy and -file are required") } + if err := validateRepositoryName("-policy", *policy); err != nil { + return err + } f, err := os.Open(*file) if err != nil { return err @@ -135,12 +141,18 @@ func profileSet(args []string) error { if *name == "" || len(policies) == 0 { return errors.New("-name and at least one -policy are required") } + if err := validateRepositoryName("-name", *name); err != nil { + return err + } refs := make([]model.ProfilePolicy, 0, len(policies)) for _, value := range policies { policy, ver, ok := strings.Cut(value, "@") if !ok || policy == "" || ver == "" { return fmt.Errorf("invalid policy reference %q; expected name@latest or name@version", value) } + if err := validateRepositoryName("policy reference", policy); err != nil { + return err + } refs = append(refs, model.ProfilePolicy{Policy: policy, Version: ver}) } body, _ := json.Marshal(map[string]any{"policies": refs}) @@ -172,6 +184,13 @@ func getList(args []string, path string) error { return doAndPrint(cfg, req) } +func validateRepositoryName(label, value string) error { + if !validRepositoryName.MatchString(value) { + return fmt.Errorf("invalid %s %q: must match %s (maximum 64 characters; no spaces)", label, value, validRepositoryName.String()) + } + return nil +} + func validateClient(server, token string, insecure bool) (clientConfig, error) { if server == "" || token == "" { return clientConfig{}, errors.New("server and token are required") diff --git a/cmd/gpoctl/main_test.go b/cmd/gpoctl/main_test.go new file mode 100644 index 0000000..e4d1591 --- /dev/null +++ b/cmd/gpoctl/main_test.go @@ -0,0 +1,32 @@ +package main + +import "testing" + +func TestValidateRepositoryName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value string + ok bool + }{ + {name: "simple", value: "windows-firewall", ok: true}, + {name: "dots and underscores", value: "MSFT.Server_2022", ok: true}, + {name: "space", value: "Server Windows Firewall", ok: false}, + {name: "umlaut", value: "server-härtung", ok: false}, + {name: "leading hyphen", value: "-server", ok: false}, + {name: "too long", value: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ok: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateRepositoryName("test", tt.value) + if tt.ok && err != nil { + t.Fatalf("expected valid name, got %v", err) + } + if !tt.ok && err == nil { + t.Fatalf("expected invalid name") + } + }) + } +} diff --git a/compose.yml b/compose.yml index d13de8e..c49da37 100644 --- a/compose.yml +++ b/compose.yml @@ -2,12 +2,12 @@ name: gpo-distributor services: gpo-server: - image: "${GPO_SERVER_IMAGE:-gpo-distributor-server:0.2.1}" + image: "${GPO_SERVER_IMAGE:-gpo-distributor-server:0.2.2}" build: context: . dockerfile: Dockerfile args: - VERSION: "${GPO_SERVER_VERSION:-0.2.1}" + VERSION: "${GPO_SERVER_VERSION:-0.2.2}" restart: unless-stopped init: true stop_grace_period: 25s diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 85cbad7..2abbbc9 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -3,12 +3,12 @@ name: gpo-distributor services: gpo-server: - image: "${GPO_SERVER_IMAGE:-gpo-distributor-server:0.2.1}" + image: "${GPO_SERVER_IMAGE:-gpo-distributor-server:0.2.2}" build: context: .. dockerfile: Dockerfile args: - VERSION: "${GPO_SERVER_VERSION:-0.2.1}" + VERSION: "${GPO_SERVER_VERSION:-0.2.2}" restart: unless-stopped init: true stop_grace_period: 25s diff --git a/go.mod b/go.mod index 1a955c8..7d2387b 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module gpo-distributor -go 1.26 +go 1.23 diff --git a/scripts/Export-And-Publish.ps1 b/scripts/Export-And-Publish.ps1 index 5455937..9b6decd 100644 --- a/scripts/Export-And-Publish.ps1 +++ b/scripts/Export-And-Publish.ps1 @@ -2,7 +2,7 @@ [CmdletBinding()] param( [Parameter(Mandatory)] [string] $GpoName, - [Parameter(Mandatory)] [string] $PolicyName, + [string] $PolicyName = '', [Parameter(Mandatory)] [string] $ServerUrl, [Parameter(Mandatory)] [string] $AdminToken, [Parameter(Mandatory)] [string] $GpoCtl, @@ -10,11 +10,82 @@ param( [string] $Domain, [string] $DomainController, [switch] $InsecureSkipVerify, - [switch] $ForceVersion + [switch] $ForceVersion, + [switch] $StrictPolicyName ) $ErrorActionPreference = 'Stop' -if (-not (Test-Path -LiteralPath $GpoCtl -PathType Leaf)) { throw "gpoctl not found: $GpoCtl" } +$policyNamePattern = '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$' + +function ConvertTo-RepositoryPolicyName { + [CmdletBinding()] + param([Parameter(Mandatory)] [string] $Value) + + $valueToConvert = $Value.Trim() + if ([string]::IsNullOrWhiteSpace($valueToConvert)) { + throw 'PolicyName must not be empty.' + } + + # Remove diacritics where possible and replace every sequence of characters + # that is unsafe for a repository identifier with a single hyphen. + $decomposed = $valueToConvert.Normalize([Text.NormalizationForm]::FormD) + $builder = [Text.StringBuilder]::new() + $separatorPending = $false + + foreach ($character in $decomposed.ToCharArray()) { + $category = [Globalization.CharUnicodeInfo]::GetUnicodeCategory($character) + if ($category -eq [Globalization.UnicodeCategory]::NonSpacingMark) { + continue + } + + $text = [string] $character + if ($text -match '^[A-Za-z0-9._-]$') { + [void] $builder.Append($character) + $separatorPending = $false + } + elseif (-not $separatorPending -and $builder.Length -gt 0) { + [void] $builder.Append('-') + $separatorPending = $true + } + } + + $converted = $builder.ToString() + $converted = $converted -replace '-{2,}', '-' + $converted = $converted.Trim([char[]]'.-_') + + if ([string]::IsNullOrWhiteSpace($converted)) { + throw "PolicyName '$Value' cannot be converted to a valid repository name." + } + if ($converted[0] -notmatch '[A-Za-z0-9]') { + $converted = 'gpo-' + $converted + } + if ($converted.Length -gt 64) { + $converted = $converted.Substring(0, 64).TrimEnd([char[]]'.-_') + } + if ($converted -notmatch $policyNamePattern) { + throw "PolicyName '$Value' cannot be converted to a name matching $policyNamePattern." + } + return $converted +} + +if (-not (Test-Path -LiteralPath $GpoCtl -PathType Leaf)) { + throw "gpoctl not found: $GpoCtl" +} + +$requestedPolicyName = if ([string]::IsNullOrWhiteSpace($PolicyName)) { $GpoName } else { $PolicyName.Trim() } +if ($requestedPolicyName -match $policyNamePattern) { + $resolvedPolicyName = $requestedPolicyName +} +elseif ($StrictPolicyName) { + throw "PolicyName '$requestedPolicyName' is invalid. It must match $policyNamePattern (maximum 64 characters; no spaces or umlauts)." +} +else { + $resolvedPolicyName = ConvertTo-RepositoryPolicyName -Value $requestedPolicyName + Write-Warning "PolicyName '$requestedPolicyName' was normalized to '$resolvedPolicyName'." +} + +Write-Host "Publishing AD GPO '$GpoName' as repository policy '$resolvedPolicyName'." + $work = Join-Path ([IO.Path]::GetTempPath()) ("gpo-publish-" + [guid]::NewGuid().ToString('N')) $backup = Join-Path $work 'backup' $zip = Join-Path $work 'gpo-backup.zip' @@ -36,12 +107,21 @@ try { $false ) - $args = @('upload', '-server', $ServerUrl, '-token', $AdminToken, '-policy', $PolicyName, '-file', $zip) - if ($Note) { $args += @('-note', $Note) } - if ($InsecureSkipVerify) { $args += '-insecure-skip-verify' } - if ($ForceVersion) { $args += '-force' } - & $GpoCtl @args - if ($LASTEXITCODE -ne 0) { throw "gpoctl exited with code $LASTEXITCODE" } + $arguments = @( + 'upload', + '-server', $ServerUrl, + '-token', $AdminToken, + '-policy', $resolvedPolicyName, + '-file', $zip + ) + if ($Note) { $arguments += @('-note', $Note) } + if ($InsecureSkipVerify) { $arguments += '-insecure-skip-verify' } + if ($ForceVersion) { $arguments += '-force' } + + & $GpoCtl @arguments + if ($LASTEXITCODE -ne 0) { + throw "gpoctl exited with code $LASTEXITCODE" + } } finally { Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue