diff --git a/README.md b/README.md index 468578c0..4448eecd 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,10 @@ Start: ``` ./scripts/start_with_tor ``` + or, with Nginx reverse proxy and Let's Encrypt SSL: + ``` + RELAY_DOMAIN=relay.example.com CERTBOT_EMAIL=you@example.com ./scripts/start_with_nginx + ``` Stop the server with: ``` diff --git a/docker-compose.nginx.yml b/docker-compose.nginx.yml new file mode 100644 index 00000000..b401d8ce --- /dev/null +++ b/docker-compose.nginx.yml @@ -0,0 +1,48 @@ +services: + nginx: + image: nginx:1.25-alpine + container_name: nostream-nginx + ports: + - 80:80 + - 443:443 + volumes: + - ${PWD}/nginx/conf.d:/etc/nginx/conf.d + - ${PWD}/nginx/ssl:/etc/nginx/ssl + - certbot-webroot:/var/www/certbot + depends_on: + - nostream + restart: on-failure + # Run nginx in foreground (so container exits if nginx dies). + # A background loop watches for a signal file created by certbot + # after cert issuance/renewal, and reloads nginx within seconds. + command: > + /bin/sh -c "while :; do + if [ -f /etc/nginx/ssl/reload-nginx ]; then + if nginx -t && nginx -s reload; then + rm -f /etc/nginx/ssl/reload-nginx; + fi; + fi; + sleep 5; + done & nginx -g 'daemon off;'" + networks: + default: + + certbot: + image: certbot/certbot:v2.11.0 + container_name: nostream-certbot + environment: + RELAY_DOMAIN: ${RELAY_DOMAIN:?RELAY_DOMAIN required} + CERTBOT_EMAIL: ${CERTBOT_EMAIL:?CERTBOT_EMAIL required} + volumes: + - ${PWD}/nginx/ssl:/etc/letsencrypt + - ${PWD}/scripts/certbot_entrypoint.sh:/entrypoint.sh:ro + - certbot-webroot:/var/www/certbot + entrypoint: /entrypoint.sh + depends_on: + - nginx + restart: on-failure + networks: + default: + +volumes: + certbot-webroot: diff --git a/nginx/conf.d/nostream.conf.template b/nginx/conf.d/nostream.conf.template new file mode 100644 index 00000000..c15a601e --- /dev/null +++ b/nginx/conf.d/nostream.conf.template @@ -0,0 +1,53 @@ +# Nginx configuration template for Nostream relay +# ${RELAY_DOMAIN} is substituted automatically by the start script + +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +# HTTP — redirect to HTTPS and serve ACME challenge for Let's Encrypt +server { + listen 80; + server_name ${RELAY_DOMAIN}; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +# HTTPS — reverse proxy to nostream relay +server { + listen 443 ssl; + server_name ${RELAY_DOMAIN}; + + ssl_certificate /etc/nginx/ssl/live/${RELAY_DOMAIN}/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/live/${RELAY_DOMAIN}/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + location / { + proxy_pass http://nostream:8008; + + # WebSocket support + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # Pass client IP to relay + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket timeouts + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } +} diff --git a/scripts/certbot_entrypoint.sh b/scripts/certbot_entrypoint.sh new file mode 100755 index 00000000..db066bc5 --- /dev/null +++ b/scripts/certbot_entrypoint.sh @@ -0,0 +1,30 @@ +#!/bin/sh +set -e + +DOMAIN="${RELAY_DOMAIN:?RELAY_DOMAIN required}" +EMAIL="${CERTBOT_EMAIL:?CERTBOT_EMAIL required}" + +# Remove dummy self-signed cert if certbot hasn't created a real lineage yet +if [ ! -f "/etc/letsencrypt/renewal/${DOMAIN}.conf" ]; then + rm -rf "/etc/letsencrypt/live/${DOMAIN}" +fi + +# Obtain certificate +certbot certonly \ + --webroot \ + --webroot-path=/var/www/certbot \ + --email "${EMAIL}" \ + --agree-tos \ + --no-eff-email \ + --keep-until-expiring \ + -n \ + -d "${DOMAIN}" \ + --deploy-hook 'touch /etc/letsencrypt/reload-nginx' + +# Renew loop +trap exit TERM +while :; do + certbot renew --quiet \ + --deploy-hook 'touch /etc/letsencrypt/reload-nginx' + sleep 12h & wait $! +done diff --git a/scripts/start_with_nginx b/scripts/start_with_nginx new file mode 100755 index 00000000..45a2bed9 --- /dev/null +++ b/scripts/start_with_nginx @@ -0,0 +1,81 @@ +#!/bin/bash +PROJECT_ROOT="$(dirname $(readlink -f "${BASH_SOURCE[0]}"))/.." +DOCKER_COMPOSE_FILE="${PROJECT_ROOT}/docker-compose.yml" +DOCKER_COMPOSE_NGINX_FILE="${PROJECT_ROOT}/docker-compose.nginx.yml" +NGINX_CONF_DIR="${PROJECT_ROOT}/nginx/conf.d" +NGINX_TEMPLATE="${NGINX_CONF_DIR}/nostream.conf.template" +NGINX_CONF="${NGINX_CONF_DIR}/nostream.conf" +NOSTR_CONFIG_DIR="${PROJECT_ROOT}/.nostr" +SETTINGS_FILE="${NOSTR_CONFIG_DIR}/settings.yaml" +DEFAULT_SETTINGS_FILE="${PROJECT_ROOT}/resources/default-settings.yaml" +CURRENT_DIR=$(pwd) + +if [[ ${CURRENT_DIR} =~ /scripts$ ]]; then + echo "Please run this script from the Nostream root folder, not the scripts directory." + echo "To do this, change up one directory, and then run the following command:" + echo "./scripts/start_with_nginx" + exit 1 +fi + +if [ "$EUID" -eq 0 ] + then echo "Error: Nostream should not be run as root." + exit 1 +fi + +if [[ -z "${RELAY_DOMAIN}" ]]; then + echo "Error: RELAY_DOMAIN environment variable is not set." + echo "Usage: RELAY_DOMAIN=relay.example.com CERTBOT_EMAIL=you@example.com ./scripts/start_with_nginx" + exit 1 +fi + +FQDN_REGEX='^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$' +if [[ ! "${RELAY_DOMAIN}" =~ ${FQDN_REGEX} ]]; then + echo "Error: RELAY_DOMAIN must be a valid fully-qualified domain name." + echo "Usage: RELAY_DOMAIN=relay.example.com CERTBOT_EMAIL=you@example.com ./scripts/start_with_nginx" + exit 1 +fi + +if [[ -z "${CERTBOT_EMAIL}" ]]; then + echo "Error: CERTBOT_EMAIL environment variable is not set." + echo "Usage: RELAY_DOMAIN=relay.example.com CERTBOT_EMAIL=you@example.com ./scripts/start_with_nginx" + exit 1 +fi + +if [[ ! -d "${NOSTR_CONFIG_DIR}" ]]; then + echo "Creating folder ${NOSTR_CONFIG_DIR}" + mkdir -p "${NOSTR_CONFIG_DIR}" +fi + +if [[ ! -f "${SETTINGS_FILE}" ]]; then + echo "Copying ${DEFAULT_SETTINGS_FILE} to ${SETTINGS_FILE}" + cp "${DEFAULT_SETTINGS_FILE}" "${SETTINGS_FILE}" +fi + +# Generate nginx config from template +echo "Generating nginx config for domain: ${RELAY_DOMAIN}" +sed "s/\${RELAY_DOMAIN}/${RELAY_DOMAIN}/g" "${NGINX_TEMPLATE}" > "${NGINX_CONF}" + +# Generate a temporary self-signed cert if no real cert exists yet. +# This lets nginx boot so it can serve the ACME challenge for certbot +# to obtain the real Let's Encrypt certificate. +SSL_CERT_DIR="${PROJECT_ROOT}/nginx/ssl/live/${RELAY_DOMAIN}" +if [[ ! -f "${SSL_CERT_DIR}/fullchain.pem" ]]; then + echo "No SSL certificate found. Generating a temporary self-signed certificate..." + mkdir -p "${SSL_CERT_DIR}" + if ! openssl req -x509 -nodes -newkey rsa:2048 \ + -days 1 \ + -keyout "${SSL_CERT_DIR}/privkey.pem" \ + -out "${SSL_CERT_DIR}/fullchain.pem" \ + -subj "/CN=${RELAY_DOMAIN}" 2>/dev/null; then + echo "Error: Failed to generate self-signed certificate. Is openssl installed?" + exit 1 + fi +fi + +# Ensure compose uses the project root for volume mounts +cd "${PROJECT_ROOT}" + +docker compose \ + -f "${DOCKER_COMPOSE_FILE}" \ + -f "${DOCKER_COMPOSE_NGINX_FILE}" \ + up --build --remove-orphans "$@"