├── etc ├── .gitkeep ├── rc.d │ ├── _rails_helper │ └── myapp ├── acme-client.conf ├── pf.conf ├── httpd.conf └── relayd.conf ├── var ├── .gitkeep └── nsd │ ├── etc │ └── nsd.conf │ └── zones │ └── master │ ├── myapp1.com.zone │ └── myapp2.com.zone └── README.md /etc/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /var/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /etc/rc.d/_rails_helper: -------------------------------------------------------------------------------- 1 | #!/bin/ksh 2 | 3 | # Helper to wrap Puma inside rcctl(8) 4 | 5 | command=$1 6 | restricted_user=$2 7 | app=$3 8 | port=$4 9 | 10 | cd /home/"$restricted_user"/"$app" && \ 11 | doas -u "$restricted_user" env \ 12 | PORT="$port" \ 13 | RAILS_ENV=production \ 14 | GEM_HOME=/home/"$restricted_user"/.gem \ 15 | bundle exec pumactl "$command" 16 | 17 | -------------------------------------------------------------------------------- /var/nsd/etc/nsd.conf: -------------------------------------------------------------------------------- 1 | server: 2 | hide-version: yes 3 | verbosity: 1 4 | 5 | remote-control: 6 | control-enable: yes 7 | control-interface: /var/run/nsd.sock 8 | 9 | zone: 10 | name: myapp1.com 11 | zonefile: master/%s.zone 12 | notify: XXX.XX.XXX.XX NOKEY 13 | provide-xfr: XXX.XX.XXX.XX NOKEY 14 | 15 | zone: 16 | name: myapp2.com 17 | zonefile: master/%s.zone 18 | notify: XXX.XX.XXX.XX NOKEY 19 | provide-xfr: XXX.XX.XXX.XX NOKEY 20 | 21 | -------------------------------------------------------------------------------- /var/nsd/zones/master/myapp1.com.zone: -------------------------------------------------------------------------------- 1 | $ORIGIN myapp1.com. 2 | $TTL 24h 3 | 4 | @ 1h IN SOA ns1.myapp1.com. admin.example.com. ( 5 | 2021052101 ; Serial YYYYMMDDnn 6 | 1h ; Refresh 7 | 15m ; Retry 8 | 1w ; Expire 9 | 3m ; Minimum TTL 10 | ) 11 | 12 | @ IN NS ns1.myapp1.com. 13 | @ IN NS ns2.myapp1.com. 14 | 15 | www IN CNAME @ 16 | 17 | @ IN A XX.XXX.XXX.XX 18 | 19 | ; https://letsencrypt.org/docs/caa/ 20 | myapp1.com. 3m IN CAA 0 issue "letsencrypt.org" 21 | 22 | -------------------------------------------------------------------------------- /var/nsd/zones/master/myapp2.com.zone: -------------------------------------------------------------------------------- 1 | $ORIGIN myapp2.com. 2 | $TTL 24h 3 | 4 | @ 1h IN SOA ns1.myapp2.com. admin.example.com. ( 5 | 2021052101 ; Serial YYYYMMDDnn 6 | 1h ; Refresh 7 | 15m ; Retry 8 | 1w ; Expire 9 | 3m ; Minimum TTL 10 | ) 11 | 12 | @ IN NS ns1.myapp2.com. 13 | @ IN NS ns2.myapp2.com. 14 | 15 | www IN CNAME @ 16 | 17 | @ IN A XX.XXX.XXX.XX 18 | 19 | ; https://letsencrypt.org/docs/caa/ 20 | myapp2.com. 3m IN CAA 0 issue "letsencrypt.org" 21 | 22 | -------------------------------------------------------------------------------- /etc/rc.d/myapp: -------------------------------------------------------------------------------- 1 | #!/bin/ksh 2 | 3 | # Rails/Puma startup script 4 | # https://cvsweb.openbsd.org/cgi-bin/cvsweb/ports/infrastructure/templates/rc.template 5 | 6 | restricted_user="apps" 7 | app="myapp" 8 | port="12345" 9 | 10 | # Get full path to helper 11 | helper_file="$0" 12 | helper_full_path=$(dirname "$0") 13 | daemon="$helper_full_path/_rails_helper" 14 | 15 | # Run in background 16 | rc_bg=YES 17 | 18 | . /etc/rc.d/rc.subr 19 | 20 | rc_start() { 21 | ${rcexec} "${daemon} start ${restricted_user} ${app} ${port}" 22 | } 23 | 24 | rc_check() { 25 | ${rcexec} "${daemon} status ${restricted_user} ${app} ${port}" 26 | } 27 | 28 | rc_restart() { 29 | ${rcexec} "${daemon} phased-restart ${restricted_user} ${app} ${port}" 30 | } 31 | 32 | rc_stop() { 33 | ${rcexec} "${daemon} stop ${restricted_user} ${app} ${port}" 34 | } 35 | 36 | rc_cmd "$1" 37 | 38 | -------------------------------------------------------------------------------- /etc/acme-client.conf: -------------------------------------------------------------------------------- 1 | authority letsencrypt { 2 | api url "https://acme-v02.api.letsencrypt.org/directory" 3 | account key "/etc/ssl/private/letsencrypt.key" 4 | } 5 | 6 | # ---------------------------------------- 7 | 8 | domain myapp1.com { 9 | domain key "/etc/ssl/private/myapp1.com.key" 10 | domain full chain certificate "/etc/ssl/myapp1.com.crt" 11 | sign with letsencrypt 12 | } 13 | 14 | domain www.myapp1.com { 15 | domain key "/etc/ssl/private/www.myapp1.com.key" 16 | domain full chain certificate "/etc/ssl/www.myapp1.com.crt" 17 | sign with letsencrypt 18 | } 19 | 20 | # ---------------------------------------- 21 | 22 | domain myapp2.com { 23 | domain key "/etc/ssl/private/myapp2.com.key" 24 | domain full chain certificate "/etc/ssl/myapp2.com.crt" 25 | sign with letsencrypt 26 | } 27 | 28 | domain www.myapp2.com { 29 | domain key "/etc/ssl/private/www.myapp2.com.key" 30 | domain full chain certificate "/etc/ssl/www.myapp2.com.crt" 31 | sign with letsencrypt 32 | } 33 | 34 | -------------------------------------------------------------------------------- /etc/pf.conf: -------------------------------------------------------------------------------- 1 | ext_if = "" 2 | 3 | # Allow all on localhost 4 | set skip on lo 5 | 6 | # Block stateless traffic 7 | block return 8 | 9 | # Establish keep-state 10 | pass 11 | 12 | # Block all incoming by default 13 | block in 14 | 15 | # Ban brute-force attackers 16 | # http://home.nuug.no/~peter/pf/en/bruteforce.html 17 | # 18 | # pfctl -t bruteforce -T show 19 | # pfctl -t bruteforce -T flush 20 | # pfctl -t bruteforce -T delete 21 | # 22 | table persist 23 | block quick from 24 | 25 | # SSH 26 | pass in on $ext_if inet proto tcp from any to ($ext_if) port 22 keep state (max-src-conn 15, max-src-conn-rate 5/3, overload flush global) 27 | 28 | # DNS 29 | pass in on $ext_if inet proto { tcp, udp } from any to ($ext_if) port 53 keep state (max-src-conn 100, max-src-conn-rate 15/5, overload flush global) 30 | 31 | # HTTP/HTTPS 32 | pass in on $ext_if inet proto tcp from any to ($ext_if) port { 80, 443 } keep state 33 | 34 | # Allow relayd(8) redirects 35 | anchor "relayd/*" 36 | 37 | -------------------------------------------------------------------------------- /etc/httpd.conf: -------------------------------------------------------------------------------- 1 | ext_if="" 2 | 3 | types { 4 | include "/usr/share/misc/mime.types" 5 | } 6 | 7 | # ---------------------------------------- 8 | 9 | server "myapp1.com" { 10 | listen on $ext_if port 80 11 | no log 12 | location "/.well-known/acme-challenge/*" { 13 | root "/acme" 14 | request strip 2 15 | } 16 | location "*" { 17 | block return 302 "https://$HTTP_HOST$REQUEST_URI" 18 | } 19 | } 20 | 21 | server "www.myapp1.com" { 22 | listen on $ext_if port 80 23 | no log 24 | location "/.well-known/acme-challenge/*" { 25 | root "/acme" 26 | request strip 2 27 | } 28 | location "*" { 29 | block return 302 "https://$HTTP_HOST$REQUEST_URI" 30 | } 31 | } 32 | 33 | # ---------------------------------------- 34 | 35 | server "myapp2.com" { 36 | listen on $ext_if port 80 37 | no log 38 | location "/.well-known/acme-challenge/*" { 39 | root "/acme" 40 | request strip 2 41 | } 42 | location "*" { 43 | block return 302 "https://$HTTP_HOST$REQUEST_URI" 44 | } 45 | } 46 | 47 | server "www.myapp2.com" { 48 | listen on $ext_if port 80 49 | no log 50 | location "/.well-known/acme-challenge/*" { 51 | root "/acme" 52 | request strip 2 53 | } 54 | location "*" { 55 | block return 302 "https://$HTTP_HOST$REQUEST_URI" 56 | } 57 | } 58 | 59 | -------------------------------------------------------------------------------- /etc/relayd.conf: -------------------------------------------------------------------------------- 1 | egress="XX.XXX.XXX.XX" 2 | 3 | table { 127.0.0.1 } 4 | myapp1_port="XXXXX" 5 | 6 | table { 127.0.0.1 } 7 | myapp2_port="XXXXX" 8 | 9 | table { 127.0.0.1 } 10 | httpd_port="80" 11 | 12 | http protocol "http" { 13 | match request header set "Connection" value "close" 14 | match response header remove "Server" 15 | } 16 | 17 | http protocol "https" { 18 | pass request header "Host" value "myapp1.com" forward to 19 | pass request header "Host" value "www.myapp1.com" forward to 20 | tls keypair "myapp1.com" 21 | tls keypair "www.myapp1.com" 22 | 23 | pass request header "Host" value "myapp2.com" forward to 24 | pass request header "Host" value "www.myapp2.com" forward to 25 | tls keypair "myapp2.com" 26 | tls keypair "www.myapp2.com" 27 | 28 | # Preserve address headers 29 | match request header append "X-Forwarded-For" value "$REMOTE_ADDR" 30 | match request header append "X-Forwarded-Port" value "$REMOTE_PORT" 31 | match request header append "X-Forwaded-By" value "$SERVER_ADDR:$SERVER_PORT" 32 | 33 | match request header set "Connection" value "close" 34 | 35 | match response header remove "Server" 36 | 37 | # Best practice security headers 38 | # https://securityheaders.com/ 39 | match response header append "Strict-Transport-Security" value "max-age=31536000; includeSubDomains" 40 | match response header append "X-Frame-Options" value SAMEORIGIN 41 | match response header append "X-XSS-Protection" value "1; mode=block" 42 | match response header append "X-Content-Type-Options" value nosniff 43 | match response header append "Referrer-Policy" value strict-origin 44 | match response header append "Feature-Policy" value "accelerometer 'none'; camera 'none'; geolocation 'none'; gyroscope 'none'; magnetometer 'none'; microphone 'none'; payment 'none'; usb 'none'" 45 | } 46 | 47 | relay "http" { 48 | listen on $egress port http 49 | 50 | protocol "http" 51 | 52 | forward to port $httpd_port 53 | } 54 | 55 | relay "https" { 56 | listen on $egress port https tls 57 | 58 | protocol "https" 59 | 60 | forward to port $httpd_port 61 | forward to port $myapp1_port 62 | forward to port $myapp2_port 63 | } 64 | 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ruby On Rails 7.0 on OpenBSD 7.0 2 | 3 | ### Template for a minimalistic ultra-secure server setup for your Ruby On Rails apps. 4 | 5 | > Choose OpenBSD for your Unix needs. OpenBSD -- the world's simplest and most secure Unix-like OS. An alternatve to the vulnerabilities and overengineering of Linux and its related network services such as NGiNX/Apache, OpenSSL, iptables/nftables, systemd, BIND, Postfix and much, much more. OpenBSD -- the cleanest kernel, the cleanest userland and the cleanest configuration syntax. 6 | 7 | [`httpd(8)`](https://man.openbsd.org/httpd.8) [(PDF: brief intro)](https://www.openbsd.org/papers/httpd-asiabsdcon2015.pdf) and [`acme-client(8)`](https://man.openbsd.org/acme-client.1) are for [Let's Encrypt](https://letsencrypt.com/) TLS-certificates and their [ACME-challenges](https://letsencrypt.org/docs/challenge-types/). [`relayd(8)`](https://man.openbsd.org/relayd.8) does the reverse proxying and TLS-termination, and behind all that is Ruby On Rails' [Puma](https://puma.io/) webserver, managed by [`rc.subr(8)`](https://man.openbsd.org/rc.subr) system startup scripts. [`nsd(8)`](https://man.openbsd.org/nsd.8) does the DNS, and [`pf(8)`](https://www.openbsd.org/faq/pf/) the firewalling. 8 | 9 | ## As root 10 | 11 | Edit config files to taste, then copy them to root: 12 | 13 | cp -R etc/ / 14 | cp -R var/ / 15 | 16 | Add packages: 17 | 18 | pkg_add zsh ruby postgresql-server node redis sass 19 | 20 | Symlinks from Ruby's post-install message: 21 | 22 | ln -s /usr/local/bin/ruby30 /usr/local/bin/ruby 23 | ln -s /usr/local/bin/erb30 /usr/local/bin/erb 24 | ln -s /usr/local/bin/irb30 /usr/local/bin/irb 25 | ln -s /usr/local/bin/rdoc30 /usr/local/bin/rdoc 26 | ln -s /usr/local/bin/ri30 /usr/local/bin/ri 27 | ln -s /usr/local/bin/rake30 /usr/local/bin/rake 28 | ln -s /usr/local/bin/gem30 /usr/local/bin/gem 29 | ln -s /usr/local/bin/bundle30 /usr/local/bin/bundle 30 | ln -s /usr/local/bin/bundler30 /usr/local/bin/bundler 31 | ln -s /usr/local/bin/racc30 /usr/local/bin/racc 32 | ln -s /usr/local/bin/racc2y30 /usr/local/bin/racc2y 33 | ln -s /usr/local/bin/y2racc30 /usr/local/bin/y2racc 34 | 35 | Nokogiri dependencies: 36 | 37 | pkg_add libiconv libxml libxslt 38 | 39 | Create unprivileged user and group `myuser`: 40 | 41 | useradd -m -s /usr/local/bin/zsh myuser 42 | passwd myuser 43 | 44 | ## As unprivileged user 45 | 46 | Make Bundler install gems locally: 47 | 48 | bundle config set path /home/myuser/.bundle/ 49 | 50 | Set gem path: 51 | 52 | echo "path+=(/home/myuser/.gem/ruby/3.0/bin)" >> ~/.zprofile 53 | source ~/.zprofile 54 | 55 | Install Rails, and make sure Nokogiri uses OpenBSD's packages when compiling: 56 | 57 | gem install --user-install rails -- --use-system-libraries 58 | 59 | ## Let's Encrypt HTTPS/TLS 60 | 61 | Modify `etc/acme-client.conf`, `etc/relayd.conf` and `etc/httpd.conf` accordingly: 62 | 63 | acme-client -v myapp.com 64 | acme-client -v www.myapp.com 65 | 66 | Check ratings at [SSL Labs](https://ssllabs.com/ssltest/) and [Security Headers](https://securityheaders.com/). 67 | 68 | ## PostgreSQL 69 | 70 | doas -u _postgresql initdb -D /var/postgresql/data/ -U postgres 71 | 72 | rcctl enable postgresql 73 | rcctl start postgresql 74 | 75 | Create database: 76 | 77 | doas -u _postgresql psql -U postgres 78 | CREATE ROLE myapp LOGIN SUPERUSER PASSWORD ''; 79 | 80 | Encrypt passwords: 81 | 82 | EDITOR=vim rails credentials:edit 83 | 84 | database: 85 | username: myapp 86 | password: 87 | 88 | ### Start system services 89 | 90 | rcctl enable nsd 91 | rcctl start nsd 92 | 93 | rcctl enable httpd 94 | rcctl start httpd 95 | 96 | rcctl enable relayd 97 | rcctl start relayd 98 | 99 | ### Start your app 100 | 101 | rcctl enable myapp 102 | rcctl start myapp 103 | 104 | --------------------------------------------------------------------------------