The companion bash scripts for every lab - view them here or download the raw .sh. Each is idempotent and meant to run inside its lab environment.
Authorized-lab use only. Read before you run; these touch firewall, web, and mail config.
Deploy and harden an Nginx web server on Debian, then view your site at your subdomain.
Open the lab guide →#!/usr/bin/env bash
# harden-nginx.sh - Hardened LN Stack lab (ch.5)
# ---------------------------------------------------------------------------
# Stands up a hardened Linux + Nginx web server on Debian 13: tightened nginx
# core, a full security-headers snippet, TLS-ready ssl params, a default-deny
# catch-all for scanner noise, a UFW firewall, and fail2ban on ssh + nginx.
# Idempotent. Run inside the lab as root: ./harden-nginx.sh [domain]
# ---------------------------------------------------------------------------
set -uo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
print_header(){ echo -e "\n${GREEN}=== $1 ===${NC}"; }
die(){ echo -e "${RED}[x] $1${NC}"; exit 1; }
[[ ${EUID:-$(id -u)} -eq 0 ]] || die "Run as root: sudo ./harden-nginx.sh [domain]"
export DEBIAN_FRONTEND=noninteractive
DOMAIN="${1:-localhost}"
WEBROOT="/var/www/${DOMAIN}"
print_header "Installing packages"
apt-get update -qq
apt-get install -y nginx fail2ban ufw curl openssl || die "package install failed"
print_header "Hardening the Nginx core"
cat > /etc/nginx/conf.d/00-hardening.conf <<'EOF'
server_tokens off; # don't leak the version
server_names_hash_bucket_size 128;
client_max_body_size 16m; # cap uploads
client_body_timeout 10s;
client_header_timeout 10s;
send_timeout 10s;
keepalive_timeout 30s;
types_hash_max_size 2048;
# TLS defaults for any server block that enables ssl
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
# rate-limit zone (apply with limit_req in a location)
limit_req_zone $binary_remote_addr zone=req_per_ip:10m rate=10r/s;
EOF
print_header "Writing the security-headers snippet"
install -d /etc/nginx/snippets
cat > /etc/nginx/snippets/security-headers.conf <<'EOF'
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
# Enable HSTS only once TLS is live (it is sticky - hard to undo):
# add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
EOF
print_header "Creating the ${DOMAIN} site (default-deny)"
install -d "$WEBROOT"
[ -f "$WEBROOT/index.html" ] || echo "<!doctype html><title>${DOMAIN}</title><h1>${DOMAIN} is up.</h1>" > "$WEBROOT/index.html"
# Catch-all that drops requests for hosts we don't serve (kills a lot of scanner noise).
cat > /etc/nginx/sites-available/000-default-deny <<'EOF'
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 444; # close the connection, send nothing
}
EOF
cat > /etc/nginx/sites-available/"$DOMAIN" <<EOF
server {
listen 80;
listen [::]:80;
server_name ${DOMAIN};
root ${WEBROOT};
index index.html;
include snippets/security-headers.conf;
location / {
limit_req zone=req_per_ip burst=20 nodelay;
try_files \$uri \$uri/ =404;
}
location ~ /\. { deny all; } # .env / .git / dotfiles
location ~* \.(bak|old|sql|swp)\$ { deny all; } # editor/backup leftovers
}
EOF
ln -sf /etc/nginx/sites-available/"$DOMAIN" /etc/nginx/sites-enabled/"$DOMAIN"
ln -sf /etc/nginx/sites-available/000-default-deny /etc/nginx/sites-enabled/000-default-deny
rm -f /etc/nginx/sites-enabled/default
print_header "Firewall (UFW)"
ufw allow OpenSSH >/dev/null 2>&1 || ufw allow 22/tcp
ufw allow 80/tcp; ufw allow 443/tcp
ufw --force enable
print_header "fail2ban (ssh + nginx)"
cat > /etc/fail2ban/jail.local <<'EOF'
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
[sshd]
enabled = true
[nginx-http-auth]
enabled = true
[nginx-botsearch]
enabled = true
EOF
systemctl enable --now fail2ban >/dev/null 2>&1 || true
print_header "Validating + reloading"
nginx -t || die "nginx config test failed"
systemctl enable --now nginx
systemctl reload nginx
print_header "Done"
echo -e "${GREEN}Hardened Nginx is serving ${DOMAIN}.${NC}"
echo "Check headers: curl -sI http://${DOMAIN}/ | grep -iE 'content-security|x-frame|referrer|permissions|content-type'"
echo -e "${YELLOW}Add TLS: apt-get install -y certbot python3-certbot-nginx && certbot --nginx -d ${DOMAIN}${NC}"
echo "Then uncomment the HSTS line in /etc/nginx/snippets/security-headers.conf."
Configure and test security headers on a live server.
Open the lab guide →#!/usr/bin/env bash
# test-headers.sh - Security Headers lab (ch.6)
# ---------------------------------------------------------------------------
# Two jobs:
# ./test-headers.sh --apply write a strong security-headers snippet (root)
# ./test-headers.sh [url] probe a live URL and grade its headers (A–F)
# ---------------------------------------------------------------------------
set -uo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
print_header(){ echo -e "\n${GREEN}=== $1 ===${NC}"; }
apply() {
[[ ${EUID:-$(id -u)} -eq 0 ]] || { echo -e "${RED}--apply needs root${NC}"; exit 1; }
install -d /etc/nginx/snippets
cat > /etc/nginx/snippets/security-headers.conf <<'EOF'
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; upgrade-insecure-requests" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
EOF
echo -e "${GREEN}[+] Wrote /etc/nginx/snippets/security-headers.conf${NC}"
echo " 'include snippets/security-headers.conf;' in your server block, then: nginx -t && systemctl reload nginx"
exit 0
}
[[ "${1:-}" == "--apply" ]] && apply
URL="${1:-http://localhost/}"
print_header "Grading security headers on ${URL}"
H="$(curl -sSIL --max-time 10 "$URL")" || { echo -e "${RED}could not reach ${URL}${NC}"; exit 1; }
val(){ grep -i "^$1:" <<<"$H" | head -1 | cut -d: -f2- | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'; }
score=0; max=0
grade1(){ # name weight [must-contain]
local name="$1" w="$2" need="${3:-}" v; max=$((max+w)); v="$(val "$name")"
if [[ -z "$v" ]]; then printf ' \033[31mx %-28s missing\033[0m\n' "$name"; return; fi
if [[ -n "$need" && "$v" != *"$need"* ]]; then
printf ' \033[33m~ %-28s weak: %s\033[0m\n' "$name" "$v"; score=$((score + w/2)); return; fi
printf ' \033[32m+ %-28s %s\033[0m\n' "$name" "$v"; score=$((score + w))
}
grade1 "Content-Security-Policy" 30
grade1 "Strict-Transport-Security" 20 "max-age=6"
grade1 "X-Content-Type-Options" 15 "nosniff"
grade1 "X-Frame-Options" 15
grade1 "Referrer-Policy" 10
grade1 "Permissions-Policy" 10
# penalties
if grep -qiE "^Server:.*[0-9]+\.[0-9]+" <<<"$H"; then
printf ' \033[31mx %-28s leaks version: %s\033[0m\n' "Server" "$(val Server)"; score=$((score-5)); fi
if grep -qi "unsafe-inline" <<<"$H"; then
printf ' \033[33m~ %-28s CSP allows unsafe-inline\033[0m\n' "Content-Security-Policy"; score=$((score-5)); fi
(( score<0 )) && score=0
pct=0; (( max>0 )) && pct=$(( score*100/max ))
g="F"; (( pct>=90 )) && g="A"; (( pct>=80 && pct<90 )) && g="B"; (( pct>=70 && pct<80 )) && g="C"; (( pct>=60 && pct<70 )) && g="D"
echo -e "\n Score: ${pct}/100 Grade: ${GREEN}${g}${NC}"
echo " Apply a strong baseline: sudo $0 --apply"
Build Suricata 8.0.5 from source on Debian 13 (full toolchain), run it inline via NFQUEUE, and drop a live attack.
Open the lab guide →#!/usr/bin/env bash
# install-suricata-ips.sh - Suricata Inline IPS lab (ch.8)
# ---------------------------------------------------------------------------
# Builds Suricata 8.0.5 FROM SOURCE on Debian 13 (Trixie) with NFQUEUE support,
# installs the full toolchain (Rust + cargo + cbindgen + gcc + every -dev lib),
# configures inline IPS mode, ships a systemd unit, and applies SSH/web-safe
# iptables that route the rest of the traffic through Suricata so it can DROP,
# not just alert. Non-interactive. Run inside the lab as root.
#
# Then attack it from the separate Kali box and watch the blocks land in
# tail -f /var/log/suricata/fast.log | grep -i drop
# ---------------------------------------------------------------------------
set -uo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
print_header(){ echo -e "\n${GREEN}=== $1 ===${NC}"; }
die(){ echo -e "${RED}[x] $1${NC}"; exit 1; }
[[ ${EUID:-$(id -u)} -eq 0 ]] || die "Run as root: sudo ./install-suricata-ips.sh"
SURI_VER="8.0.5"
SURI_URL="https://www.openinfosecfoundation.org/download/suricata-${SURI_VER}.tar.gz"
SURICATA_YAML="/etc/suricata/suricata.yaml"
export DEBIAN_FRONTEND=noninteractive
# --- environment detection (interface, server IP, SSH port) ---
print_header "Detecting network environment"
INTERFACE="$(ip route show default 2>/dev/null | grep -oP 'dev \K\S+' | head -n1)"
[[ -z "$INTERFACE" ]] && { echo -e "${RED}No default interface; using eth0${NC}"; INTERFACE="eth0"; }
IPV4="$(ip -4 route get 1.1.1.1 2>/dev/null | grep -oP 'src \K\S+' | head -n1 || true)"
IPV6="$(ip -6 route get 2606:4700:4700::1111 2>/dev/null | grep -oP 'src \K\S+' | head -n1 || true)"
SSH_PORT="$(ss -tlnp 2>/dev/null | awk '/sshd/{print $4}' | awk -F: '{print $NF}' | head -n1)"
[[ -z "$SSH_PORT" ]] && SSH_PORT=22
echo -e "${GREEN}interface=$INTERFACE ipv4=${IPV4:-none} ipv6=${IPV6:-none} ssh=$SSH_PORT${NC}"
# --- 1. Rust toolchain + cbindgen (Suricata 8 REQUIRES both) ---
print_header "Installing Rust + cbindgen"
if ! command -v cargo >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
fi
# shellcheck disable=SC1090,SC1091
source "$HOME/.cargo/env" 2>/dev/null || source /root/.cargo/env
command -v cbindgen >/dev/null 2>&1 || cargo install --force cbindgen
echo -e "${GREEN}$(rustc --version) | cbindgen $(cbindgen --version 2>/dev/null)${NC}"
# --- 2. Build dependencies (Debian 13 / Trixie package names) ---
print_header "Installing Suricata 8 build dependencies"
apt-get update -qq
DEPS="build-essential gcc make pkg-config autoconf automake libtool \
libpcap-dev libnet1-dev libyaml-0-2 libyaml-dev zlib1g-dev \
libcap-ng-dev libcap-ng0 libmagic-dev libjansson-dev libpcre2-dev \
liblz4-dev libnss3-dev libevent-dev libhiredis-dev libmaxminddb-dev \
libnetfilter-queue-dev libnetfilter-queue1 libnfnetlink-dev libnfnetlink0 \
python3 python3-yaml python3-pip jq curl wget git ca-certificates \
netfilter-persistent iptables"
apt-get install -y $DEPS || die "Dependency install failed. Verify names for your release: apt-get install -y $DEPS"
echo -e "${GREEN}Build dependencies installed${NC}"
# --- 3. Fetch, verify, and build Suricata 8.0.5 from source ---
print_header "Building Suricata ${SURI_VER} from source"
cd /tmp
wget -q "$SURI_URL" -O "suricata-${SURI_VER}.tar.gz" || die "Download failed: $SURI_URL"
wget -q "${SURI_URL}.sig" -O "suricata-${SURI_VER}.tar.gz.sig" || true
# Verify: GPG signature if a key is present, and always print the SHA256 to compare against OISF.
if command -v gpg >/dev/null 2>&1 && [[ -s "suricata-${SURI_VER}.tar.gz.sig" ]]; then
if gpg --verify "suricata-${SURI_VER}.tar.gz.sig" "suricata-${SURI_VER}.tar.gz" 2>/dev/null; then
echo -e "${GREEN}GPG signature verified${NC}"
else
echo -e "${YELLOW}[!] GPG signature NOT verified (import the OISF signing key to check). Confirm the hash below before trusting this build.${NC}"
fi
fi
echo -e "${YELLOW}SHA256:$(sha256sum "suricata-${SURI_VER}.tar.gz" | awk '{print " "$1}') - compare against the value published by OISF.${NC}"
tar -xzf "suricata-${SURI_VER}.tar.gz"
cd "suricata-${SURI_VER}"
# NFQUEUE = inline IPS. (lua/hyperscan left off for clean portability on Debian 13.)
./configure --enable-nfqueue --prefix=/usr --sysconfdir=/etc --localstatedir=/var \
|| die "configure failed - check the dependency list above."
make -j"$(nproc)" || die "compilation failed."
make install || die "install failed."
make install-conf
ldconfig
cd /; rm -rf "/tmp/suricata-${SURI_VER}" "/tmp/suricata-${SURI_VER}.tar.gz" "/tmp/suricata-${SURI_VER}.tar.gz.sig"
echo -e "${GREEN}$(suricata -V)${NC}"
# --- 4. HOME_NET = this server ---
print_header "Configuring Suricata"
cp -n "$SURICATA_YAML" "${SURICATA_YAML}.bak" 2>/dev/null || true
if [[ -n "${IPV4:-}" ]]; then sed -i "s#HOME_NET:.*#HOME_NET: \"[${IPV4}/32]\"#" "$SURICATA_YAML"
elif [[ -n "${IPV6:-}" ]]; then sed -i "s#HOME_NET:.*#HOME_NET: \"[${IPV6}/128]\"#" "$SURICATA_YAML"
fi
# --- 5. Inline IPS (NFQUEUE) config ---
print_header "Configuring NFQUEUE inline IPS"
sed -i "s/^\(\s*interface:\).*/\1 $INTERFACE/" "$SURICATA_YAML" || true
if ! grep -q '^nfq:' "$SURICATA_YAML"; then
cat >> "$SURICATA_YAML" <<EOF
# --- inline IPS via NFQUEUE 0 (added by the lab) ---
nfq:
- interface: $INTERFACE
mode: accept
queue: 0
verdict: accept
fail-open: yes
batchcount: 50
copy-mode: ips
copy-iface: $INTERFACE
bypass: no
max-size: 40000
qtimeout: 150
buffer-size: 65535
threads: 4
accept-mark: 1
EOF
fi
# --- 6. Rules ---
print_header "Updating rules (Emerging Threats open)"
suricata-update || echo -e "${YELLOW}[!] suricata-update failed; run it manually later.${NC}"
# --- 7. systemd unit (inline, validates config before start) ---
print_header "Installing systemd service"
cat > /etc/systemd/system/suricata.service <<EOF
[Unit]
Description=Suricata IDS/IPS (inline, NFQUEUE 0)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStartPre=/usr/bin/suricata -T -c ${SURICATA_YAML} -v
ExecStart=/usr/bin/suricata -c ${SURICATA_YAML} -q 0 --pidfile /run/suricata.pid
ExecReload=/bin/kill -HUP \$MAINPID
PIDFile=/run/suricata.pid
Restart=on-failure
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now suricata
sleep 2
systemctl --no-pager --full status suricata | head -n 6 || true
# --- 8. SSH/web-safe iptables that feed the rest into NFQUEUE 0 ---
# Preserves your SSH session and ports 80/443, then routes everything else
# through Suricata. Remove UFW first if present - it owns its own chains.
print_header "Applying SSH/web-safe iptables (NFQUEUE 0)"
modprobe nfnetlink_queue 2>/dev/null || true
for c in INPUT OUTPUT FORWARD; do iptables -D "$c" -j NFQUEUE --queue-num 0 2>/dev/null || true; done
iptables -I INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -I INPUT -p tcp --dport "$SSH_PORT" -j ACCEPT
iptables -I OUTPUT -p tcp --sport "$SSH_PORT" -j ACCEPT
for port in 80 443; do
iptables -I INPUT -p tcp --dport "$port" -j ACCEPT
iptables -I OUTPUT -p tcp --sport "$port" -j ACCEPT
done
iptables -A INPUT -j NFQUEUE --queue-num 0
iptables -A OUTPUT -j NFQUEUE --queue-num 0
iptables -A FORWARD -j NFQUEUE --queue-num 0
echo "netfilter-persistent netfilter-persistent/autosave_v4 boolean true" | debconf-set-selections
echo "netfilter-persistent netfilter-persistent/autosave_v6 boolean true" | debconf-set-selections
netfilter-persistent save || echo -e "${YELLOW}[!] Could not persist rules; they are active until reboot.${NC}"
print_header "Done"
echo -e "${GREEN}Suricata ${SURI_VER} is inline on NFQUEUE 0 and dropping on 'drop' rules.${NC}"
echo "Watch blocks: tail -f /var/log/suricata/fast.log | grep -i drop"
echo -e "${YELLOW}If you run UFW, remove it (it fights these chains): apt-get purge -y ufw${NC}"
Stand up Postfix + Dovecot for internal validation; outbound :25 is blocked for anti-abuse.
Open the lab guide →#!/usr/bin/env bash
# setup-mail.sh - Self-Hosted Email Server lab (ch.7)
# ---------------------------------------------------------------------------
# Stands up Postfix (SMTP + submission) + Dovecot (IMAP) + OpenDKIM/OpenDMARC on
# Debian 13 with self-signed TLS, a test mailbox, and signing keys.
# LAB-SAFE: Postfix is bound loopback-only and outbound :25 is blocked - you
# validate delivery INTERNALLY, you do not send to the internet.
# Run as root: ./setup-mail.sh [domain]
# ---------------------------------------------------------------------------
set -uo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
print_header(){ echo -e "\n${GREEN}=== $1 ===${NC}"; }
die(){ echo -e "${RED}[x] $1${NC}"; exit 1; }
[[ ${EUID:-$(id -u)} -eq 0 ]] || die "Run as root."
export DEBIAN_FRONTEND=noninteractive
DOMAIN="${1:-lab.local}"
FQDN="mail.${DOMAIN}"
print_header "Installing Postfix, Dovecot, OpenDKIM, OpenDMARC"
debconf-set-selections <<<"postfix postfix/mailname string ${DOMAIN}"
debconf-set-selections <<<"postfix postfix/main_mailer_type string 'Internet Site'"
apt-get update -qq
apt-get install -y postfix dovecot-imapd dovecot-pop3d opendkim opendkim-tools \
opendmarc mailutils openssl || die "package install failed"
print_header "Self-signed TLS (lab only)"
install -d /etc/mail/tls
[ -f /etc/mail/tls/mail.crt ] || openssl req -x509 -newkey rsa:2048 -nodes -days 825 \
-keyout /etc/mail/tls/mail.key -out /etc/mail/tls/mail.crt -subj "/CN=${FQDN}" >/dev/null 2>&1
chmod 600 /etc/mail/tls/mail.key
print_header "Configuring Postfix"
postconf -e "myhostname = ${FQDN}"
postconf -e "mydomain = ${DOMAIN}"
postconf -e "myorigin = \$mydomain"
postconf -e "mydestination = \$myhostname, ${DOMAIN}, localhost.localdomain, localhost"
postconf -e "home_mailbox = Maildir/"
postconf -e "inet_interfaces = loopback-only" # lab safety: do not listen on the wire
postconf -e "inet_protocols = ipv4"
postconf -e "relayhost =" # never relay to the internet
postconf -e "smtpd_tls_cert_file = /etc/mail/tls/mail.crt"
postconf -e "smtpd_tls_key_file = /etc/mail/tls/mail.key"
postconf -e "smtpd_tls_security_level = may"
postconf -e "smtp_tls_security_level = may"
postconf -e "smtpd_sasl_type = dovecot"
postconf -e "smtpd_sasl_path = private/auth"
postconf -e "smtpd_sasl_auth_enable = yes"
postconf -e "smtpd_recipient_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination"
postconf -e "milter_default_action = accept"
postconf -e "smtpd_milters = local:opendkim/opendkim.sock, local:opendmarc/opendmarc.sock"
postconf -e "non_smtpd_milters = \$smtpd_milters"
# authenticated submission on 587 (TLS required)
postconf -M submission/inet="submission inet n - y - - smtpd"
postconf -P "submission/inet/smtpd_tls_security_level=encrypt"
postconf -P "submission/inet/smtpd_sasl_auth_enable=yes"
print_header "Configuring Dovecot (IMAP + SASL socket for Postfix)"
sed -i 's|^#\?mail_location =.*|mail_location = maildir:~/Maildir|' /etc/dovecot/conf.d/10-mail.conf
sed -i 's/^#\?disable_plaintext_auth =.*/disable_plaintext_auth = yes/' /etc/dovecot/conf.d/10-auth.conf
sed -i 's/^#\?auth_mechanisms =.*/auth_mechanisms = plain login/' /etc/dovecot/conf.d/10-auth.conf
cat > /etc/dovecot/conf.d/99-postfix-sasl.conf <<'EOF'
service auth {
unix_listener /var/spool/postfix/private/auth {
mode = 0660
user = postfix
group = postfix
}
}
EOF
print_header "OpenDKIM signing + DNS records to publish"
install -d -o opendkim -g opendkim "/etc/opendkim/keys/${DOMAIN}"
opendkim-genkey -b 2048 -d "${DOMAIN}" -s mail -D "/etc/opendkim/keys/${DOMAIN}" 2>/dev/null || true
chown -R opendkim:opendkim /etc/opendkim
cat > /etc/opendkim.conf <<EOF
Syslog yes
UMask 007
Mode sv
Socket local:/var/spool/postfix/opendkim/opendkim.sock
PidFile /run/opendkim/opendkim.pid
KeyTable /etc/opendkim/KeyTable
SigningTable refile:/etc/opendkim/SigningTable
InternalHosts 127.0.0.1, ::1, ${DOMAIN}
EOF
echo "mail._domainkey.${DOMAIN} ${DOMAIN}:mail:/etc/opendkim/keys/${DOMAIN}/mail.private" > /etc/opendkim/KeyTable
echo "*@${DOMAIN} mail._domainkey.${DOMAIN}" > /etc/opendkim/SigningTable
install -d -o opendkim -g postfix /var/spool/postfix/opendkim
install -d -o opendmarc -g postfix /var/spool/postfix/opendmarc
print_header "Creating a test mailbox"
id labuser >/dev/null 2>&1 || useradd -m -s /usr/sbin/nologin labuser
echo "labuser:labpass123" | chpasswd
print_header "Starting services"
systemctl restart opendkim opendmarc dovecot postfix
postfix check && echo -e "${GREEN}postfix config OK${NC}"
print_header "Done"
echo -e "${GREEN}Mail stack up on ${FQDN} (lab-local).${NC}"
echo "Send a test: echo 'hi' | mail -s 'lab test' labuser@${DOMAIN}"
echo "Read it: ls -l /home/labuser/Maildir/new/ (or IMAP on 127.0.0.1:143 as labuser/labpass123)"
echo
echo -e "${YELLOW}For real use (NOT the lab) publish these, then unblock :25 and bind to the wire:${NC}"
echo " DKIM : mail._domainkey.${DOMAIN} TXT -> /etc/opendkim/keys/${DOMAIN}/mail.txt"
echo " SPF : ${DOMAIN}. TXT \"v=spf1 mx -all\""
echo " DMARC: _dmarc.${DOMAIN}. TXT \"v=DMARC1; p=quarantine; rua=mailto:dmarc@${DOMAIN}\""
Intercept and analyze TLS client fingerprints from live traffic.
Open the lab guide →#!/usr/bin/env bash
# capture-ja3.sh - JA3/JA4 Fingerprinting lab (ch.9)
# ---------------------------------------------------------------------------
# Enables JA3 + JA4 TLS fingerprinting in Suricata, captures for a window, ranks
# the fingerprints by frequency (with SNI), and emits a ready-to-use Suricata
# rule that alerts on the most common JA3. Run as root.
# Usage: ./capture-ja3.sh [iface] [seconds]
# ---------------------------------------------------------------------------
set -uo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
print_header(){ echo -e "\n${GREEN}=== $1 ===${NC}"; }
die(){ echo -e "${RED}[x] $1${NC}"; exit 1; }
[[ ${EUID:-$(id -u)} -eq 0 ]] || die "Run as root."
export DEBIAN_FRONTEND=noninteractive
IFACE="${1:-$(ip route show default 2>/dev/null | grep -oP 'dev \K\S+' | head -n1)}"; IFACE="${IFACE:-eth0}"
SECS="${2:-30}"
YAML="/etc/suricata/suricata.yaml"
LOG="/var/log/suricata"; EVE="${LOG}/eve.json"
print_header "Ensuring Suricata + jq"
command -v suricata >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y suricata; }
command -v jq >/dev/null 2>&1 || apt-get install -y jq
print_header "Enabling JA3 + JA4 fingerprinting"
sed -i 's/\(ja3-fingerprints:\).*/\1 yes/' "$YAML" 2>/dev/null || true
sed -i 's/\(ja4-fingerprints:\).*/\1 yes/' "$YAML" 2>/dev/null || true
echo -e "${YELLOW}If Suricata says JA3/JA4 are off, set them to 'yes' under app-layer.protocols.tls and make sure eve-log 'tls' logs ja3/ja4.${NC}"
print_header "Capturing on ${IFACE} for ${SECS}s - generate some TLS traffic now"
install -d "$LOG"
timeout "${SECS}s" suricata -i "$IFACE" -l "$LOG" >/dev/null 2>&1 || true
[ -s "$EVE" ] || die "no eve.json output - check the interface and that traffic flowed."
print_header "Top JA3 fingerprints (count · hash · SNI)"
jq -r 'select(.event_type=="tls") | (.tls.ja3.hash // .tls.ja3 // empty) as $h
| select($h!=null and $h!="") | "\($h)\t\(.tls.sni // "-")"' "$EVE" \
| sort | uniq -c | sort -rn | head -15 \
| awk '{printf " %4d %s %s\n",$1,$2,$3}'
print_header "Top JA4 fingerprints"
jq -r 'select(.tls.ja4) | .tls.ja4' "$EVE" 2>/dev/null | sort | uniq -c | sort -rn | head -10 \
| awk '{printf " %4d %s\n",$1,$2}'
TOP="$(jq -r 'select(.tls.ja3.hash) | .tls.ja3.hash' "$EVE" 2>/dev/null | sort | uniq -c | sort -rn | awk 'NR==1{print $2}')"
if [[ -n "${TOP:-}" ]]; then
print_header "Sample detection rule (alerts on the most common JA3)"
echo " alert tls any any -> any any (msg:\"LAB JA3 match\"; ja3.hash; content:\"${TOP}\"; sid:9000001; rev:1;)"
echo " # Put it in /var/lib/suricata/rules/local.rules, reload, and swap 'alert'->'drop' in inline IPS mode."
fi
echo -e "\n${GREEN}A JA3/JA4 is the shape of the client's TLS ClientHello - stable across IPs, great for spotting tooling and bots.${NC}"
Build an automated dead man's switch that fires when you go silent.
Open the lab guide →#!/usr/bin/env bash
# deadman.sh - Dead Man's Switch lab (ch.11)
# ---------------------------------------------------------------------------
# A real dead man's switch: arm an ENCRYPTED payload, 'check in' to push the
# deadline, and a systemd timer fires a one-time RELEASE of the ciphertext if
# you go silent past the TTL. Keys live OFF the box - recipients decrypt.
# deadman.sh install set up state + an hourly systemd timer
# deadman.sh arm <file> <age-recip> encrypt <file> to the recipient, arm it
# deadman.sh checkin reset the deadline (do this regularly)
# deadman.sh check evaluate; fire if past TTL (timer runs this)
# deadman.sh status | disarm
# TTL is hours; override with TTL_HOURS=48 deadman.sh ...
# ---------------------------------------------------------------------------
set -uo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
STATE="/var/lib/deadman"; TTL_HOURS="${TTL_HOURS:-72}"
SELF="$(readlink -f "$0")"
need_root(){ [[ ${EUID:-$(id -u)} -eq 0 ]] || { echo -e "${RED}run as root${NC}"; exit 1; }; }
install_timer() {
need_root; install -d -m 0700 "$STATE"
command -v age >/dev/null 2>&1 || { export DEBIAN_FRONTEND=noninteractive; apt-get update -qq && apt-get install -y age; }
date +%s > "$STATE/last_checkin"
cat > /etc/systemd/system/deadman.service <<EOF
[Unit]
Description=Dead man's switch check
[Service]
Type=oneshot
Environment=TTL_HOURS=${TTL_HOURS}
ExecStart=${SELF} check
EOF
cat > /etc/systemd/system/deadman.timer <<'EOF'
[Unit]
Description=Run the dead man's switch check hourly
[Timer]
OnBootSec=15min
OnUnitActiveSec=1h
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload; systemctl enable --now deadman.timer
echo -e "${GREEN}[+] Installed. TTL=${TTL_HOURS}h. Arm a payload, then check in regularly:${NC}"
echo " ${SELF} arm /path/to/secret.txt age1yourrecipient... ; ${SELF} checkin"
}
arm() {
need_root; local file="${1:-}" recip="${2:-}"
[[ -f "$file" && -n "$recip" ]] || { echo "usage: $0 arm <file> <age-recipient (age1...)>"; exit 1; }
command -v age >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y age; }
age -r "$recip" -o "$STATE/payload.age" "$file" || { echo -e "${RED}encryption failed${NC}"; exit 1; }
chmod 600 "$STATE/payload.age"; rm -f "$STATE/fired"; date +%s > "$STATE/last_checkin"
echo -e "${GREEN}[+] Armed. Ciphertext at $STATE/payload.age - plaintext NOT stored. Keep the age identity OFF this box.${NC}"
}
checkin(){ need_root; date +%s > "$STATE/last_checkin"; echo -e "${GREEN}[+] Checked in $(date -u). Deadline pushed ${TTL_HOURS}h.${NC}"; }
release() {
echo -e "${RED}[!] DEAD MAN'S SWITCH FIRED $(date -u)${NC}"
logger -t deadman "dead man's switch fired"
# The payload is already ciphertext - dispatch it; recipients hold the key. Wire your channels:
# mail -s "release" recipient@example.org < "$STATE/payload.age"
# cp "$STATE/payload.age" /var/www/onion/ # publish to your .onion drop
# ... matrix / torrent magnet / etc.
}
check() {
local last now age_h
last="$(cat "$STATE/last_checkin" 2>/dev/null || echo 0)"
now="$(date +%s)"; age_h=$(( (now - last) / 3600 ))
if (( age_h >= TTL_HOURS )) && [ ! -f "$STATE/fired" ] && [ -f "$STATE/payload.age" ]; then
: > "$STATE/fired"; release # idempotent: fire exactly once
else
echo "[i] ${age_h}h since check-in (TTL ${TTL_HOURS}h) · armed=$([ -f "$STATE/payload.age" ] && echo y || echo n) · fired=$([ -f "$STATE/fired" ] && echo y || echo n)"
fi
}
status() {
local last age_h; last="$(cat "$STATE/last_checkin" 2>/dev/null || echo 0)"
age_h=$(( ($(date +%s) - last) / 3600 ))
echo "TTL=${TTL_HOURS}h last_checkin=$([ "$last" != 0 ] && date -u -d "@$last" || echo never) age=${age_h}h"
echo "armed=$([ -f "$STATE/payload.age" ] && echo yes || echo no) fired=$([ -f "$STATE/fired" ] && echo yes || echo no) timer=$(systemctl is-active deadman.timer 2>/dev/null || echo inactive)"
}
disarm(){ need_root; systemctl disable --now deadman.timer 2>/dev/null || true; rm -f "$STATE/fired"; echo -e "${GREEN}[+] Timer stopped.${NC}"; }
case "${1:-status}" in
install) install_timer ;;
arm) shift; arm "$@" ;;
checkin) checkin ;;
check) check ;;
status) status ;;
disarm) disarm ;;
*) echo "usage: $0 {install|arm <file> <age-recip>|checkin|check|status|disarm}"; exit 1 ;;
esac