Compare commits

...

7 Commits

57 changed files with 769 additions and 216 deletions

View File

@@ -1,16 +1,26 @@
ROLES_DIR := ./roles
APPLICATIONS_OUT := ./group_vars/all/03_applications.yml
APPLICATIONS_OUT := ./group_vars/all/04_applications.yml
APPLICATIONS_SCRIPT := ./cli/generate-applications-defaults.py
USERS_OUT := ./group_vars/all/03_users.yml
USERS_SCRIPT := ./cli/generate_users.py
INCLUDES_OUT := ./tasks/utils/docker-roles.yml
INCLUDES_SCRIPT := ./cli/generate_playbook.py
EXTRA_USERS := $(shell \
find $(ROLES_DIR) -maxdepth 1 -type d -name 'docker*' -printf '%f\n' \
| sed -E 's/^docker[_-]?//' \
| paste -sd, - \
)
.PHONY: build install test
build:
@echo "🔧 Generating applications defaults → $(APPLICATIONS_OUT) from roles in $(ROLES_DIR)"
@mkdir -p $(dir $(APPLICATIONS_OUT))
python3 $(USERS_SCRIPT) --roles-dir $(ROLES_DIR) --output $(USERS_OUT) --extra-users "$(EXTRA_USERS)"
@echo "✅ Users defaults written to $(USERS_OUT)\n"
python3 $(APPLICATIONS_SCRIPT) --roles-dir $(ROLES_DIR) --output-file $(APPLICATIONS_OUT)
@echo "✅ Applications defaults written to $(APPLICATIONS_OUT)\n"
@echo "🔧 Generating users defaults → $(USERS_OUT) from roles in $(ROLES_DIR)"
@echo "🔧 Generating Docker role includes → $(INCLUDES_OUT)"
@mkdir -p $(dir $(INCLUDES_OUT))
python3 $(INCLUDES_SCRIPT) $(ROLES_DIR) -o $(INCLUDES_OUT) -p docker-

View File

@@ -13,20 +13,31 @@ def load_yaml_file(path):
with path.open("r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
def main():
parser = argparse.ArgumentParser(description="Generate defaults_applications YAML from docker roles.")
parser.add_argument("--roles-dir", default="roles", help="Path to the roles directory (default: roles)")
parser.add_argument("--output-file", default="group_vars/all/03_applications.yml", help="Path to output YAML file")
parser = argparse.ArgumentParser(
description="Generate defaults_applications YAML from docker roles and include users meta data for each role."
)
parser.add_argument(
"--roles-dir",
help="Path to the roles directory (default: roles)"
)
parser.add_argument(
"--output-file",
help="Path to output YAML file"
)
args = parser.parse_args()
cwd = Path.cwd()
roles_dir = (cwd / args.roles_dir).resolve()
output_file = (cwd / args.output_file).resolve()
# Ensure output directory exists
output_file.parent.mkdir(parents=True, exist_ok=True)
# Initialize result structure
result = {"defaults_applications": {}}
# Process each role for application configs
for role_dir in sorted(roles_dir.iterdir()):
role_name = role_dir.name
vars_main = role_dir / "vars" / "main.yml"
@@ -40,9 +51,10 @@ def main():
try:
application_id = vars_data.get("application_id")
except Exception as e:
# print the exception message
print(f"Warning: failed to read application_id from {vars_data} in {vars_main}.\nException: {e}", file=sys.stderr)
# exit with status 0
print(
f"Warning: failed to read application_id from {vars_main}\nException: {e}",
file=sys.stderr
)
sys.exit(1)
if not application_id:
@@ -56,7 +68,19 @@ def main():
config_data = load_yaml_file(config_file)
if config_data:
result["defaults_applications"][application_id] = config_data
users_meta_file = role_dir / "meta" / "users.yml"
transformed_users = {}
if users_meta_file.exists():
users_meta = load_yaml_file(users_meta_file)
users_data = users_meta.get("users", {})
for user, role_user_attrs in users_data.items():
transformed_users[user] = f"{{{{ users[\"{user}\"] }}}}"
# Attach transformed users under each application
if transformed_users:
result["defaults_applications"][application_id]["users"] = transformed_users
# Write out result YAML
with output_file.open("w", encoding="utf-8") as f:
yaml.dump(result, f, sort_keys=False)
@@ -65,5 +89,6 @@ def main():
except ValueError:
print(f"✅ Generated: {output_file}")
if __name__ == "__main__":
main()

228
cli/generate_users.py Normal file
View File

@@ -0,0 +1,228 @@
#!/usr/bin/env python3
import os
import sys
import argparse
import yaml
import glob
from collections import OrderedDict
def build_users(defs, primary_domain, start_id, become_pwd):
"""
Build user entries with auto-incremented uid/gid, default username/email, and optional description.
Args:
defs (OrderedDict): Keys are user IDs, values are dicts with optional overrides.
primary_domain (str): e.g., 'example.com'.
start_id (int): Starting uid/gid (e.g., 1001).
become_pwd (str): Password string for all users.
Returns:
OrderedDict: Merged user definitions with full fields.
Raises:
ValueError: If duplicate override uids/gids or conflicts in generated values.
"""
users = OrderedDict()
used_uids = set()
# Pre-collect any provided uids/gids and check for duplicates
for key, overrides in defs.items():
if 'uid' in overrides:
uid = overrides['uid']
if uid in used_uids:
raise ValueError(f"Duplicate uid {uid} for user '{key}'")
used_uids.add(uid)
next_free = start_id
def allocate_free_id():
nonlocal next_free
# find next free id not in used_uids
while next_free in used_uids:
next_free += 1
free = next_free
used_uids.add(free)
next_free += 1
return free
# Build entries
for key, overrides in defs.items():
username = overrides.get('username', key)
email = overrides.get('email', f"{username}@{primary_domain}")
description = overrides.get('description')
password = overrides.get('password',become_pwd)
# UID assignment
if 'uid' in overrides:
uid = overrides['uid']
else:
uid = allocate_free_id()
gid = overrides.get('gid',uid)
entry = {
'username': username,
'email': email,
'password': password,
'uid': uid,
'gid': gid
}
if description is not None:
entry['description'] = description
if overrides.get('is_admin', False):
entry['is_admin'] = True
users[key] = entry
# Validate uniqueness of username, email, and gid
seen_usernames = set()
seen_emails = set()
seen_gids = set()
for key, entry in users.items():
un = entry['username']
em = entry['email']
gd = entry['gid']
if un in seen_usernames:
raise ValueError(f"Duplicate username '{un}' in merged users")
if em in seen_emails:
raise ValueError(f"Duplicate email '{em}' in merged users")
seen_usernames.add(un)
seen_emails.add(em)
seen_gids.add(gd)
return users
def load_user_defs(roles_dir):
"""
Scan all roles/*/meta/users.yml files and extract 'users:' sections.
Raises an exception if conflicting definitions are found.
Args:
roles_dir (str): Path to the directory containing role subdirectories.
Returns:
OrderedDict: Merged user definitions.
Raises:
ValueError: On invalid format or conflicting field values.
"""
pattern = os.path.join(roles_dir, '*/meta/users.yml')
files = sorted(glob.glob(pattern))
merged = OrderedDict()
for filepath in files:
with open(filepath, 'r') as f:
data = yaml.safe_load(f) or {}
users = data.get('users', {})
if not isinstance(users, dict):
continue
for key, overrides in users.items():
if not isinstance(overrides, dict):
raise ValueError(f"Invalid definition for user '{key}' in {filepath}")
if key not in merged:
merged[key] = overrides.copy()
else:
existing = merged[key]
for field, value in overrides.items():
if field in existing and existing[field] != value:
raise ValueError(
f"Conflict for user '{key}': field '{field}' has existing value "
f"'{existing[field]}', tried to set '{value}' in {filepath}"
)
existing.update(overrides)
return merged
def dictify(data):
"""
Recursively convert OrderedDict to regular dict before YAML dump.
"""
if isinstance(data, OrderedDict):
return {k: dictify(v) for k, v in data.items()}
if isinstance(data, dict):
return {k: dictify(v) for k, v in data.items()}
if isinstance(data, list):
return [dictify(v) for v in data]
return data
def parse_args():
parser = argparse.ArgumentParser(
description='Generate a users.yml by merging all roles/*/meta/users.yml users sections.'
)
parser.add_argument(
'--roles-dir', '-r', required=True,
help='Directory containing roles (e.g., roles/*/meta/users.yml).'
)
parser.add_argument(
'--output', '-o', required=True,
help='Path to the output YAML file (e.g., users.yml).'
)
parser.add_argument(
'--start-id', '-s', type=int, default=1001,
help='Starting uid/gid number (default: 1001).'
)
parser.add_argument(
'--extra-users', '-e',
help='Comma-separated list of additional usernames to include.',
default=None
)
return parser.parse_args()
def main():
args = parse_args()
primary_domain = '{{ primary_domain }}'
become_pwd = '{{ lookup("password", "/dev/null length=42 chars=ascii_letters,digits") }}'
try:
user_defs = load_user_defs(args.roles_dir)
except ValueError as e:
print(f"Error merging user definitions: {e}", file=sys.stderr)
sys.exit(1)
# Add extra users if any
if args.extra_users:
for name in args.extra_users.split(','):
user = name.strip()
if not user:
continue
if user in user_defs:
print(f"Warning: extra user '{user}' already defined; skipping.", file=sys.stderr)
else:
user_defs[user] = {}
try:
users = build_users(
defs=user_defs,
primary_domain=primary_domain,
start_id=args.start_id,
become_pwd=become_pwd
)
except ValueError as e:
print(f"Error building user entries: {e}", file=sys.stderr)
sys.exit(1)
default_users = {'default_users': users}
plain_data = dictify(default_users)
# Ensure strings are represented without Python-specific tags
yaml.SafeDumper.add_representer(
str,
lambda dumper, data: dumper.represent_scalar('tag:yaml.org,2002:str', data)
)
with open(args.output, 'w') as f:
yaml.safe_dump(
plain_data,
f,
default_flow_style=False,
sort_keys=False,
width=120
)
if __name__ == '__main__':
main()

View File

@@ -1 +1,2 @@
*_applications.yml
*_applications.yml
*_users.yml

View File

@@ -3,7 +3,7 @@ default_system_email:
domain: "{{primary_domain}}"
host: "mail.{{primary_domain}}"
port: 465
tls: true
tls: true # true for TLS and false for SSL
start_tls: false
smtp: true
# password: # Needs to be defined in inventory file

View File

@@ -1,110 +0,0 @@
# Helper Variables
# Helper Variables for administrator
_users_administrator_username: "{{ users.administrator.username | default('administrator') }}"
_users_administrator_email: "{{ users.administrator.email | default(_users_administrator_username ~ '@' ~ primary_domain) }}"
# Helper Variables for bounce
_users_bounce_username: "{{ users.bounce.username | default('bounce') }}"
_users_bounce_email: "{{ users.bounce.email | default(_users_bounce_username ~ '@' ~ primary_domain) }}"
# Helper Variables for no-reply
_users_no_reply_username: "{{ users['no-reply'].username | default('no-reply') }}"
_users_no_reply_email: "{{ users['no-reply'].email | default(_users_no_reply_username ~ '@' ~ primary_domain) }}"
# Helper Variables for blackhole
_users_blackhole_username: "{{ users.blackhole.username | default('no-reply') }}"
_users_blackhole_email: "{{ users.blackhole.email | default(_users_blackhole_username ~ '@' ~ primary_domain) }}"
# Helper Variables for contact user
_users_contact_username: "{{ users.contact.username | default('contact') }}"
_users_contact_email: "{{ users.contact.email | default(_users_contact_username ~ '@' ~ primary_domain) }}"
# Helper Variables for support
_users_support_username: "{{ users.support.username | default('support') }}"
_users_support_email: "{{ users.support.email | default(_users_support_username ~ '@' ~ primary_domain) }}"
# Helper Variables for helpdesk
_users_helpdesk_username: "{{ users.helpdesk.username | default('helpdesk') }}"
_users_helpdesk_email: "{{ users.helpdesk.email | default(_users_helpdesk_username ~ '@' ~ primary_domain) }}"
# Extract SLD and TLD from primary_domain
_users_sld_username: "{{ primary_domain.split('.')[0] }}"
_users_sld_email: "{{ _users_sld_username ~ '@' ~ primary_domain }}"
_users_tld_username: "{{ primary_domain.split('.')[-1] }}"
_users_tld_email: "{{ _users_tld_username ~ '@' ~ primary_domain }}"
# Administrator
default_users:
# Credentials will be used as administration credentials for all applications and the system
administrator:
username: "{{_users_administrator_username}}" # Username of the administrator
email: "{{_users_administrator_email}}" # Email of the administrator
password: "{{ansible_become_password}}" # Example initialisation password needs to be set in inventory file
uid: 1001 # Posix User ID
gid: 1001 # Posix Group ID
is_admin: true # Define as admin user
# Account for Newsletter bouncing
bounce:
username: "{{ _users_bounce_username }}" # Bounce-handler account username
email: "{{ _users_bounce_email }}" # Email address for handling bounces
password: "{{ansible_become_password}}" # Example initialisation password needs to be set in inventory file
uid: 1002 # Posix User ID for bounce
gid: 1002 # Posix Group ID for bounce
# User to send System Emails from
no-reply:
username: "{{ _users_no_reply_username }}" # No-reply account username
email: "{{ _users_no_reply_email }}" # Email address for outgoing no-reply mails
password: "{{ansible_become_password}}" # Example initialisation password needs to be set in inventory file
uid: 1003 # Posix User ID for no-reply
gid: 1003 # Posix Group ID for no-reply
# Emails etc, what you send to this user will be forgetten
blackhole:
username: "{{ _users_blackhole_username }}" # Blackhole account username
email: "{{ _users_blackhole_email }}" # Email address to which emails can be send which well be forgetten
password: "{{ansible_become_password}}" # Example initialisation password needs to be set in inventory file
uid: 1004 # Posix User ID for bounce
gid: 1004 # Posix Group ID for bounce
# The contact user account which clients and plattform users can contact
contact:
username: "{{ _users_contact_username }}" # Contact account username
email: "{{ _users_contact_email }}" # Email address to which initial contacct emails can be send
password: "{{ansible_become_password}}" # Example initialisation password needs to be set in inventory file
uid: 1005 # Posix User ID for bounce
gid: 1005 # Posix Group ID for bounce
# Support and Helpdesk accounts
support:
username: "{{ _users_support_username }}" # Support account username
email: "{{ _users_support_email }}" # Email for customer and platform support communication
password: "{{ ansible_become_password }}" # Example initialisation password needs to be set in inventory file
uid: 1006 # Posix User ID for support
gid: 1006 # Posix Group ID for support
helpdesk:
username: "{{ _users_helpdesk_username }}" # Helpdesk account username
email: "{{ _users_helpdesk_email }}" # Email for internal technical helpdesk communication
password: "{{ ansible_become_password }}" # Example initialisation password needs to be set in inventory file
uid: 1007 # Posix User ID for helpdesk
gid: 1007 # Posix Group ID for helpdesk
sld_user:
username: "{{ _users_sld_username }}" # Username based on SLD of the primary domain
email: "{{ _users_sld_email }}" # Email address with SLD username
password: "{{ ansible_become_password }}" # Init password from inventory
uid: 1008
gid: 1008
tld_user:
username: "{{ _users_tld_username }}" # Username based on TLD of the primary domain
email: "{{ _users_tld_email }}" # Email address with TLD username
password: "{{ ansible_become_password }}" # Init password from inventory
uid: 1009
gid: 1009

View File

@@ -1,8 +1,8 @@
images:
akaunting: "docker.io/akaunting/akaunting:latest"
company_name: "{{primary_domain}}"
company_email: "{{users.administrator.email}}"
setup_admin_email: "{{users.administrator.email}}"
company_email: "{{ users.administrator.email }}"
setup_admin_email: "{{ users.administrator.email }}"
features:
matomo: true
css: true

View File

@@ -0,0 +1,3 @@
users:
administrator:
email: "administrator@{{ primary_domain }}"

View File

@@ -1,6 +1,3 @@
users:
administrator:
email: "{{users.administrator.email}}"
images:
pds: "ghcr.io/bluesky-social/pds:latest"
pds:

View File

@@ -0,0 +1,4 @@
users:
blackhole:
description: "Everything what will be send to this user will disapear"
username: "blackhole"

View File

View File

@@ -48,7 +48,7 @@ env:
#DOCKER_USE_HOSTNAME: true
## on initial signup example 'user1@example.com,user2@example.com'
DISCOURSE_DEVELOPER_EMAILS: {{users.administrator.email}}
DISCOURSE_DEVELOPER_EMAILS: {{ users.administrator.email }}
# Set Logo
{% if service_provider.platform.logo | bool %}
@@ -135,8 +135,8 @@ run:
- exec: rails r "SiteSetting.username_change_period = 0" # Deactivate changing of username
# Activate Administrator User
#- exec: printf '{{users.administrator.email}}\n{{users.administrator.password}}\n{{users.administrator.password}}\nY\n' | rake admin:create
#- exec: rails r "User.find_by_email('{{users.administrator.email}}').update(username: '{{users.administrator.username}}')"
#- exec: printf '{{ users.administrator.email }}\n{{users.administrator.password}}\n{{users.administrator.password}}\nY\n' | rake admin:create
#- exec: rails r "User.find_by_email('{{ users.administrator.email }}').update(username: '{{users.administrator.username}}')"
# The following code is just an inspiration, how to connect with the oidc account. as long as this is not set the admini account needs to be manually connected with oidc
# docker exec -it discourse_application rails runner "user = User.find_by_email('test@cymais.cloud'); UserAuth.create(user_id: user.id, provider: 'oidc', uid: 'eindeutige_oidc_id', info: { name: user.username, email: user.email })"

View File

@@ -20,4 +20,5 @@ galaxy_info:
logo:
class: "fa-solid fa-phone"
run_after:
- docker-keycloak
- docker-keycloak
- docker-mailu

View File

@@ -0,0 +1,6 @@
users:
administrator:
username: "administrator"
contact:
description: "General contact account"
username: "contact"

View File

@@ -50,12 +50,12 @@ ESPOCRM_CONFIG_LOGGER_ROTATION=false
# ------------------------------------------------
ESPOCRM_CONFIG_SMTP_SERVER={{ system_email.host }}
ESPOCRM_CONFIG_SMTP_PORT={{ system_email.port }}
ESPOCRM_CONFIG_SMTP_SECURITY=TLS
ESPOCRM_CONFIG_SMTP_SECURITY={{ "TLS" if system_email.start_tls else "SSL"}}
ESPOCRM_CONFIG_SMTP_AUTH=true
ESPOCRM_CONFIG_SMTP_USERNAME={{ users['no-reply'].email }}
ESPOCRM_CONFIG_SMTP_PASSWORD={{ users['no-reply'].mailu_token }}
ESPOCRM_CONFIG_SMTP_USERNAME={{ users['contact'].email }}
ESPOCRM_CONFIG_SMTP_PASSWORD={{ users['contact'].mailu_token }}
ESPOCRM_CONFIG_OUTBOUND_EMAIL_FROM_NAME={{ service_provider.company.titel }} - CRM
ESPOCRM_CONFIG_OUTBOUND_EMAIL_FROM_ADDRESS={{ users['no-reply'].email }}
ESPOCRM_CONFIG_OUTBOUND_EMAIL_FROM_ADDRESS={{ users['contact'].email }}
# ------------------------------------------------
# LDAP settings (optional)

View File

@@ -1,11 +1,5 @@
images:
espocrm: "espocrm/espocrm:latest"
users:
administrator:
username: "{{ users.administrator.username }}"
email: "{{ users.administrator.email }}"
credentials:
features:
matomo: true
css: false
@@ -26,6 +20,8 @@ csp:
connect-src:
- wss://espocrm.{{ primary_domain }}
- "data:"
frame-src:
- https://s.espocrm.com/
domains:
aliases:
- "crm.{{ primary_domain }}"

View File

@@ -27,5 +27,5 @@ SMTP_STARTTLS= {{ 'on' if system_email.start_tls else 'off' }}
SMTP_FROM= no-reply
# Administrator Credentials
FRIENDICA_ADMIN_MAIL= {{users.administrator.email}}
MAILNAME= {{users.administrator.email}}
FRIENDICA_ADMIN_MAIL= {{ users.administrator.email }}
MAILNAME= {{ users.administrator.email }}

View File

@@ -0,0 +1,3 @@
users:
administrator:
username: "administrator"

View File

@@ -1,8 +1,5 @@
images:
keycloak: "quay.io/keycloak/keycloak:latest"
users:
administrator:
username: "{{users.administrator.username}}" # Administrator Username for Keycloak
import_realm: True # If True realm will be imported. If false skip.
credentials:
features:

View File

@@ -0,0 +1,3 @@
users:
administrator:
username: "administrator"

View File

@@ -6,9 +6,6 @@ network:
public: False # Set to true in inventory file if you want to expose the LDAP port to the internet
hostname: "ldap" # Hostname of the LDAP Server in the central_ldap network
webinterface: "lam" # The webinterface which should be used. Possible: lam and phpldapadmin
users:
administrator:
username: "{{users.administrator.username}}" # Administrator username
credentials:
features:
ldap: true

View File

@@ -0,0 +1,7 @@
users:
administrator:
username: "administrator"
bounce:
username: "bounce"
newsletter:
username: "newsletter"

View File

@@ -1,8 +1,5 @@
images:
listmonk: "listmonk/listmonk:latest"
users:
administrator:
username: "{{users.administrator.username}}" # Listmonk administrator account username
public_api_activated: False # Security hole. Can be used for spaming
version: "latest" # Docker Image version
features:

View File

@@ -0,0 +1,3 @@
users:
administrator:
email: "administrator@{{ primary_domain }}" # Administrator Email for DNS Records

View File

@@ -1,7 +1,4 @@
version: "2024.06" # Docker Image Version
users:
administrator:
email: "{{users.administrator.email}}" # Administrator Email for DNS Records
oidc:
email_by_username: true # If true, then the mail is set by the username. If wrong then the OIDC user email is used
enable_user_creation: true # Users will be created if not existing

View File

@@ -9,7 +9,7 @@
- name: Create admin account via tootctl
command:
cmd: 'docker compose exec -u root web bash -c "RAILS_ENV=production bin/tootctl accounts create {{users.administrator.username}} --email {{users.administrator.email}} --confirmed --role Owner"'
cmd: 'docker compose exec -u root web bash -c "RAILS_ENV=production bin/tootctl accounts create {{users.administrator.username}} --email {{ users.administrator.email }} --confirmed --role Owner"'
chdir: "{{docker_compose.directories.instance}}"
register: tootctl_create
changed_when: tootctl_create.rc == 0

View File

@@ -46,7 +46,7 @@ devture_traefik_config_entrypoint_web_forwardedHeaders_insecure: true
# you won't be required to define this variable (see `docs/configuring-playbook-ssl-certificates.md`).
#
# Example value: someone@example.com
devture_traefik_config_certificatesResolvers_acme_email: "{{users.administrator.email}}"
devture_traefik_config_certificatesResolvers_acme_email: "{{ users.administrator.email }}"
# A Postgres password to use for the superuser Postgres user (called `matrix` by default).
#

View File

@@ -0,0 +1,3 @@
users:
administrator:
username: "administrator"

View File

@@ -28,7 +28,7 @@ web_client_location: "{{ web_protocol }}://{{domains.matrix.element}}
public_baseurl: "{{ web_protocol }}://{{domains.matrix.synapse}}"
trusted_key_servers:
- server_name: "matrix.org"
admin_contact: 'mailto:{{users.administrator.email}}'
admin_contact: 'mailto:{{ users.administrator.email }}'
email:
smtp_host: "{{system_email.host}}"

View File

@@ -1,10 +1,6 @@
images:
synapse: "matrixdotorg/synapse:latest"
element: "vectorim/element-web:latest"
# Set bridges
users:
administrator:
username: "{{users.administrator.username}}" # Accountname of the matrix admin
playbook_tags: "setup-all,start" # For the initial update use: install-all,ensure-matrix-users-created,start
server_name: "{{primary_domain}}" # Adress for the account names etc.
synapse:

View File

@@ -0,0 +1,3 @@
users:
administrator:
username: "administrator"

View File

@@ -1,8 +1,4 @@
site_titel: "Academy on {{primary_domain}}"
users:
administrator:
username: "{{users.administrator.username}}"
email: "{{users.administrator.email}}"
version: "4.5" # Latest LTS - Necessary for OIDC
features:
matomo: true

View File

@@ -0,0 +1,5 @@
users:
administrator:
username: "administrator"
no-reply:
username: "no-reply"

View File

@@ -20,7 +20,7 @@ SMTP_NAME= {{ users['no-reply'].email }}
SMTP_PASSWORD= {{ users['no-reply'].mailu_token }}
# Email from configuration
MAIL_FROM_ADDRESS= "no-reply"
MAIL_FROM_ADDRESS= "{{ users['no-reply'].username }}"
MAIL_DOMAIN= "{{system_email.domain}}"
# Initial Admin Data

View File

@@ -27,9 +27,6 @@ features:
ldap: true
oidc: true
central_database: true
users:
administrator:
username: "{{users.administrator.username}}"
default_quota: '1000000000' # Quota to assign if no quota is specified in the OIDC response (bytes)
legacy_login_mask:
enabled: False # If true, then legacy login mask is shown. Otherwise just SSO

View File

@@ -0,0 +1,3 @@
users:
administrator:
email: "administrator@{{ primary_domain }}"

View File

@@ -1,9 +1,6 @@
version: "latest"
server_mode: False # If true then the preconfigured database file is loaded. Recommended False. True is a security risk.
master_password_required: True # Master password is required. Recommended True. False is a security risk.
users:
administrator:
email: "{{ users.administrator.email }}" # Initial login email address
oauth2_proxy:
application: "application"
port: "80"

View File

@@ -0,0 +1,4 @@
users: # Credentials
administrator: # Wordpress administrator
username: "administrator"
email: "administrator@{{ primary_domain }}"

View File

@@ -1,8 +1,4 @@
title: "Blog" # Wordpress titel
users: # Credentials
administrator: # Wordpress administrator
username: "{{users.administrator.username}}" # Username of the wordpress administrator
email: "{{users.administrator.email}}" # Email of the wordpress adminsitrator
plugins:
wp-discourse:
enabled: "{{ 'discourse' in group_names | lower }}"

View File

@@ -0,0 +1,3 @@
users:
administrator:
username: "administrator"

View File

@@ -1,6 +1,3 @@
users:
administrator:
username: "{{users.administrator.username}}"
version: "latest"
oauth2_proxy:
application: "application"

View File

@@ -1,7 +1,7 @@
#!/bin/bash
/usr/bin/sendmail -t <<ERRMAIL
To: {{users.administrator.email}}
To: {{ users.administrator.email }}
From: systemd <{{ users['no-reply'].email }}>
Subject: $1
Content-Transfer-Encoding: 8bit

View File

@@ -0,0 +1,9 @@
users:
administrator:
description: "System Administrator"
username: "administrator"
email: "administrator@{{ primary_domain }}"
password: "{{ ansible_become_password }}"
uid: 1001
gid: 1001
is_admin: true

132
roles/user/meta/users.yml Normal file
View File

@@ -0,0 +1,132 @@
# Reserved usernames
users:
sld:
description: "Auto Generated Account to reserve the SLD"
username: "{{ primary_domain.split('.')[0] }}"
tld:
description: "Auto Generated Account to reserve the TLD"
username: "{{ primary_domain.split('.')[1] }}"
root:
username: root
uid: 0
gid: 0
description: "System superuser"
daemon:
username: daemon
description: "Daemon processes owner"
bin:
username: bin
description: "Owner of essential binaries"
sys:
username: sys
description: "System files owner"
sync:
username: sync
description: "Sync user for filesystem synchronization"
games:
username: games
description: "Games and educational software owner"
man:
username: man
description: "Manual pages viewer"
lp:
username: lp
description: "Printer spooler"
mail:
username: mail
description: "Mail system"
news:
username: news
description: "Network news system"
uucp:
username: uucp
description: "UUCP system"
proxy:
username: proxy
description: "Proxy user"
www-data:
username: www-data
description: "Web server user"
backup:
username: backup
description: "Backup operator"
list:
username: list
description: "Mailing list manager"
irc:
username: irc
description: "IRC services user"
gnats:
username: gnats
description: "GNATS bug-reporting system"
nobody:
username: nobody
description: "Unprivileged user"
messagebus:
username: messagebus
description: "D-Bus message bus system"
sshd:
username: sshd
description: "SSH daemon"
rpc:
username: rpc
description: "Rpcbind daemon"
ftp:
username: ftp
description: "FTP server"
postfix:
username: postfix
description: "Postfix mail transfer agent"
mysql:
username: mysql
description: "MySQL database server"
mongodb:
username: mongodb
description: "MongoDB database server"
admin:
username: admin
description: "Generic reserved username"
administrator:
username: administrator
user:
username: user
description: "Generic reserved username"
test:
username: test
description: "Generic reserved username"
guest:
username: guest
description: "Generic reserved username"
demo:
username: demo
description: "Generic reserved username"
info:
username: info
description: "Generic reserved username"
support:
username: support
description: "Generic reserved username"
helpdesk:
username: helpdesk
description: "Generic reserved username"
operator:
username: operator
description: "Generic reserved username"
staff:
username: staff
description: "Generic reserved username"
smtp:
username: smtp
description: "Generic reserved username"
imap:
username: imap
description: "Generic reserved username"
pop:
username: pop
description: "Generic reserved username"
webmaster:
username: webmaster
description: "Generic reserved username"
mailman:
username: mailman
description: "Generic reserved username"

View File

@@ -12,7 +12,7 @@ class TestDomainUniqueness(unittest.TestCase):
and assert that no domain appears more than once.
"""
repo_root = Path(__file__).resolve().parents[2]
yaml_file = repo_root / 'group_vars' / 'all' / '03_applications.yml'
yaml_file = repo_root / 'group_vars' / 'all' / '04_applications.yml'
# Generate the file if it doesn't exist
if not yaml_file.exists():

View File

@@ -8,7 +8,7 @@ class TestOAuth2ProxyPorts(unittest.TestCase):
def setUpClass(cls):
# Set up root paths and load oauth2_proxy ports mapping
cls.ROOT = Path(__file__).parent.parent.parent.resolve()
cls.PORTS_FILE = cls.ROOT / 'group_vars' / 'all' / '08_ports.yml'
cls.PORTS_FILE = cls.ROOT / 'group_vars' / 'all' / '09_ports.yml'
with cls.PORTS_FILE.open() as f:
data = yaml.safe_load(f)
cls.oauth2_ports = (
@@ -50,7 +50,7 @@ class TestOAuth2ProxyPorts(unittest.TestCase):
if app_id not in self.oauth2_ports:
self.fail(
f"Missing oauth2_proxy port mapping for application '{app_id}' "
f"in group_vars/all/08_ports.yml"
f"in group_vars/all/09_ports.yml"
)

View File

@@ -0,0 +1,36 @@
# File: tests/integration/test_unittest_imports.py
import os
import unittest
class TestUnittestImports(unittest.TestCase):
TEST_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
def test_all_test_files_import_unittest(self):
missing = []
for root, dirs, files in os.walk(self.TEST_ROOT):
for filename in files:
if not filename.endswith('.py'):
continue
# only consider test files named like "test_*.py"
if not filename.startswith('test_'):
continue
filepath = os.path.join(root, filename)
with open(filepath, encoding='utf-8') as f:
content = f.read()
# check for either import form
if 'import unittest' not in content and 'from unittest import' not in content:
rel_path = os.path.relpath(filepath, os.getcwd())
missing.append(rel_path)
if missing:
self.fail(
"The following test files do not import unittest:\n" +
"\n".join(f"- {path}" for path in missing)
)
if __name__ == '__main__':
unittest.main()

View File

@@ -2,49 +2,59 @@
import os
import sys
import unittest
# Add module_utils/ to the import path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..", "module_utils")))
sys.path.insert(
0,
os.path.abspath(
os.path.join(
os.path.dirname(__file__),
"../../..",
"module_utils",
)
),
)
from module_utils.cert_utils import CertUtils
def test_matches():
tests = [
# Exact matches
("example.com", "example.com", True),
("www.example.com", "www.example.com", True),
("api.example.com", "api.example.com", True),
# Wildcard matches
("sub.example.com", "*.example.com", True),
("www.example.com", "*.example.com", True),
class TestCertUtilsMatches(unittest.TestCase):
def setUp(self):
# prepare your test cases
self.tests = [
# Exact matches
("example.com", "example.com", True),
("www.example.com", "www.example.com", True),
("api.example.com", "api.example.com", True),
# Wildcard non-matches
("example.com", "*.example.com", False), # base domain is not covered
("deep.sub.example.com", "*.example.com", False), # too deep
("sub.deep.example.com", "*.deep.example.com", True), # correct: one level below
# Wildcard matches
("sub.example.com", "*.example.com", True),
("www.example.com", "*.example.com", True),
# Special cases
("deep.api.example.com", "*.api.example.com", True),
("api.example.com", "*.api.example.com", False), # base not covered by wildcard
# Wildcard non-matches
("example.com", "*.example.com", False), # base domain is not covered
("deep.sub.example.com", "*.example.com", False), # too deep
("sub.deep.example.com", "*.deep.example.com", True), # correct: one level below
# Completely different domains
("test.other.com", "*.example.com", False),
]
# Special cases
("deep.api.example.com", "*.api.example.com", True),
("api.example.com", "*.api.example.com", False), # base not covered by wildcard
passed = 0
failed = 0
# Completely different domains
("test.other.com", "*.example.com", False),
]
for domain, san, expected in tests:
result = CertUtils.matches(domain, san)
if result == expected:
print(f"✅ PASS: {domain} vs {san} -> {result}")
passed += 1
else:
print(f"❌ FAIL: {domain} vs {san} -> {result} (expected {expected})")
failed += 1
def test_matches(self):
for domain, san, expected in self.tests:
with self.subTest(domain=domain, san=san):
result = CertUtils.matches(domain, san)
self.assertEqual(
result,
expected,
msg=f"CertUtils.matches({domain!r}, {san!r}) returned {result}, expected {expected}",
)
print(f"\nSummary: {passed} passed, {failed} failed")
if __name__ == "__main__":
test_matches()
unittest.main()

View File

@@ -0,0 +1,65 @@
import os
import unittest
import tempfile
import shutil
import yaml
from pathlib import Path
import subprocess
class TestGenerateDefaultApplicationsUsers(unittest.TestCase):
def setUp(self):
# Setup temporary roles directory
self.temp_dir = Path(tempfile.mkdtemp())
self.roles_dir = self.temp_dir / "roles"
self.roles_dir.mkdir()
# Sample role with users meta
self.role = self.roles_dir / "docker-app-with-users"
(self.role / "vars").mkdir(parents=True)
(self.role / "meta").mkdir(parents=True)
# Write application_id and configuration
(self.role / "vars" / "main.yml").write_text("application_id: app_with_users\n")
(self.role / "vars" / "configuration.yml").write_text("setting: value\n")
# Write users meta
users_meta = {
'users': {
'alice': {'uid': 2001, 'gid': 2001},
'bob': {'uid': 2002, 'gid': 2002}
}
}
with (self.role / "meta" / "users.yml").open('w', encoding='utf-8') as f:
yaml.dump(users_meta, f)
# Output file path
self.output_file = self.temp_dir / "output.yml"
def tearDown(self):
shutil.rmtree(self.temp_dir)
def test_users_injection(self):
"""
When a users.yml exists with defined users, the script should inject a 'users'
mapping in the generated YAML, mapping each username to a Jinja2 reference.
"""
script_path = Path(__file__).resolve().parents[2] / "cli" / "generate-applications-defaults.py"
result = subprocess.run([
"python3", str(script_path),
"--roles-dir", str(self.roles_dir),
"--output-file", str(self.output_file)
], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, msg=result.stderr)
data = yaml.safe_load(self.output_file.read_text())
apps = data.get('defaults_applications', {})
# Only the app with users should be present
self.assertIn('app_with_users', apps)
# 'users' section should be present and correct
users_map = apps['app_with_users']['users']
expected = {'alice': '{{ users["alice"] }}', 'bob': '{{ users["bob"] }}'}
self.assertEqual(users_map, expected)
if __name__ == '__main__':
unittest.main()

View File

@@ -23,7 +23,7 @@ class TestGenerateDefaultApplications(unittest.TestCase):
(self.sample_role / "vars" / "configuration.yml").write_text("foo: bar\nbaz: 123\n")
# Output file path
self.output_file = self.temp_dir / "group_vars" / "all" / "03_applications.yml"
self.output_file = self.temp_dir / "group_vars" / "all" / "04_applications.yml"
def tearDown(self):
shutil.rmtree(self.temp_dir)

View File

@@ -0,0 +1,136 @@
import os
import sys
import unittest
import tempfile
import shutil
import yaml
from collections import OrderedDict
# Add cli/ to import path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..", "cli")))
import generate_users
class TestGenerateUsers(unittest.TestCase):
def test_build_users_auto_increment_and_overrides(self):
defs = {
'alice': {},
'bob': {'uid': 2000, 'email': 'bob@custom.com', 'description': 'Custom user'},
'carol': {}
}
users = generate_users.build_users(
defs=defs,
primary_domain='example.com',
start_id=1001,
become_pwd='pw'
)
# alice should get uid/gid 1001
self.assertEqual(users['alice']['uid'], 1001)
self.assertEqual(users['alice']['gid'], 1001)
self.assertEqual(users['alice']['email'], 'alice@example.com')
# bob overrides
self.assertEqual(users['bob']['uid'], 2000)
self.assertEqual(users['bob']['gid'], 2000)
self.assertEqual(users['bob']['email'], 'bob@custom.com')
self.assertIn('description', users['bob'])
# carol should get next free id = 1002
self.assertEqual(users['carol']['uid'], 1002)
self.assertEqual(users['carol']['gid'], 1002)
def test_build_users_default_lookup_password(self):
"""
When no 'password' override is provided,
the become_pwd lookup template string must be used as the password.
"""
defs = {'frank': {}}
lookup_template = '{{ lookup("password", "/dev/null length=42 chars=ascii_letters,digits") }}'
users = generate_users.build_users(
defs=defs,
primary_domain='example.com',
start_id=1001,
become_pwd=lookup_template
)
self.assertEqual(
users['frank']['password'],
lookup_template,
"The lookup template string was not correctly applied as the default password"
)
def test_build_users_override_password(self):
"""
When a 'password' override is provided,
that custom password must be used instead of become_pwd.
"""
defs = {'eva': {'password': 'custompw'}}
lookup_template = '{{ lookup("password", "/dev/null length=42 chars=ascii_letters,digits") }}'
users = generate_users.build_users(
defs=defs,
primary_domain='example.com',
start_id=1001,
become_pwd=lookup_template
)
self.assertEqual(
users['eva']['password'],
'custompw',
"The override password was not correctly applied"
)
def test_build_users_duplicate_override_uid(self):
defs = {
'u1': {'uid': 1001},
'u2': {'uid': 1001}
}
with self.assertRaises(ValueError):
generate_users.build_users(defs, 'ex.com', 1001, 'pw')
def test_build_users_shared_gid_allowed(self):
# Allow two users to share the same GID when one overrides gid and the other uses that as uid
defs = {
'a': {'uid': 1500},
'b': {'gid': 1500}
}
users = generate_users.build_users(defs, 'ex.com', 1500, 'pw')
# Both should have gid 1500
self.assertEqual(users['a']['gid'], 1500)
self.assertEqual(users['b']['gid'], 1500)
def test_build_users_duplicate_username_email(self):
defs = {
'u1': {'username': 'same', 'email': 'same@ex.com'},
'u2': {'username': 'same'}
}
# second user with same username should raise
with self.assertRaises(ValueError):
generate_users.build_users(defs, 'ex.com', 1001, 'pw')
def test_dictify_converts_ordereddict(self):
od = generate_users.OrderedDict([('a', 1), ('b', {'c': 2})])
result = generate_users.dictify(OrderedDict(od))
self.assertIsInstance(result, dict)
self.assertEqual(result, {'a': 1, 'b': {'c': 2}})
def test_load_user_defs_and_conflict(self):
# create temp roles structure
tmp = tempfile.mkdtemp()
try:
os.makedirs(os.path.join(tmp, 'role1/meta'))
os.makedirs(os.path.join(tmp, 'role2/meta'))
# role1 defines user x
with open(os.path.join(tmp, 'role1/meta/users.yml'), 'w') as f:
yaml.safe_dump({'users': {'x': {'email': 'x@a'}}}, f)
# role2 defines same user x with same value
with open(os.path.join(tmp, 'role2/meta/users.yml'), 'w') as f:
yaml.safe_dump({'users': {'x': {'email': 'x@a'}}}, f)
defs = generate_users.load_user_defs(tmp)
self.assertIn('x', defs)
# now conflict definition
with open(os.path.join(tmp, 'role2/meta/users.yml'), 'w') as f:
yaml.safe_dump({'users': {'x': {'email': 'x@b'}}}, f)
with self.assertRaises(ValueError):
generate_users.load_user_defs(tmp)
finally:
shutil.rmtree(tmp)
if __name__ == '__main__':
unittest.main()