From patchwork Thu Jun 4 14:18:25 2026 Return-Path: Received: from ringo (2a01cb00021ec0002e23edbec21b0e73.ipv6.abo.wanadoo.fr [IPv6:2a01:cb00:21e:c000:2e23:edbe:c21b:e73]) by patches.jarry.cc (Postfix) with ESMTP id D713C1BC435B for ; Thu, 04 Jun 2026 16:18:30 +0200 (CEST) From: Robin Jarry To: pw@patches.jarry.cc Subject: [PATCH v3 16/16] deploy Date: Thu, 4 Jun 2026 16:18:25 +0200 Message-ID: <20260604141826.2998337-17-robin@jarry.cc> X-Mailer: git-send-email 2.54.0 In-Reply-To: <20260604141826.2998337-1-robin@jarry.cc> References: <20260604141826.2998337-1-robin@jarry.cc> MIME-Version: 1.0 List-ID: X-Patchwork-Submitter: Robin Jarry X-Patchwork-Id: 113 Content-Type: text/plain; charset=utf-8 Content-Transfer-Encoding: 8bit Signed-off-by: Robin Jarry --- Makefile | 147 +++++++++++++++++++++++++++++++ nginx.conf | 62 +++++++++++++ parsemail.sh | 14 +++ patchwork.service | 22 +++++ patchwork/settings/production.py | 67 ++++++++++++++ postfix/client_access | 2 + postfix/header_checks | 2 + postfix/main.cf | 53 +++++++++++ postfix/master.cf | 28 ++++++ postfix/recipient_bcc | 1 + postfix/transport | 2 + uwsgi.ini | 10 +++ 12 files changed, 410 insertions(+) create mode 100644 Makefile create mode 100644 nginx.conf create mode 100755 parsemail.sh create mode 100644 patchwork.service create mode 100644 patchwork/settings/production.py create mode 100644 postfix/client_access create mode 100644 postfix/header_checks create mode 100644 postfix/main.cf create mode 100644 postfix/master.cf create mode 100644 postfix/recipient_bcc create mode 100644 postfix/transport create mode 100644 uwsgi.ini diff --git a/Makefile b/Makefile new file mode 100644 index 000000000000..32a8167ae9bb --- /dev/null +++ b/Makefile @@ -0,0 +1,147 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Robin Jarry +# +# Makefile for deploying pwforge + patchwork on a single host. +# Run as root or with sudo. + +DOMAIN ?= patches.jarry.cc +DB_NAME ?= patchwork +DB_USER ?= patchwork +DB_PASSWORD ?= $(shell openssl rand -base64 24) +DJANGO_SECRET ?= $(shell openssl rand -base64 48) +WEBHOOK_SECRET ?= $(shell openssl rand -base64 48) + +.PHONY: help +help: + @echo "usage: make [VAR=value ...]" + @echo "" + @echo "targets:" + @echo " deps install system packages" + @echo " database create postgresql database" + @echo " patchwork install patchwork" + @echo " uwsgi install uwsgi config and service" + @echo " postfix install postfix config" + @echo " nginx install nginx config and obtain TLS cert" + @echo " install install everything (idempotent)" + @echo "" + @echo "variables:" + @echo " DOMAIN $(DOMAIN)" + @echo " DB_NAME $(DB_NAME)" + @echo " DB_USER $(DB_USER)" + @echo " DB_PASSWORD (auto-generated if unset)" + @echo " DJANGO_SECRET (auto-generated if unset)" + +DEB := git nginx certbot python3-certbot-nginx postfix opendkim opendkim-tools +DEB += uwsgi uwsgi-plugin-python3 +DEB += postgresql python3-psycopg2 +DEB += python3-django python3-djangorestframework python3-django-filters + +.PHONY: deps +deps: + apt-get update -q + apt-get install -qy --no-install-recommends $(DEB) + usermod -a -G opendkim postfix + +/etc/default/patchwork: + @echo "DJANGO_SETTINGS_MODULE=patchwork.settings.production" > $@ + @echo "DJANGO_SECRET_KEY=$(DJANGO_SECRET)" >> $@ + @echo "DATABASE_NAME=$(DB_NAME)" >> $@ + @echo "DATABASE_USER=$(DB_USER)" >> $@ + @echo "DATABASE_PASSWORD=$(DB_PASSWORD)" >> $@ + @echo "DATABASE_HOST=localhost" >> $@ + @echo "DATABASE_PORT=" >> $@ + @echo "STATIC_ROOT=/var/www/patchwork" >> $@ + @echo "ALLOWED_HOSTS=$(DOMAIN)" >> $@ + @echo "FROM_EMAIL='Patchwork '" >> $@ + @echo "GH_WEBHOOK_SECRET=$(WEBHOOK_SECRET)" >> $@ + @echo "GH_TOKEN=replace_me" >> $@ + chmod 640 $@ + chgrp www-data $@ + +.PHONY: database +database: /etc/default/patchwork + @set -e; . $<; \ + user=`sudo -u postgres psql -tXAc "SELECT 1 FROM pg_roles WHERE rolname='$$DATABASE_USER';"`; \ + if [ -z "$$user" ]; then \ + sudo -u postgres psql -c "CREATE USER $$DATABASE_USER PASSWORD '$$DATABASE_PASSWORD';"; \ + sudo -u postgres createdb -O $$DATABASE_USER $$DATABASE_NAME; \ + fi + +.PHONY: patchwork +patchwork: database + mkdir -p /var/cache/patchwork/mirrors + chmod 770 /var/cache/patchwork/mirrors + chgrp -R www-data /var/cache/patchwork/mirrors + mkdir -p /var/www/patchwork + set -a && . /etc/default/patchwork && set +a -xe && \ + python3 manage.py migrate --noinput && \ + python3 manage.py collectstatic --noinput && \ + python3 manage.py loaddata default_tags default_states + +/etc/uwsgi/apps-available/patchwork.ini: uwsgi.ini + install -Dm 0644 $< $@ + sed -i 's,^chdir.*,chdir = $(CURDIR),' $@ + +/etc/uwsgi/apps-enabled/patchwork.ini: /etc/uwsgi/apps-available/patchwork.ini + ln -sfr $< $@ + +/etc/systemd/system/%.service: %.service + install -Dm 0644 $< $@ + +.PHONY: uwsgi +uwsgi: /etc/uwsgi/apps-enabled/patchwork.ini /etc/systemd/system/patchwork.service + +/etc/postfix/%: postfix/% + install -D -m 0644 $< $@ + sed -i 's/patches.example.com/$(DOMAIN)/g' $@ + sed -i 's,/opt/patchwork,$(CURDIR),' $@ + +/etc/dkimkeys/$(DOMAIN)/mail.private: + mkdir -p $(@D) + opendkim-genkey -d $(DOMAIN) -s mail -D $(@D) + chown -R opendkim:opendkim $(@D) + sed -Ei '/^(Domain|Selector|KeyFile)/d' /etc/opendkim.conf + echo 'Domain $(DOMAIN)' >> /etc/opendkim.conf + echo 'Selector mail' >> /etc/opendkim.conf + echo 'KeyFile $@' >> /etc/opendkim.conf + +.PHONY: postfix +postfix: $(addprefix /etc/,$(wildcard postfix/*)) /etc/dkimkeys/$(DOMAIN)/mail.private + postmap /etc/postfix/transport + postmap /etc/postfix/recipient_bcc + +/etc/nginx/sites-enabled/$(DOMAIN): nginx.conf + @if ! [ -r "/etc/letsencrypt/live/$(DOMAIN)/cert.pem" ]; then \ + set -xe; \ + rm -f $@ /etc/nginx/sites-enabled/pw_; \ + awk '/^server / {count++} count<2' $< | \ + sed 's/patches.example.com/$(DOMAIN)/g' > \ + /etc/nginx/sites-enabled/pw_; \ + systemctl reload nginx; \ + mkdir -p /var/www/acme; \ + certbot certonly --webroot -w /var/www/acme -d $(DOMAIN); \ + rm -f /etc/nginx/sites-enabled/pw_; \ + fi + install -Dm 644 $< /etc/nginx/sites-available/$(DOMAIN) + sed -i 's/patches.example.com/$(DOMAIN)/g' /etc/nginx/sites-available/$(DOMAIN) + ln -sfr /etc/nginx/sites-available/$(DOMAIN) $@ + nginx -t + +.PHONY: nginx +nginx: /etc/nginx/sites-enabled/$(DOMAIN) + +.PHONY: install +install: database patchwork uwsgi postfix nginx + systemctl daemon-reload + systemctl enable --now patchwork postfix nginx opendkim + systemctl restart patchwork postfix nginx opendkim + @echo + @echo "Update your DNS zone with the following entries:" + @echo + @ip -j addr | jq -r '.[].addr_info[] | select(.scope == "global" and .family == "inet") | .local' | \ + sed 's/^/$(DOMAIN). 10800 IN A /' + @ip -j addr | jq -r '.[].addr_info[] | select(.scope == "global" and .family == "inet6") | .local' | \ + sed 's/^/$(DOMAIN). 10800 IN AAAA /' + @echo "$(DOMAIN). 10800 IN MX 10 $(DOMAIN)." + @echo '$(DOMAIN). 10800 IN TXT "v=spf1 a -all"' + @sed -E 's/^(mail._domainkey)/\1.$(DOMAIN)./' /etc/dkimkeys/$(DOMAIN)/mail.txt diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 000000000000..1be8d5c1aabe --- /dev/null +++ b/nginx.conf @@ -0,0 +1,62 @@ +# /etc/nginx/sites-available/pwforge +# +# Reverse proxy for patchwork and pwforge behind nginx. +# Use certbot to obtain TLS certificates: +# certbot certonly --webroot -w /var/www/acme -d patches.example.com + +limit_req_zone $pw_limit zone=pw:10m rate=20r/s; + +# Only rate-limit write requests (POST, PUT, DELETE) +map $request_method $pw_limit { + GET ""; + HEAD ""; + default $binary_remote_addr; +} + +server { + listen 80; + listen [::]:80; + server_name patches.example.com; + + location /.well-known/acme-challenge/ { + root /var/www/acme; + } + location / { + return 301 https://patches.example.com$request_uri; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name patches.example.com; + + ssl_certificate /etc/letsencrypt/live/patches.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/patches.example.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + limit_req zone=pw burst=10 nodelay; + + server_tokens off; + + location = favicon.ico { + access_log off; + log_not_found off; + } + + location /static { + alias /var/www/patchwork; + expires 3h; + gzip on; + gzip_types text/css application/javascript; + gzip_min_length 500; + gzip_disable msie6; + } + + # patchwork web UI and API + location / { + include uwsgi_params; + uwsgi_pass unix:/run/patchwork/uwsgi.sock; + } +} diff --git a/parsemail.sh b/parsemail.sh new file mode 100755 index 000000000000..a597b4ec1e05 --- /dev/null +++ b/parsemail.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026 Robin Jarry + +# Called by postfix pipe transport to feed incoming mail to patchwork. +# Exit 75 (EX_TEMPFAIL) on failure so postfix retries later. + +set -a +. /etc/default/patchwork +set +a + +cd $(dirname $0) +systemd-cat --identifier=parsemail --priority=debug --stderr-priority=err \ + python3 manage.py parsemail || exit 75 diff --git a/patchwork.service b/patchwork.service new file mode 100644 index 000000000000..3b8c3102473b --- /dev/null +++ b/patchwork.service @@ -0,0 +1,22 @@ +[Unit] +Description=Patchwork uWSGI +After=postgresql.service + +[Service] +ExecStart=/usr/bin/uwsgi --ini /etc/uwsgi/apps-enabled/patchwork.ini +Restart=always +KillSignal=SIGQUIT +Type=notify +NotifyAccess=all +User=www-data +Group=www-data +EnvironmentFile=/etc/default/patchwork +ReadWritePaths=/var/cache/patchwork/mirrors +RuntimeDirectory=patchwork +ProtectSystem=strict +ProtectHome=true +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/patchwork/settings/production.py b/patchwork/settings/production.py new file mode 100644 index 000000000000..647a2585e0f4 --- /dev/null +++ b/patchwork/settings/production.py @@ -0,0 +1,67 @@ +""" +Patchwork production settings for forge sync deployment. +""" + +import os + +from .base import * # noqa + +DEFAULT_FROM_EMAIL = os.environ.get("FROM_EMAIL", DEFAULT_FROM_EMAIL) +SERVER_EMAIL = DEFAULT_FROM_EMAIL +NOTIFICATION_FROM_EMAIL = DEFAULT_FROM_EMAIL +EMAIL_SUBJECT_PREFIX = "[patchwork] " + +SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] + +ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost").split(",") + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql_psycopg2", + "HOST": os.environ.get("DATABASE_HOST", "localhost"), + "PORT": os.environ.get("DATABASE_PORT", ""), + "NAME": os.environ.get("DATABASE_NAME", "patchwork"), + "USER": os.environ.get("DATABASE_USER", "patchwork"), + "PASSWORD": os.environ.get("DATABASE_PASSWORD", ""), + }, +} + +STATIC_ROOT = os.environ.get("STATIC_ROOT", "/var/www/patchwork") + +STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage" + +TIME_ZONE = "UTC" + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "syslog": { + "format": "%(name)s: %(message)s", + }, + }, + "handlers": { + "syslog": { + "class": "logging.handlers.SysLogHandler", + "address": "/dev/log", + "facility": "daemon", + "formatter": "syslog", + }, + }, + "root": { + "handlers": ["syslog"], + "level": "INFO", + }, +} + +ENABLE_FORGE = True +FORGE_BACKENDS = ["patchwork.forge.github"] +FORGE_WEBHOOK_SECRETS = { + "github": os.environ.get("GH_WEBHOOK_SECRET", ""), +} +FORGE_GIT_MIRROR_PATH = "/var/cache/patchwork/mirrors" +FORGE_AUTH = { + "github": { + "token": os.environ.get("GH_TOKEN", ""), + }, +} diff --git a/postfix/client_access b/postfix/client_access new file mode 100644 index 000000000000..91907452942b --- /dev/null +++ b/postfix/client_access @@ -0,0 +1,2 @@ +# mails.dpdk.org +217.70.189.124/32 OK diff --git a/postfix/header_checks b/postfix/header_checks new file mode 100644 index 000000000000..751678685afb --- /dev/null +++ b/postfix/header_checks @@ -0,0 +1,2 @@ +/^From:.*pw@patches\.jarry\.cc/ PREPEND List-Id: +/^To:.*pw@patches\.jarry\.cc/ PREPEND List-Id: diff --git a/postfix/main.cf b/postfix/main.cf new file mode 100644 index 000000000000..d5160981bdaa --- /dev/null +++ b/postfix/main.cf @@ -0,0 +1,53 @@ +# Postfix configuration for pwforge patchwork mail ingestion. +# +# This instance only accepts mail from a designated upstream MTA and +# delivers it to patchwork via a pipe transport. It does not relay +# or deliver mail to local users. + +compatibility_level = 3.6 + +# Replace with your actual hostname and domain. +myhostname = patches.jarry.cc +mydomain = patches.jarry.cc +myorigin = $mydomain +mydestination = $myhostname, localhost + +inet_interfaces = all +inet_protocols = all + +# Only accept mail from the upstream MTA and localhost (for pwforge's +# git send-email). Replace CHANGE_ME with the upstream MTA address. +mynetworks = 127.0.0.0/8, [::1]/128 +smtpd_client_restrictions = + permit_mynetworks, + check_client_access cidr:/etc/postfix/client_access, + reject + +# Route list mail to patchwork parsemail pipe. +header_checks = regexp:/etc/postfix/header_checks +recipient_bcc_maps = hash:/etc/postfix/recipient_bcc +transport_maps = hash:/etc/postfix/transport + +# Discard mail to unknown local users silently. +local_recipient_maps = +fallback_transport = discard: + +# Do not relay email, only allow local send +smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination +default_transport = smtp: +relay_domains = +relayhost = + +# Generous limits for patch series. +message_size_limit = 52428800 +mailbox_size_limit = 0 + +# Retry quickly on transient failures (e.g. database locks). +minimal_backoff_time = 30s +maximal_backoff_time = 300s +queue_run_delay = 30s + +# DKIM +milter_default_action = accept +smtpd_milters = unix:/run/opendkim/opendkim.sock +non_smtpd_milters = unix:/run/opendkim/opendkim.sock diff --git a/postfix/master.cf b/postfix/master.cf new file mode 100644 index 000000000000..54e8e0b248ab --- /dev/null +++ b/postfix/master.cf @@ -0,0 +1,28 @@ +# Postfix master process configuration for pwforge patchwork ingestion. + +smtp inet n - n - - smtpd +pickup unix n - n 60 1 pickup +cleanup unix n - n - 0 cleanup +qmgr unix n - n 300 1 qmgr +tlsmgr unix - - n 1000? 1 tlsmgr +rewrite unix - - n - - trivial-rewrite +bounce unix - - n - 0 bounce +defer unix - - n - 0 bounce +trace unix - - n - 0 bounce +verify unix - - n - 1 verify +flush unix n - n 1000? 0 flush +proxymap unix - - n - - proxymap +proxywrite unix - - n - 1 proxymap +smtp unix - - n - - smtp +relay unix - - n - - smtp -o syslog_name=postfix/$service_name +showq unix n - n - - showq +error unix - - n - - error +retry unix - - n - - error +discard unix - - n - - discard +local unix - n n - - local +virtual unix - n n - - virtual +lmtp unix - - n - - lmtp +anvil unix - - n - 1 anvil +scache unix - - n - 1 scache +postlog unix-dgram n - n - 1 postlogd +patchwork unix - n n - 1 pipe flags=FR user=www-data argv=/opt/patchwork/parsemail.sh diff --git a/postfix/recipient_bcc b/postfix/recipient_bcc new file mode 100644 index 000000000000..7c6c2230904c --- /dev/null +++ b/postfix/recipient_bcc @@ -0,0 +1 @@ +pw@patches.jarry.cc robin@jarry.cc diff --git a/postfix/transport b/postfix/transport new file mode 100644 index 000000000000..742b403310c3 --- /dev/null +++ b/postfix/transport @@ -0,0 +1,2 @@ +# Route mail for the subscribed address to patchwork +patches.jarry.cc patchwork: diff --git a/uwsgi.ini b/uwsgi.ini new file mode 100644 index 000000000000..12811e2c63d9 --- /dev/null +++ b/uwsgi.ini @@ -0,0 +1,10 @@ +[uwsgi] +plugins = python3 +chdir = /opt/patchwork +module = patchwork.wsgi:application +master = true +processes = 4 +socket = /run/patchwork/uwsgi.sock +chmod-socket = 660 +buffer-size = 16384 +vacuum = true