|
#!/usr/bin/env zsh |
|
set -e |
|
|
|
OPENBSD_AMSTERDAM_IP="X.X.X.X" |
|
|
|
ALL_DOMAINS=( |
|
"mydomain.com:subdomain1, subdomain2" |
|
) |
|
|
|
RAILS_APPS=("myapp1" "myapp2") |
|
|
|
install_packages() { |
|
packages=("ruby-3.3.5" "postgresql-server" "dnscrypt-proxy" "sshguard" "monit" "redis" "varnish" "ldns-utils") |
|
for package in "${packages[@]}"; do |
|
doas pkg_add -UI "$package" |
|
done |
|
} |
|
|
|
generate_random_port() { |
|
while true; do |
|
port=$((2000 + RANDOM % 63000)) |
|
if ! netstat -an | grep -q "\.$port "; then |
|
echo "$port" |
|
return |
|
fi |
|
done |
|
} |
|
|
|
configure_pf() { |
|
cat <<EOF | doas tee /etc/pf.conf > /dev/null |
|
set skip on lo |
|
block all |
|
|
|
# Brute-force protection |
|
table <bruteforce> persist |
|
block in quick from <bruteforce> |
|
|
|
# Allow SSH with rate limiting and overload protection |
|
pass in on vio0 proto tcp to port 22 keep state (max-src-conn 50, max-src-conn-rate 20/60, overload <bruteforce> flush global, no-sync) |
|
|
|
# Allow DNS |
|
pass in on vio0 proto { tcp, udp } from any to port 53 keep state |
|
|
|
# Allow HTTP/HTTPS with logging |
|
pass in log on vio0 proto tcp to port { 80, 443 } keep state |
|
|
|
# Allow all outgoing traffic |
|
pass out on vio0 keep state |
|
|
|
# Relay rules |
|
anchor "relayd/*" |
|
|
|
# Log blocked connections for troubleshooting |
|
block log all |
|
EOF |
|
|
|
doas pfctl -nf /etc/pf.conf |
|
doas pfctl -f /etc/pf.conf |
|
} |
|
|
|
configure_nsd() { |
|
cat <<EOF | doas tee /var/nsd/etc/nsd.conf > /dev/null |
|
server: |
|
ip-address: $OPENBSD_AMSTERDAM_IP |
|
hide-version: yes |
|
ip4-only: yes |
|
verbosity: 2 |
|
zonesdir: "/zones" |
|
EOF |
|
|
|
for domain_info in "${ALL_DOMAINS[@]}"; do |
|
domain="${domain_info%%:*}" |
|
|
|
# Cleanup old keys |
|
doas rm -f /var/nsd/zones/K${domain}.* |
|
|
|
# Set serial number based on the current date/time |
|
serial=$(date +"%Y%m%d%H") |
|
|
|
# Generate DNSSEC keys for the domain and set permissions |
|
doas ldns-keygen -a ECDSAP256SHA256 -b 256 -r /dev/urandom "$domain" |
|
key_files=(/var/nsd/zones/K${domain}.*) |
|
doas chown root:_nsd "${key_files[@]}" |
|
doas chmod 640 "${key_files[@]}" |
|
|
|
# Extract DNSKEY using zsh |
|
dnskey_line=$(doas grep -h "DNSKEY" "${key_files[@]}") |
|
read -r _ _ _ dnskey _ <<< "$dnskey_line" |
|
|
|
# Create or update the zone file with the new serial |
|
cat <<ZONE | doas tee "/var/nsd/zones/$domain.zone" > /dev/null |
|
\$ORIGIN $domain. |
|
\$TTL 3600 |
|
|
|
@ IN SOA ns.brgen.no. hostmaster.$domain. ( |
|
$serial ; Serial |
|
3600 ; Refresh |
|
900 ; Retry |
|
1209600 ; Expire |
|
3600 ; Minimum TTL |
|
) |
|
@ IN NS ns.brgen.no. |
|
@ IN NS ns.hyp.net. |
|
|
|
@ IN A $OPENBSD_AMSTERDAM_IP |
|
|
|
@ IN CAA 0 issue "letsencrypt.org" |
|
@ IN DNSKEY 257 3 13 "$dnskey" |
|
ZONE |
|
|
|
# Generate DS records for each DNSSEC key |
|
for key_file in /var/nsd/zones/K${domain}+*.key; do |
|
ds_record=$(doas ldns-key2ds "$key_file") |
|
echo "Register the following DS record with your registrar for domain $domain:" |
|
echo "$ds_record" |
|
done |
|
|
|
# Append the zone configuration for nsd |
|
cat <<EOF >> /var/nsd/etc/nsd.conf |
|
zone: |
|
name: "$domain" |
|
zonefile: "/zones/$domain.zone.signed" |
|
allow-notify: ns.hyp.net NOKEY |
|
provide-xfr: ns.hyp.net NOKEY |
|
EOF |
|
|
|
# Sign and verify the zone |
|
doas ldns-signzone -n -p -o "$domain" "/var/nsd/zones/$domain.zone" |
|
doas ldns-verify-zone "/var/nsd/zones/$domain.zone.signed" |
|
done |
|
|
|
doas nsd-checkconf /var/nsd/etc/nsd.conf |
|
|
|
# Check each zone file |
|
for domain_info in "${ALL_DOMAINS[@]}"; do |
|
domain="${domain_info%%:*}" |
|
doas nsd-checkzone "$domain" "/zones/$domain.zone.signed" |
|
done |
|
|
|
doas rcctl enable nsd |
|
doas rcctl restart nsd |
|
} |
|
|
|
configure_httpd_and_acme_client() { |
|
cat <<EOF | doas tee /etc/httpd.conf > /dev/null |
|
server "acme" { |
|
listen on $OPENBSD_AMSTERDAM_IP port 80 |
|
location "/.well-known/acme-challenge/*" { |
|
root "/var/www/acme" |
|
request strip 2 |
|
} |
|
} |
|
EOF |
|
|
|
doas rcctl enable httpd |
|
doas rcctl restart httpd |
|
|
|
for domain_info in "${ALL_DOMAINS[@]}"; do |
|
domain="${domain_info%%:*}" |
|
[[ -z "$domain" ]] && continue |
|
|
|
cat <<EOF | doas tee -a /etc/acme-client.conf > /dev/null |
|
domain $domain { |
|
domain key "/etc/ssl/private/$domain.key" |
|
domain fullchain "/etc/ssl/$domain.fullchain.pem" |
|
sign with default |
|
} |
|
EOF |
|
done |
|
|
|
doas acme-client -n |
|
|
|
for domain_info in "${ALL_DOMAINS[@]}"; do |
|
domain="${domain_info%%:*}" |
|
[[ -z "$domain" ]] && continue |
|
if ! openssl x509 -checkend 2592000 -noout -in "/etc/ssl/$domain.fullchain.pem"; then |
|
doas acme-client -v "$domain" |
|
else |
|
echo "Certificate for $domain is still valid. Skipping renewal." |
|
fi |
|
done |
|
} |
|
|
|
configure_relayd() { |
|
acme_client_port=$(generate_random_port) |
|
varnish_port=$(generate_random_port) |
|
|
|
cat <<EOF | doas tee /etc/relayd.conf > /dev/null |
|
log connection |
|
|
|
# Define a table for routing ACME client challenges to localhost |
|
table <acme_client> { 127.0.0.1:$acme_client_port } |
|
|
|
# HTTP protocol for ACME challenges |
|
http protocol "filter_challenge" { |
|
pass request path "/.well-known/acme-challenge/*" forward to <acme_client> |
|
} |
|
|
|
# HTTP protocol for backend Varnish server |
|
http protocol "varnish_backend" { |
|
# Add X-Forwarded-By header to track the original server address |
|
match request header set "X-Forwarded-By" value "\$SERVER_ADDR:\$SERVER_PORT" |
|
|
|
# Add X-Forwarded-For header to record the client IP address |
|
match request header set "X-Forwarded-For" value "\$REMOTE_ADDR" |
|
|
|
# Add HSTS header to ensure secure connections |
|
match response header set "Strict-Transport-Security" value "max-age=31536000; includeSubDomains; preload" |
|
|
|
# Prevent clickjacking by restricting framing to the same origin |
|
match response header set "X-Frame-Options" value "SAMEORIGIN" |
|
|
|
# Upgrade all requests to HTTPS |
|
match response header set "Content-Security-Policy" value "upgrade-insecure-requests" |
|
|
|
# Prevent MIME-type sniffing for enhanced security |
|
match response header set "X-Content-Type-Options" value "nosniff" |
|
|
|
# Ensure no referrer information is sent to external domains |
|
match response header set "Referrer-Policy" value "no-referrer" |
|
|
|
# Limit geolocation permissions to approved origins only |
|
match response header set "Permissions-Policy" value "geolocation=*" |
|
} |
|
|
|
relay "acme_relay" { |
|
listen on $OPENBSD_AMSTERDAM_IP port 80 |
|
forward to 127.0.0.1 port $acme_client_port protocol "filter_challenge" |
|
} |
|
|
|
relay "https_relay" { |
|
listen on $OPENBSD_AMSTERDAM_IP port 443 tls |
|
protocol "varnish_backend" |
|
forward to 127.0.0.1 port $varnish_port |
|
} |
|
EOF |
|
|
|
doas relayd -n -f /etc/relayd.conf |
|
doas rcctl enable relayd |
|
doas rcctl restart relayd |
|
} |
|
|
|
configure_varnish() { |
|
varnish_port=$(generate_random_port) |
|
falcon_backend_port=$(generate_random_port) |
|
|
|
# Varnish configuration to use Falcon as backend on port $falcon_backend_port |
|
cat <<EOF | doas tee /etc/varnish/default.vcl > /dev/null |
|
vcl 4.0; |
|
|
|
backend default { |
|
.host = "127.0.0.1"; |
|
.port = "$falcon_backend_port"; # Falcon server port for Rails apps |
|
} |
|
|
|
# Cache assets and strip cookies for improved caching |
|
sub vcl_recv { |
|
if (req.url ~ "^/assets/") { |
|
unset req.http.cookie; |
|
} |
|
} |
|
|
|
# Set TTL for cached responses |
|
sub vcl_backend_response { |
|
if (beresp.status == 200) { |
|
set beresp.ttl = 1h; |
|
} |
|
} |
|
EOF |
|
|
|
# Start Varnish on dynamically assigned port $varnish_port |
|
doas rcctl enable varnishd |
|
doas rcctl set varnishd flags "-a :$varnish_port -b localhost:$falcon_backend_port" |
|
doas rcctl start varnishd |
|
} |
|
|
|
configure_startup_scripts() { |
|
for app in "${RAILS_APPS[@]}"; do |
|
if ! grep -q "^$app:" /etc/master.passwd; then |
|
doas useradd -m -s /bin/ksh "$app" |
|
fi |
|
|
|
# Assign a unique random port for each Rails app instance |
|
backend_port=$(generate_random_port) |
|
|
|
# Falcon startup script |
|
cat <<EOF | doas tee "/etc/rc.d/$app" > /dev/null |
|
#!/bin/ksh |
|
|
|
# Startup script for the $app application on port $backend_port |
|
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:$backend_port'" |
|
daemon_user="$app" |
|
|
|
# Unveil limits file access to only essential directories |
|
unveil -r /home/$app/$app |
|
unveil /var/www/log |
|
unveil /etc |
|
unveil |
|
|
|
# Pledge restricts system calls for enhanced security |
|
pledge stdio rpath wpath cpath inet |
|
|
|
. /etc/rc.d/rc.subr |
|
rc_cmd \$1 |
|
EOF |
|
|
|
doas chmod +x "/etc/rc.d/$app" |
|
doas rcctl enable "$app" |
|
doas rcctl start "$app" |
|
done |
|
} |
|
|
|
main() { |
|
install_packages |
|
configure_pf |
|
configure_nsd |
|
configure_httpd_and_acme_client |
|
configure_relayd |
|
configure_varnish |
|
configure_startup_scripts |
|
echo "OpenBSD VPS setup for Ruby on Rails applications completed successfully." |
|
} |
|
|
|
main "$@" |
|
|