Skip to content

Repository files navigation

TLS-Distributor

🌐 Versão em português

Distributes renewed TLS certificates to web servers (Apache, Nginx, Docker) over SSH/SFTP. Backs up old certificates, applies ownership declaratively, and reloads the service. Works as a declarative remote executor: a YAML inventory describes the desired state, and the distributor reconciles on each run.

Designed to be used together with TLS-Renewer, which renews certificates via a webhook API and places them in a local directory. The distributor reads that directory and propagates the certificates to dozens of web servers.


Features

  • Declarative inventory (hosts.yml) — easy to version-control, review, and temporarily disable entries with #.
  • OS handlers — Rocky/AlmaLinux/CentOS/RHEL, Ubuntu/Debian, openSUSE/SLES. Easily extensible.
  • Non-root user support — uploads to /tmp then sudo mv to final destination.
  • Per-host grouping — multiple domains on the same server trigger a single reload.
  • Operation modes:
    • --preflight — tests SSH connectivity only (no distribution)
    • --dry-run — shows what would be done, without executing
    • --domain X — distributes only one domain
    • --force — redistributes even if already distributed
  • Multiple files per entry:
    • cert_path — individual certificate or fullchain
    • intermediate_path — separate intermediate.pem (Apache SSLCertificateChainFile)
    • ca_bundle_path — intermediate+root concatenated (PHP curl.cainfo / openssl.cafile)
  • Declarative ownershipcert_owner, intermediate_owner, ca_bundle_owner.
  • post_commands — escape hatch for custom post-deploy actions.
  • Automatic backup with timestamp before overwriting.
  • Email notifications with HTML summary.

Architecture

+------------------------------------------------------------------+
|                       Orchestrator host                           |
|                                                                   |
|   tls-distributor.timer (daily, configurable)                     |
|        |                                                          |
|        v                                                          |
|   tls_distributor.py                                              |
|        |                                                          |
|        +-- 1. Loads hosts.yml                                     |
|        +-- 2. For each domain:                                    |
|        |     +-- .crt exists in CERT_DIR? No -> SKIP              |
|        |     +-- Newer than .distributed? No -> SKIP              |
|        |     |   (unless --force)                                 |
|        |     +-- Builds cert_content                              |
|        |         (cert alone, or fullchain if chain: true)        |
|        +-- 3. Groups entries by (user@host)                       |
|        +-- 4. For each host (paramiko SSH/SFTP):                  |
|        |     +-- Backup of existing file (with timestamp)         |
|        |     +-- Upload cert (chmod + chown if defined)           |
|        |     +-- Upload intermediate (if intermediate_path)       |
|        |     +-- Upload CA bundle (if ca_bundle_path)             |
|        |     +-- Run post_commands (if any)                       |
|        |     +-- Reload web server (1x per host)                  |
|        +-- 5. Sends HTML email summary                            |
+------------------------------------------------------------------+
                          |
                          v SSH/SFTP
        +------------------------------------------+
        |  Web servers (Apache / Nginx / Docker)    |
        |  Rocky, AlmaLinux, Ubuntu, etc.           |
        +------------------------------------------+

Requirements

  • Linux (tested on Rocky Linux 9)
  • Python 3.10+
  • SSH access with public-key authentication to all target servers
  • For non-root users on targets: sudo NOPASSWD configured
  • tls-renewer installed (or another process populating CERT_DIR with .crt files)

Python dependencies (requirements.txt):

  • paramiko >= 3.4.0
  • pyyaml >= 6.0.0
  • python-dotenv >= 1.0.0

Installation

git clone https://github.com/wgfreitas/tls-distributor.git
cd tls-distributor
sudo bash install.sh

After installation:

sudo $EDITOR /opt/tls-distributor/.env         # SMTP, paths, SSH key
sudo $EDITOR /opt/tls-distributor/hosts.yml    # Your host inventory

# Set up SSH access (one-time per host)
ssh-copy-id -i /root/.ssh/id_ed25519.pub user@host

# Validate before enabling automation
sudo tls-distribute --preflight                # Test SSH on all hosts
sudo tls-distribute --dry-run                  # Simulate without executing
sudo tls-distribute --domain app.example.org --force   # Test single host

# Enable daily execution
sudo systemctl enable --now tls-distributor.timer

hosts.yml schema

Each entry in the inventory describes a certificate for a domain on a specific host. See hosts.yml.example for examples covering all scenarios.

Required fields

- domain: app.example.org              # Certificate CN (must match the .csr)
  host: 10.0.0.10                      # SSH IP or hostname of target server
  user: root                           # SSH user
  os: rocky                            # OS (see handler table below)
  webserver: apache                    # Web server type (informational)
  cert_path: /etc/pki/tls/certs/app.crt   # Destination path for the certificate
  reload_cmd: systemctl reload httpd   # Reload command (1x per host)

Optional fields

  chain: false                         # true = fullchain (cert+intermediate+root)
  intermediate_path: /path/intermediate.pem
  ca_bundle_path: /path/ca-bundle.pem
  cert_owner: apache:apache
  intermediate_owner: apache:apache
  ca_bundle_owner: apache:apache
  post_commands:
    - chmod 640 /etc/pki/tls/certs/app.crt
    - systemctl restart php-fpm

OS handler table

os value Family Default cert dir
rocky, almalinux, centos, rhel RHEL /etc/pki/tls/certs
ubuntu, debian Debian /etc/ssl/certs
opensuse, sles RHEL-like /etc/pki/trust/anchors

Unknown OS falls back to Rocky. To add a new OS, edit os_handlers.py.

⚠️ Warning about ca_bundle_path

This field overwrites the destination file with only 2 certificates (intermediate + root from your internal CA). If the path points to a standard Mozilla CA bundle (e.g. WordPress's wp-includes/certificates/ca-bundle.crt, which has ~150 public CAs), you will break SSL verification for external sites (plugin updates, external APIs, etc.).

Use ca_bundle_path only if the application validates exclusively internal certificates.

For WordPress/Laravel/applications that make external HTTPS calls, use post_commands to sync the system's ca-bundle (which already includes Mozilla CAs + your internal CAs via update-ca-trust):

post_commands:
  - cp /etc/pki/tls/certs/ca-bundle.crt /var/www/blog/wp-includes/certificates/ca-bundle.crt
  - chown apache:apache /var/www/blog/wp-includes/certificates/ca-bundle.crt

Usage

# Tests SSH on ALL unique hosts, without distributing
tls-distribute --preflight

# Simulates distribution without executing any remote action
tls-distribute --dry-run

# Full distribution (default)
tls-distribute

# Single domain only
tls-distribute --domain app.example.org

# Force redistribution (ignore .distributed marker)
tls-distribute --force

# Force a single domain
tls-distribute --domain app.example.org --force

# Use alternative inventory file
tls-distribute --hosts-file /tmp/hosts-test.yml

Detailed flow per entry

  1. Validates required fields via the OS handler.
  2. Checks CERT_DIR/<domain>.crt — if missing, SKIP.
  3. Checks cert_is_newer — compares mtime of .crt with .distributed marker. If already distributed and older, SKIP (unless --force).
  4. Builds cert_content:
    • chain: false → just the .crt content
    • chain: true → concatenates cert + intermediate.pem + root.pem
  5. Groups by user@host to optimize reload.
  6. Connects via SSH using paramiko and the key in SSH_KEY.
  7. For each entry:
    • Backup with timestamp: <file>.bak.YYYYMMDDHHMMSS
    • mkdir -p of destination directory
    • Upload via SFTP (non-root: upload to /tmp + sudo mv)
    • chown if *_owner defined
    • Same for intermediate_path and ca_bundle_path
    • Run post_commands
  8. Reload (1x per host).
  9. Marks distributed: creates/touches <domain>.distributed in CERT_DIR.

Configuration (.env)

See .env.example. Main variables:

Variable Description Default
CERT_DIR Where .crt files are (tls-renewer output) /opt/tls-renewer/certs
CHAIN_DIR intermediate.pem and root.pem /opt/tls-distributor/chain
HOSTS_FILE YAML inventory /opt/tls-distributor/hosts.yml
SSH_KEY SSH private key /root/.ssh/id_ed25519
SSH_PORT / SSH_TIMEOUT SSH config 22 / 30
SMTP_* Email settings

Common operations

Add a new host

  1. Edit hosts.yml and add the entry.
  2. Set up passwordless SSH:
    ssh-copy-id -i /root/.ssh/id_ed25519.pub user@host
  3. For non-root users, configure sudo NOPASSWD:
    ssh user@host
    sudo bash -c 'echo "user ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/user'
    sudo chmod 440 /etc/sudoers.d/user
  4. Validate:
    tls-distribute --preflight
    tls-distribute --domain new.example.org --dry-run --force

Redistribute after losing a server

rm /opt/tls-renewer/certs/domain.example.org.distributed
tls-distribute --domain domain.example.org

Or simply tls-distribute --force (redistributes everything).


Troubleshooting

"SFTP Error: Permission denied" (non-root user)

SFTP upload runs without sudo. For non-root, the distributor uploads to /tmp and then runs sudo mv. Confirm the user has sudo NOPASSWD: ALL:

ssh user@host "sudo -n whoami"   # Should return 'root' without prompting

"Authentication failed" via SSH

grep SSH_KEY /opt/tls-distributor/.env
ssh -i $SSH_KEY user@host "cat ~/.ssh/authorized_keys"
# Permissions on target:
# ~/.ssh/             700
# ~/.ssh/authorized_keys  600

"httpd.service not found" on reload

Host runs Docker but reload_cmd is systemctl reload httpd. Adjust in hosts.yml:

reload_cmd: docker exec container_name apachectl graceful
# or
reload_cmd: docker compose -f /path/to/docker-compose.yml restart

Certificate seems unchanged on the server

The web server may be using a different file. Check VirtualHost config:

ssh root@host "grep -rE 'SSLCertificateFile|ssl_certificate' /etc/httpd/conf.d/" | grep domain

Adjust cert_path in hosts.yml to point to the file actually referenced.

WordPress broke after configuring ca_bundle_path

See the warning in the ca_bundle_path section. Restore the system bundle:

ssh root@host "cp /etc/pki/tls/certs/ca-bundle.crt /path/wp-includes/certificates/ca-bundle.crt"
ssh root@host "chown apache:apache /path/wp-includes/certificates/ca-bundle.crt"

And remove ca_bundle_path from hosts.yml (use post_commands instead).


Security

  • .env should have permission 600, owned by root.
  • Dedicated SSH key for the distributor (don't use a personal key).
  • sudo NOPASSWD only where necessary, with specific users.
  • Automatic backup before overwriting (*.bak.YYYYMMDDHHMMSS) — clean periodically.
  • Systemd unit with ProtectSystem=strict, NoNewPrivileges=yes, PrivateTmp=yes.
  • hosts.yml contains internal IPs — listed in .gitignore. Version only hosts.yml.example.

Extending

Adding a new OS

Edit os_handlers.py:

class MyOSHandler(BaseOSHandler):
    name = "My OS"
    family = "rhel"   # or "debian"

    @property
    def default_cert_dir(self) -> str:
        return "/custom/path"

# Register in the dictionary:
OS_HANDLERS["my_os"] = MyOSHandler()

Then use os: my_os in hosts.yml.

Notification through another channel

Replace send_notification() in tls_distributor.py with integration to Slack, Microsoft Teams, or another service.


Companion project

  • tls-renewer — Renews certificates via webhook API and populates the CERT_DIR read by this project.

Origin

Originally developed at SINFO/IRR/Fiocruz (Instituto René Rachou, Brazil) to distribute TLS certificates across dozens of web servers (Apache, Nginx, Docker) running Rocky Linux, AlmaLinux, and Ubuntu. Generalized and released as open source under MIT.


License

MIT — see LICENSE.

About

Declarative TLS certificate distribution to web servers via SSH/SFTP

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages