Browse Source

security, rails, postgresql: Import files from original repo

pjones/monitoring
Peter J. Jones 8 months ago
commit
3980c37fa0
Signed by: Peter Jones <pjones@devalot.com> GPG Key ID: 9DAFAA8D01941E49

+ 26
- 0
LICENSE View File

@@ -0,0 +1,26 @@
Copyright (c) 2016-2018 Peter J. Jones <pjones@devalot.com>
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:

1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.

2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the
distribution.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

+ 26
- 0
README.md View File

@@ -0,0 +1,26 @@
Phoebe is a set of [NixOS][] modules that provide additional
functionality on top of the existing modules in [Nixpkgs][]. The name
of this package was taken from the name of [Saturn's moon][phoebe].

Module List
-----------

* `phoebe.security`:

Automatically enable various security related settings for NixOS.

* `phoebe.services.postgresql`:

Start and manage PostgreSQL, including automatic user and database
creation.

* `phoebe.services.rails`:

Configure and manage Ruby on Rails applications. Includes a
helper function to help package Rails applications so they can be
used by this service.


[nixos]: https://nixos.org/
[nixpkgs]: https://nixos.org/nixpkgs/
[phoebe]: https://en.wikipedia.org/wiki/Phoebe_(moon)

+ 7
- 0
default.nix View File

@@ -0,0 +1,7 @@
{ config, lib, pkgs, ...}:

{
imports = [
./modules
];
}

+ 14
- 0
helpers.nix View File

@@ -0,0 +1,14 @@
{ pkgs }:

let
callPackage = pkgs.lib.callPackageWith self;

self = {
inherit pkgs;

rails = callPackage ./modules/services/web/rails/helpers.nix { };
};
in
{
inherit (self.rails) mkRailsDerivation;
}

+ 8
- 0
modules/default.nix View File

@@ -0,0 +1,8 @@
{ config, lib, pkgs, ...}:

{
imports = [
./security
./services
];
}

+ 41
- 0
modules/security/default.nix View File

@@ -0,0 +1,41 @@
{ config, lib, pkgs, ...}:

# Bring in library functions:
with lib;

let
cfg = config.phoebe.security;

in
{
#### Interface
options.phoebe.security = {
enable = mkOption {
type = types.bool;
default = true;
description = ''
Whether or not to enable security settings. Usually this will
be left at the default value of true. However, for testing
inside virtual machines you probably wnat to turn this off.
'';
};
};

#### Implementation
config = mkMerge [
(mkIf (!cfg.enable) {
# Only really useful for development VMs:
networking.firewall.enable = false;
})

(mkIf cfg.enable {
# Firewall:
networking.firewall = {
enable = true;
allowPing = true;
pingLimit = "--limit 1/minute --limit-burst 5";
allowedTCPPorts = config.services.openssh.ports;
};
})
];
}

+ 7
- 0
modules/services/databases/default.nix View File

@@ -0,0 +1,7 @@
{ config, lib, pkgs, ...}:

{
imports = [
./postgresql
];
}

+ 24
- 0
modules/services/databases/postgresql/create-user.nix View File

@@ -0,0 +1,24 @@
{ config, lib, pkgs, ...}:

pkgs.stdenvNoCC.mkDerivation {
name = "pg-create-user";
phases = [ "installPhase" "fixupPhase" ];

installPhase = ''
# Substitution variables:
export sudo=${pkgs.sudo}/bin/sudo
export superuser=${config.services.postgresql.superUser}

mkdir -p $out/bin $out/sql
cp ${./create-user.sql} $out/sql/create-user.sql
substituteAll ${./create-user.sh} $out/bin/create-user.sh
chmod 555 $out/bin/create-user.sh
'';

meta = with lib; {
description = "Automatically create PosgreSQL databases and users as needed.";
homepage = https://git.devalot.com/pjones/phoebe/;
maintainers = with maintainers; [ pjones ];
platforms = platforms.all;
};
}

+ 120
- 0
modules/services/databases/postgresql/create-user.sh View File

@@ -0,0 +1,120 @@
#!/bin/bash

################################################################################
set -e

################################################################################
option_username=""
option_password_file=""
option_database=""
option_extensions=""
option_sqlfile="@out@/sql/create-user.sql"

################################################################################
usage () {
cat <<EOF
Usage: create-user.sh [options]

-d NAME Database name to create for USER
-e LIST Space-separated list of extensions to enable
-h This message
-p FILE File containing USER's password
-s FILE The SQL template file (pg-create-user.sql)
-u USER Username to create
EOF
}

################################################################################
while getopts "d:e:hp:s:u:" o; do
case "${o}" in
d) option_database=$OPTARG
;;

e) option_extensions=$OPTARG
;;

h) usage
exit
;;

p) option_password_file=$OPTARG
;;

s) option_sqlfile=$OPTARG
;;

u) option_username=$OPTARG
;;

*) exit 1
;;
esac
done

shift $((OPTIND-1))

################################################################################
tmp_sql_file=$(mktemp --suffix=.sql --tmpdir new-user.XXXXXXXXX)

cleanup() {
rm -f "$tmp_sql_file"
}

trap cleanup EXIT

################################################################################
_psql() {
@sudo@ -u @superuser@ -H psql "$@"
}

################################################################################
mksql() {
# FIXME: Passwords can't contain single quotes due to this simple logic:
if head -n 1 "$option_password_file" | grep -q "'"; then
>&2 echo "ERROR: password for $option_username contains single quote!"
exit 1
fi

password=$(head -n 1 "$option_password_file")

awk -v 'USERNAME'="$option_username" \
-v 'PASSWORD'="$password" \
' { gsub(/@@USERNAME@@/, USERNAME);
gsub(/@@PASSWORD@@/, PASSWORD);
print;
}
' < "$option_sqlfile" > "$tmp_sql_file"

# Let the database user read the generated file.
chmod go+r "$tmp_sql_file"
}

################################################################################
create_user() {
mksql
_psql -d postgres -f "$tmp_sql_file" > /dev/null
}

################################################################################
create_database() {
has_db=$(_psql -tAl | cut -d'|' -f1 | grep -cF "$option_database" || :)

if [ "$has_db" -eq 0 ]; then
@sudo@ -u @superuser@ -H \
createdb --owner "$option_username" "$option_database"
fi
}

################################################################################
enable_extensions() {
if [ -n "$option_extensions" ]; then
for ext in $option_extensions; do
_psql "$option_database" -c "CREATE EXTENSION IF NOT EXISTS $ext"
done
fi
}

################################################################################
create_user
create_database
enable_extensions

+ 12
- 0
modules/services/databases/postgresql/create-user.sql View File

@@ -0,0 +1,12 @@
DO
$body$
BEGIN
IF NOT EXISTS (
SELECT
FROM pg_catalog.pg_roles
WHERE rolname = '@@USERNAME@@') THEN

CREATE ROLE @@USERNAME@@ LOGIN ENCRYPTED PASSWORD '@@PASSWORD@@';
END IF;
END
$body$;

+ 145
- 0
modules/services/databases/postgresql/default.nix View File

@@ -0,0 +1,145 @@
# Configure PostgreSQL:
{ config, lib, pkgs, ...}:

# Bring in library functions:
with lib;

let
cfg = config.phoebe.services.postgresql;
superuser = config.services.postgresql.superUser;
create-user = import ./create-user.nix { inherit config lib pkgs; };
afterservices = concatMap (a: a.afterServices) (attrValues cfg.accounts);

# Per-account options:
account = { name, ... }: {

#### Interface:
options = {
user = mkOption {
type = types.str;
default = null;
example = "jdoe";
description = "The name of the account (username).";
};

passwordFile = mkOption {
type = types.path;
default = null;
example = "/run/keys/pgpass.txt";
description = ''
A file containing the password of this database user.
You'll want to use something like NixOps to get the password
file onto the target machine.
'';
};

afterServices = mkOption {
type = types.listOf types.str;
default = [ ];
example = [ "dbpassword.service" ];
description = ''
A list of services that need to run before this user account
can be created. This is really useful if you are using
NixOps to deploy the password file and want to wait for the
key to appear in /run/keys.
'';
};

database = mkOption {
type = types.str;
default = null;
example = "jdoe";
description = ''
The name of the database this user can access. Defaults to
the account name.
'';
};

extensions = mkOption {
type = types.listOf types.str;
default = [ ];
example = [ "pg_trgm" ];
description = "A list of extension modules to enable for the database.";
};

netmask = mkOption {
type = types.nullOr types.str;
default = null;
example = "127.0.0.1/32";
description = ''
IP netmask of remote machines allowed to connect. Leaving
this at it's default value means this account can only
connect through Unix domain sockets.
'';
};
};

#### Implementation:
config = {
user = mkDefault name;
database = mkDefault name;
};
};

# Create HBA authentication entries:
accountToHBA = account:
''
local ${account.database} ${account.user} md5
host ${account.database} ${account.user} 127.0.0.1/32 md5
host ${account.database} ${account.user} ::1/28 md5
'' + optionalString (account.netmask != null) ''
host ${account.database} ${account.user} ${account.netmask} md5
'';

# Commands to run to create accounts/databases:
createScript = account:
''
${create-user}/bin/create-user.sh \
-u "${account.user}" \
-d "${account.database}" \
-p "${account.passwordFile}" \
-e "${concatStringsSep " " account.extensions}"
'';

in
{
#### Interface
options.phoebe.services.postgresql = {
enable = mkEnableOption "PostgreSQL";

accounts = mkOption {
type = types.attrsOf (types.submodule account);
default = { };
description = "Additional user accounts";
};
};

#### Implementation
config = mkIf cfg.enable {

# Set up PosgreSQL:
services.postgresql = {
enable = true;
enableTCPIP = true;
package = pkgs.postgresql;

# The superuser can access all databases locally, remote access
# for some users.
authentication = mkForce (
"local all ${superuser} peer\n" +
"host all ${superuser} 127.0.0.1/32 ident\n" +
"host all ${superuser} ::1/128 ident\n" +
concatMapStringsSep "\n" accountToHBA (attrValues cfg.accounts));
};

# Create missing accounts:
systemd.services.pg-accounts = mkIf (length (attrValues cfg.accounts) > 0) {
description = "PostgreSQL Account Manager";
path = [ pkgs.gawk config.services.postgresql.package ];
script = (concatMapStringsSep "\n" createScript (attrValues cfg.accounts));
wantedBy = [ "postgresql.service" ];
after = [ "postgresql.service" ] ++ afterservices;
wants = afterservices;
};
};
}

+ 8
- 0
modules/services/default.nix View File

@@ -0,0 +1,8 @@
{ config, lib, pkgs, ...}:

{
imports = [
./databases
./web
];
}

+ 7
- 0
modules/services/web/default.nix View File

@@ -0,0 +1,7 @@
{ config, lib, pkgs, ...}:

{
imports = [
./rails
];
}

+ 10
- 0
modules/services/web/rails/database.yml View File

@@ -0,0 +1,10 @@
<%= ENV['RAILS_ENV'] %>:
adapter: postgresql
encoding: unicode
pool: 5
timeout: 5000
host: <%= ENV['DATABASE_HOST'] %>
port: <%= ENV['DATABASE_PORT'] %>
database: <%= ENV['DATABASE_NAME'] %>
username: <%= ENV['DATABASE_USER'] %>
password: '<%= File.read(ENV['DATABASE_PASSWORD_FILE']).chomp %>'

+ 58
- 0
modules/services/web/rails/db-migrate.sh View File

@@ -0,0 +1,58 @@
#!/bin/bash

################################################################################
# Migrate a Ruby on Rails database to its latest version (which might
# mean going back in time for a rollback).
set -e
set -u

################################################################################
option_env=${RAILS_ENV:-production}
option_root=$(pwd)

################################################################################
usage () {
cat <<EOF
Usage: db-migrate.sh [options]

-e NAME Set RAILS_ENV to NAME
-h This message
-r DIR The root directory of the Rails app
EOF
}

################################################################################
while getopts "he:r:" o; do
case "${o}" in
e) option_env=$OPTARG
;;

h) usage
exit
;;

r) option_root=$OPTARG
;;

*) exit 1
;;
esac
done

shift $((OPTIND-1))

################################################################################
cd "$option_root"
export RAILS_ENV=$option_env

################################################################################
# If this is a new database, load the schema file:
if [ ! -e config/database-loaded.flag ]; then
rake db:schema:load
touch config/database-loaded.flag
fi

################################################################################
# Migrate to the most recent migration version:
latest=$(find db/migrate -type f -exec basename '{}' ';' | sort | tail -n 1 | cut -d_ -f1)
rake db:migrate VERSION="$latest"

+ 159
- 0
modules/services/web/rails/default.nix View File

@@ -0,0 +1,159 @@
# Configure Ruby on Rails applications:
{ config, lib, pkgs, ...}:

# Bring in library functions:
with lib;

let
##############################################################################
# Save some typing.
cfg = config.phoebe.services.rails;
scripts = import ./scripts.nix { inherit lib pkgs; };
options = import ./options.nix { inherit config lib pkgs; };

##############################################################################
# Is PostgreSQL local?
localpg = config.phoebe.services.postgresql.enable;

##############################################################################
# Packages to put in the application's PATH. FIXME:
# propagatedBuildInputs won't always be set.
appPath = app: [ app.package.rubyEnv ] ++ app.package.propagatedBuildInputs;

##############################################################################
# Collect all apps into a single set using the given function:
collectApps = f: foldr (a: b: recursiveUpdate b (f a)) {} (attrValues cfg.apps);

##############################################################################
# Generate an NGINX configuration for an application:
appToVirtualHost = app: {
"${app.domain}" = {
forceSSL = config.phoebe.security.enable;
enableACME = config.phoebe.security.enable;
root = "${app.package}/share/${app.name}/public";

locations = {
"/assets/" = {
extraConfig = ''
gzip_static on;
expires 1M;
add_header Cache-Control public;
'';
};

"/" = {
tryFiles = "$uri @app";
};

"@app" = {
proxyPass = "http://localhost:${toString app.port}";
};
};
};
};

##############################################################################
# Generate a systemd service for a Ruby on Rails application:
appService = app: {
"rails-${app.name}" = {
description = "${app.name} (Ruby on Rails)";
path = appPath app;

environment = {
HOME = "${app.home}/home";
RAILS_ENV = app.railsEnv;
DATABASE_HOST = app.database.host;
DATABASE_PORT = toString app.database.port;
DATABASE_NAME = app.database.name;
DATABASE_USER = app.database.user;
DATABASE_PASSWORD_FILE = "${app.home}/config/database.password";
} // app.environment;

wantedBy = [ "multi-user.target" ];
wants = optional (app.database.passwordService != null) app.database.passwordService;
after = [ "network.target" ] ++
optional localpg "postgresql.service" ++
optional (app.database.passwordService != null) app.database.passwordService;

preStart = ''
# Prepare the config directory:
rm -rf ${app.home}/config
mkdir -p ${app.home}/{config,log,tmp,db}
cp -rf ${app.package}/share/${app.name}/config.dist/* ${app.home}/config/
cp ${app.package}/share/${app.name}/db/schema.rb.dist ${app.home}/db/schema.rb
cp ${./database.yml} ${app.home}/config/database.yml
cp ${app.database.passwordFile} ${app.home}/config/database.password

mkdir -p ${app.home}/home
ln -nfs ${app.package}/share/${app.name} ${app.home}/home/${app.name}

# Fix permissions:
chown -R rails-${app.name}:rails-${app.name} ${app.home}
chmod go+rx $(dirname "${app.home}")
chmod u+w ${app.home}/db/schema.rb

'' + optionalString app.database.migrate ''
# Migrate the database (use sudo so environment variables go through):
${pkgs.sudo}/bin/sudo -u rails-${app.name} -EH \
${scripts}/bin/db-migrate.sh -r ${app.package}/share/${app.name}
'';

serviceConfig = {
WorkingDirectory = "${app.package}/share/${app.name}";
Restart = "on-failure";
TimeoutSec = "infinity"; # FIXME: what's a reasonable amount of time?
Type = "simple";
PermissionsStartOnly = true;
User = "rails-${app.name}";
Group = "rails-${app.name}";
UMask = "0077";
ExecStart = "${app.package.rubyEnv}/bin/puma -e ${app.railsEnv} -p ${toString app.port}";
};
};
};

##############################################################################
# Generate a user account for a Ruby on Rails application:
appUser = app: {
users."rails-${app.name}" = {
description = "${app.name} Ruby on Rails Application";
home = "${app.home}/home";
createHome = true;
group = "rails-${app.name}";
shell = "${pkgs.bash}/bin/bash";
extraGroups = [ config.services.nginx.group ];
packages = appPath app;
};
groups."rails-${app.name}" = {};
};

in
{
#### Interface
options.phoebe.services.rails = {
apps = mkOption {
type = types.attrsOf (types.submodule options.application);
default = { };
description = "Rails applications to configure.";
};
};

#### Implementation
config = mkIf (length (attrValues cfg.apps) != 0) {
# Use NGINX to proxy requests to the apps:
services.nginx = {
enable = true;
recommendedTlsSettings = config.phoebe.security.enable;
recommendedOptimisation = true;
recommendedGzipSettings = true;
recommendedProxySettings = true;
virtualHosts = collectApps appToVirtualHost;
};

# Each application gets a user account:
users = collectApps appUser;

# Each application gets a systemd service to keep it running.
systemd.services = collectApps appService;
};
}

+ 7
- 0
modules/services/web/rails/functions.nix View File

@@ -0,0 +1,7 @@
rec {
# The default base directory for Rails applications:
base = "/var/lib/rails";

# Where a Rails application lives:
home = name: "${base}/${name}";
}

+ 52
- 0
modules/services/web/rails/helpers.nix View File

@@ -0,0 +1,52 @@
# Helper package Ruby on Rails applications that work with the rails
# service in this directory.
{ pkgs, ... }:

let
functions = import ./functions.nix;

mkRailsDerivation =
{ name
, env # The output of bundlerEnv
, extraPackages ? [ ]
, buildPhase ? ""
, installPhase ? ""
, buildInputs ? [ ]
, propagatedBuildInputs ? [ ]
, ...
}@args:
pkgs.stdenv.mkDerivation (args // {
buildInputs = [ env env.wrappedRuby ] ++ buildInputs;
propagatedBuildInputs = extraPackages ++ propagatedBuildInputs;
passthru = { rubyEnv = env; ruby = env.wrappedRuby; };

buildPhase = ''
${buildPhase}

# Build all the assets into the package:
rake assets:precompile

# Move some files out of the way since they will be created
# in production:
rm config/database.yml
mv config config.dist
mv db/schema.rb db/schema.rb.dist
'';

installPhase = ''
mkdir -p "$out/share"
${installPhase}

cp -r . "$out/share/${name}"
rm -rf "$out/share/${name}/log"
rm -rf "$out/share/${name}/tmp"

# Install some links to where the app lives in production:
for f in log config tmp db/schema.rb; do
ln -sf "${functions.home name}/$f" "$out/share/${name}/$f"
done
'';
});
in
{ inherit mkRailsDerivation;
}

+ 123
- 0
modules/services/web/rails/options.nix View File

@@ -0,0 +1,123 @@
{ config, lib, pkgs, ...}:

with lib;

let
##############################################################################
functions = import ./functions.nix;

##############################################################################
# Database configuration:
database = {
options = {
name = mkOption {
type = types.str;
example = "marketing";
description = "Database name.";
};

user = mkOption {
type = types.str;
example = "jdoe";
description = "Database user name.";
};

passwordFile = mkOption {
type = types.path;
example = "/run/keys/db-password";
description = ''
A file containing the database password. This allows you to
deploy a password with NixOps.
'';
};

passwordService = mkOption {
type = types.nullOr types.str;
default = null;
example = "db-password.service";
description = ''
A service to wait on before starting the Rails application.
This service should provide the password file for the
passwordFile option. Useful when deploying passwords with
NixOps.
'';
};

migrate = mkOption {
type = types.bool;
default = true;
example = false;
description = "Whether or not database migrations should run on start.";
};

host = mkOption {
type = types.str;
default = "localhost";
description = "Host name for the database server.";
};

port = mkOption {
type = types.int;
default = config.services.postgresql.port;
description = "Port number for the database server";
};
};
};

##############################################################################
# Application configuration:
application = { name, ... }: {
options = {
name = mkOption {
type = types.str;
description = "The name of the Ruby on Rails application.";
};

home = mkOption {
type = types.path;
description = "The directory where the application is deployed to.";
};

domain = mkOption {
type = types.str;
default = null;
description = "The FQDN to use for this application.";
};

port = mkOption {
type = types.int;
default = null;
description = "The port number to forward requests to.";
};

package = mkOption {
type = types.package;
description = "The derivation for the Ruby on Rails application.";
};

database = mkOption {
type = types.submodule database;
description = "Database configuration.";
};

railsEnv = mkOption {
type = types.str;
default = "production";
example = "development";
description = "What to use for RAILS_ENV.";
};

environment = mkOption {
type = types.attrs;
default = { };
description = "Environment variables.";
};
};

config = {
name = mkDefault name;
home = mkDefault (functions.home name);
};
};

in { inherit database application; }

+ 19
- 0
modules/services/web/rails/scripts.nix View File

@@ -0,0 +1,19 @@
{ lib, pkgs, ...}:

pkgs.stdenvNoCC.mkDerivation {
name = "rails-scripts";
phases = [ "installPhase" "fixupPhase" ];

installPhase = ''
mkdir -p $out/bin
substituteAll ${./db-migrate.sh} $out/bin/db-migrate.sh
find $out/bin -type f -exec chmod 555 '{}' ';'
'';

meta = with lib; {
description = "Scripts for working with Ruby on Rails applications.";
homepage = https://git.devalot.com/pjones/phoebe/;
maintainers = with maintainers; [ pjones ];
platforms = platforms.all;
};
}

Loading…
Cancel
Save