Mert Özen Her Satırda Daha İleri
DevOps Linux ve Shell (geliştirici için) — #11

Sunucuyu Sertleştirmek: SSH Ayarları, ufw, Fail2ban, Otomatik Güncellemeler ve En Az Yetki

Mert Özen 14 Kas 2026 16 dk 3 görüntülenme
Sunucuyu Sertleştirmek: SSH Ayarları, ufw, Fail2ban, Otomatik Güncellemeler ve En Az Yetki

Ubuntu'da şifreyle girişi kapatmak için ayarı /etc/ssh/sshd_config.d/ altında, cloud-init'in dosyasından önce okunan bir drop-in'e yazmak ve sshd -T ile doğrulamak gerekir; sshd ilk okuduğu değeri kullanır. Bookstore sunucusunda ikinci oturum kuralını, ufw'yi doğru sırayla açmayı, Docker'ın yayımlanan port'larda ufw'yi atlamasını, Fail2ban'ı ve systemd ile en az yetkiyi gerçek çıktılarla gösteriyoruz.

Sürüm notu: Bu yazıdaki komutlar Ubuntu Server 24.04.5 LTS (kernel 6.8, systemd 255, OpenSSH 9.6p1, ufw 0.36.2, iptables 1.8.10 nf_tables, Fail2Ban 1.0.2, Ubuntu deposundan Docker 29.1.3) çalıştıran bir test VM'inde ve aynı ağdaki bir Ubuntu 24.04 istemcide; Bookstore.Api ASP.NET Core 10.0.15 runtime'ıyla denenmiştir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) sunucusunda şifreyle girişi kapatmak istiyorsun. /etc/ssh/sshd_config'te yorum satırı olan ayarı açıyorsun, config'i test edip SSH (uzak bağlantı protokolü) service'ini reload ediyorsun:

$ sudo sed -i 's/^#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config
$ grep -n "^PasswordAuthentication" /etc/ssh/sshd_config
66:PasswordAuthentication no
$ sudo sshd -t && sudo systemctl reload ssh

Sonra kendi makinenden key'i bilerek devre dışı bırakıp şifreyle bağlanmayı deniyorsun:

ada@my-pc:~$ ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password ada@bookstore-dev
ada@10.20.30.10's password:
Welcome to Ubuntu 24.04.5 LTS (GNU/Linux 6.8.0-149-generic x86_64)
...
ada@bookstore-dev:~$

Şifre soruldu ve giriş başarılı oldu. Ayar dosyada duruyor, sshd -t hata vermedi, reload yapıldı. Neden işe yaramadı? Cevap bir sonraki bölümde.

Bu yazıda internete açık bir Linux sunucusunda yapılacak temel sertleştirme adımlarını Bookstore sunucusu üzerinde uyguluyoruz: SSH ayarları, ufw ile firewall, Docker'ın firewall'u nasıl atladığı, Fail2ban, otomatik güncellemeler ve en az yetki. Amaç savunma: hangi kapının açık olduğunu bilmek, gereksizleri kapatmak ve bunu yaparken kendini sunucunun dışında bırakmamak.

sshd_config.d: İlk Okunan Değer Geçerli

$ grep -n "^Include" /etc/ssh/sshd_config
12:Include /etc/ssh/sshd_config.d/*.conf
$ ls -l /etc/ssh/sshd_config.d/
-rw------- 1 root root 27 Nov 14 14:05 50-cloud-init.conf
-rw-r--r-- 1 root root 26 Oct 17 12:57 60-cloudimg-settings.conf
$ sudo head /etc/ssh/sshd_config.d/*.conf
==> /etc/ssh/sshd_config.d/50-cloud-init.conf <==
PasswordAuthentication yes

==> /etc/ssh/sshd_config.d/60-cloudimg-settings.conf <==
PasswordAuthentication no
$ sudo sshd -T | grep "^passwordauthentication"
passwordauthentication yes

Tahminin cevabı iki kuralın birleşiminde. Birincisi, sshd_config(5) man sayfasına göre bir ayar için sshd'nin ilk okuduğu değer geçerlidir; sonradan gelen aynı ayar yok sayılır. İkincisi, Ubuntu'nun sshd_config'i 12. satırda sshd_config.d/ altındaki dosyaları içeri alıyor, yani drop-in dosyalar ana dosyadaki ayarlardan önce okunuyor. Bu VM'i kuran cloud-init, birinci yazıdaki ssh_pwauth ayarı yüzünden 50-cloud-init.conf'a PasswordAuthentication yes yazmış. O dosya hem image'ın kendi 60-cloudimg-settings.conf'undan hem de 66. satırdaki bizim ayarımızdan önce okunuyor ve kazanıyor. Dosyaya bakarak "ayar doğru" demek bu yüzden yanıltıcı; sshd'nin gerçekte ne kullandığını sshd -T söyler.

Doğru yer, adı alfabetik olarak cloud-init'in dosyasından önce gelen kendi drop-in'imiz. Ana dosyayı paketin bıraktığı gibi geri aldım ve 10-bookstore.conf'u yazdım (repoda deploy/linux/ssh/sshd_config.d/10-bookstore.conf):

# Bookstore sunucusu: yalnız key ile giriş, root yok, yalnız bu kullanıcılar
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
AllowUsers ada deploy
MaxAuthTries 3
X11Forwarding no

KbdInteractiveAuthentication, PAM (Linux'un takılabilir kimlik doğrulama modülleri) üzerinden şifre sormanın ikinci yolu; onu da kapatmazsan bazı kurulumlarda şifre bu kapıdan sorulur. PermitRootLogin'in OpenSSH varsayılanı prohibit-password (root key ile girebilir; sshd -T bunu eski adıyla without-password diye gösteriyor, birinci yazı); root'a hiç SSH ile girmiyoruz, herkes kendi hesabıyla girip sudo kullanıyor. AllowUsers listede olmayan her kullanıcıyı, key'i doğru olsa bile reddeder: ada yönetim hesabı, deploy ise GitHub Actions serisinin SSH ile deploy yazısındaki CI kullanıcısı. Kaydetmeden önce her zaman sshd -t:

$ echo "PermitRootLogin noo" | sudo tee /etc/ssh/sshd_config.d/99-typo.conf >/dev/null
$ sudo sshd -t; echo "exit=$?"
/etc/ssh/sshd_config.d/99-typo.conf line 1: unsupported option "noo".
exit=255
$ sudo rm /etc/ssh/sshd_config.d/99-typo.conf
$ sudo sshd -T | grep -E "^(passwordauthentication|kbdinteractiveauthentication|permitrootlogin|allowusers|maxauthtries|x11forwarding) "
maxauthtries 3
permitrootlogin no
passwordauthentication no
kbdinteractiveauthentication no
x11forwarding no
allowusers ada
allowusers deploy
ada@my-pc:~$ ssh -o PubkeyAuthentication=no ada@bookstore-dev true; echo "exit=$?"
ada@10.20.30.10: Permission denied (publickey).
exit=255
ada@my-pc:~$ ssh -o BatchMode=yes root@bookstore-dev true; echo "exit=$?"
root@10.20.30.10: Permission denied (publickey).
exit=255

Sunucu artık şifre yöntemini teklif bile etmiyor; hata mesajında yalnızca publickey var. sshd -t yazım hatalarını yakalar ama mantık hatalarını yakalamaz: AllowUsers'a kendini yazmayı unutmak geçerli bir config'dir. Onu yakalayan şey bir sonraki bölümdeki kural.

İkinci Oturum Kuralı

SSH ayarını değiştirirken mevcut oturumunu kapatma ve değişikliği ayrı, yeni bir bağlantıyla dene. Bu kural systemctl reload ssh'un nasıl çalıştığına dayanıyor: dördüncü yazıdaki process ağacında gördüğümüz gibi her oturumun kendi sshd process'i var; reload yalnızca dinleyen ana process'in config'i yeniden okumasını sağlar, açık oturumlar eski hâliyle devam eder. Kuralı bilerek yanlış yazdım: deploy için kural eklerken kendimi listeden düşürdüm.

# oturum A: açık kalıyor
ada@bookstore-dev:~$ sudo install -m 644 /tmp/10-bookstore.bad /etc/ssh/sshd_config.d/10-bookstore.conf
ada@bookstore-dev:~$ sudo sshd -t && sudo systemctl reload ssh && echo reloaded
reloaded
ada@bookstore-dev:~$ sudo sshd -T | grep -E '^(passwordauthentication|permitrootlogin|allowusers) '
permitrootlogin no
passwordauthentication no
allowusers deploy

# oturum B: yeni bir terminal
ada@my-pc:~$ ssh -o BatchMode=yes bookstore-dev "echo second session ok"
ada@10.20.30.10: Permission denied (publickey).

# oturum A: hâlâ içerideyim
ada@bookstore-dev:~$ sudo journalctl _COMM=sshd -n 2 --no-pager -o short-iso
2026-11-14T17:21:31+00:00 bookstore-dev sshd[1718]: User ada from 10.20.30.5 not allowed because not listed in AllowUsers
2026-11-14T17:21:31+00:00 bookstore-dev sshd[1718]: Connection closed by invalid user ada 10.20.30.5 port 50338 [preauth]
ada@bookstore-dev:~$ sudo install -m 644 /tmp/10-bookstore.good /etc/ssh/sshd_config.d/10-bookstore.conf && sudo sshd -t && sudo systemctl reload ssh && echo reloaded
reloaded

# oturum C: yeni bir terminal
ada@my-pc:~$ ssh -o BatchMode=yes bookstore-dev "echo second session ok"
second session ok

sshd -t hiçbir şey söylemedi, çünkü dosya geçerliydi. Yeni bağlantı reddedildi ve sebep sunucu tarafında net: "not listed in AllowUsers". İstemci tarafındaki mesaj ise yanlış key ile aynı, Permission denied (publickey); yani dışarıdan bakınca neyin bozulduğunu anlayamazsın. A oturumunu kapatmış olsaydım sunucuya girmenin tek yolu bulut sağlayıcının web konsolu ya da bir rescue modu olurdu. Sıra her zaman aynı: sshd -t, reload, ikinci bir terminalde yeni bağlantı, ancak o başarılı olursa ilk oturumu kapat.

Ubuntu 24.04'te SSH socket activation ile çalışıyor (birinci yazı): 22. port'u systemd'nin ssh.socket'i dinliyor. Bu yüzden Port ya da ListenAddress değişikliği reload ile değil, Ubuntu dokümanına göre daemon-reload ve ssh.socket'in yeniden başlatılmasıyla geçerli olur. Bu yazıda port'u değiştirmedim; 22 yerine başka bir port seçmek log'daki otomatik denemeleri azaltır ama tek başına güvenlik sağlamaz.

ufw: Önce SSH'a İzin Ver, Sonra Aç

ufw (Uncomplicated Firewall; Ubuntu'nun firewall aracı) sekizinci yazıda bir arızanın parçasıydı ve sonunda sıfırlanmıştı. Şimdi baştan kuruyoruz. Sırayı yine bilerek yanlış yaptım: SSH'a izin vermeden firewall'u açtım.

# oturum A
ada@bookstore-dev:~$ sudo ufw default deny incoming
Default incoming policy changed to 'deny'
(be sure to update your rules accordingly)
ada@bookstore-dev:~$ sudo ufw enable
Command may disrupt existing ssh connections. Proceed with operation (y|n)? y
Firewall is active and enabled on system startup

# oturum B: yeni bir terminal
ada@my-pc:~$ ssh -o BatchMode=yes -o ConnectTimeout=10 bookstore-dev "echo second session ok"
ssh: connect to host 10.20.30.10 port 22: Connection timed out

# oturum A: hâlâ içerideyim
ada@bookstore-dev:~$ sudo ufw allow OpenSSH
Rule added
Rule added (v6)
ada@bookstore-dev:~$ sudo ufw app info 'Nginx Full' | tail -n 4
Description: Small, but very powerful and efficient web server
Ports:
  80,443/tcp
ada@bookstore-dev:~$ sudo ufw allow 'Nginx Full'
Rule added
Rule added (v6)

# oturum C: yeni bir terminal
ada@my-pc:~$ ssh -o BatchMode=yes -o ConnectTimeout=10 bookstore-dev "echo second session ok"
second session ok

ufw uyardı ve uyarı doğruydu. Açık oturum yaşamaya devam etti, çünkü ufw'nin hazır kuralları kurulmuş bağlantılara (connection tracking'de ESTABLISHED durumundakilere) izin veriyor; yeni bağlantı ise sessizce düşürüldü ve istemci timeout ile döndü. Bu farkı sekizinci yazıdan hatırla: firewall'un düşürdüğü paket "Connection refused" değil timeout verir. Doğru sıra şu: default deny incoming, allow OpenSSH, gereken diğer port'lar, en son enable. OpenSSH ve Nginx Full, paketlerin /etc/ufw/applications.d/ altına bıraktığı uygulama profilleri; port numarası yazmak yerine bunları kullanmak kuralı okunur yapıyor.

$ sudo ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip
To                         Action      From
--                         ------      ----
22/tcp (OpenSSH)           ALLOW IN    Anywhere
80,443/tcp (Nginx Full)    ALLOW IN    Anywhere
22/tcp (OpenSSH (v6))      ALLOW IN    Anywhere (v6)
80,443/tcp (Nginx Full (v6)) ALLOW IN    Anywhere (v6)
$ sudo ufw show listening
tcp:
  22 * (sshd)
   [ 1] allow OpenSSH

  80 * (nginx)
   [ 2] allow 'Nginx Full'

tcp6:
  22 * (sshd)
   [ 3] allow OpenSSH

udp:
$ sudo iptables -V
iptables v1.8.10 (nf_tables)
$ sudo nft list chain ip filter ufw-user-input
# Warning: table ip filter is managed by iptables-nft, do not touch!
table ip filter {
	chain ufw-user-input {
		tcp dport 22  counter packets 3 bytes 180 accept
		ip protocol tcp tcp dport { 80, 443 }  counter packets 0 bytes 0 accept
	}
}

ufw show listening açık port envanterini kurallarla yan yana koyuyor: dışarıda dinleyen her port'un yanında hangi kuralın ona izin verdiği yazıyor. Bookstore.Api burada yok, çünkü sekizinci yazıda gördüğümüz gibi yalnızca 127.0.0.1:5000'i dinliyor; firewall olmasa da dışarıdan ulaşılamaz. ufw kendi başına paket filtrelemez: kuralları iptables komutlarına çevirir. Ubuntu 24.04'te iptables da nf_tables backend'iyle çalışıyor, yani kurallar sonunda kernel'in nftables altyapısına yazılıyor. nft çıktısındaki counter packets 3, 22. port kuralının üç paketi kabul ettiğini gösteriyor. nftables'ın kendi syntax'ı ve iptables'tan geçiş son yazının konusu; ufw kullanırken o tabloları elle değiştirme, uyarının da söylediği gibi ufw onları yönetiyor.

Docker ve ufw: Yayımlanan Port Firewall'u Atlar

Sunucuda Docker da çalışıyorsa ufw kurallarının bir kısmı geçersiz kalır. Ubuntu deposundaki docker.io paketini kurup bir Nginx container'ını 8080'de yayımladım; ufw'de 8080 için hiçbir kural yok:

$ sudo docker run -d --name bookstore-web-preview -p 8080:80 nginx:alpine
$ sudo ufw status | grep -E "8080|Status"
Status: active
$ sudo ss -tlnp | grep 8080
LISTEN 0      4096         0.0.0.0:8080       0.0.0.0:*    users:(("docker-proxy",pid=3664,fd=7))
LISTEN 0      4096            [::]:8080          [::]:*    users:(("docker-proxy",pid=3670,fd=7))
$ sudo iptables -t nat -S DOCKER | grep 8080
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80
ada@my-pc:~$ curl -s -o /dev/null -w "%{http_code}\n" --max-time 5 http://10.20.30.10:8080/
200

Firewall "deny incoming" diyor, ama istemci 200 aldı. Docker'ın dokümanı bunu açıkça yazıyor: yayımlanan port'a gelen paket nat tablosunda DNAT (paketin hedef adresini değiştiren kural) ile container'ın adresine yönlendiriliyor. Paket artık bu makinenin kendisine değil container'a gidiyor; kernel onu ufw kurallarının durduğu INPUT zincirinden değil FORWARD zincirinden geçiriyor ve orada Docker'ın kendi kuralları izin veriyor. Yayımlamak istemediğin bir port için çözüm, container'ı yalnızca loopback'e bağlamak:

$ sudo docker rm -f bookstore-web-preview
$ sudo docker run -d --name bookstore-web-preview -p 127.0.0.1:8080:80 nginx:alpine
$ sudo ss -tlnp | grep 8080
LISTEN 0      4096       127.0.0.1:8080       0.0.0.0:*    users:(("docker-proxy",pid=3959,fd=7))
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/
200
ada@my-pc:~$ curl -sS -o /dev/null --max-time 5 http://10.20.30.10:8080/
curl: (28) Connection timed out after 5047 milliseconds

Sunucunun içinden ulaşılıyor, Nginx onu proxy_pass ile dışarı açabilir; dışarıdan ise ulaşılamıyor. Docker dokümanı 28.0'dan önceki sürümlerde aynı L2 (veri bağlantı katmanı; aynı switch'e bağlı makineler) ağındaki makinelerin loopback'e yayımlanan port'lara da ulaşabildiğini not ediyor; Ubuntu deposundaki sürüm 29 olduğu için bu sunucuda sorun değil. Docker'ın network tarafını, DOCKER-USER zincirini ve daemon'un varsayılan bind adresini Docker serisinin network yazısı anlatıyor. Denemeden sonra container'ı ve Docker'ı sunucudan kaldırdım; Bookstore bu seride systemd ile çalışıyor.

Fail2ban: Tekrarlanan Denemeleri Engellemek

Şifreyle giriş kapalıyken kaba kuvvet saldırısı başarılı olamaz, ama log'u doldurur ve her deneme bir sshd process'i harcar. Fail2ban log'u okur ve aynı adresten çok sayıda başarısız deneme görünce o adresi firewall'da bir süre engeller.

$ sudo apt install fail2ban
$ cat /etc/fail2ban/jail.d/defaults-debian.conf
[DEFAULT]
banaction = nftables
banaction_allports = nftables[type=allports]
backend = systemd

[sshd]
enabled = true
$ grep -E "^(bantime|findtime|maxretry) " /etc/fail2ban/jail.conf
bantime  = 10m
findtime  = 10m
maxretry = 5

Ubuntu paketi kurulur kurulmaz sshd jail'ini açıyor, log'u journald'dan okuyor ve engellemeyi nftables ile yapıyor. Varsayılan: 10 dakikada 5 başarısız deneme 10 dakikalık ban. jail.conf'u değiştirme, paket güncellemesinde üzerine yazılır; ayarlarını jail.d/ altında .local uzantılı bir dosyaya koy (repoda deploy/linux/fail2ban/jail.d/bookstore.local):

[DEFAULT]
# yönetim makinesi (my-pc) hiçbir zaman ban'lanmaz
ignoreip = 127.0.0.1/8 ::1 10.20.30.5

[sshd]
# 10 dakikada 3 başarısız deneme = 1 saat ban
maxretry = 3
findtime = 10m
bantime = 1h
$ sudo fail2ban-client -t
OK: configuration test is successful
$ sudo systemctl reload fail2ban
$ sudo fail2ban-client get sshd ignoreip
These IP addresses/networks are ignored:
|- 127.0.0.0/8
|- 10.20.30.5
`- ::1

Saldırganı taklit etmek için istemciye ikinci bir adres (10.20.30.66) ekleyip o adresten var olmayan kullanıcılarla bağlanmayı denedim:

ada@my-pc:~$ for u in admin oracle test ubuntu; do ssh -b 10.20.30.66 -o BatchMode=yes -o ConnectTimeout=5 $u@10.20.30.10 true; echo "$u exit=$?"; sleep 2; done
admin@10.20.30.10: Permission denied (publickey).
admin exit=255
oracle@10.20.30.10: Permission denied (publickey).
oracle exit=255
test@10.20.30.10: Permission denied (publickey).
test exit=255
ssh: connect to host 10.20.30.10 port 22: Connection refused
ubuntu exit=255
$ sudo fail2ban-client status sshd
Status for the jail: sshd
|- Filter
|  |- Currently failed:	0
|  |- Total failed:	5
|  `- Journal matches:	_SYSTEMD_UNIT=sshd.service + _COMM=sshd
`- Actions
   |- Currently banned:	1
   |- Total banned:	1
   `- Banned IP list:	10.20.30.66
$ sudo grep -E "Found|Ban" /var/log/fail2ban.log | tail -n 5 | cut -c1-120
2026-11-14 17:30:38,459 fail2ban.filter         [5037]: INFO    [sshd] Found 10.20.30.5 - 2026-11-14 17:21:45
2026-11-14 17:31:46,697 fail2ban.filter         [5037]: INFO    [sshd] Found 10.20.30.66 - 2026-11-14 17:31:46
2026-11-14 17:31:48,921 fail2ban.filter         [5037]: INFO    [sshd] Found 10.20.30.66 - 2026-11-14 17:31:48
2026-11-14 17:31:51,148 fail2ban.filter         [5037]: INFO    [sshd] Found 10.20.30.66 - 2026-11-14 17:31:50
2026-11-14 17:31:51,412 fail2ban.actions        [5037]: NOTICE  [sshd] Ban 10.20.30.66
$ sudo nft list table inet f2b-table
table inet f2b-table {
	set addr-set-sshd {
		type ipv4_addr
		elements = { 10.20.30.66 }
	}

	chain f2b-chain {
		type filter hook input priority filter - 1; policy accept;
		tcp dport 22 ip saddr @addr-set-sshd reject with icmp port-unreachable
	}
}

Üç denemeden sonra 10.20.30.66 ban'landı ve dördüncü deneme "Connection refused" aldı: Fail2ban'ın nftables kuralı paketi düşürmüyor, reject ediyor. Kural ufw'nin tablosunda değil, Fail2ban'ın kendi f2b-table'ında ve ondan önce çalışıyor (priority filter - 1). Log'daki ilk satıra dikkat: yukarıdaki testlerde 10.20.30.5'ten reddedilen bir bağlantım da başarısız deneme sayıldı, ama ignoreip'te olduğu için ban'lanmadı. ignoreip'i yazmadan önce bu senaryoyu düşün: config hatası yapıp üç kez yeniden bağlanmayı deneyen yönetici kendi adresini bir saatliğine kilitler. Yanlışlıkla ban'lanan bir adresi elle açmak için:

$ sudo fail2ban-client set sshd unbanip 10.20.30.66
1

Fail2ban yalnızca SSH için değil; Nginx log'larına bakan jail'ler de var (nginx-http-auth, nginx-botsearch). API'ye yönelik istek sınırlama için ise uygulamanın ya da Nginx'in rate limiting'i daha doğru yer; Web Server'lar ve Hosting serisinin HTTPS ve rate limiting yazısı onu anlatıyor.

En Az Yetki ve Denetim

Sertleştirmenin içeriden yüzü, bir şey ele geçirildiğinde zararı sınırlamak. Önceki yazılarda kurduğumuz yapıyı tek tek kontrol ettim:

$ sudo -l -U deploy | tail -n 2
User deploy may run the following commands on bookstore-dev:
    (root) NOPASSWD: /usr/bin/systemctl restart bookstore-api, /usr/bin/systemctl status bookstore-api
$ sudo stat -c "%A %U:%G %n" /etc/bookstore /etc/bookstore/secrets.env /opt/bookstore/current/appsettings.json
drwxr-x--- root:bookstore /etc/bookstore
-rw-r----- root:bookstore /etc/bookstore/secrets.env
-rw-r--r-- deploy:bookstore /opt/bookstore/current/appsettings.json
$ getent passwd bookstore
bookstore:x:999:988::/home/bookstore:/usr/sbin/nologin
$ ps -o user=,pid=,cmd= -C dotnet
booksto+     657 /usr/bin/dotnet /opt/bookstore/current/Bookstore.Api.dll
$ systemd-analyze security bookstore-api --no-pager | tail -n 1
→ Overall exposure level for bookstore-api.service: 9.2 UNSAFE :-{

deploy yalnızca iki komutu root olarak çalıştırabiliyor (üçüncü yazı). Secret dosyasını yalnızca root yazabiliyor ve yalnızca bookstore grubu okuyabiliyor. Uygulama login shell'i olmayan bir system kullanıcısıyla çalışıyor ve release dosyalarını değiştiremiyor, çünkü onlar deploy'a ait. Eksik olan tek katman systemd sandbox'ı: puan 9.2. Web Server'lar ve Hosting serisinin systemd yazısındaki hardening ayarlarını unit'in kendisine ekledim (repoda deploy/linux/systemd/bookstore-api.service; ayarların tek tek anlamı o yazıda):

NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=true
LockPersonality=true
CapabilityBoundingSet=
ReadWritePaths=/var/lib/bookstore
$ sudo systemctl daemon-reload && sudo systemctl restart bookstore-api
$ systemctl is-active bookstore-api
active
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:5000/api/books
200
$ systemd-analyze security bookstore-api --no-pager | tail -n 1
→ Overall exposure level for bookstore-api.service: 3.8 OK :-)

Denetim için iki soru var: kim girdi ve girenler ne yaptı. last, /var/log/wtmp'den terminal açan girişleri listeler; komut çalıştırıp çıkan (ssh host komut) bağlantılar orada görünmez. Eksiksiz liste sshd'nin journal kayıtlarında:

$ last -n 2 -i
ada      pts/0        10.20.30.5       Sat Nov 14 17:22 - 17:22  (00:00)
ada      pts/0        10.20.30.5       Sat Nov 14 17:21 - 17:21  (00:00)

wtmp begins Sat Nov 14 14:04:11 2026
$ journalctl _COMM=sshd --since today --no-pager -o cat | grep -oE "^(Accepted [a-z]+ for [a-z]+|Invalid user [a-z]+) from [0-9.]+" | sort | uniq -c | sort -rn
    285 Accepted publickey for ada from 10.20.30.5
      4 Accepted password for ada from 10.20.30.5
      1 Invalid user test from 10.20.30.66
      1 Invalid user oracle from 10.20.30.66
      1 Invalid user oracle from 10.20.30.5
      1 Invalid user admin from 10.20.30.66
      1 Invalid user admin from 10.20.30.5
$ journalctl _COMM=sudo --since "-30min" --no-pager -o short-iso | grep COMMAND | tail -n 2 | cut -c1-170
2026-11-14T17:32:58+00:00 bookstore-dev sudo[5862]:      ada : PWD=/home/ada ; USER=root ; COMMAND=/usr/bin/systemctl daemon-reload
2026-11-14T17:33:05+00:00 bookstore-dev sudo[5908]:      ada : PWD=/home/ada ; USER=root ; COMMAND=/usr/bin/systemctl restart bookstore-api

Yedinci yazının grep -o | sort | uniq -c zinciri burada bir güvenlik raporuna dönüştü. Dört "Accepted password" satırı şifre girişi kapatılmadan önceki girişler, tahmindeki deneme de aralarında; şifre kapatıldıktan sonra bu sayının artmaması gerekiyor ve düzenli bakılacak bir sayı bu. sudo kayıtları kimin hangi komutu root olarak çalıştırdığını tutuyor; herkesin kendi hesabıyla girmesinin değeri burada görünüyor.

Otomatik güvenlik güncellemelerini dokuzuncu yazıda gördük; bu sunucuda açık (APT::Periodic::Unattended-Upgrade "1") ve kernel güncellemesi sonrası reboot'u sen planlıyorsun. Sertleştirme listesinin son maddesi envanter: systemctl list-units --type=service --state=running bu VM'de 22 service gösterdi. Bulut image'ından gelen ModemManager ya da udisks2 gibi bir sunucuda işi olmayan service'leri kapatmak attack surface'i küçültür, ama her birinin ne yaptığını bilmeden kapatma.

Üretimde Dikkat

Bu yazıdaki her şeyi elle değil kodla yap. 10-bookstore.conf, bookstore.local ve unit dosyası repoda; ufw komutları da sunucuyu kuran script'te ya da configuration management aracında durmalı. Yeni bir sunucu açtığında sertleştirme adımlarından birini atlamamanın tek güvenilir yolu bu. İlerideki Infrastructure as Code: Terraform serisi sunucuyu kurmayı, Bash Scripting serisi bu tür kurulum script'lerini anlatıyor.

Bulut ortamında iki firewall katmanı var: sunucunun içindeki ufw ve sağlayıcının network katmanındaki kurallar (Azure'da Network Security Group, AWS (Amazon'un bulut platformu) tarafında security group). İkisini birlikte kullan; SSH'ı mümkünse yalnızca ofis ya da VPN (şirket ağına şifreli tünel) adresine aç. Sağlayıcının web konsoluna erişimin olduğunu ve çalıştığını kilitlenmeden önce kontrol et; ikinci oturum kuralının yedeği o konsol.

Sertleştirmeyi doğrula. Ayarı yazmak yetmez: sshd -T etkin değeri, ufw show listening açık port'ları, başka bir makineden nc -zv ya da curl dışarıdan görüneni gösterir. Bunu her değişiklikten sonra ve düzenli aralıklarla yap; Docker örneğindeki gibi bir sonraki kurulum bir kuralı sessizce geçersiz kılabilir.

Sık Düşülen Tuzaklar

Ana sshd_config'i değiştirip sshd_config.d/'yi unutmak. Tahminde gördüğümüz gibi drop-in dosyaları önce okunur ve ilk değer kazanır. Ayarı her zaman sshd -T ile doğrula.

SSH ayarını değiştirip tek oturumla çalışmak. AllowUsers'a kendini yazmamak, yanlış bir AllowGroups ya da key'i eklemeden şifreyi kapatmak geçerli config'lerdir; sshd -t onları yakalamaz. İkinci terminalde yeni bağlantı başarılı olmadan ilk oturumu kapatma.

ufw'yi SSH'a izin vermeden açmak. Mevcut oturum yaşar, sen de her şeyin yolunda olduğunu sanırsın; bağlantı koptuğunda bir daha giremezsin. ufw enable'dan önce ufw allow OpenSSH.

ufw'nin Docker'ı da koruduğunu varsaymak. -p 8080:80 port'u bütün adreslerde açar ve ufw kuralları ona uygulanmaz. Container port'unu yalnızca 127.0.0.1'e yayımla ve dışarıdan test et.

Fail2ban'ı şifre girişinin yerine koymak. Fail2ban denemeleri yavaşlatır, ama yavaş ve çok adresten gelen saldırıyı yakalayamaz. Asıl koruma key ile giriş; Fail2ban log'u temiz tutan ek bir katman.

Küçük Bir Deneme

★☆☆ Kendi sunucunda sudo sshd -T | grep -E "^(passwordauthentication|permitrootlogin|kbdinteractiveauthentication) " çalıştır ve sshd_config.d/ altındaki dosyaları listele. Etkin değer hangi dosyadan geliyor?

★★☆ Bir test VM'inde ufw'yi bu yazıdaki doğru sırayla kur, sonra ufw show listening çıktısında kuralı olmayan bir port bırakmadığını kontrol et. Başka bir makineden nc -zv ile 22, 80, 443 ve 5000'i dene ve sonuçları açıkla.

★★★ Fail2ban'a /var/log/nginx/bookstore.access.log'u okuyan ve kısa sürede çok sayıda 404 üreten adresleri ban'layan bir jail yaz. fail2ban-regex ile filtreni log'a karşı test et ve ignoreip'i unutma.

Sunucuyu dışarıya karşı sertleştirdik. Bir sonraki yazıda bir şeyler ters gittiğinde ne yapacağımıza bakıyoruz: "site açılmıyor" şikâyetinden root cause'a giden bir sorun giderme playbook'u ve üç gerçekçi Bookstore vakası.