|
# OpenBSD is the world's simplest and most secure Unix-like OS. |
|
|
|
#!/usr/bin/env zsh |
|
# Sets up OpenBSD 7.6 for Rails with Norid.no DNS |
|
# Usage: doas zsh openbsd.sh [--help | --resume] |
|
# Updated: 2025-03-29 |
|
|
|
set -e |
|
setopt nullglob extendedglob |
|
|
|
# Rails apps with domains (primary apps) |
|
RAILS_APPS=( |
|
"brgen:brgen.no" |
|
"amber:amberapp.com" |
|
"bsdports:bsdports.org" |
|
) |
|
|
|
# All domains with subdomains (used for NSD, ACME, Relayd, etc.) |
|
ALL_DOMAINS=( |
|
"brgen.no:markedsplass,playlist,dating,tv,takeaway,maps" |
|
"longyearbyn.no:markedsplass,playlist,dating,tv,takeaway,maps" |
|
"oshlo.no:markedsplass,playlist,dating,tv,takeaway,maps" |
|
"stvanger.no:markedsplass,playlist,dating,tv,takeaway,maps" |
|
"trmso.no:markedsplass,playlist,dating,tv,takeaway,maps" |
|
"trndheim.no:markedsplass,playlist,dating,tv,takeaway,maps" |
|
"reykjavk.is:markadur,playlist,dating,tv,takeaway,maps" |
|
"kbenhvn.dk:markedsplads,playlist,dating,tv,takeaway,maps" |
|
"gtebrg.se:marknadsplats,playlist,dating,tv,takeaway,maps" |
|
"mlmoe.se:marknadsplats,playlist,dating,tv,takeaway,maps" |
|
"stholm.se:marknadsplats,playlist,dating,tv,takeaway,maps" |
|
"hlsinki.fi:markkinapaikka,playlist,dating,tv,takeaway,maps" |
|
"brmingham.uk:marketplace,playlist,dating,tv,takeaway,maps" |
|
"cardff.uk:marketplace,playlist,dating,tv,takeaway,maps" |
|
"edinbrgh.uk:marketplace,playlist,dating,tv,takeaway,maps" |
|
"glasgw.uk:marketplace,playlist,dating,tv,takeaway,maps" |
|
"lndon.uk:marketplace,playlist,dating,tv,takeaway,maps" |
|
"lverpool.uk:marketplace,playlist,dating,tv,takeaway,maps" |
|
"mnchester.uk:marketplace,playlist,dating,tv,takeaway,maps" |
|
"amstrdam.nl:marktplaats,playlist,dating,tv,takeaway,maps" |
|
"rottrdam.nl:marktplaats,playlist,dating,tv,takeaway,maps" |
|
"utrcht.nl:marktplaats,playlist,dating,tv,takeaway,maps" |
|
"brssels.be:marche,playlist,dating,tv,takeaway,maps" |
|
"zrich.ch:marktplatz,playlist,dating,tv,takeaway,maps" |
|
"lchtenstein.li:marktplatz,playlist,dating,tv,takeaway,maps" |
|
"frankfrt.de:marktplatz,playlist,dating,tv,takeaway,maps" |
|
"brdeaux.fr:marche,playlist,dating,tv,takeaway,maps" |
|
"mrseille.fr:marche,playlist,dating,tv,takeaway,maps" |
|
"mlan.it:mercato,playlist,dating,tv,takeaway,maps" |
|
"lisbon.pt:mercado,playlist,dating,tv,takeaway,maps" |
|
"wrsawa.pl:marktplatz,playlist,dating,tv,takeaway,maps" |
|
"gdnsk.pl:marktplatz,playlist,dating,tv,takeaway,maps" |
|
"austn.us:marketplace,playlist,dating,tv,takeaway,maps" |
|
"chcago.us:marketplace,playlist,dating,tv,takeaway,maps" |
|
"denvr.us:marketplace,playlist,dating,tv,takeaway,maps" |
|
"dllas.us:marketplace,playlist,dating,tv,takeaway,maps" |
|
"dnver.us:marketplace,playlist,dating,tv,takeaway,maps" |
|
"dtroit.us:marketplace,playlist,dating,tv,takeaway,maps" |
|
"houstn.us:marketplace,playlist,dating,tv,takeaway,maps" |
|
"lsangeles.com:marketplace,playlist,dating,tv,takeaway,maps" |
|
"mnnesota.com:marketplace,playlist,dating,tv,takeaway,maps" |
|
"newyrk.us:marketplace,playlist,dating,tv,takeaway,maps" |
|
"prtland.com:marketplace,playlist,dating,tv,takeaway,maps" |
|
"wshingtondc.com:marketplace,playlist,dating,tv,takeaway,maps" |
|
"pub.healthcare" |
|
"pub.attorney" |
|
"freehelp.legal" |
|
"bsdports.org" |
|
"bsddocs.org" |
|
"discordb.org" |
|
"privcam.no" |
|
"foodielicio.us" |
|
"stacyspassion.com" |
|
"antibettingblog.com" |
|
"anticasinoblog.com" |
|
"antigamblingblog.com" |
|
"foball.no" |
|
) |
|
|
|
# Nameserver IPs |
|
BRGEN_IP="46.23.95.45" # ns.brgen.no |
|
HYP_IP="194.63.248.53" # ns.hyp.net (DOMENESHOP) |
|
|
|
# State file in CWD |
|
STATE_FILE="./openbsd_setup_state" |
|
|
|
# App ports array |
|
typeset -A APP_PORTS |
|
|
|
# Generate random port (10000-60000) |
|
generate_random_port() { |
|
local port |
|
while true |
|
do |
|
port=$((RANDOM % 50000 + 10000)) |
|
if ! netstat -an | grep -q "\.${port} " |
|
then |
|
echo "$port" |
|
break |
|
fi |
|
done |
|
} |
|
|
|
# Exit with error |
|
error_exit() { |
|
echo "ERROR: $1" >&2 |
|
exit 1 |
|
} |
|
|
|
# Start service with check |
|
enable_and_start_service() { |
|
echo "Enabling and starting $1..." >&2 |
|
rcctl enable "$1" |
|
rcctl start "$1" |
|
if [ $? -ne 0 ] |
|
then |
|
error_exit "$1 failed to start via rcctl" |
|
fi |
|
sleep 5 |
|
if ! rcctl check "$1" |
|
then |
|
echo "Service $1 failed. Logs from /var/log/messages:" >&2 |
|
tail -n 10 /var/log/messages >&2 |
|
error_exit "$1 failed to start" |
|
fi |
|
} |
|
|
|
# Check root |
|
check_root() { |
|
if [ "$(id -u)" -ne 0 ] |
|
then |
|
error_exit "Run with doas" |
|
fi |
|
} |
|
|
|
# Clean up NSD and port 53 |
|
cleanup_nsd() { |
|
echo "Cleaning up NSD processes..." >&2 |
|
zap -f nsd |
|
fuser -k 53/udp 2>/dev/null |
|
sleep 2 |
|
if netstat -an | grep -q "46.23.95.45.53" |
|
then |
|
error_exit "Port 53 still in use after cleanup" |
|
fi |
|
} |
|
|
|
# Phase 1: Minimal DNS |
|
phase_1() { |
|
pkg_add -U ldns-utils ruby-3.3.5 postgresql-server redis sshguard |
|
sysctl kern.maxfiles=10000 |
|
cat > /etc/sysctl.conf <<-EOF |
|
kern.maxfiles=10000 |
|
EOF |
|
if ! grep -q "openfiles-max=2048" /etc/login.conf |
|
then |
|
cat << 'EOF' >> /etc/login.conf |
|
daemon:\ |
|
:openfiles-max=2048:\ |
|
:tc=default: |
|
EOF |
|
fi |
|
|
|
rm -rf /var/nsd/*/* |
|
mkdir -p /var/nsd/zones/master /var/nsd/etc |
|
chmod 750 /var/nsd/zones/master /var/nsd/etc |
|
chown _nsd:_nsd /var/nsd/zones/master /var/nsd/etc |
|
|
|
cat > /var/nsd/etc/nsd.conf <<-EOF |
|
server: |
|
ip-address: $BRGEN_IP |
|
hide-version: yes |
|
verbosity: 2 |
|
zonesdir: "/var/nsd/zones/master" |
|
EOF |
|
for domain_entry in "${ALL_DOMAINS[@]}" |
|
do |
|
local domain="${domain_entry%%:*}" |
|
cat >> /var/nsd/etc/nsd.conf <<-EOF |
|
zone: |
|
name: "$domain" |
|
zonefile: "$domain.zone" |
|
EOF |
|
done |
|
nsd-checkconf /var/nsd/etc/nsd.conf |
|
if [ $? -ne 0 ] |
|
then |
|
error_exit "nsd.conf invalid" |
|
fi |
|
chown root:_nsd /var/nsd/etc/nsd.conf |
|
chmod 640 /var/nsd/etc/nsd.conf |
|
|
|
local serial=$(date +"%Y%m%d%H") |
|
for domain_entry in "${ALL_DOMAINS[@]}" |
|
do |
|
local domain="${domain_entry%%:*}" |
|
local subdomains="${domain_entry#*:}" |
|
cat > "/var/nsd/zones/master/$domain.zone" <<-EOF |
|
\$ORIGIN $domain. |
|
\$TTL 3600 |
|
@ IN SOA ns.brgen.no. hostmaster.$domain. ($serial 1800 900 604800 86400) |
|
@ IN NS ns.brgen.no. |
|
@ IN A $BRGEN_IP |
|
ns IN A $BRGEN_IP |
|
EOF |
|
if [ -n "$subdomains" ] |
|
then |
|
for subdomain in ${(s/,/)subdomains} |
|
do |
|
echo "$subdomain IN A $BRGEN_IP" >> "/var/nsd/zones/master/$domain.zone" |
|
done |
|
fi |
|
nsd-checkzone "$domain" "/var/nsd/zones/master/$domain.zone" |
|
if [ $? -ne 0 ] |
|
then |
|
error_exit "Zone file for $domain invalid" |
|
fi |
|
chown root:_nsd "/var/nsd/zones/master/$domain.zone" |
|
chmod 640 "/var/nsd/zones/master/$domain.zone" |
|
done |
|
|
|
if ! [ -f /var/nsd/etc/nsd_server.key ] |
|
then |
|
nsd-control-setup |
|
fi |
|
|
|
cleanup_nsd |
|
enable_and_start_service nsd |
|
|
|
echo "Phase 1 done. Set ns.brgen.no ($BRGEN_IP) glue records at DOMENESHOP." >&2 |
|
echo "Propagation takes up to 48 hours." >&2 |
|
cat > "$STATE_FILE" <<-EOF |
|
phase_1_complete |
|
EOF |
|
exit 0 |
|
} |
|
|
|
# Phase 2: Full Setup |
|
phase_2() { |
|
rm -rf /var/nsd/*/* |
|
mkdir -p /var/nsd/zones/master /var/nsd/etc |
|
chmod 750 /var/nsd/zones/master /var/nsd/etc |
|
chown _nsd:_nsd /var/nsd/zones/master /var/nsd/etc |
|
|
|
cat > /var/nsd/etc/nsd.conf <<-EOF |
|
server: |
|
ip-address: $BRGEN_IP |
|
ip-address: 127.0.0.1 |
|
hide-version: yes |
|
verbosity: 2 |
|
zonesdir: "/var/nsd/zones/master" |
|
EOF |
|
for domain_entry in "${ALL_DOMAINS[@]}" |
|
do |
|
local domain="${domain_entry%%:*}" |
|
cat >> /var/nsd/etc/nsd.conf <<-EOF |
|
zone: |
|
name: "$domain" |
|
zonefile: "$domain.zone.signed" |
|
EOF |
|
if [ "$domain" = "brgen.no" ] |
|
then |
|
cat >> /var/nsd/etc/nsd.conf <<-EOF |
|
notify: $HYP_IP NOKEY |
|
provide-xfr: $HYP_IP NOKEY |
|
EOF |
|
fi |
|
done |
|
nsd-checkconf /var/nsd/etc/nsd.conf |
|
if [ $? -ne 0 ] |
|
then |
|
error_exit "nsd.conf invalid" |
|
fi |
|
chown root:_nsd /var/nsd/etc/nsd.conf |
|
chmod 640 /var/nsd/etc/nsd.conf |
|
|
|
# HTTPD and ACME setup first for TLSA |
|
chmod 750 /var/www/acme/.well-known/acme-challenge |
|
if ! [ -f /etc/acme/letsencrypt-privkey.pem ] |
|
then |
|
openssl genpkey -algorithm RSA -out /etc/acme/letsencrypt-privkey.pem -pkeyopt rsa_keygen_bits:4096 |
|
fi |
|
chmod 600 /etc/acme/letsencrypt-privkey.pem |
|
cat > /etc/httpd.conf <<-EOF |
|
server "acme" { |
|
listen on $BRGEN_IP port 80 |
|
location "/.well-known/acme-challenge/*" { root "/acme"; request strip 2 } |
|
location "*" { block return 301 "https://\$HTTP_HOST\$REQUEST_URI" } |
|
} |
|
EOF |
|
httpd -n -f /etc/httpd.conf |
|
if [ $? -ne 0 ] |
|
then |
|
error_exit "httpd.conf invalid" |
|
fi |
|
cat > /etc/acme-client.conf <<-EOF |
|
authority letsencrypt { |
|
api url "https://acme-v02.api.letsencrypt.org/directory" |
|
account key "/etc/acme/letsencrypt-privkey.pem" |
|
} |
|
EOF |
|
for domain_entry in "${ALL_DOMAINS[@]}" |
|
do |
|
local domain="${domain_entry%%:*}" |
|
cat >> /etc/acme-client.conf <<-EOF |
|
domain $domain { |
|
domain key "/etc/ssl/private/$domain.key" |
|
domain full chain certificate "/etc/ssl/$domain.fullchain.pem" |
|
sign with letsencrypt |
|
challengedir "/var/www/acme" |
|
} |
|
EOF |
|
done |
|
acme-client -n -f /etc/acme-client.conf |
|
if [ $? -ne 0 ] |
|
then |
|
error_exit "acme-client.conf invalid" |
|
fi |
|
chmod 700 /etc/ssl/private |
|
enable_and_start_service httpd |
|
acme-client -v -f /etc/acme-client.conf # Fetch certificates |
|
|
|
local serial=$(date +"%Y%m%d%H") |
|
for domain_entry in "${ALL_DOMAINS[@]}" |
|
do |
|
local domain="${domain_entry%%:*}" |
|
local subdomains="${domain_entry#*:}" |
|
cat > "/var/nsd/zones/master/$domain.zone" <<-EOF |
|
\$ORIGIN $domain. |
|
\$TTL 3600 |
|
@ IN SOA ns.brgen.no. hostmaster.$domain. ($serial 1800 900 604800 86400) |
|
@ IN NS ns.brgen.no. |
|
@ IN A $BRGEN_IP |
|
ns IN A $BRGEN_IP |
|
EOF |
|
if [ "$domain" = "brgen.no" ] |
|
then |
|
cat >> "/var/nsd/zones/master/$domain.zone" <<-EOF |
|
@ IN NS ns.hyp.net. |
|
@ IN CAA 0 issue "letsencrypt.org" |
|
www IN A $BRGEN_IP |
|
EOF |
|
fi |
|
if [ -n "$subdomains" ] |
|
then |
|
for subdomain in ${(s/,/)subdomains} |
|
do |
|
echo "$subdomain IN A $BRGEN_IP" >> "/var/nsd/zones/master/$domain.zone" |
|
done |
|
fi |
|
local cert="/etc/ssl/$domain.fullchain.pem" |
|
if [ -f "$cert" ] |
|
then |
|
local tlsa_hash=$(openssl x509 -noout -pubkey -in "$cert" | openssl rsa -pubin -outform DER 2>/dev/null | sha256sum | cut -d' ' -f1) |
|
echo "_443._tcp IN TLSA 3 1 1 $tlsa_hash" >> "/var/nsd/zones/master/$domain.zone" |
|
fi |
|
cd /var/nsd/zones/master |
|
local zsk_key=$(ldns-keygen -a RSASHA256 -b 1024 "$domain") |
|
local ksk_key=$(ldns-keygen -k -a RSASHA256 -b 1024 "$domain") |
|
ldns-signzone "$domain.zone" "$ksk_key" "$zsk_key" |
|
nsd-checkzone "$domain" "$domain.zone.signed" |
|
if [ $? -ne 0 ] |
|
then |
|
error_exit "Signed zone for $domain invalid" |
|
fi |
|
chown root:_nsd "$domain.zone" "$domain.zone.signed" *.key *.private |
|
chmod 640 "$domain.zone" "$domain.zone.signed" *.key *.private |
|
done |
|
|
|
cleanup_nsd |
|
enable_and_start_service nsd |
|
|
|
cat > /etc/pf.conf <<-EOF |
|
ext_if="vio0" |
|
set skip on lo |
|
pass in on \$ext_if proto tcp to \$ext_if port { 22, 80, 443 } keep state |
|
pass in on \$ext_if proto { tcp, udp } from $HYP_IP to $BRGEN_IP port 53 keep state |
|
pass out on \$ext_if proto { tcp, udp } from $BRGEN_IP to $HYP_IP port 53 keep state |
|
anchor "relayd/*" |
|
EOF |
|
pfctl -nf /etc/pf.conf |
|
if [ $? -ne 0 ] |
|
then |
|
error_exit "pf.conf invalid" |
|
fi |
|
pfctl -f /etc/pf.conf |
|
enable_and_start_service sshguard |
|
|
|
if ! [ -d /var/postgresql/data ] |
|
then |
|
install -d -o _postgresql -g _postgresql /var/postgresql/data |
|
su -l _postgresql -c "/usr/local/bin/initdb -D /var/postgresql/data -U postgres -A scram-sha-256 -E UTF8" |
|
fi |
|
enable_and_start_service postgresql |
|
|
|
cat > /etc/redis.conf <<-EOF |
|
bind 127.0.0.1 |
|
port 6379 |
|
protected-mode yes |
|
daemonize yes |
|
dir /var/redis |
|
EOF |
|
redis-server --dry-run /etc/redis.conf |
|
if [ $? -ne 0 ] |
|
then |
|
error_exit "redis.conf invalid" |
|
fi |
|
enable_and_start_service redis |
|
|
|
for domain_entry in "${ALL_DOMAINS[@]}" |
|
do |
|
local domain="${domain_entry%%:*}" |
|
local app="${domain%%.*}" |
|
local port=$(generate_random_port) |
|
APP_PORTS[$app]=$port |
|
if ! id "$app" >/dev/null 2>&1 |
|
then |
|
useradd -m -s /bin/ksh -L rails "$app" |
|
fi |
|
mkdir -p "/home/$app/$app" "/var/www/log/$app" |
|
chown -R "$app:$app" "/home/$app" "/var/www/log/$app" |
|
if ! [ -f "/home/$app/$app/Gemfile" ] |
|
then |
|
doas su - "$app" -c "cd /home/$app/$app && rails new . --force --database=postgresql" |
|
fi |
|
doas su - "$app" -c "cd /home/$app/$app && gem install bundler && bundle install" |
|
cat > "/etc/rc.d/$app" <<-EOF |
|
#!/bin/ksh |
|
daemon="/bin/ksh -c 'cd /home/$app/$app && export RAILS_ENV=production && /usr/local/bin/bundle exec falcon serve -b tcp://127.0.0.1:$port'" |
|
daemon_user="$app" |
|
. /etc/rc.d/rc.subr |
|
rc_cmd \$1 |
|
EOF |
|
chmod +x "/etc/rc.d/$app" |
|
# No direct checker for rc.d scripts; assume valid if syntax is correct |
|
enable_and_start_service "$app" |
|
done |
|
|
|
cat > /etc/relayd.conf <<-EOF |
|
log connection |
|
table <acme_client> { 127.0.0.1:80 } |
|
EOF |
|
for domain_entry in "${ALL_DOMAINS[@]}" |
|
do |
|
local domain="${domain_entry%%:*}" |
|
local app="${domain%%.*}" |
|
local port="${APP_PORTS[$app]}" |
|
cat >> /etc/relayd.conf <<-EOF |
|
table <${app}_backend> { 127.0.0.1:$port } |
|
relay "relay_${app}" { |
|
listen on $BRGEN_IP port 443 tls |
|
protocol "secure_rails" |
|
forward to <${app}_backend> check tcp |
|
} |
|
relay "acme_${domain}" { |
|
listen on $BRGEN_IP port 80 |
|
protocol "filter_challenge" |
|
forward to <acme_client> check tcp |
|
} |
|
EOF |
|
done |
|
cat >> /etc/relayd.conf <<-EOF |
|
http protocol "filter_challenge" { |
|
match request header set "X-Forwarded-For" value "\$REMOTE_ADDR" |
|
pass request path "/.well-known/acme-challenge/*" forward to <acme_client> |
|
} |
|
http protocol "secure_rails" { |
|
match request header set "X-Forwarded-For" value "\$REMOTE_ADDR" |
|
match response header set "Strict-Transport-Security" value "max-age=31536000" |
|
} |
|
EOF |
|
relayd -n -f /etc/relayd.conf |
|
if [ $? -ne 0 ] |
|
then |
|
error_exit "relayd.conf invalid" |
|
fi |
|
enable_and_start_service relayd |
|
|
|
# Cron script for auto-signing zones |
|
cat > /usr/local/bin/auto-sign-zones <<-EOF |
|
#!/bin/sh |
|
ZONES="/var/nsd/zones/master" |
|
for domain_entry in "${ALL_DOMAINS[@]}" |
|
do |
|
DOMAIN="\${domain_entry%%:*}" |
|
ZONE="\${ZONES}/\${DOMAIN}.zone" |
|
if [ ! -f "\${ZONE}" ] |
|
then |
|
echo "Unable to locate zone \${ZONE}" |
|
exit 1 |
|
fi |
|
echo "Convert zone \${DOMAIN} to \${DOMAIN}.tosign" |
|
ldns-read-zone -S $serial "\${ZONE}" > "\${ZONE}.tosign" |
|
KSK=\$(find "\${ZONES}" -name "K\${DOMAIN}.+008+*.key" | sort -nr | head -1 | sed 's|\./||;s|[0-9]\+ ||;s|.key\$||') |
|
ZSK=\$(find "\${ZONES}" -name "K\${DOMAIN}.+008+*.key" | sort -n | head -1 | sed 's|\./||;s|[0-9]\+ ||;s|.key\$||') |
|
echo "Signing zone \${ZONE}" |
|
ldns-signzone -f "\${ZONE}.signed" "\${ZONE}.tosign" "\${KSK}" "\${ZSK}" |
|
nsd-checkzone "\${DOMAIN}" "\${ZONE}.signed" |
|
if [ \$? -ne 0 ] |
|
then |
|
echo "Signed zone for \${DOMAIN} invalid" |
|
exit 1 |
|
fi |
|
done |
|
nsd-control reload |
|
EOF |
|
chmod +x /usr/local/bin/auto-sign-zones |
|
|
|
rm -f "$STATE_FILE" |
|
echo "Setup complete" >&2 |
|
} |
|
|
|
# Main function |
|
main() { |
|
check_root |
|
if [ "$1" = "--help" ] |
|
then |
|
echo "Sets OpenBSD 7.6 for Rails." |
|
echo "Usage: doas zsh openbsd.sh [--help | --resume]" |
|
exit 0 |
|
fi |
|
if [ "$1" = "--resume" ] || [ -f "$STATE_FILE" ] |
|
then |
|
if ! [ -f "$STATE_FILE" ] |
|
then |
|
error_exit "No state file. Run without --resume." |
|
fi |
|
echo "Resuming Phase 2" >&2 |
|
phase_2 |
|
else |
|
echo "Starting Phase 1" >&2 |
|
phase_1 |
|
echo "Starting Phase 2" >&2 |
|
phase_2 |
|
fi |
|
} |
|
|
|
main "$@" |