# ============================================================================= # build-and-publish # # Compiles a custom nginx (with ModSecurity, naxsi, lua, brotli, geoip2, etc.), # packages the result as a Debian .deb named `twiy`, and uploads it to a # Sonatype Nexus apt-hosted repository so users can install via `apt`. # # Triggers: # * Every push to master. # * Manual run from the Actions UI (workflow_dispatch). # # Required repository secrets (see the "Publish to Nexus" step for details): # NEXUS_USER, NEXUS_PASS, NEXUS_URL, NEXUS_REPO # ============================================================================= name: build-and-publish on: push: branches: [master] workflow_dispatch: jobs: build: # Pinned to ubuntu-22.04 because the build script targets the toolchain # versions that ship with that release. Bumping this needs validation # against the modules pinned in /version. runs-on: ubuntu-22.04 steps: - name: Checkout source uses: actions/checkout@v4 - name: Install build dependencies run: | set -euo pipefail # Minimal toolchain to: build nginx (build-essential), package the # output (dpkg-dev, fakeroot), and fetch sources (git, curl, wget). # gnupg is kept in case a future step needs to verify upstream sigs. sudo apt-get update -y sudo apt-get install -y --no-install-recommends \ git curl wget ca-certificates dpkg-dev fakeroot \ build-essential gnupg - name: Compile nginx and modules run: | set -euo pipefail # Touch /.dockerenv so build/run.sh's container-detection branch is # taken: it skips `systemctl start nginx` (the runner has no systemd). # The .deb's own postinst handles service start on the user's host. sudo touch /.dockerenv sudo bash build/run.sh new # download sources for nginx + modules sudo bash build/run.sh build # configure, compile, install sudo bash build/run.sh postfix # drop default configs into /nginx # ───────────────────────────────────────────────────────────────────────── # Assemble the .deb by hand (we don't use debhelper because the build # script already places everything at its final paths under the runner's # root; we just need to mirror those paths into PKG_DIR and add control # metadata). # ───────────────────────────────────────────────────────────────────────── - name: Assemble .deb package id: pkg run: | set -euo pipefail PKG_NAME="twiy" NGINX_VER="$(nginx -v 2>&1 | awk -F'/' '{print $2}')" # Append the CI run number as the Debian revision so each rebuild # produces a strictly-greater version (e.g. 1.26.0-3 > 1.26.0-2 > # 1.26.0). Without this, `apt upgrade twiy` would be a no-op when # upstream nginx hasn't moved, so packaging fixes wouldn't reach # users who already have the package installed. VERSION="${NGINX_VER}-${GITHUB_RUN_NUMBER:-1}" ARCH="amd64" PKG_DIR="/opt/${PKG_NAME}_${VERSION}_${ARCH}" DEB_DIR="${PKG_DIR}/DEBIAN" # The `*_temp` dirs under /usr/local/nginx are nginx's compiled-in # defaults for client_body / proxy / fastcgi / uwsgi / scgi temp # storage (no --http-*-temp-path was passed to ./configure). They # must exist before `nginx -t` runs, so we ship them empty in the # .deb and the postinst chowns them to the nginx user. sudo mkdir -p "${PKG_DIR}/usr/sbin" "${PKG_DIR}/nginx" \ "${PKG_DIR}/etc/systemd/system" "${PKG_DIR}/var/log/nginx" \ "${PKG_DIR}/usr/lib" "${PKG_DIR}/usr/local/lib" \ "${PKG_DIR}/hostdata/default/public_html" \ "${PKG_DIR}/usr/nginx_lua" \ "${PKG_DIR}/usr/local/nginx/client_body_temp" \ "${PKG_DIR}/usr/local/nginx/proxy_temp" \ "${PKG_DIR}/usr/local/nginx/fastcgi_temp" \ "${PKG_DIR}/usr/local/nginx/uwsgi_temp" \ "${PKG_DIR}/usr/local/nginx/scgi_temp" # Pull every artifact the build produced into the package tree. # `|| true` on the recursive copies tolerates a missing source dir # (e.g. when rebuilding without re-running postfix locally). sudo cp /usr/sbin/nginx "${PKG_DIR}/usr/sbin/" sudo cp -R /nginx/* "${PKG_DIR}/nginx/" || true sudo cp /etc/systemd/system/nginx.service "${PKG_DIR}/etc/systemd/system/" sudo cp -R /hostdata/default "${PKG_DIR}/hostdata/" || true sudo cp -R /usr/nginx_lua "${PKG_DIR}/usr/" || true # Bundle every shared library nginx links against. This makes the # package self-contained: users don't need our exact build-host # versions of libssl, libluajit, libmodsecurity, etc. The grep # filters out the vDSO and the dynamic linker (which never appear # as `=> /...`). for lib in $(ldd /usr/sbin/nginx | grep '=> /' | awk '{print $3}'); do sudo cp "$lib" "${PKG_DIR}/usr/lib/" || true done # ---- DEBIAN/control -------------------------------------------------- # Minimum metadata dpkg requires. We don't declare runtime Depends: # the .deb bundles every shared library nginx needs (see the ldd # loop above), so the only thing the host must provide is glibc. sudo mkdir -p "${DEB_DIR}" sudo tee "${DEB_DIR}/control" >/dev/null < Description: Nginx L7 DDoS Protection (The-World-Is-Yours), built by RAWeb CI. EOF # ---- DEBIAN/postinst ------------------------------------------------- # Runs after dpkg unpacks the files. Designed to be safe to re-run: # `apt install --reinstall twiy` and `apt upgrade twiy` both invoke # this script and must not fail. # # Every step that may legitimately fail on a re-run (user already # exists, service already enabled, host has no systemd, etc.) ends # in `|| true`, and we `exit 0` explicitly so a flaky systemctl # never aborts a dpkg transaction. sudo tee "${DEB_DIR}/postinst" >/dev/null <<'EOF' #!/bin/bash # Idempotent: safe on first install, upgrade, and reinstall. # System user nginx workers run as. -r = system account (no aging, # UID below SYS_UID_MAX), no shell, home set to nginx's prefix. useradd -r -d /usr/local/nginx -s /bin/false nginx 2>/dev/null || true # nginx was compiled without --http-*-temp-path, so it defaults to # / (/usr/local/nginx/client_body_temp etc.). The dirs # already ship in the .deb, but `install -d` is the cleanest way to # set owner/group/mode in one shot and is a no-op when the dir # already exists with the right attributes. install -d -o nginx -g nginx -m 0755 \ /usr/local/nginx \ /usr/local/nginx/client_body_temp \ /usr/local/nginx/proxy_temp \ /usr/local/nginx/fastcgi_temp \ /usr/local/nginx/uwsgi_temp \ /usr/local/nginx/scgi_temp \ /var/log/nginx # Recursive chown picks up any user-supplied configs already under # /nginx (vhosts, certs) so reloads don't trip on permissions. chown -R nginx:nginx /var/log/nginx /nginx /usr/local/nginx 2>/dev/null || true # Refresh systemd's view of unit files we just dropped, then bring # the service up. `restart` (rather than `start`) handles the case # where a previous broken install left the unit failed. systemctl daemon-reload 2>/dev/null || true systemctl enable nginx.service 2>/dev/null || true systemctl restart nginx.service 2>/dev/null || true exit 0 EOF sudo chmod 755 "${DEB_DIR}/postinst" # Build the .deb and hand ownership back to the runner user so the # next step can read it without sudo. sudo dpkg-deb --build "${PKG_DIR}" DEB_FILE="${PKG_DIR}.deb" sudo chown "$(id -u):$(id -g)" "${DEB_FILE}" { echo "deb_file=${DEB_FILE}" echo "version=${VERSION}" echo "pkg_name=${PKG_NAME}" } >> "$GITHUB_OUTPUT" ls -la "${DEB_FILE}" sha256sum "${DEB_FILE}" # ───────────────────────────────────────────────────────────────────────── # Publish the built .deb to a Sonatype Nexus apt-hosted repository. # # Threat model for this step (the workflow file is public): # * Credentials come exclusively from repository secrets, never source. # * Credentials must never appear in argv (visible via /proc//cmdline # to any local user) or in the runner's persistent filesystem. # * If the job is cancelled or killed, secrets must still be wiped. # # To run this in your own fork, set four repository secrets: # NEXUS_USER — Nexus account with write access to the apt repo # NEXUS_PASS — its password (or token) # NEXUS_URL — base URL, e.g. https://apt.example.com # NEXUS_REPO — the apt-hosted repository name in Nexus # ───────────────────────────────────────────────────────────────────────── - name: Publish to Nexus env: NEXUS_USER: ${{ secrets.NEXUS_USER }} NEXUS_PASS: ${{ secrets.NEXUS_PASS }} NEXUS_URL: ${{ secrets.NEXUS_URL }} NEXUS_REPO: ${{ secrets.NEXUS_REPO }} DEB_FILE: ${{ steps.pkg.outputs.deb_file }} PKG_NAME: ${{ steps.pkg.outputs.pkg_name }} run: | set -euo pipefail umask 077 # any file we create is rw for us only # ---- Secret-handling scratch dir ------------------------------------ # /dev/shm is tmpfs (RAM-backed). Even if the runner's disk is later # imaged or recovered, secrets written here never touch persistent # storage. Fall back to /tmp on minimal images that lack /dev/shm. SECDIR="$(mktemp -d -p /dev/shm twiy-XXXXXXXX 2>/dev/null \ || mktemp -d -t twiy-XXXXXXXX)" chmod 700 "$SECDIR" # Trap covers normal exit, errors (set -e), and the common cancellation # signals Gitea / GitHub send when a job is cancelled or times out. # `shred -uz` overwrites then unlinks; on tmpfs the overwrite is mostly # symbolic, but it's free defence-in-depth in case /dev/shm wasn't # available and we fell back to a disk-backed /tmp. cleanup() { find "$SECDIR" -type f -exec shred -uz {} + 2>/dev/null || true rm -rf "$SECDIR" } trap cleanup EXIT INT TERM HUP # ---- Build the netrc ------------------------------------------------- # Why netrc and not `curl -u user:pass`: # - `-u` puts the password in argv; any local user can read it from # /proc//cmdline while the curl is in flight. # - netrc is a 0600 file curl reads itself; the password never # appears on a command line. # Why `printf` (a bash builtin): builtins don't fork an external # process, so the password is never an argv to any executable. # The host string in netrc must match the URL host exactly, so we # derive it from $NEXUS_URL rather than hardcoding it — this lets # forks reuse the workflow without editing it. NEXUS_HOST="$(printf '%s' "$NEXUS_URL" | awk -F/ '{print $3}')" printf 'machine %s login %s password %s\n' \ "$NEXUS_HOST" "$NEXUS_USER" "$NEXUS_PASS" > "$SECDIR/netrc" # Drop the in-memory copies now that the file is the source of truth. unset NEXUS_USER NEXUS_PASS # ---- Replace any prior version of this package ----------------------- # Nexus's apt-hosted format keeps every uploaded .deb forever unless we # explicitly delete the old component. Without this, the repo grows # unboundedly and `apt` may pick a stale version. Best-effort: a # missing prior component is not an error. OLD_ID="$(curl -fsS --netrc-file "$SECDIR/netrc" \ "$NEXUS_URL/service/rest/v1/components?repository=$NEXUS_REPO" \ | PKG_NAME="$PKG_NAME" python3 -c ' import sys, json, os for c in json.load(sys.stdin).get("items", []): if c.get("name") == os.environ["PKG_NAME"]: print(c["id"]); break ' || true)" if [ -n "$OLD_ID" ]; then curl -fsS -X DELETE --netrc-file "$SECDIR/netrc" \ "$NEXUS_URL/service/rest/v1/components/$OLD_ID" -o /dev/null fi # ---- Upload the new .deb -------------------------------------------- # Body goes to a file inside SECDIR so the trap shreds it too — Nexus # error responses sometimes echo request metadata we'd rather not # leave on disk. HTTP="$(curl -sS --netrc-file "$SECDIR/netrc" \ -o "$SECDIR/upload.body" -w '%{http_code}' \ -X POST -F "apt.asset=@$DEB_FILE" \ "$NEXUS_URL/service/rest/v1/components?repository=$NEXUS_REPO")" case "$HTTP" in 201|204) echo "Uploaded $(basename "$DEB_FILE") to $NEXUS_URL/repository/$NEXUS_REPO/" ;; *) echo "Upload failed (HTTP $HTTP)"; head -c 400 "$SECDIR/upload.body"; exit 1 ;; esac # ---- Why we don't sign each .deb ourselves --------------------------- # apt's trust chain on the client is: # Release.gpg → Packages (verified by SHA256 in Release) # → the .deb (verified by SHA256 in Packages) # Signing the Release file is enough; per-.deb signatures are not # consulted by apt during install. Nexus signs Release on every # upload using a key bound at repo-creation time, and that private # key never leaves the Nexus host — so we deliberately keep all # signing material off the CI runner.