Lewati ke konten

Uji subdomain

DNS, jalur ACME, dan sertifikat, berurutan.

Berkas: alat/hari-3/Praktik 11/tools/uji-subdomain.mjs

Praktik 11/tools/uji-subdomain.mjs
#!/usr/bin/env node
// =====================================================================
// Praktik 11 bagian 3 - uji-subdomain.mjs
//
// Praktik 11 bagian 3 mengandalkan tiga hal yang sulit diperiksa secara
// manual dan sering baru ketahuan gagal di tahap paling akhir:
//
// - propagasi DNS subdomain
// - kesiapan jalur ACME SEBELUM menjalankan certbot
// - apakah sertifikat dan HTTPS benar-benar aktif SESUDAH-nya
//
// Skrip ini menjalankan pemeriksaan berurutan itu, dan berhenti lebih awal
// kalau syarat berikutnya belum terpenuhi — supaya tidak membuang kuota
// penerbitan sertifikat Let's Encrypt.
//
// Pakai:
// node uji-subdomain.mjs nama01.gisbigtrainer.com
// node uji-subdomain.mjs nama01.gisbigtrainer.com --ip 34.1.2.3
// =====================================================================
import { Resolver } from 'node:dns/promises';
import { connect } from 'node:tls';
const argv = process.argv.slice(2);
const idxIp = argv.indexOf('--ip');
const ipHarapan = idxIp > -1 ? argv[idxIp + 1] : null;
// Buang nama opsi beserta nilainya; sisanya adalah argumen posisional.
const buang = new Set();
if (idxIp > -1) { buang.add(idxIp); buang.add(idxIp + 1); }
const subdomain = argv.find((a, i) => !a.startsWith('--') && !buang.has(i));
if (!subdomain) {
console.error('Pakai: node uji-subdomain.mjs <subdomain> [--ip <ip-statis-vm>]');
process.exit(2);
}
const TIMEOUT = Number(process.env.TIMEOUT_MS ?? 10000);
const hasil = [];
const catat = (severity, judul, detail) => hasil.push({ severity, judul, detail });
async function ambil(url, opsi = {}) {
const kontrol = new AbortController();
const timer = setTimeout(() => kontrol.abort(), TIMEOUT);
try {
return await fetch(url, { ...opsi, signal: kontrol.signal, redirect: 'manual' });
} finally {
clearTimeout(timer);
}
}
console.log(`Subdomain : ${subdomain}`);
if (ipHarapan) console.log(`IP harapan: ${ipHarapan}`);
console.log('');
// ------------------------------------------------------------------ 1
// Tahap 4: resolusi DNS
let ipResolusi = [];
try {
const resolver = new Resolver();
resolver.setServers(['1.1.1.1', '8.8.8.8']);
ipResolusi = await resolver.resolve4(subdomain);
if (ipResolusi.length === 0) ipResolusi = await resolver.resolveCname(subdomain);
} catch (e) {
catat('kritis', 'Subdomain belum bisa di-resolve oleh resolver publik', e.message);
}
if (ipResolusi.length) {
console.log(`1. DNS : ${ipResolusi.join(', ')}`);
if (ipHarapan && !ipResolusi.includes(ipHarapan)) {
catat('kritis', `DNS menunjuk ke ${ipResolusi.join(', ')}, seharusnya ${ipHarapan}`,
'Record A belum diperbarui, atau TTL lama masih tersimpan di cache resolver.');
} else if (ipHarapan) {
console.log(' -> cocok dengan IP statis VM');
}
} else if (hasil.every((h) => h.severity !== 'kritis')) {
catat('kritis', 'Record A subdomain belum ada',
'Buat record A pada pengelola DNS, TTL 300, menunjuk ke IP statis VM.');
}
// ------------------------------------------------------------------ 2
// Tahap 5: HTTP lewat subdomain
if (ipResolusi.length) {
try {
const r = await ambil(`http://${subdomain}/portal`);
const lokasi = r.headers.get('location');
console.log(`2. HTTP : ${r.status}${lokasi ? ' -> ' + lokasi : ''}`);
if (r.status === 0) {
catat('tinggi', 'Tidak ada balasan HTTP lewat subdomain',
'Periksa firewall GCP: port 80 harus terbuka, dan container nginx harus jalan.');
}
} catch (e) {
catat('tinggi', 'Permintaan HTTP lewat subdomain gagal', e.message);
}
}
// ------------------------------------------------------------------ 3
// Tahap 8: jalur ACME. INI YANG PALING PENTING DIPERIKSA SEBELUM CERTBOT.
try {
const uji = `http://${subdomain}/.well-known/acme-challenge/uji-coba`;
const r = await ambil(uji);
const badan = await r.text();
console.log(`3. ACME : ${r.status} (${badan.slice(0, 40).replace(/\n/g, ' ') || 'tanpa isi'})`);
if (r.status === 404) {
// Yang kita inginkan: 404 dari Nginx karena berkas uji memang tidak ada.
// Yang tidak kita inginkan: 404 dari halaman aplikasi, yang berarti
// permintaan tidak pernah sampai ke webroot.
//
// Penanda aplikasi dicari secara spesifik. Sebelumnya penanda dibuat
// terlalu longgar (<html saja) sehingga halaman 404 milik server apa pun
// — termasuk Cloudflare — ikut dilaporkan sebagai salah konfigurasi.
const penandaNext = /__NEXT_DATA__|\/_next\/static|__next_f/i.test(badan);
const penandaNginx = /nginx/i.test(r.headers.get('server') ?? '') || badan.trim() === '';
if (penandaNext) {
catat('kritis', 'Permintaan ACME diteruskan ke aplikasi Next.js, bukan ke webroot Nginx',
'Location /.well-known/acme-challenge/ belum ada di nginx.conf.\n' +
'Certbot akan gagal dengan "Invalid response ... 404".\n' +
'Tambahkan: location ^~ /.well-known/acme-challenge/ { root /var/www/certbot; }');
} else if (!penandaNginx) {
catat('sedang', 'Balasan 404 pada jalur ACME tidak jelas berasal dari Nginx',
'Jalur ACME belum bisa dipastikan siap. Sebelum menjalankan certbot, pastikan\n' +
'nginx.conf memuat: location ^~ /.well-known/acme-challenge/ { root /var/www/certbot; }');
} else {
console.log(' -> 404 dari Nginx, sesuai harapan. Certbot siap dijalankan.');
}
} else if (r.status === 502 || r.status === 200) {
catat('kritis', `Jalur ACME membalas ${r.status}, bukan 404`,
'Periksa konfigurasi location untuk ACME pada Nginx.');
}
} catch (e) {
catat('sedang', 'Tidak bisa memeriksa jalur ACME', e.message);
}
// ------------------------------------------------------------------ 4
// Tahap 11 & 14: HTTPS
try {
const r = await ambil(`https://${subdomain}/portal`);
console.log(`4. HTTPS : ${r.status}`);
if (r.status === 0) catat('sedang', 'HTTPS belum aktif', 'Normal bila sertifikat belum diterbitkan.');
} catch (e) {
catat('sedang', 'Permintaan HTTPS gagal', `${e.message}\nNormal bila sertifikat belum diterbitkan (Tahap 9-10 belum dijalankan).`);
}
// ------------------------------------------------------------------ 5
// Periksa sertifikat langsung di tingkat TLS: nama, masa berlaku, penerbit.
try {
const info = await new Promise((selesai, tolak) => {
const soket = connect({ host: subdomain, port: 443, servername: subdomain, timeout: TIMEOUT }, () => {
const sert = soket.getPeerCertificate();
const sah = soket.authorized;
const alasan = soket.authorizationError;
soket.end();
selesai({ sert, sah, alasan });
});
soket.once('error', tolak);
soket.once('timeout', () => { soket.destroy(); tolak(new Error('timeout')); });
});
if (info.sert && Object.keys(info.sert).length) {
const berlakuSampai = new Date(info.sert.valid_to);
const sisaHari = Math.round((berlakuSampai - Date.now()) / 86400000);
console.log(`5. Sertifikat: subjek ${info.sert.subject?.CN ?? '?'}, berlaku sampai ${berlakuSampai.toISOString().slice(0, 10)} (${sisaHari} hari lagi)`);
const cn = info.sert.subject?.CN ?? '';
const alt = info.sert.subjectaltname ?? '';
if (cn !== subdomain && !alt.includes(subdomain)) {
catat('kritis', `Sertifikat tidak berlaku untuk ${subdomain}`,
`CN=${cn}, SAN=${alt}. Certbot mungkin menerbitkan untuk domain yang berbeda.`);
}
if (sisaHari < 20) {
catat('sedang', `Sertifikat kedaluwarsa dalam ${sisaHari} hari`,
'Periksa Tahap 12: perpanjangan otomatis dan hook reload Nginx.');
}
if (!info.sah) {
catat('tinggi', `Sertifikat tidak tepercaya: ${info.alasan}`,
'Bisa jadi rantai sertifikat belum lengkap (pakai fullchain.pem, bukan cert.pem).');
}
}
} catch {
// Diabaikan: HTTPS belum tentu sudah aktif.
}
// ------------------------------------------------------------------ ringkasan
const WARNA = { kritis: '\u001b[31m', tinggi: '\u001b[31m', sedang: '\u001b[33m', rendah: '\u001b[36m', info: '\u001b[2m' };
const URUT = { kritis: 0, tinggi: 1, sedang: 2, rendah: 3, info: 4 };
const R = '\u001b[0m';
const w = (s, t) => (process.stdout.isTTY ? WARNA[s] + t + R : t);
console.log('');
if (hasil.length === 0) {
console.log('\u001b[32mSemua pemeriksaan lolos.\u001b[0m');
process.exit(0);
}
console.log(`HASIL: ${hasil.length} temuan`);
for (const t of [...hasil].sort((a, b) => URUT[a.severity] - URUT[b.severity])) {
console.log(`${w(t.severity, '[' + t.severity.toUpperCase() + ']')} ${t.judul}`);
if (t.detail) for (const b of String(t.detail).split('\n')) console.log(` ${b}`);
}
process.exit(hasil.some((t) => t.severity === 'kritis') ? 1 : 0);