Files

9.4 KiB

Web publishing

The collected tilde.club configuration uses nginx, PHP-FPM, and fcgiwrap. Apache's mod_userdir, .htaccess, and a2enmod do not configure this stack.

Production layout

The main site's document root is /usr/share/nginx/html. Its server block serves HTTP and HTTPS. Several separate service vhosts redirect HTTP to HTTPS, but the main host's block serves both. The main nginx config includes /etc/nginx/sites-enabled/*; this is a local layout to reproduce explicitly, not an assumption about the distribution default.

Request Backend or directory
Main site /usr/share/nginx/html
/~USER/ and /u/USER/ /home/USER/public_html/
User PHP /run/php-fpm/php-fpm-php-user.sock, pool UID php-user, group club
Main site PHP /run/php-fpm/www.sock, pool UID/group nginx
User CGI /run/fcgiwrap/fcgiwrap-cgi.sock; service runs as cgiwrap
/~USER/git Local cgit wrapper through fcgiwrap
/~USER/myplant Central Botany PHP application
WeeChat relay beneath a user page A socket beneath the user's .weechat directory
Webmail SnappyMail in a separate vhost

The user-page location enables directory listings and SSI, and uses a per-user HTML 404 page. Account for those features when replacing a running config.

The collected pool generator can create per-user FPM sockets, but the main user-page route uses the shared pool. makeuser does not call the pool generator. Do not describe this as per-user PHP execution.

The CGI unit has UID/GID-changing capabilities. Those capabilities do not, by themselves, establish that each script is switched to its owner. That needs the wrapper implementation and a runtime identity test. Likewise, the collected CGI SCRIPT_FILENAME expression prepends a home path to $request_filename inside an alias location. Verify the expanded path before using it in a new config. The new-host examples below use explicit per-account paths instead.

Start with static pages

Use tilde-static.conf inside nginx's http context and replace the example domain. It provides both user URL forms, blocks dotfiles and script-source downloads, and disables symlinks in public user content. The example leaves directory listings and SSI off; enable those features deliberately if your community wants them.

A 0711 home can be traversed without being listed. Public directories need 0755 and public files 0644. Keep the user's other files private. Check every path component with namei -l, not just the page's final mode.

For a new SELinux-enforcing host, use the installed policy's home publishing support. A common Fedora layout is:

setsebool -P httpd_enable_homedirs on
semanage fcontext -a -t httpd_user_content_t '/home/[^/]+/public_html(/.*)?'
restorecon -RFv /home/member/public_html

If a matching custom rule already exists, review it and use semanage fcontext -m to modify it instead of adding a duplicate. Test access as the service and inspect AVC denials. Policy versions and service domains can require additional booleans or labels for writing or connecting to other services; enable only the access actually needed. The production snapshot with SELinux disabled cannot validate those policy rules for a new enforcing installation.

Useful checks:

nginx -t
namei -l /home/member/public_html/index.html
ls -Zd /home/member /home/member/public_html
ausearch -m AVC,USER_AVC -ts recent
journalctl -u nginx --since today

The production host sends nginx logs to syslog; do not assume every nginx error will be in /var/log/nginx/error.log. Check the effective error_log setting.

Add HTTPS

After obtaining a certificate, retain the HTTP challenge location and decide whether other HTTP requests should be served or redirected. For an HTTPS-only policy, redirect the non-challenge requests to a fixed configured hostname, rather than constructing a redirect from an unvalidated Host header.

Copy the publishing locations into an HTTPS server block with these settings, using the certificate paths returned by Certbot:

listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name tilde.example.net;
ssl_certificate /etc/letsencrypt/live/tilde.example.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/tilde.example.net/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;

Keep shared locations in one root-owned include if both server blocks use them. Set response headers in the context where they are intended to apply. Test headers on error responses and nested locations; inheritance is version-specific. A blanket CSP, frame policy, or cross-origin isolation policy on every member's page can break the projects you are hosting. Keep the operator application and member publishing policies separate. Add HSTS only after deciding that all of its affected names will continue to support HTTPS.

A Certbot deploy hook for nginx can be a root-owned executable file in /etc/letsencrypt/renewal-hooks/deploy/:

#!/usr/bin/env bash
set -euo pipefail
/usr/sbin/nginx -t
/usr/bin/systemctl reload nginx

Enable the packaged renewal timer and test renewal. The collected production service instead invokes /usr/local/bin/certbot renew --standalone, with hooks to stop and start nginx. Its timer checks at 06:09 and 18:09 in the host's local time, with Persistent=true. That produces a different availability profile from webroot renewal. Verify all certificate consumers after renewal, including Dovecot and Postfix if they share the certificate.

Add PHP with a deliberate execution identity

A new host can give an enabled member a dedicated pool. Install maintained packages, including the process extension if you use the identity test below:

dnf install php-fpm php-cli php-process

Review member.conf, then install it as /etc/php-fpm.d/member.conf. The Unix account and group must already exist. Create the private session directory named in that file:

sudo -u member -- install -d -m 0700 /home/member/.local/state/php/sessions
php-fpm -t
systemctl enable --now php-fpm

If PHP-FPM is already running, reload it after successful validation. Ensure the nginx worker can connect to the new socket. With SELinux enabled, directory labels, FPM's domain, and socket policy need to be checked on the target system.

Insert member-php.inc into the site's server block after the dotfile-denial location but before the generic script source-denial and static userdir locations. Keep both HTTP and HTTPS policy consistent. The example supports .php endpoints for this named member; it does not implement PATH_INFO routing for arbitrary frameworks. If you want PHP index files, add index.php to the userdir index list after enabling its handler.

Validate and reload nginx. Use this temporary test in the member's public directory, then remove it:

<?php

declare(strict_types=1);

header('Content-Type: text/plain; charset=utf-8');
echo 'sapi=', PHP_SAPI, "\n";
echo 'uid=', posix_geteuid(), "\n";

Compare the response UID with id -u member. Do not use get_current_user() as an execution-identity test: it reports the script file's owner. Also test that the backend cannot read a second member's private file. FPM running as the member still has all the access that member has; it is not a container or a separate security identity from their shell.

Tune pm.max_children, process lifetime, memory, uploads, and logging to the machine. Avoid eagerly starting several workers for every dormant account. The collected shared pool uses 20 maximum children and a 120-second request limit; those are production settings for that pool, not universal defaults.

CGI

A new installation can use Fedora's fcgiwrap@.service template with a separate instance for an enabled member. Inspect the installed unit with systemctl cat and confirm the service runs as that member. Override the matching socket's owner/group so nginx can connect, keeping the socket at 0660. Do not grant a shared CGI worker UID-changing capabilities merely to imitate the current unit.

Restrict a new CGI handler to the specific member's public_html/cgi-bin path. Use a fixed SCRIPT_FILENAME mapping for that account, verify the target is a real executable, and test the interpreter, UID, environment, and filesystem access before enabling it. Keep source-file denial for scripts without an enabled handler. PATH_INFO and executable index support need their own tests. The existing host's generalized CGI wrapper was not supplied, so this guide does not claim it can reproduce its owner-switching behavior.

Publishing tests

Use two disposable members and check response bodies and filesystem effects, not just status codes:

  • Both user URL forms, nested directories, index files, and trailing-slash redirects.
  • Missing users and scripts, dotfiles, percent-encoded paths, and traversal attempts.
  • A symlink from public content to a nonpublic readable file.
  • PHP source denial before enabling PHP, then the correct runtime UID afterward.
  • A script under one account trying to read or write the other account's private data.
  • User custom error pages, directory listings, SSI, and optional routes where enabled.
  • TLS certificate validation and all response headers on successful and error responses.

Remove diagnostics and downloadable configuration archives from the web root after use. Keep configs root-owned, validate before reload, and keep the previous config available for rollback.