~/yazilar/powershell-ile-windows-server-otomasyonu

PowerShell ile tekrarlanan Windows işlerini otomatiğe almak

10 dk okuma

Ad;Soyad;Birim;Unvan
Ayşe;Yılmaz;Bilgi İşlem;Sistem Mühendisi
Mehmet Can;Öztürk;Kütüphane;Uzman
Şule;Çağlar;Bilgi İşlem;Yazılım Geliştirici
İsmail;Güneş;Personel Daire Başkanlığı;Memur

Yeni personel listesi bu biçimde, Excel'den kaydedilmiş bir CSV olarak geliyorsa her satır için Active Directory Users and Computers'ta aynı formu doldurmak hem yavaş hem hataya açık. Bir kişinin birimi yanlış yazılıyor, birinin "Kullanıcı ilk girişte parolasını değiştirsin" kutusu işaretlenmiyor. Aynı işi bir script'e verince her hesap aynı kurala göre açılıyor ve ne yapıldığı bir log dosyasında kalıyor.

Burada iki script var. Biri bu CSV'den kullanıcı açıyor ve elle çalıştırılıyor. Diğeri uzun süredir oturum açmamış hesapları raporluyor ve her hafta zamanlanmış görev olarak çalışıyor.

Nerede çalışacak

Script'leri domain controller'da değil, ayrı bir yönetim sunucusunda (srv01) çalıştırıyorum. DC'de ne kadar az şey çalışırsa o kadar iyi. srv01'in AD modülüne ihtiyacı var:

Install-WindowsFeature RSAT-AD-PowerShell

Klasör düzeni:

C:\Scripts\
    Ortak.ps1
    Yeni-Kullanicilar.ps1
    Pasif-Hesap-Raporu.ps1
    Logs\
    Raporlar\

Script'ler Windows PowerShell (5.1) için yazıldı, zamanlanmış görev de powershell.exe ile onu çağırıyor.

İki script için tek log fonksiyonu

İki script de aynı küçük fonksiyonu kullanıyor. Her satıra zaman damgası ve seviye yazıyor, -Verbose ile çalıştırınca ekrana da basıyor. Ayrı bir dosyada duruyor, script'ler onu dot-source ile yüklüyor:

# C:\Scripts\Ortak.ps1
function Write-Log {
    param(
        [Parameter(Mandatory)][string]$Mesaj,
        [ValidateSet('BILGI', 'UYARI', 'HATA')][string]$Seviye = 'BILGI'
    )
    $satir = '{0} [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Seviye, $Mesaj
    Add-Content -Path $script:LogDosyasi -Value $satir -Encoding UTF8
    Write-Verbose $satir
}

Start-Transcript de bir seçenek ama ekrana yazılan her şeyi topluyor, aradığın satırı bulmak zorlaşıyor. Kendi log satırlarımı kendim yazınca dosyada sadece olaylar kalıyor ve Select-String 'HATA' ile bir bakışta neyin bozulduğu görünüyor.

CSV'den kullanıcı açmak

Kullanıcı adı (sAMAccountName) olarak ad.soyad kullanıyorum, birden fazla adı olanlarda ilk adı alıyorum. Türkçe karakterleri ASCII karşılıklarına çeviriyorum, çünkü kullanıcı adının e-posta adreslerinde, Linux sistemlerinde ve eski uygulamalarda da sorunsuz çalışması gerekiyor.

Burada küçük bir tuzak var. Önce küçük harfe çevirip sonra Türkçe karakterleri değiştirmeye kalkarsan, sistemin kültür ayarına göre I harfi ı'ya dönüşebiliyor. Ben önce harfleri tek tek değiştiriyorum, metin tamamen ASCII olduktan sonra kültürden bağımsız ToLowerInvariant() ile küçültüyorum.

Script'in tamamı:

#Requires -Modules ActiveDirectory
[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory)]
    [string]$CsvYolu,

    [string]$HedefOU = 'OU=Personel,DC=ad,DC=example,DC=com',
    [string]$UpnSoneki = 'ad.example.com'
)

$ErrorActionPreference = 'Stop'
$script:LogDosyasi = Join-Path $PSScriptRoot ('Logs\yeni-kullanicilar-{0:yyyyMMdd-HHmm}.log' -f (Get-Date))
New-Item -ItemType Directory -Path (Split-Path $script:LogDosyasi) -Force | Out-Null

. (Join-Path $PSScriptRoot 'Ortak.ps1')

function ConvertTo-KullaniciAdi {
    param([string]$Ad, [string]$Soyad)
    $tr  = 'çÇğĞıİöÖşŞüÜ'
    $asc = 'cCgGiIoOsSuU'
    $metin = '{0}.{1}' -f $Ad.Trim().Split(' ')[0], $Soyad.Trim()
    for ($i = 0; $i -lt $tr.Length; $i++) {
        $metin = $metin.Replace($tr[$i], $asc[$i])
    }
    $metin = $metin.ToLowerInvariant() -replace '[^a-z0-9.]', ''
    # sAMAccountName en fazla 20 karakter
    if ($metin.Length -gt 20) { $metin = $metin.Substring(0, 20) }
    $metin
}

$ilkParola = Read-Host -AsSecureString 'Yeni hesaplar için geçici parola'
$kayitlar  = Import-Csv -Path $CsvYolu -Delimiter ';' -Encoding UTF8
Write-Log "$($kayitlar.Count) satır okundu: $CsvYolu"

$sonuc = foreach ($k in $kayitlar) {
    $sam   = ConvertTo-KullaniciAdi -Ad $k.Ad -Soyad $k.Soyad
    $durum = 'Oluşturuldu'
    try {
        if (Get-ADUser -Filter "SamAccountName -eq '$sam'") {
            $durum = 'Zaten var'
            Write-Log "$sam zaten var, atlandı" -Seviye UYARI
        }
        elseif ($PSCmdlet.ShouldProcess($sam, 'Kullanıcı oluştur')) {
            $p = @{
                Name                  = '{0} {1}' -f $k.Ad, $k.Soyad
                GivenName             = $k.Ad
                Surname               = $k.Soyad
                DisplayName           = '{0} {1}' -f $k.Ad, $k.Soyad
                SamAccountName        = $sam
                UserPrincipalName     = "$sam@$UpnSoneki"
                Department            = $k.Birim
                Title                 = $k.Unvan
                Path                  = $HedefOU
                AccountPassword       = $ilkParola
                ChangePasswordAtLogon = $true
                Enabled               = $true
            }
            New-ADUser @p
            Write-Log "$sam oluşturuldu ($($k.Birim))"
        }
        else {
            $durum = 'WhatIf'
        }
    }
    catch {
        $durum = "Hata: $($_.Exception.Message)"
        Write-Log "$sam oluşturulamadı: $($_.Exception.Message)" -Seviye HATA
    }

    [pscustomobject]@{
        Ad             = $k.Ad
        Soyad          = $k.Soyad
        SamAccountName = $sam
        Durum          = $durum
    }
}

$sonucDosyasi = $script:LogDosyasi -replace '\.log$', '.csv'
$sonuc | Export-Csv -Path $sonucDosyasi -NoTypeInformation -Encoding UTF8 -Delimiter ';'
$sonuc | Format-Table -AutoSize

$ErrorActionPreference = 'Stop' olmadan AD cmdlet'lerinin çoğu hatayı ekrana basıp devam ediyor, try/catch hiçbir şey yakalamıyor. Bunu en başa koymak, her hatanın catch bloğuna düşmesini sağlıyor. Bir satırdaki hata da sadece o satırı etkiliyor, döngü sıradakiyle devam ediyor.

Aynı kullanıcı adı zaten varsa script otomatik olarak ayse.yilmaz2 gibi bir ad üretmiyor, satırı atlayıp raporluyor. Aynı adlı iki kişi de olabilir, aynı kişinin listede ikinci kez gelmesi de. Hangisi olduğuna bir insan bakmalı.

Geçici parola script'e gömülü değil, her çalıştırmada Read-Host -AsSecureString ile soruluyor ve hiçbir dosyaya yazılmıyor. ChangePasswordAtLogon sayesinde kullanıcı ilk girişte kendi parolasını belirliyor. Daha sıkı bir düzen istiyorsan hesapları Enabled = $false ile açıp kişi kimliğini gösterdiğinde etkinleştirmek de bir seçenek.

Önce WhatIf

SupportsShouldProcess sayesinde script -WhatIf parametresini tanıyor. İlk çalıştırmayı her zaman böyle yapıyorum:

cd C:\Scripts
.\Yeni-Kullanicilar.ps1 -CsvYolu .\yeni-personel.csv -WhatIf
What if: Performing the operation "Kullanıcı oluştur" on target "ayse.yilmaz".
What if: Performing the operation "Kullanıcı oluştur" on target "mehmet.ozturk".
What if: Performing the operation "Kullanıcı oluştur" on target "sule.caglar".
What if: Performing the operation "Kullanıcı oluştur" on target "ismail.gunes".
What if: Performing the operation "Add Content" on target "Path: C:\Scripts\Logs\...".
...

-WhatIf sadece benim ShouldProcess çağrıma değil, script içindeki New-Item, Add-Content ve Export-Csv gibi cmdlet'lere de geçiyor. Bu yüzden çıktıda log ve CSV yazma işlemleri için de What if: satırları çıkıyor ve bu çalıştırmada log dosyası yazılmıyor. Beklenen davranış bu.

Kullanıcı adları beklediğim gibiyse -WhatIf olmadan tekrar çalıştırıyorum. Gerçek çalıştırmada log klasöründe her satırın sonucunu içeren bir .csv de oluşuyor.

Pasif hesap raporu

İkinci script, belli bir gün sayısından uzun süredir oturum açmamış ve hâlâ etkin olan hesapları buluyor. Hiç oturum açmamış ama açılalı uzun zaman olmuş hesapları da yakalıyor, çünkü "açıldı ama kimse kullanmadı" durumu da raporlanması gereken bir şey.

#Requires -Modules ActiveDirectory
[CmdletBinding()]
param(
    [int]$Gun = 90,
    [string]$AramaKoku = 'OU=Personel,DC=ad,DC=example,DC=com',
    [string]$RaporKlasoru
)

$ErrorActionPreference = 'Stop'
if (-not $RaporKlasoru) { $RaporKlasoru = Join-Path $PSScriptRoot 'Raporlar' }
$script:LogDosyasi = Join-Path $PSScriptRoot 'Logs\pasif-hesap-raporu.log'

. (Join-Path $PSScriptRoot 'Ortak.ps1')

try {
    New-Item -ItemType Directory -Path $RaporKlasoru, (Split-Path $script:LogDosyasi) -Force | Out-Null
    $sinir = (Get-Date).AddDays(-$Gun)
    Write-Log "Tarama başladı: $AramaKoku, sınır $($sinir.ToString('yyyy-MM-dd'))"

    $pasifler = Get-ADUser -SearchBase $AramaKoku -Filter "Enabled -eq 'True'" `
                    -Properties LastLogonDate, whenCreated, Department |
        Where-Object {
            ($_.LastLogonDate -and $_.LastLogonDate -lt $sinir) -or
            (-not $_.LastLogonDate -and $_.whenCreated -lt $sinir)
        } |
        Sort-Object LastLogonDate |
        Select-Object SamAccountName, Name, Department, LastLogonDate, whenCreated

    $dosya = Join-Path $RaporKlasoru ('pasif-hesaplar-{0:yyyy-MM-dd}.csv' -f (Get-Date))
    $pasifler | Export-Csv -Path $dosya -NoTypeInformation -Encoding UTF8 -Delimiter ';'
    Write-Log "$(@($pasifler).Count) pasif hesap bulundu, rapor: $dosya"

    # 180 günden eski raporları temizle
    Get-ChildItem -Path $RaporKlasoru -Filter 'pasif-hesaplar-*.csv' |
        Where-Object LastWriteTime -lt (Get-Date).AddDays(-180) |
        Remove-Item
    exit 0
}
catch {
    Write-Log "Rapor üretilemedi: $($_.Exception.Message)" -Seviye HATA
    exit 1
}

exit 0 ve exit 1 zamanlanmış görev için, çıkış kodu "Last Run Result" olarak görünüyor. Hata durumunda 1 dönmezse görev her hafta "başarılı" görünüyor ama rapor klasörü boş kalıyor.

LastLogonDate aslında lastLogonTimestamp özniteliğinin okunabilir hali. Bu öznitelik DC'ler arasında replike oluyor ama her girişte güncellenmiyor, varsayılan olarak 9 ile 14 gün arasında bir gecikmesi var. 90 günlük bir rapor için bu fark önemsiz. "Dün girmiş mi" sorusunun cevabını ise bu alandan alamazsın, onun için her DC'deki lastLogon değerine tek tek bakmak gerekiyor.

Rapor sadece rapor. Hesapları otomatik kapatmıyorum, çünkü bazı hesaplar AD'ye bir uygulama üzerinden (ör. LDAP ile kimlik doğrulayan bir web uygulaması) doğrulanıyor ve bu girişler lastLogonTimestamp'e her zaman yansımayabiliyor. Listeyi ilgili birime gösterip onay aldıktan sonra kapatma işi ayrı ve bilinçli bir adım:

Import-Csv .\Raporlar\pasif-hesaplar-2026-08-03.csv -Delimiter ';' |
    ForEach-Object { Disable-ADAccount -Identity $_.SamAccountName -WhatIf }

Zamanlanmış görev olarak çalıştırmak

Raporun her pazartesi sabahı hazır olmasını istiyorum. Asıl soru görevin hangi hesapla çalışacağı. Kendi hesabımla çalıştırırsam parolam Görev Zamanlayıcı'da saklanıyor ve parolamı değiştirdiğim gün görev bozuluyor. Ayrı bir servis hesabı açarsam onun parolası birinin not defterinde duruyor.

Bunun yerine group Managed Service Account (gMSA) kullanıyorum. Parolasını AD üretiyor, düzenli olarak kendisi değiştiriyor ve sadece izin verilen bilgisayarlar alabiliyor.

gMSA oluşturmak

Ormanda daha önce hiç gMSA oluşturulmadıysa önce bir KDS kök anahtarı gerekiyor. Bu komut domain'de bir kez çalıştırılıyor:

Add-KdsRootKey -EffectiveImmediately

Adı yanıltıcı. Anahtar, bütün DC'lere replike olabilsin diye 10 saat sonra kullanılabilir hale geliyor. O süre dolmadan gMSA oluşturmaya çalışırsan şu hatayı alıyorsun:

New-ADServiceAccount : Key does not exist

Ertesi gün devam ediyorum. Hesabı oluşturup parolasını sadece srv01'in alabilmesine izin veriyorum:

New-ADServiceAccount -Name gmsaRapor -DNSHostName gmsaRapor.ad.example.com `
    -PrincipalsAllowedToRetrieveManagedPassword 'srv01$'

srv01 üzerinde hesabı kurup test ediyorum:

Install-ADServiceAccount -Identity gmsaRapor
Test-ADServiceAccount -Identity gmsaRapor
True

Klasör yetkileri

Zamanlanmış görev bir script çalıştırıyorsa o script'i kimin değiştirebildiği, görevin hangi yetkiyle çalıştığı kadar önemli. Sistem diskinin kökünde açılan bir klasör, kökün izinlerini miras alıyor ve bu izinler normal kullanıcılara klasörün altında dosya oluşturma ve değiştirme yetkisi verebiliyor. Mirası kesip izinleri baştan veriyorum:

icacls C:\Scripts /inheritance:r /grant:r "BUILTIN\Administrators:(OI)(CI)F" "NT AUTHORITY\SYSTEM:(OI)(CI)F" "ORNEK\gmsaRapor$:(OI)(CI)RX"
icacls C:\Scripts\Logs /grant "ORNEK\gmsaRapor$:(OI)(CI)M"
icacls C:\Scripts\Raporlar /grant "ORNEK\gmsaRapor$:(OI)(CI)M"

gMSA script'leri okuyabiliyor ama değiştiremiyor, sadece log ve rapor klasörlerine yazabiliyor.

Görevi kaydetmek

$eylem = New-ScheduledTaskAction -Execute 'powershell.exe' `
    -Argument '-NoProfile -NonInteractive -File "C:\Scripts\Pasif-Hesap-Raporu.ps1"'
$tetik = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday -At '07:00'
$kim   = New-ScheduledTaskPrincipal -UserId 'ORNEK\gmsaRapor$' -LogonType Password

Register-ScheduledTask -TaskName 'Pasif hesap raporu' -TaskPath '\Bilgi Islem\' `
    -Action $eylem -Trigger $tetik -Principal $kim

-LogonType Password gMSA için doğru değer, parola vermiyorsun, Windows hesabın parolasını AD'den kendisi alıyor. -NonInteractive ise script içinde yanlışlıkla bir Read-Host kalırsa görevin sonsuza kadar beklemesi yerine hatayla bitmesini sağlıyor.

-ExecutionPolicy Bypass eklemedim. Sunucularda varsayılan politika RemoteSigned ve yerel diskte yazılmış bir script bu politikada çalışıyor. Bypass'ı her yere eklemek, politikayı fiilen kapatmak oluyor.

Elle tetikleyip sonucuna bakıyorum:

Start-ScheduledTask -TaskPath '\Bilgi Islem\' -TaskName 'Pasif hesap raporu'
Get-ScheduledTaskInfo -TaskPath '\Bilgi Islem\' -TaskName 'Pasif hesap raporu' |
    Select-Object LastRunTime, LastTaskResult

LastTaskResult 0 ise iş tamam. 1 ise script'in kendi catch bloğuna düşmüş, log dosyasında sebebi yazıyor. 267009 görevin hâlâ sürdüğünü gösteriyor, biraz bekleyip tekrar bakıyorum.

Sık hatalar

Script'teki Türkçe karakterler bozuk

Windows PowerShell 5.1, BOM'suz UTF-8 dosyayı sistemin ANSI kod sayfasıyla okuyor. Script'teki 'çÇğĞıİöÖşŞüÜ' satırı farklı karakterlere dönüşüyor ve kullanıcı adı üretimi sessizce bozuluyor. .ps1 dosyalarını "UTF-8 with BOM" olarak kaydet. PowerShell 7'de bu sorun yok.

CSV tek sütun olarak geliyor

Türkçe bölge ayarlı Excel CSV'yi virgül yerine noktalı virgülle kaydediyor. Script'te -Delimiter ';' bu yüzden var. Karakterler bozuksa da Excel'de "CSV UTF-8" biçimini seçmek gerekiyor, düz "CSV" seçeneği ANSI kaydedebiliyor.

Parola ilkesine takılan hesaplar

The password does not meet the length, complexity, or history requirement of the domain.

Geçici parola domain parola ilkesine uymuyorsa New-ADUser hata veriyor ama hesap çoğu zaman devre dışı halde oluşmuş oluyor. Script'i daha güçlü bir parolayla tekrar çalıştırınca bu sefer "Zaten var" deyip atlıyor. Yarım kalan hesaplar için ya hesabı silip tekrar çalıştır ya da Set-ADAccountPassword -Reset ve Enable-ADAccount ile elle tamamla.

Elle çalışıyor, görevde çalışmıyor

En sık üç sebep var. Birincisi göreli yollar: görev C:\Windows\System32 içinde başlıyor, .\Logs gibi bir yol orayı gösteriyor. Script'lerde her yolun $PSScriptRoot ile kurulması bu yüzden. İkincisi profil: -NoProfile ile çalıştığı için profilinde tanımladığın fonksiyonlar ve modüller yok. Üçüncüsü hesap: gMSA'nın "Log on as a batch job" hakkı yoksa görev hiç başlamıyor, bu hak grup ilkesiyle veriliyor.

Görev 1 dönüyor ama log dosyası yok

Script log klasörüne yazamadan düşmüş demek, büyük ihtimalle klasör izni. Görev Zamanlayıcı'nın Microsoft-Windows-TaskScheduler/Operational günlüğüne bak. Sebebi bulamazsan script'in en başına geçici olarak Start-Transcript -Path C:\Scripts\Logs\debug.txt koy, görevin gördüğü her şey dosyaya düşüyor.

Başka makineden kopyalanan script çalışmıyor

İnternetten ya da bir e-posta ekinden gelen dosyalar "web'den geldi" işaretini taşıyor ve RemoteSigned bunları engelliyor. İçeriğini okuduktan sonra Unblock-File .\Pasif-Hesap-Raporu.ps1 ile işareti kaldır.

İki script de bir sonraki ihtiyaç için kalıp olarak kullanılabilir: aynı log fonksiyonu, aynı try/catch düzeni, -WhatIf ile önce deneme ve gMSA ile parolasız zamanlanmış görev. Değişen tek şey ortadaki birkaç satır.

Sıradaki yazı: Hyper-V ile sanallaştırmaya giriş