Browse Source

psono: first implementation

Fixes: main/infrastructure#277
keep-around/415cb15038fda63e5dc029bed32e2025db5038db
Loïc Dachary 7 months ago
parent
commit
415cb15038
Signed by: dachary GPG Key ID: 992D23B392F9E4F2
  1. 1
      docs/services/index.rst
  2. 14
      docs/services/psono.rst
  3. 1
      docs/user-guide.rst
  4. 2
      enough-playbook.yml
  5. 1
      inventory/all.yml
  6. 8
      inventory/services.yml
  7. 14
      playbooks/psono/conftest.py
  8. 12
      playbooks/psono/inventory/services.yml
  9. 10
      playbooks/psono/playbook.yml
  10. 77
      playbooks/psono/psono-playbook.yml
  11. 17
      playbooks/psono/roles/psono/defaults/main.yml
  12. 2
      playbooks/psono/roles/psono/tasks/main.yml
  13. 51
      playbooks/psono/roles/psono/tasks/psono.yml
  14. 1
      playbooks/psono/roles/psono/templates/crontab.j2
  15. 31
      playbooks/psono/roles/psono/templates/docker-compose.yml.j2
  16. 14
      playbooks/psono/roles/psono/templates/psono-client/config.json.j2
  17. 144
      playbooks/psono/roles/psono/templates/settings.yaml.j2
  18. 15
      playbooks/psono/tests/test_icinga.py
  19. 34
      playbooks/psono/tests/test_postfix.py
  20. 20
      playbooks/psono/tests/test_psono.py
  21. 2
      tox.ini

1
docs/services/index.rst

@ -17,6 +17,7 @@ Services
bind
VPN
postfix
psono
ids
monitoring
backup

14
docs/services/psono.rst

@ -0,0 +1,14 @@
Psono
=====
`Psono <https://psono.com/>`__ is documented in `this file
<https://lab.enough.community/main/infrastructure/blob/master/playbooks/psono/roles/psono/defaults/main.yml>`__
and can be modified in the
`~/.enough/example.com/inventory/group_vars/psono-service-group.yml`
file.
The service is created with:
.. code::
$ enough --domain example.com service create psono

1
docs/user-guide.rst

@ -136,6 +136,7 @@ The following services are available:
* :doc:`gitlab <services/gitlab>`, for `software development <https://gitlab.com/>`__ at ``lab.example.com``
* ``api``, for :doc:`Enough development <community/contribute>` at ``api.example.com``
* :doc:`Jitsi <services/jitsi>`, for `video conferencing <https://jitsi.org/>`__ at ``jitsi.example.com``
* :doc:`Psono <services/psono>`, for `password management <https://psono.com/>`__ at ``psono.example.com``
As an example, the cloud service can be created as follows:

2
enough-playbook.yml

@ -26,4 +26,6 @@
when: (groups['openvpn-service-group'] | length) > 0
- import_playbook: "{{ '$SHARE_DIR/playbooks/openvpn/openvpn-client-playbook.yml' | expandvars }}"
when: (groups['openvpn-service-group'] | length) > 0
- import_playbook: "{{ '$SHARE_DIR/playbooks/psono/psono-playbook.yml' | expandvars }}"
when: (groups['psono-service-group'] | length) > 0
- import_playbook: "{{ '$SHARE_DIR/enough-after-playbook.yml' | expandvars }}"

1
inventory/all.yml

@ -12,6 +12,7 @@ all-hosts:
openedx-host:
packages-host:
postfix-host:
psono-host:
runner-host:
wazuh-host:
weblate-host:

8
inventory/services.yml

@ -138,6 +138,14 @@ wekan-service-hosts:
wekan-service-group:
essential-service-group:
psono-service-group:
hosts: {}
psono-service-hosts:
children:
psono-service-group:
essential-service-group:
openedx-service-group:
hosts:
openedx-host:

14
playbooks/psono/conftest.py

@ -0,0 +1,14 @@
def pytest_addoption(parser):
parser.addoption(
"--enough-hosts",
action="store",
default="bind-host,postfix-host,psono-host",
help="list of hosts"
)
parser.addoption(
"--enough-service",
action="store",
default="psono",
help="service"
)

12
playbooks/psono/inventory/services.yml

@ -0,0 +1,12 @@
---
icinga-service-group:
hosts:
bind-host:
postfix-service-group:
hosts:
postfix-host:
psono-service-group:
hosts:
psono-host:

10
playbooks/psono/playbook.yml

@ -0,0 +1,10 @@
---
# - import_playbook: ../infrastructure/buster-playbook.yml
# - import_playbook: ../infrastructure/network-playbook.yml
# - import_playbook: ../firewall/firewall-playbook.yml
# - import_playbook: ../icinga/test-icinga-playbook.yml
# - import_playbook: ../bind/bind-playbook.yml
# - import_playbook: ../bind/bind-client-playbook.yml
# - import_playbook: ../icinga/icinga-playbook.yml
# - import_playbook: ../postfix/postfix-playbook.yml
- import_playbook: psono-playbook.yml

77
playbooks/psono/psono-playbook.yml

@ -0,0 +1,77 @@
---
- name: firewall for web
hosts: localhost
gather_facts: false
tasks:
- include_role:
name: firewall
vars:
firewall_server: "{{ item }}"
firewall_clients: [ 0.0.0.0/0 ]
firewall_protocols: [ tcp ]
firewall_ports: [ 80, 443 ]
when: hostvars[item].ansible_host is defined
with_items: "{{ groups['psono-service-group'] | default([]) }}"
- name: setup psono DNS
hosts: psono-service-group
become: true
pre_tasks:
- name: set CNAME
nsupdate:
server: "127.0.0.1"
zone: "{{ domain }}"
record: "psono.{{ domain }}."
ttl: 1800
type: CNAME
value: "{{ groups['psono-service-group'][0] }}.{{ domain }}."
delegate_to: bind-host
- name: set CNAME
nsupdate:
server: "127.0.0.1"
zone: "{{ domain }}"
record: "psono-server.{{ domain }}."
ttl: 1800
type: CNAME
value: "{{ groups['psono-service-group'][0] }}.{{ domain }}."
delegate_to: bind-host
- name: install psono
hosts: psono-service-group
become: true
roles:
- role: ansible-role-docker
docker_install_compose: true
- role: docker
- role: enough-nginx
enough_nginx_reverse_proxy: 127.0.0.1:{{ psono_client_port }}
enough_nginx_reverse_proxy_name: psonoclientbackend
enough_nginx_fqdn: "psono.{{ domain }}"
- role: certificate
certificate_fqdn: "psono.{{ domain }}"
certificate_installer: nginx
- role: psono
- role: monitor_http_vhost
http_vhost_https: true
http_vhost_name: psono
http_vhost_fqdn: "psono.{{ domain }}"
http_vhost_uri: "/"
http_vhost_string: "psono"
- role: enough-nginx
enough_nginx_reverse_proxy: 127.0.0.1:{{ psono_server_port }}
enough_nginx_reverse_proxy_name: psonoserverbackend
enough_nginx_fqdn: "psono-server.{{ domain }}"
- role: certificate
certificate_fqdn: "psono-server.{{ domain }}"
certificate_installer: nginx

17
playbooks/psono/roles/psono/defaults/main.yml

@ -0,0 +1,17 @@
---
#
####################################################
#
# Emails sent are from this address
#
psono_contact: admin@enough.community
#
####################################################
# DO NOT MODIFY BELOW THIS LINE
####################################################
#
psono_version: latest
psono_server_port: 8600
psono_client_port: 8601
psono_root: /srv/psono
psono_mail_host: "{{ groups['psono-service-group'][0] }}.{{ domain }}"

2
playbooks/psono/roles/psono/tasks/main.yml

@ -0,0 +1,2 @@
---
- import_tasks: psono.yml

51
playbooks/psono/roles/psono/tasks/psono.yml

@ -0,0 +1,51 @@
---
- name: apt-get install git virtualenv python-pip and python-setuptools python-backports.ssl-match-hostname
apt:
name: [ git, virtualenv, python-pip, python-setuptools, python-backports.ssl-match-hostname ]
state: present
- name: "mkdir -p {{ psono_root }}/psono-client"
file:
path: "{{ psono_root }}/psono-client"
state: directory
owner: debian
- name: Copy templates
template:
src: "{{ item[0] }}.j2"
dest: "{{ psono_root }}/{{ item[0] }}"
owner: debian
mode: "{{ item[1]|default('0600') }}"
loop:
- [docker-compose.yml]
- [settings.yaml]
- [crontab]
- [psono-client/config.json, '0644']
- name: generate server keys
shell: |
set -ex
if ! grep -q '^EMAIL_SECRET_SALT' {{ psono_root }}/settings.yaml ; then
docker run --rm psono/psono-server:{{ psono_version }} python3 ./psono/manage.py generateserverkeys >> {{ psono_root }}/settings.yaml
echo Changed
fi
register: result
changed_when: '"Changed" in result.stdout'
- name: python3 ./psono/manage.py migrate
command: "docker-compose run server python3 ./psono/manage.py migrate"
args:
chdir: "{{ psono_root }}"
register: result
until: result is success
retries: 5
delay: 5
- name: Activate crontab
command: "crontab {{ psono_root }}/crontab"
become: False
- name: (re)create psono
command: "docker-compose up -d"
args:
chdir: "{{ psono_root }}"

1
playbooks/psono/roles/psono/templates/crontab.j2

@ -0,0 +1 @@
30 2 * * * cd {{ psono_root }} && docker-compose run server python3 ./psono/manage.py cleartoken >> /var/log/cron-psono.log 2>&1

31
playbooks/psono/roles/psono/templates/docker-compose.yml.j2

@ -0,0 +1,31 @@
version: '2'
services:
server:
image: psono/psono-server:{{ psono_version }}
ports:
- '{{ psono_server_port }}:80'
depends_on:
- postgres
volumes:
- ./settings.yaml:/root/.psono_server/settings.yaml
restart: always
postgres:
image: postgres:9.6-alpine
environment:
- POSTGRES_USER=psono
- POSTGRES_PASSWORD=password
volumes:
- ./postgres-data:/var/lib/postgresql/data
restart: always
client:
image: psono/psono-client:{{ psono_version }}
depends_on:
- postgres
- server
ports:
- '{{ psono_client_port }}:80'
volumes:
- ./psono-client/config.json:/usr/share/nginx/html/config.json
restart: always

14
playbooks/psono/roles/psono/templates/psono-client/config.json.j2

@ -0,0 +1,14 @@
{
"backend_servers": [{
"title": "{{ domain }}",
"domain": "psono.{{ domain }}",
"url": "https://psono-server.{{ domain }}"
}],
"base_url": "https://psono.{{ domain }}/",
"allow_custom_server": true,
"allow_registration": true,
"allow_lost_password": true,
"disable_download_bar": false,
"authentication_methods": ["AUTHKEY", "LDAP"],
"saml_provider": []
}

144
playbooks/psono/roles/psono/templates/settings.yaml.j2

@ -0,0 +1,144 @@
# The URL of the web client (path to e.g activate.html without the trailing slash)
WEB_CLIENT_URL: 'https://psono.{{ domain }}'
# Switch DEBUG to false if you go into production
DEBUG: False
# Adjust this according to Django Documentation https://docs.djangoproject.com/en/2.2/ref/settings/
ALLOWED_HOSTS: ['*']
# Should be your domain without "www.". Will be the last part of the username
ALLOWED_DOMAINS: ['psono.{{ domain }}']
# If you want to disable registration, you can comment in the following line
ALLOW_REGISTRATION: True
# If you want to disable the lost password functionality, you can comment in the following line
ALLOW_LOST_PASSWORD: True
# If you want to enforce that the email address and username needs to match upon registration
# ENFORCE_MATCHING_USERNAME_AND_EMAIL: False
# If you want to restrict registration to some email addresses you can specify here a list of domains to filter
# REGISTRATION_EMAIL_FILTER: ['company1.com', 'company2.com']
# Should be the URL of the host under which the host is reachable
# If you open the url and append /info/ to it you should have a text similar to {"info":"{\"version\": \"....}
HOST_URL: 'https://psono-server.{{ domain }}'
# The email used to send emails, e.g. for activation
# ATTENTION: If executed in a docker container, then "localhost" will resolve to the docker container, so
# "localhost" will not work as host. Use the public IP or DNS record of the server.
EMAIL_FROM: '{{ psono_contact }}'
EMAIL_HOST: '{{ psono_mail_host }}'
EMAIL_HOST_USER: ''
EMAIL_HOST_PASSWORD : ''
EMAIL_PORT: 25
EMAIL_SUBJECT_PREFIX: ''
EMAIL_USE_TLS: False
EMAIL_USE_SSL: False
EMAIL_SSL_CERTFILE:
EMAIL_SSL_KEYFILE:
EMAIL_TIMEOUT:
# In case one wants to use mailgun, comment in below lines and provide the mailgun access key and server name
# EMAIL_BACKEND: 'anymail.backends.mailgun.EmailBackend'
# MAILGUN_ACCESS_KEY: ''
# MAILGUN_SERVER_NAME: ''
# In case you want to offer Yubikey support, create a pair of credentials here https://upgrade.yubico.com/getapikey/
# and update the following two lines before commenting them in
# YUBIKEY_CLIENT_ID: '123456'
# YUBIKEY_SECRET_KEY: '8I65IA6ASDFIUHGIH5021FKJA='
# If you have own Yubico servers, you can specify here the urls as a list
# YUBICO_API_URLS: ['https://api.yubico.com/wsapi/2.0/verify']
# Cache enabled without belows Redis may lead to unexpected behaviour
# Cache with Redis
# By default you should use something different than database 0 or 1, e.g. 13 (default max is 16, can be configured in
# redis.conf) possible URLS are:
# redis://[:password]@localhost:6379/0
# rediss://[:password]@localhost:6379/0
# unix://[:password]@/path/to/socket.sock?db=0
# CACHE_ENABLE: False
# CACHE_REDIS: False
# CACHE_REDIS_LOCATION: 'redis://127.0.0.1:6379/13'
# Disables Throttling (necessary for unittests to pass) by overriding the cache with a dummy cache
# https://docs.djangoproject.com/en/2.2/topics/cache/#dummy-caching-for-development
# THROTTLING: False
# Enables the management API, required for the psono-admin-client / admin portal (Default is set to False)
# MANAGEMENT_ENABLED: True
# Enables the fileserver API, required for the psono-fileserver
# FILESERVER_HANDLER_ENABLED: False
# Enables files for the client
# FILES_ENABLED: False
# Allows that users can search for partial usernames
# ALLOW_USER_SEARCH_BY_USERNAME_PARTIAL: True
# Allows that users can search for email addresses too
# ALLOW_USER_SEARCH_BY_EMAIL: True
# Disables central security reports
# DISABLE_CENTRAL_SECURITY_REPORTS: True
# Configures a system wide DUO connection for all clients
# DUO_INTEGRATION_KEY: ''
# DUO_SECRET_KEY: ''
# DUO_API_HOSTNAME: ''
# If you are using the DUO proxy, you can configure here the necessary HTTP proxy
# DUO_PROXY_HOST: 'the-ip-or-dns-name-goes-here'
# DUO_PROXY_PORT: 80
# DUO_PROXY_TYPE: 'CONNECT'
# If your proxy requires specific headers you can also configure these here
# DUO_PROXY_HEADERS: ''
# Normally only one of the configured second factors needs to be solved. Setting this to True forces the client to solve all
# MULTIFACTOR_ENABLED: True
# Allows admins to limit the offered second factors in the client
# ALLOWED_SECOND_FACTORS: ['yubikey_otp', 'google_authenticator', 'duo']
# Your Postgres Database credentials
# ATTENTION: If executed in a docker container, then "localhost" will resolve to the docker container, so
# "localhost" will not work as host. Use the public IP or DNS record of the server.
DATABASES:
default:
'ENGINE': 'django.db.backends.postgresql_psycopg2'
'NAME': 'psono'
'USER': 'psono'
'PASSWORD': 'password'
'HOST': 'postgres'
'PORT': '5432'
# for master / slave replication setup comment in the following (all reads will be redirected to the slave
# slave:
# 'ENGINE': 'django.db.backends.postgresql_psycopg2'
# 'NAME': 'YourPostgresDatabase'
# 'USER': 'YourPostgresUser'
# 'PASSWORD': 'YourPostgresPassword'
# 'HOST': 'YourPostgresHost'
# 'PORT': 'YourPostgresPort'
# The path to the template folder can be "shadowed" if required later
TEMPLATES: [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': ['/root/psono/templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]

15
playbooks/psono/tests/test_icinga.py

@ -0,0 +1,15 @@
from tests.icinga_helper import IcingaHelper
testinfra_hosts = ['ansible://bind-host']
IcingaHelper.icinga_host = 'bind-host'
class TestChecks(IcingaHelper):
def test_host(self):
r = self.get_client().objects.get('Host', 'psono-host')
assert r['attrs']['name'] == 'psono-host'
def test_service(self, host):
assert self.is_service_ok('psono-host!psono')

34
playbooks/psono/tests/test_postfix.py

@ -0,0 +1,34 @@
import logging
import time
import testinfra
testinfra_hosts = ['ansible://psono-host']
logger = logging.getLogger(__name__)
def test_psono_send_mail(host):
psono_host = host
postfix_host = testinfra.host.Host.get_host(
'ansible://postfix-host',
ssh_identity_file=host.backend.ssh_identity_file,
ansible_inventory=host.backend.ansible_inventory)
cmd = psono_host.run("""
cd /srv/psono
sudo docker-compose run server python3 \
./psono/manage.py sendtestemail loic-doomtofail@dachary.org
""")
logger.debug('stdout %s', cmd.stdout)
logger.debug('stderr %s', cmd.stderr)
assert 0 == cmd.rc
check = ("grep -q 'connection established to spool.mail.gandi.net' "
"/var/log/mail.log")
for _ in range(300):
print(check)
cmd = postfix_host.run(check)
if cmd.rc == 0:
break
time.sleep(1)
assert 0 == postfix_host.run(check).rc

20
playbooks/psono/tests/test_psono.py

@ -0,0 +1,20 @@
import time
import requests
import yaml
def get_address(inventory):
vars_dir = f'{inventory}/group_vars/all'
return 'https://psono.' + yaml.load(
open(vars_dir + '/domain.yml'))['domain']
def test_psono(pytestconfig):
# psono freshly recreated may take few mins to be operationnal
url = get_address(pytestconfig.getoption("--ansible-inventory"))
for i in range(60, 0, -1):
r = requests.get(url, timeout=5, verify='certs')
if r.status_code == requests.codes.ok:
break
time.sleep(5)
assert 'psono' in r.text

2
tox.ini

@ -24,7 +24,7 @@ commands = flake8 {posargs}
#
# Integration tests
#
[testenv:{infrastructure,bind,authorized_keys,backup,certificate,postfix,icinga,openvpn,wekan,misc,pad,firewall,gitlab,api,wazuh,weblate,website,chat,cloud,enough,forum,packages,securedrop,jitsi,wordpress,openedx}]
[testenv:{infrastructure,bind,authorized_keys,backup,certificate,postfix,icinga,openvpn,wekan,misc,pad,firewall,gitlab,api,wazuh,weblate,website,chat,cloud,enough,forum,packages,securedrop,jitsi,wordpress,openedx,psono}]
passenv =
ENOUGH_API_TOKEN
PYTEST_ADDOPTS

Loading…
Cancel
Save