● ● ●

Lab Scripts

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.

Hardened LN Stack Security Headers Suricata Inline IPS Self-Hosted Email Server Tor Hidden Service JA3/JA4 Fingerprinting Dead Man's Switch ⇩ download all (.tar.gz)
Chapter 5 · 1 script

Hardened LN Stack

Deploy and harden an Nginx web server on Debian, then view your site at your subdomain.

Open the lab guide →
harden-nginx.shbash · 121 lines · 4.7 KB ⇩ Download
#!/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."
Chapter 6 · 1 script

Security Headers

Configure and test security headers on a live server.

Open the lab guide →
test-headers.shbash · 61 lines · 3.1 KB ⇩ Download
#!/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"
Chapter 8 · 1 script

Suricata Inline IPS

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 →
install-suricata-ips.shbash · 169 lines · 7.8 KB ⇩ Download
#!/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}"
Chapter 7 · 1 script

Self-Hosted Email Server

Stand up Postfix + Dovecot for internal validation; outbound :25 is blocked for anti-abuse.

Open the lab guide →
setup-mail.shbash · 106 lines · 5.1 KB ⇩ Download
#!/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}\""
Chapter 10 · 1 script

Tor Hidden Service

Publish a .onion site behind a hardened Tor setup, without deanonymizing the box.

Open the lab guide →
setup-onion.shbash · 71 lines · 2.9 KB ⇩ Download
#!/usr/bin/env bash
# setup-onion.sh - Tor Hidden Service lab (ch.10)
# ---------------------------------------------------------------------------
# Publishes a hardened v3 .onion in front of a local Nginx, configured so the
# box does not leak: Nginx binds 127.0.0.1 ONLY, access logs off, no version
# string; Tor runs onion-only (no SOCKS, no relay) with scrubbed logging.
# Run as root:  ./setup-onion.sh [local_port]
# ---------------------------------------------------------------------------
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
PORT="${1:-8080}"
HS_DIR="/var/lib/tor/lab_hs"

print_header "Installing Tor + Nginx"
apt-get update -qq
apt-get install -y tor nginx curl || die "package install failed"

print_header "Local site on 127.0.0.1:${PORT} (never the clearnet)"
install -d /var/www/onion
echo "<!doctype html><title>onion</title><h1>Served over Tor.</h1>" > /var/www/onion/index.html
cat > /etc/nginx/sites-available/onion <<EOF
server {
    listen 127.0.0.1:${PORT};       # loopback ONLY - never 0.0.0.0
    server_name _;
    root /var/www/onion;
    index index.html;
    server_tokens off;
    access_log off;                 # privacy: keep no visitor logs
    location ~ /\. { deny all; }
}
EOF
ln -sf /etc/nginx/sites-available/onion /etc/nginx/sites-enabled/onion
nginx -t || die "nginx config test failed"
systemctl reload nginx

print_header "Configuring the hardened v3 hidden service"
install -d -m 0700 -o debian-tor -g debian-tor "$HS_DIR"
if ! grep -q "$HS_DIR" /etc/tor/torrc; then
cat >> /etc/tor/torrc <<EOF

# --- lab hidden service (v3), hardened ---
HiddenServiceDir ${HS_DIR}
HiddenServicePort 80 127.0.0.1:${PORT}
SocksPort 0                 # client SOCKS off - onion service only
SafeLogging 1               # scrub IPs from Tor logs
EOF
fi

print_header "Leak check"
if ss -ltn 2>/dev/null | grep -qE "(0\.0\.0\.0|\[::\]|\*):${PORT}\b"; then
  echo -e "${RED}[!] ${PORT} is listening on a PUBLIC interface - fix before exposing.${NC}"
else
  echo -e "${GREEN}[+] ${PORT} is loopback-only.${NC}"
fi

print_header "Starting Tor"
systemctl restart tor
for _ in $(seq 1 15); do [ -s "${HS_DIR}/hostname" ] && break; sleep 1; done
[ -s "${HS_DIR}/hostname" ] || die "hidden service did not come up - check: journalctl -u tor"
ONION="$(cat "${HS_DIR}/hostname")"

print_header "Done"
echo -e "${GREEN}Your v3 onion:${NC}  ${ONION}"
echo    "Test it:  apt-get install -y torsocks && torsocks curl -s http://${ONION}/ | head"
echo -e "${YELLOW}Read next: Vanguards (guard-discovery defence), client authorization"
echo -e "(HiddenServiceAuthorizeClient), OnionBalance for scale. Never run a relay here.${NC}"
echo    "Keep ${HS_DIR} at 0700 owned by debian-tor; the private key there IS the onion."
Chapter 9 · 1 script

JA3/JA4 Fingerprinting

Intercept and analyze TLS client fingerprints from live traffic.

Open the lab guide →
capture-ja3.shbash · 50 lines · 2.8 KB ⇩ Download
#!/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}"
Chapter 11 · 1 script

Dead Man's Switch

Build an automated dead man's switch that fires when you go silent.

Open the lab guide →
deadman.shbash · 95 lines · 4.4 KB ⇩ Download
#!/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