This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
17
README.md
17
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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
Binary file not shown.
BIN
bin/gpo-server
BIN
bin/gpo-server
Binary file not shown.
BIN
bin/gpoctl
BIN
bin/gpoctl
Binary file not shown.
Binary file not shown.
@@ -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")
|
||||
|
||||
32
cmd/gpoctl/main_test.go
Normal file
32
cmd/gpoctl/main_test.go
Normal file
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user