diff --git a/config/deploy.rb b/config/deploy.rb deleted file mode 100644 index dda33c61..00000000 --- a/config/deploy.rb +++ /dev/null @@ -1,85 +0,0 @@ -require "bundler/capistrano" -require "rvm/capistrano" - -set :application, "newimpress.openneo.net" -set :repository, "git://github.com/matchu/openneo-impress-rails.git" -set :deploy_to, "/home/rails/impress" -set :user, "rails" -set :branch, "master" -default_run_options[:pty] = true - -set :scm, :git -# Or: `accurev`, `bzr`, `cvs`, `darcs`, `git`, `mercurial`, `perforce`, `subversion` or `none` - -role :web, application -role :app, application, :memcached => true -role :db, application, :primary => true - -set :bundle_without, [:development, :test] - -set :rvm_ruby_string, 'ruby-1.9.3-p484' # Or whatever env you want it to run in. -set :rvm_type, :system -set :rvm_install_type, :head -set :rvm_bin_path, "/usr/local/rvm/bin" - -namespace :deploy do - task :start, :roles => :app do - run "touch #{current_release}/tmp/restart.txt" - sudo "monit -g impress_workers start" - end - - task :stop do - sudo "monit -g impress_workers stop" - end - - task :restart do - run "touch #{current_release}/tmp/restart.txt" - sudo "monit -g impress_workers restart" - end - - desc "Link shared files" - task :link do - links = { - "#{shared_path}/app/views/static/_announcement.html" => "#{release_path}/app/views/static/_announcement.html", - #"#{shared_path}/config/aws_s3.yml" => "#{release_path}/config/aws_s3.yml", - "#{shared_path}/config/database.yml" => "#{release_path}/config/database.yml", - #"#{shared_path}/config/openneo_auth.yml" => "#{release_path}/config/openneo_auth.yml", - #"#{shared_path}/config/initializers/secret_token.rb" => "#{release_path}/config/initializers/secret_token.rb", - #"#{shared_path}/config/initializers/stripe.rb" => "#{release_path}/config/initializers/stripe.rb" - "#{shared_path}/public/beta.html" => "#{release_path}/public/beta.html", - "#{shared_path}/public/javascripts/analytics.js" => "#{release_path}/app/assets/javascripts/analytics.js", - "#{shared_path}/public/swfs/outfit" => "#{release_path}/public/swfs/outfit", - "#{shared_path}/.rvmrc" => "#{release_path}/.rvmrc" - } - links.each do |specific_shared_path, specific_release_path| - run "rm -rf #{specific_release_path} && ln -nfs #{specific_shared_path} #{specific_release_path}" - end - end -end - -namespace :memcached do - desc "Start memcached" - task :start, :roles => [:app], :only => {:memcached => true} do - sudo "/etc/init.d/memcached start" - end - desc "Stop memcached" - task :stop, :roles => [:app], :only => {:memcached => true} do - sudo "/etc/init.d/memcached stop" - end - desc "Restart memcached" - task :restart, :roles => [:app], :only => {:memcached => true} do - sudo "/etc/init.d/memcached restart" - end - desc "Flush memcached - this assumes memcached is on port 11211" - task :flush, :roles => [:app], :only => {:memcached => true} do - run "echo 'flush_all' | nc localhost 11211" - end - desc "Symlink the memcached.yml file into place if it exists" - task :symlink_configs, :roles => [:app], :only => {:memcached => true }, :except => { :no_release => true } do - run "if [ -f #{shared_path}/config/memcached.yml ]; then ln -nfs #{shared_path}/config/memcached.yml #{latest_release}/config/memcached.yml; fi" - end -end - -before "deploy:symlink", "memcached:flush" -after "deploy:update_code", "deploy:link" - diff --git a/deploy/README b/deploy/README new file mode 100644 index 00000000..525c144d --- /dev/null +++ b/deploy/README @@ -0,0 +1,22 @@ +Dress to Impress is deployed to a VPS server. We use this Ansible Playbook to +automate the environment setup! + +We expect to be deploying to Ubuntu 20.04 LTS, initially with nothing +installed. The user you deploy with should have sudoers access. That should be +all it takes! + +First, run `yarn deploy:setup` in the app root, to run the `setup.yml` +playbook. This will prompt you for your root password, to set up system +dependencies. It should be safe to re-run this, including if you add a new +dependency to the playbook, because the steps are non-destructive and Ansible +will skip steps that are already satisfied. + +Then, to deploy a new version of the app, run `yarn deploy`. This will build +the app from the code on your machine, then send the source and build output +to the remote machine, and switch it to be the new production version. Nice! + +Note that the setup script references a file named `production.env`, which is +gitignored because it contains sensitive information, like database passwords. +You should create a `production.env` file in the local `deploy/files` +directory, to be copied to the remote server and used as its environment +variables. diff --git a/deploy/files/.gitignore b/deploy/files/.gitignore new file mode 100644 index 00000000..c4cdf543 --- /dev/null +++ b/deploy/files/.gitignore @@ -0,0 +1 @@ +/production.env \ No newline at end of file diff --git a/deploy/files/authorized-ssh-keys.txt b/deploy/files/authorized-ssh-keys.txt new file mode 100644 index 00000000..a870f085 --- /dev/null +++ b/deploy/files/authorized-ssh-keys.txt @@ -0,0 +1,16 @@ +# These are the SSH public keys that allow a user to log in and setup or deploy. +# +# It's dangerous to add a new key to this file! When you run +# `yarn deploy-setup`, it will copy these keys to the deploy server, which will +# allow the owner of these keys to log into the deploy server in the future. +# +# But the keys themselves aren't necessarily sensitive data, except for the name +# at the end, which might tell a reader about who is allowed to log in and what +# devices they own. +# +# When a computer tries to log in, it perform a cryptographic challenge that +# proves it owns this key - but that requires the *private* key, whereas this is +# the *public* key. That's why it's secure to publish these! + +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID2/yLvpetD14BVK+Zd88ZofOxIfLRdl4FI2pdV+fmy3 Matchu's Desktop (Leviathan) WSL +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKFwWryq6slOQqkrJ7HIig7BvEQVQeH19hFwb+9VpXgz Matchu's Laptop (Ebon Hawk) diff --git a/deploy/files/initial-placeholder/Gemfile b/deploy/files/initial-placeholder/Gemfile new file mode 100644 index 00000000..a1d525e2 --- /dev/null +++ b/deploy/files/initial-placeholder/Gemfile @@ -0,0 +1,2 @@ +source 'http://rubygems.org' +gem 'puma', '~> 6.3' \ No newline at end of file diff --git a/deploy/files/initial-placeholder/Gemfile.lock b/deploy/files/initial-placeholder/Gemfile.lock new file mode 100644 index 00000000..85651768 --- /dev/null +++ b/deploy/files/initial-placeholder/Gemfile.lock @@ -0,0 +1,15 @@ +GEM + remote: http://rubygems.org/ + specs: + nio4r (2.5.9) + puma (6.3.0) + nio4r (~> 2.0) + +PLATFORMS + x86_64-linux + +DEPENDENCIES + puma (~> 6.3) + +BUNDLED WITH + 2.4.18 diff --git a/deploy/files/initial-placeholder/config.ru b/deploy/files/initial-placeholder/config.ru new file mode 100644 index 00000000..c061f2d9 --- /dev/null +++ b/deploy/files/initial-placeholder/config.ru @@ -0,0 +1,3 @@ +run lambda { |env| + [200, {}, ["Hello world! The server is up - now it's time to deploy the app for real!"]] +} \ No newline at end of file diff --git a/deploy/inventory.cfg b/deploy/inventory.cfg new file mode 100644 index 00000000..7a93711e --- /dev/null +++ b/deploy/inventory.cfg @@ -0,0 +1,3 @@ +# There is currently only one impress box in our Ansible inventory! +[webserver] +beta.impress.openneo.net diff --git a/deploy/setup.yml b/deploy/setup.yml new file mode 100644 index 00000000..e70b3278 --- /dev/null +++ b/deploy/setup.yml @@ -0,0 +1,302 @@ +--- +- name: Set up the environment for the impress app + hosts: webserver + become: yes + become_user: root + vars: + email_address: "emi@matchu.dev" # TODO: Extract this to personal config? + impress_hostname: beta.impress.openneo.net + tasks: + - name: Create SSH folder for logged-in user + become: no + file: + name: .ssh + mode: "700" + state: directory + + - name: Copy authorized SSH keys to logged-in user + become: no + copy: + dest: ~/.ssh/authorized_keys + src: files/authorized-ssh-keys.txt + mode: "600" + + - name: Disable root SSH login + lineinfile: + dest: /etc/ssh/sshd_config + regexp: ^#?PermitRootLogin + line: PermitRootLogin no + + - name: Disable password-based SSH authentication + lineinfile: + dest: /etc/ssh/sshd_config + regexp: ^#?PasswordAuthentication + line: PasswordAuthentication no + + - name: Enable public-key SSH authentication + lineinfile: + dest: /etc/ssh/sshd_config + regexp: ^#?PubkeyAuthentication + line: PubkeyAuthentication yes + + - name: Update the apt cache + apt: + update_cache: yes + + - name: Install fail2ban firewall with default settings + apt: + name: fail2ban + + - name: Configure ufw firewall to allow SSH connections on port 22 + community.general.ufw: + rule: allow + port: "22" + + - name: Configure ufw firewall to allow HTTP connections on port 80 + community.general.ufw: + rule: allow + port: "80" + + - name: Configure ufw firewall to allow HTTPS connections on port 443 + community.general.ufw: + rule: allow + port: "443" + + - name: Enable ufw firewall with all other ports closed by default + community.general.ufw: + state: enabled + policy: deny + + - name: Install unattended-upgrades + apt: + name: unattended-upgrades + + - name: Enable unattended-upgrades to auto-upgrade our system + copy: + content: | + APT::Periodic::Update-Package-Lists "1"; + APT::Periodic::Unattended-Upgrade "1"; + dest: /etc/apt/apt.conf.d/20auto-upgrades + + - name: Configure unattended-upgrades to auto-reboot our server when necessary + lineinfile: + regex: ^(//\s*)?Unattended-Upgrade::Automatic-Reboot ".*";$ + line: Unattended-Upgrade::Automatic-Reboot "true"; + dest: /etc/apt/apt.conf.d/50unattended-upgrades + + - name: Configure unattended-upgrades to delay necessary reboots to 3am + lineinfile: + regex: ^(//\s*)?Unattended-Upgrade::Automatic-Reboot-Time ".*";$ + line: Unattended-Upgrade::Automatic-Reboot-Time "03:00"; + dest: /etc/apt/apt.conf.d/50unattended-upgrades + + - name: Configure the system timezone to be US Pacific time + community.general.timezone: + name: America/Los_Angeles + + - name: Create "impress" user + user: + name: impress + comment: Impress App + create_home: false + + - name: Install ACL, to enable us to run commands as the "impress" user + apt: + name: acl + + - name: Install ruby-build + git: + repo: https://github.com/rbenv/ruby-build.git + dest: /opt/ruby-build + version: 4d4678bc1ed89aa6900c0ea0da23495445dbcf50 + + - name: Check if Ruby 3.1.4 is already installed + stat: + path: /opt/ruby-3.1.4 + register: ruby_dir + + - name: Install Ruby 3.1.4 + command: "/opt/ruby-build/bin/ruby-build 3.1.4 /opt/ruby-3.1.4" + when: not ruby_dir.stat.exists + + - name: Add Ruby 3.1.4 to the global PATH, for developer convenience + lineinfile: + dest: /etc/profile + line: 'PATH="/opt/ruby-3.1.4/bin:$PATH" # Added by impress deploy setup script' + + - name: Create the app folder + file: + path: /srv/impress + owner: impress + group: impress + mode: "755" + state: directory + + - name: Create the app versions folder + become_user: impress + file: + path: /srv/impress/versions + state: directory + + - name: Check for a current app version + stat: + path: /srv/impress/current + register: current_app_version + + - name: Check whether we already have a placeholder app + stat: + path: /srv/impress/versions/initial-placeholder + register: existing_placeholder_app + when: not current_app_version.stat.exists + + - name: Create a placeholder app, to run until we deploy a real version + become_user: impress + copy: + src: files/initial-placeholder + dest: /srv/impress/versions + when: | + not current_app_version.stat.exists and + not existing_placeholder_app.stat.exists + + - name: Configure the placeholder app to run in deployment mode + become_user: impress + command: + chdir: /srv/impress/versions/initial-placeholder + cmd: /opt/ruby-3.1.4/bin/bundle config set --local deployment true + when: not current_app_version.stat.exists + + - name: Install the placeholder app's dependencies + become_user: impress + command: + chdir: /srv/impress/versions/initial-placeholder + cmd: /opt/ruby-3.1.4/bin/bundle install + when: not current_app_version.stat.exists + + - name: Set the placeholder app as the current version + become_user: impress + file: + src: /srv/impress/versions/initial-placeholder + dest: /srv/impress/current + state: link + when: not current_app_version.stat.exists + + # NOTE: This file is uploaded with stricter permissions, to help protect + # the secrets inside. Most of the app is world-readable for convenience + # for debugging and letting nginx serve static files, but keep this safer! + - name: Upload the production.env file + become_user: impress + copy: + dest: /srv/impress/production.env + src: files/production.env + mode: "600" + + - name: Create service file for impress + copy: + dest: /etc/systemd/system/impress.service + content: | + [Unit] + Description=Dress to Impress webapp + + [Service] + User=impress + Restart=always + WorkingDirectory=/srv/impress/current + ExecStart=/opt/ruby-3.1.4/bin/bundle exec puma --port=3000 + EnvironmentFile=/srv/impress/production.env + + ; Some security directives, adapted from Akkoma's service file, they seem like sensible defaults! + ; Use private /tmp and /var/tmp folders inside a new file system namespace, which are discarded after the process stops. + PrivateTmp=true + ; The /home, /root, and /run/user folders can not be accessed by this service anymore. If your Akkoma user has its home folder in one of the restricted places, or use one of these folders as its working directory, you have to set this to false. + ProtectHome=true + ; Mount /usr, /boot, and /etc as read-only for processes invoked by this service. + ProtectSystem=full + ; Sets up a new /dev mount for the process and only adds API pseudo devices like /dev/null, /dev/zero or /dev/random but not physical devices. + PrivateDevices=true + ; Ensures that the service process and all its children can never gain new privileges through execve(). + NoNewPrivileges=true + ; Drops the sysadmin capability from the daemon. + CapabilityBoundingSet=~CAP_SYS_ADMIN + + [Install] + WantedBy=multi-user.target + notify: + - Reload systemctl + - Restart impress + + - name: Configure impress to run now, and automatically when the system starts + systemd: + name: impress + state: started + enabled: true + + - name: Install nginx + apt: + name: nginx + + - name: Install core snap + community.general.snap: + name: core + + - name: Install certbot as a snap + community.general.snap: + name: certbot + classic: yes + + - name: Set up certbot + command: "certbot certonly --nginx -n --agree-tos --email {{ email_address }} --domains beta.impress.openneo.net" + + - name: Add impress config file to nginx + copy: + content: | + server { + server_name {{ impress_hostname }}; + listen 80; + if ($host = {{ impress_hostname }}) { + return 301 https://$host$request_uri; + } + } + + server { + server_name {{ impress_hostname }}; + listen 443 ssl; + ssl_certificate /etc/letsencrypt/live/{{ impress_hostname }}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/{{ impress_hostname }}/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + ssl_session_cache shared:SSL:10m; # https://superuser.com/q/1484466/14127 + + root /srv/impress/current/public; + + # Try serving static files first. If not found, fall back to the app. + try_files $uri/index.html $uri @app; + + location @app { + proxy_pass http://127.0.0.1:3000; + } + } + dest: /etc/nginx/sites-available/impress.conf + notify: + - Restart nginx + + - name: Enable impress config file in nginx + file: + src: /etc/nginx/sites-available/impress.conf + dest: /etc/nginx/sites-enabled/impress.conf + state: link + notify: + - Restart nginx + + handlers: + - name: Restart nginx + systemd: + name: nginx + state: restarted + + - name: Reload systemctl + command: systemctl daemon-reload + + - name: Restart impress + systemd: + name: impress + state: restarted diff --git a/package.json b/package.json index 73286ae2..32548e2c 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ }, "scripts": { "build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=/assets --loader:.js=jsx --loader:.png=file --loader:.svg=file --loader:.min.js=text", - "build:production": "yarn build --minify" + "build:production": "yarn build --minify", + "deploy:setup": "echo $'Setup requires you to become the root user. You\\'ll need to enter the password for your account on the remote web server below, and you must be part of the `sudo` user group.' && ansible-playbook -K -i deploy/inventory.cfg deploy/setup.yml" } }