D
P
0
← Semua artikel Read in English

WordPress & PHP di Produksi

`ssh -n` dengan Heredoc Keluar 0 Tanpa Output: Bash di Server Membaca `/dev/null`, Bukan Skripmu

· · 7 menit baca
`ssh -n` dengan Heredoc Keluar 0 Tanpa Output: Bash di Server Membaca `/dev/null`, Bukan Skripmu

Perintah yang keluar dengan status 0 tanpa mencetak apa pun bisa berarti dua hal yang dari luar tampak sama persis. Skripnya berjalan dan memang tidak menemukan apa-apa, atau skripnya tidak pernah berjalan sama sekali. Pada 13 Oktober 2026 saya mendapat jenis yang kedua, dari perintah ssh ke server produksi yang bentuknya terlihat wajar. Alias host di bawah sudah saya ganti, dan yang penting di sini hanya bentuk panggilannya, bukan isi skripnya.

ssh -n prod 'bash -s' <<'EOF'
# ... skrip yang seharusnya dijalankan bash di server ...
EOF

Panggilan itu keluar dengan status 0 dan tidak mencetak satu baris pun, sehingga terbaca seperti "tidak ada temuan". Kenyataannya bash di server tidak pernah menerima skrip itu. Yang ia baca adalah /dev/null.

Ini kebalikan dari masalah ssh yang menggantung di prompt password karena DISPLAY masih ter-set, yang pernah saya tulis terpisah. Di sana ssh tidak pernah selesai, jadi setidaknya jelas ada yang salah. Di sini ssh selesai cepat dan bersih, tanpa satu tanda pun bahwa pekerjaannya tidak pernah dilakukan.

-n menahan heredoc sebelum sempat berangkat

Halaman manual ssh membuka penjelasan flag ini dengan kalimat berikut: -n "Redirects stdin from /dev/null (actually, prevents reading from stdin)." Heredoc di perintah tadi dipasang oleh shell lokal sebagai stdin proses ssh. Begitu -n ada, ssh tidak membaca stdin sama sekali, jadi skrip di dalam heredoc tetap tertinggal di laptop dan tidak ada sebaris pun yang dikirim ke server.

Di sisi server, bash -s berarti bash membaca perintahnya dari stdin. Stdin yang ia dapat kosong, jadi ia langsung bertemu akhir berkas, menjalankan nol perintah, dan keluar dengan sukses. Halaman manual ssh juga menyebut ssh keluar dengan status milik perintah remote, jadi angka 0 itu diteruskan apa adanya ke terminal saya.

Sisi server itu bisa ditiru di mesin mana pun, tanpa ssh:

bash -s </dev/null
echo $?   # 0, tanpa satu baris pun tercetak sebelumnya

Tidak ada pesan galat karena memang tidak ada yang galat. Bash diberi skrip kosong dan menyelesaikannya dengan baik, jadi status 0 itu jujur. Yang keliru adalah anggapan bahwa ada skrip yang sampai.

Tempat -n memang dibutuhkan

Flag ini tidak salah, ia hanya salah tempat. Aturan yang saya catat di runbook hari itu membatasinya ke satu situasi: -n dipakai hanya kalau ssh berjalan di dalam skrip heredoc lokal dan tidak boleh memakan sisa skrip itu.

Situasinya begini. Kalau sebuah skrip lokal diumpankan ke bash lewat heredoc, isi skrip itulah stdin bash, dan setiap perintah di dalamnya mewarisi stdin yang sama. Perintah ssh termasuk yang membaca stdin dengan rakus, karena tugasnya memang meneruskan stdin ke perintah di seberang.

# disederhanakan: skrip lokal yang diumpankan ke bash lewat heredoc
bash <<'EOF'
ssh -n prod 'uptime'
echo "baris ini milik bash lokal"
EOF

Tanpa -n, ssh di baris pertama ikut menelan baris echo sebagai stdin-nya, dan bash lokal tidak pernah sampai ke sana. Efeknya bisa dilihat tanpa server dengan mengganti baris ssh menjadi cat >/dev/null: baris echo tidak pernah dijalankan.

Kejadian 13 Oktober adalah kebalikannya. Heredoc-nya bukan milik bash lokal, melainkan skrip untuk sisi remote, jadi ssh justru wajib membawanya. Aturan yang sama menulis bagian keduanya: -n dibuang kalau heredoc-nya ditujukan untuk sisi remote.

ssh prod 'bash -s' <<'EOF'
# ... skrip untuk bash di server ...
EOF

Logikanya berlaku untuk penerima apa pun di server yang membaca skrip dari stdin, bukan hanya bash -s. Bagian runbook yang sama memuat contoh read-only yang mengalirkan skrip PHP lewat heredoc ke php -d display_errors=1 /dev/stdin di server, dengan -o BatchMode=yes dan key ed25519, dan contoh itu memang tanpa -n.

Yang berubah pada 13 Oktober adalah aturannya, bukan sebuah skrip yang dipasang. Catatan saya tidak menyimpan hasil menjalankan ulang perintah itu tanpa -n, jadi saya tidak bisa bilang apakah hari itu sebenarnya ada sesuatu yang bisa ditemukan di server.

2>/dev/null pada operasi tulis

Keheningan yang menipu tidak selalu datang dari ssh. Catatan saya yang lain menyimpan kasus sebuah wp eval-file yang dijalankan dengan 2>/dev/null dan tidak mencetak apa pun. Ternyata skrip itu fatal karena TypeError PHP 8, yaitu count() yang dipanggil pada sebuah iterator, dan fatal itu terjadi sebelum skripnya menyentuh apa pun.

Sejak PHP 8.0, count() pada nilai yang bukan array dan bukan objek Countable melempar TypeError, dan iterator tidak otomatis Countable. Dengan stderr dibuang, skrip yang mati sebelum menyentuh apa pun dan skrip yang selesai tanpa perlu bicara menghasilkan layar yang sama, yaitu kosong. Untuk operasi tulis, layar kosong itu bisa dibaca sebagai perubahan yang sudah beres, padahal perubahannya tidak pernah dimulai.

Aturan yang tercatat bersamanya ada dua. Jangan pernah menyembunyikan stderr pada operasi tulis. Kalau ada noise yang mengganggu, saring baris noise itu dengan grep -v, bukan dengan membuang seluruh stderr.

Noise seperti itu memang ada. Di server tempat kejadian ssh -n tadi, wp option get home mencetak satu baris [ERROR] dari pihak ketiga sebelum jawaban yang benar. Baris semacam itulah yang disaring, bukan seluruh alirannya.

Shell di seberang ternyata sh

Catatan ketiga datang dari pekerjaan forensik pada sebuah situs WordPress, yang berangkat dari satu pertanyaan: siapa pelakunya di balik header yang rusak. Catatan itu punya daftar jalan buntu, dan salah satu butirnya berbunyi: shell di sisi remote adalah sh, bukan bash.

Butir itu penting karena yang akhirnya menjalankan string perintah dari ssh bukan shell di laptop. Halaman manual sshd menulisnya terang: "All commands are run under the user's login shell as specified in the system password database." Kalau shell login di server adalah sh, sintaks bash yang terselip di perintah itu dibaca oleh sh.

Di catatan itu, tiga hal di dalam loop berpipa diam-diam menghasilkan keluaran sampah: $'\t', read dengan IFS tab, dan date -d @$var. Tidak ada galat, hanya data yang salah.

$'\t' adalah cara bash menulis karakter tab. Shell yang tidak mengenal bentuk itu tidak menolaknya, ia membaca karakternya apa adanya. Saya tidak mencatat sh jenis apa yang berjalan di server itu, jadi contoh berikut memakai dash yang tidak mengenal bentuk itu sebagai ilustrasi umum, bukan rekaman dari sana.

# ilustrasi umum di laptop, bukan rekaman dari server mana pun
cat > pecah.sh <<'EOF'
printf 'satu\tdua\n' | while IFS=$'\t' read a b; do echo "a=$a b=$b"; done
EOF
bash pecah.sh   # a=satu b=dua
dash pecah.sh   # a=sa b=u<TAB>dua

Di dash itu, $'\t' menjadi tiga karakter: tanda dolar, garis miring terbalik, dan huruf t. IFS-nya jadi berisi huruf t, read memotong baris di huruf itu, dan kedua perintah sama-sama keluar dengan status 0.

Aturan yang dicatat untuk kasus ini memindahkan pekerjaannya keluar dari shell: timestamp diformat di SQL dengan FROM_UNIXTIME, atau di PHP lewat wp eval. Di daftar sumber yang berhasil dalam catatan yang sama, FROM_UNIXTIME(option_value) memang dipakai di SQL untuk membaca waktu dari baris _timeout_ sebuah transient, dikurangi TTL-nya. Nilai baris itu adalah detik Unix saat transient kedaluwarsa, jadi setelah dikurangi TTL hasilnya adalah saat transient itu ditulis, dan yang mengubahnya menjadi tanggal adalah MySQL, bukan shell.

Rows affected: -1 dari literal string kosong

Butir lain di daftar jalan buntu yang sama: REPLACE(url,"...","") dengan literal kosong "" di dalam wp db query yang dikutip lewat ssh selalu kembali sebagai Rows affected: -1. Setiap kali.

Catatan itu tidak menyimpan penyebabnya, dan saya tidak akan menebaknya di sini. Yang tercatat adalah gantinya: pakai SUBSTRING(url, 24).

Ekspresi pengganti itu tidak memuat literal string sama sekali, hanya nama kolom dan angka. Posisi karakter di MySQL dihitung mulai dari 1, jadi SUBSTRING(url, 24) mengembalikan isi kolom mulai karakter ke-24, artinya membuang 24 dikurangi 1, yaitu 23 karakter pertama, apa pun isinya. Pengganti itu hanya setara dengan REPLACE tadi kalau teks yang dibuang selalu berada di awal kolom dan panjangnya tepat 23 karakter. REPLACE juga masih membuang teks yang sama kalau ia muncul lagi di tengah, sedangkan SUBSTRING hanya memotong kepalanya. Catatan saya menyimpan aturannya, bukan hasil query penggantinya.

Membaca diam dari server

Keempat kejadian ini punya bentuk yang sama. Sisi seberang membalas dengan sesuatu yang berbentuk jawaban: status 0 tanpa output dari skrip yang tidak pernah sampai, layar kosong dari operasi tulis yang mati sebelum mulai, keluaran sampah tanpa galat dari shell yang bukan bash, dan angka minus satu dari query yang tidak menjelaskan dirinya.

Di dua kejadian pertama, programnya bahkan bekerja dengan benar. Bash menjalankan skrip kosong yang diberikan kepadanya lalu melapor sukses, dan PHP 8 berhenti di count() pada iterator persis seperti seharusnya. Yang hilang adalah jalur informasinya: skrip yang tidak pernah dikirim, dan stderr yang dibuang. Sebelum membaca diam sebagai "tidak ada", pastikan dulu perintahnya punya kesempatan untuk bicara: skripnya benar-benar sampai, stderr-nya terlihat, dan shell yang membacanya memang shell yang kamu kira.