omeryanbas.com

Ömer Yanbaş

Genel Müdür, Ticofab Yazılım

OperasyonYöntem

Paylaşımlı sunucuda /tmp’ye genel adla dosya yazmayın

Paylaşımlı makinede genel bir geçici dosya adı zaten var olabilir ve başkasına ait olabilir. Yazma düşer, okuma başarılı olur, yanlış içerik yayına gider.

Paylaşımlı bir makinede çalışan deploy betiği commit mesajını geçici bir dosyaya yazdı ve commit, projede kimsenin yazmadığı bir metinle çıktı. Dosya zaten oradaydı, başka bir kullanıcıya aitti ve bizimle hiç ilgisi olmayan bir işten kalmıştı. Kopyalama reddedildi, reddedilme kimsenin okumadığı bir yönlendirmeye gitti, bir sonraki komut da orada duran dosyayı okudu. Zincirdeki her adım başarı raporladı.

Gerçekte ne oluyor

/tmp herkesin yazabildiği, sticky bit taşıyan bir dizindir: 1777. Bu kombinasyon bilinçlidir. Her kullanıcı orada dosya oluşturabilir, var olan bir dosyayı ise yalnızca sahibi ezebilir veya silebilir. Tek kullanıcılı bir makinede bu hiç görünmez. Birkaç deploy hesabı, birkaç proje ve bir avuç cron işi olan paylaşımlı bir makinede ise çarpışma sırasını bekler, çünkü dosya adı zevki herkeste aynıdır: msg.txt, out.json, list.txt, data.csv, backup.sql, payload.txt.

Sıralama kısa:

  1. Daha önceki bir iş, belki aylar önce, /tmp/msg.txt dosyasını başka bir kullanıcı olarak yazdı.
  2. Betiğiniz cp ./message.txt /tmp/msg.txt çalıştırıyor.
  3. Hedefin sahibi siz olmadığınız için kopyalama permission denied ile reddediliyor.
  4. Reddedilme kayboluyor: ya stderr yönlendirilmiş, ya komutlar noktalı virgülle zincirlenmiş, ya da her şey çıkış kodunu kimsenin bakmadığı bir ssh komut string'inin içinde koşmuş.
  5. Bir sonraki adım /tmp/msg.txt dosyasını okuyor. Dosya var ve okunabilir, dolayısıyla okuma başarılı oluyor ve diğer kullanıcının içeriğini döndürüyor.

Bu hatanın paylaşımlı makinelerde bu kadar sık görülmesinin sebebi, aynı makinede birden çok deploy hesabının, birden çok projenin ve kimsenin sahiplenmediği eski cron işlerinin yan yana koşması. Dosyayı bırakan işin aylar önce kaldırılmış olması bir şeyi değiştirmez, çünkü dosya duruyor. /tmp içinde sahiplik, onu oluşturan sürecin ömründen uzun yaşar.

Hatanın şekli şu: düşen bir yazma ve başarılı olan bir okuma. Çıkış kodunu hiç sormayan bir betik bu ikisini ayırt edemez, log da edemez.

Nasıl görülür

Betiğe değil dosyaya bakın. Sahiplik ve değiştirilme zamanı soruşturmayı genelde on saniyede bitirir:

ls -l /tmp/msg.txt
stat -c '%U %G %a %y %s' /tmp/msg.txt
id -un
# otheruser otheruser 644 2025-03-14 09:12:41 +0300 1187
# appuser

Aylar öncesine ait bir değiştirilme zamanı meselenin özetidir. Betiğiniz ne raporlarsa raporlasın, o dosyayı bugün yazmadı.

Yeniden üretmek için tek komut ve basmadığınız çıkış kodu yetiyor:

cp ./message.txt /tmp/msg.txt 2>/dev/null
echo "exit=$?"
# exit=1
head -2 /tmp/msg.txt
# baskasinin icerigi

Makinede daha ne kadarının sıra beklediğini görmek için paylaşımlı dizinde size ait olmayanları listeleyin:

find /tmp -maxdepth 1 -not -user "$(id -un)" -printf '%u %10s %p\n' 2>/dev/null | sort | head -20

Bu listede betiklerinizin de kullandığı adlar varsa, bir sonraki olayı olmadan önce bulmuşsunuz demektir. Dolan disk de aynı sınıftan sessiz yazma hatası üretir; disk baskısı ve saklama süresini size ulaşmadan önce halletmenin bir sebebi de budur.

Çözüm

Dört kural, hepsi tek betiğe sığıyor.

  • Benzersiz yol. Ya mktemp ya da kendi sahibi olduğunuz, başkasının seçmeyeceği adda bir dizin; örneğin kendi ev dizininiz altında projeye özel bir klasör.
  • Kopyalamanın veya taşımanın çıkış kodunu asla bastırmayın. Çıktıyı susturmanız gerekiyorsa önce kodu yakalayın.
  • Yazdıktan sonra doğrulayın. Bir önceki satırın çalıştığını varsaymak yerine dosyanın var olduğunu ve boş olmadığını kontrol edin.
  • Temizliği trap ile yapın ki hem başarı hem hata makineyi derli toplu bıraksın.
#!/usr/bin/env bash
set -euo pipefail

work="$(mktemp -d "${TMPDIR:-/tmp}/release.XXXXXXXX")"
trap 'rm -rf "$work"' EXIT

msg="$work/message.txt"
printf '%s\n' "$COMMIT_MESSAGE" > "$msg"

if [ ! -s "$msg" ]; then
  printf 'mesaj dosyasi yok veya bos: %s\n' "$msg" >&2
  exit 1
fi

git commit -F "$msg"

Başlıktaki set -euo pipefail üç iş yapıyor: -e ilk hata veren komutta betiği durdurur, -u tanımsız değişken kullanmayı hata sayar, pipefail de borunun ortasındaki hatanın yutulmasını engeller. Bu üçlü olmadan aşağıdaki kontrollerin çoğu yazılmış ama işlevsiz kalır.

mktemp -d, rastgele sonekli, size ait, 0700 modunda bir dizin açar. Çarpışacak bir ad yoktur, içindekini başka kullanıcı okuyamaz ve trap, betik biterse de ölürse de dizini kaldırır.

Bu hatanın en kolay yapıldığı yer uzak sunucu tarafıdır, çünkü kopyalama bir sınırı geçer ve çıkış kodunun o sınırdan geri gelmesi gerekir:

host="$1"
remote="/root/release-$(date +%Y%m%d-%H%M%S)-$$"

if ! ssh "$host" "mkdir -m 700 -p '$remote'"; then
  printf 'uzak dizin olusturulamadi\n' >&2
  exit 1
fi

if ! scp ./build.tar.gz "$host:$remote/build.tar.gz"; then
  printf 'yukleme basarisiz\n' >&2
  exit 1
fi

ssh "$host" "set -eu; cd '$remote'; tar xzf build.tar.gz; test -f public/index.html"
printf 'uzak acma cikis kodu: %s\n' "$?"

Ağırlığın çoğunu iki ayrıntı taşıyor. $(date ...) ile süreç numarası $$ birleşince, aynı makinedeki ikinci bir işin seçmeyeceği bir yol çıkıyor. Uzak komut string'inin içindeki set -eu ise zincirin ortasındaki hatanın raporlanmasını sağlıyor; yoksa yalnızca son komutun kodu döner.

Çalıştığını nasıl doğrularsınız

Betiği iz sürerek çalıştırın ve gerçekten kullandığı yola bakın:

bash -x ./release.sh 2>&1 | grep -i mktemp
# ++ mktemp -d /tmp/release.XXXXXXXX
stat -c '%a %U %n' /tmp/release.*
# 700 appuser /tmp/release.kQ9dT3ax

Doğrulanacak iki şey var: mod 700 ve kendi kullanıcı adınız. Sonra doğrulama adımının gerçekten işi durdurduğunu kanıtlayın, boş girdi vererek:

COMMIT_MESSAGE="" ./release.sh; echo "exit=$?"
# mesaj dosyasi yok veya bos: /tmp/release.kQ9dT3ax/message.txt
# exit=1

Boş dosyada duran betik, yazılamamış dosyada da durur. Kontrolün amacı tam olarak bu: dosyanın neden yanlış olduğu değil, yanlış olduğu.

Eski genel yolu ilk gün silmeyin. Birkaç gün daha yerinde bırakıp kimin yazdığına bakın; aynı makinedeki başka bir işin o dosyaya güveniyor olma ihtimali vardır ve sıradaki kırılan şey o iş olmasın.

Nelere dikkat etmeli

  • TMPDIR her zaman /tmp olmayabilir. Betiklerde ona saygı gösterin ve PrivateTmp açık bir systemd unit'inin kendi namespace'i olduğunu unutmayın; kabuktan ve servisten bakılan aynı yol iki farklı dosyadır. Tek bir yolun çelişen iki görüntüsü, kontrol paneli ile nginx dosyasının çelişmesiyle aynı kafa karışıklığıdır.
  • /tmp çoğu dağıtımda zamanlayıcıyla veya açılışta temizlenir. Yeniden başlatmadan sağ çıkması gereken hiçbir şey, adı ne olursa olsun oraya ait değildir.
  • Sticky bit silmeyi engeller, okumayı değil. /tmp içinde 644 modunda ve içinde token olan bir dosyayı makinedeki her hesap okuyabilir, ele geçirilen herhangi bir servis hesabı dahil. Kimlik bilgileri benzersiz adla bile /tmp'ye gitmez.
  • Tahmin edilebilir yol bir hedeftir. Başka bir kullanıcı oraya önceden symlink koyup yazmanızı bambaşka bir yere yönlendirebilir. mktemp'in tahmin edilemez adı bu kapıyı kapatır.
  • Aynı yolu iki işin paralel kullanması, sahiplik sorunu hiç olmasa bile bir yarıştır. İki deploy aynı anda /tmp/build.tar.gz yazarsa ikisi de başarı raporlar ve biri diğerinin dosyasını açar.
  • Bu hataların yıllarca yaşamasının sebebi genelde stderr'in /dev/null'a gönderilmesidir. Bir komut gürültülüyse çıktısını çöpe atmak yerine sonra okuyabileceğiniz bir dosyaya yazın.

Akılda tutulacak alışkanlık hatadan küçük: her yazmayı düşebilecek bir şey, her okumayı yanlış sebeple başarılı olabilecek bir şey sayın. Benzersiz bir yol, kontrol edilen bir çıkış kodu ve tek satırlık doğrulama yazarken hiçbir şeye mal olmaz, ama yabancının içeriğini sizin adınızla yayınlamaktan kurtarır. Aynı disiplin, doğru ile yanlışın ekranda ayırt edilemediği sorun sınıfını da yakalar; örneğin her mesajı ikiye bölen görünmez sonek. Başkalarıyla ve başka işlerle paylaştığınız bir makinede genel adın zaten alınmış olduğunu varsayın, çünkü er geç alınmış oluyor.

Sorular ve cevaplar

/tmp'ye kopyalama neden permission denied veriyor?
/tmp sticky bit ile 1777 modundadır. Her kullanıcı orada dosya oluşturabilir ama var olan bir dosyayı yalnızca sahibi değiştirebilir veya silebilir. Seçtiğiniz adla başka bir kullanıcı ya da başka bir servis daha önce dosya oluşturduysa, dizin herkese açık olsa bile kopyalamanız reddedilir.
Geçici dosyayı güvenli yapmak için mktemp yeterli mi?
En önemli iki sorunu çözer: ad tahmin edilemez olduğu için ne önceden var olabilir ne de yerine symlink konabilir, mktemp -d ile dizin 0700 modunda açıldığı için başka kullanıcı içini okuyamaz. Dosyayı root'tan gizlemez ve yeniden başlatmadan sağ çıkmaz; yani sır barındıran veya sonradan lazım olacak şeyler yine başka yere aittir.
Dosya neden kabuktan başka, servisten başka görünüyor?
PrivateTmp açık olan systemd unit'leri kendi özel /tmp namespace'ini alır. Yol birebir aynıdır ama arkasındaki dosya sistemi aynı değildir, yani elle yazdığınız dosya servis için görünmezdir ve tersi de geçerlidir. Yazma düştü sonucuna varmadan önce unit dosyasında PrivateTmp'ye bakın.
ssh ile çalıştırdığım komutun çıkış kodunu nasıl alırım?
ssh, uzak komutun çıkış kodunu kendi çıkış kodu olarak döner; bu yüzden ssh çağrısını düz bir if içine almak yeterlidir. İşi bozan şey çıktıyı /dev/null'a yollayıp noktalı virgülle devam etmek ya da birkaç uzak komutu set -e olmadan tek bir string'e sarmaktır; o durumda yalnızca son komutun kodu raporlanır.