# The ServerName directive sets the request scheme, hostname and port that
# the server uses to identify itself. This is used when creating
# redirection URLs. In the context of virtual hosts, the ServerName
@@ -19,10 +19,6 @@
DocumentRoot /var/www/html
- SSLEngine on
- SSLCertificateFile /etc/nginx/cert.crt
- SSLCertificateKeyFile /etc/nginx/cert.key
-
Header set Access-Control-Allow-Origin "*"
Header set Access-Control-Allow-Methods "POST, GET, OPTIONS, DELETE, PUT"
Header set Access-Control-Allow-Headers "Content-Type"
diff --git a/etc/raspberrypios/50x.html b/etc/raspberrypios/50x.html
index 16a2d0a2..3c1e4d76 100644
--- a/etc/raspberrypios/50x.html
+++ b/etc/raspberrypios/50x.html
@@ -4,7 +4,7 @@
Error!
-nginx could not find WROLPi! Are your services running?
+The web proxy could not find WROLPi! Are your services running?
Start by viewing status of your services at the
Controller
diff --git a/etc/raspberrypios/Caddyfile b/etc/raspberrypios/Caddyfile
new file mode 100644
index 00000000..a459bd76
--- /dev/null
+++ b/etc/raspberrypios/Caddyfile
@@ -0,0 +1,132 @@
+# Caddy config for WROLPi services (RPi/Debian bare metal).
+
+{
+ # No ACME - WROLPi is designed for offline use.
+ auto_https disable_redirects
+}
+
+:80 {
+ handle / {
+ root * /var/www
+ rewrite * /landing.html
+ file_server
+ }
+ handle /ca.crt {
+ root * /media/wrolpi/config/ssl
+ header Content-Type "application/x-x509-ca-cert"
+ header Content-Disposition "attachment; filename=\"wrolpi-ca.crt\""
+ file_server
+ }
+ handle {
+ redir https://{host}{uri} permanent
+ }
+}
+
+:443 {
+ tls /etc/ssl/wrolpi/cert.crt /etc/ssl/wrolpi/cert.key
+
+ header Access-Control-Allow-Origin "*"
+ header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
+ header Access-Control-Allow-Headers "Content-Type, Accept"
+
+ handle /favicon.ico {
+ root * /opt/wrolpi
+ rewrite * /icon.ico
+ file_server
+ }
+
+ handle /apple-touch-icon.png {
+ root * /opt/wrolpi
+ rewrite * /icon.png
+ file_server
+ }
+
+ # WebSocket for React dev server.
+ handle /ws {
+ reverse_proxy 0.0.0.0:3000
+ }
+
+ # Controller - system management API and UI.
+ handle_path /controller/* {
+ reverse_proxy 0.0.0.0:8087
+ }
+ handle /controller {
+ redir * /controller/ 301
+ }
+
+ # Main API.
+ handle /api/* {
+ reverse_proxy 0.0.0.0:8081
+ }
+
+ # API docs.
+ handle /docs/* {
+ reverse_proxy 0.0.0.0:8081
+ }
+ handle /docs {
+ redir * /docs/ 301
+ }
+
+ # Static media files with directory browsing.
+ handle_path /media/* {
+ root * /media/wrolpi
+ file_server browse
+ }
+ handle /media {
+ redir * /media/ 301
+ }
+
+ # Downloads with Content-Disposition header.
+ handle_path /download/* {
+ root * /media/wrolpi
+ header Content-Disposition "attachment"
+ file_server browse
+ }
+ handle /download {
+ redir * /download/ 301
+ }
+
+ # React app with fallback to Controller.
+ handle {
+ reverse_proxy 0.0.0.0:3000 {
+ @bad status 502 503 504
+ handle_response @bad {
+ header Location /controller/?fallback=true
+ respond "" 302
+ }
+ }
+ }
+
+ handle_errors {
+ header Access-Control-Allow-Origin "*"
+ header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
+ header Access-Control-Allow-Headers "Content-Type, Accept"
+ root * /var/www
+ rewrite * /50x.html
+ file_server
+ }
+}
+
+# Kiwix HTTPS proxy.
+:8085 {
+ tls /etc/ssl/wrolpi/cert.crt /etc/ssl/wrolpi/cert.key
+ header ?Access-Control-Allow-Origin "*"
+ header ?Access-Control-Allow-Methods "GET, OPTIONS"
+ reverse_proxy 0.0.0.0:9085
+}
+
+# Help HTTPS proxy.
+:8086 {
+ tls /etc/ssl/wrolpi/cert.crt /etc/ssl/wrolpi/cert.key
+ header ?Access-Control-Allow-Origin "*"
+ header ?Access-Control-Allow-Methods "GET, OPTIONS"
+ reverse_proxy 0.0.0.0:9086
+}
+
+# Map HTTPS proxy (Apache2 HTTP-only on 9084).
+:8084 {
+ tls /etc/ssl/wrolpi/cert.crt /etc/ssl/wrolpi/cert.key
+ header ?Access-Control-Allow-Origin "*"
+ header ?Access-Control-Allow-Methods "GET, OPTIONS"
+ reverse_proxy 0.0.0.0:9084
+}
diff --git a/etc/raspberrypios/landing.html b/etc/raspberrypios/landing.html
new file mode 100644
index 00000000..1ac7b3ec
--- /dev/null
+++ b/etc/raspberrypios/landing.html
@@ -0,0 +1,174 @@
+
+
+
+
+
+Welcome to WROLPi
+
+
+
+
+
Welcome to WROLPi
+
Your offline library needs a one-time certificate setup.
+
+
+
Certificate already trusted! Redirecting...
+
+
+
+
Step 1: Download the Root CA Certificate
+
Install this certificate on your device to trust all WROLPi HTTPS services permanently.
+
Download WROLPi Root CA
+
+
+
+
Step 2: Trust the Certificate
+
Follow the instructions for your device:
+
+
+ iOS / iPadOS
+
+
+ - Tap the download button above, then tap Allow when prompted.
+ - Open Settings → General → VPN & Device Management.
+ - Tap WROLPi Root CA → Install → enter your passcode.
+ - Go to Settings → General → About → Certificate Trust Settings.
+ - Enable full trust for WROLPi Root CA.
+
+
+
+
+
+ Android
+
+
+ - Tap the download button above.
+ - Open Settings → Security & privacy → More security settings → Encryption & credentials → Install a certificate → CA certificate.
+ - Select the downloaded
wrolpi-ca.crt file and confirm.
+
+
Menu paths vary by manufacturer. If you can't find it, search for "certificate" in Settings.
+
+
+
+
+ macOS
+
+
+ - Double-click the downloaded
wrolpi-ca.crt file to open Keychain Access.
+ - Add it to the System or login keychain.
+ - Find WROLPi Root CA in the keychain, double-click it.
+ - Expand Trust, set When using this certificate to Always Trust.
+ - Close and enter your password to confirm.
+
+
+
+
+
+ Windows
+
+
+ - Double-click the downloaded
wrolpi-ca.crt file.
+ - Click Install Certificate → Local Machine → Next.
+ - Select Place all certificates in the following store → Browse → Trusted Root Certification Authorities.
+ - Click Finish.
+
+
+
+
+
+ Linux (Debian / Ubuntu)
+
+
+ - Copy the certificate (the
.crt extension is required): sudo cp wrolpi-ca.crt /usr/local/share/ca-certificates/
+ - Update the trust store:
sudo update-ca-certificates
+ - Restart your browser. Chromium-based browsers (Chrome, Edge) will use the system store automatically.
+
+
Firefox on Linux does not use the system trust store by default — see the Firefox section below.
+
+
+
+
+ Firefox (all platforms)
+
+
Since Firefox 120, Firefox automatically trusts root certificates installed in the OS certificate store on Windows, macOS, and Android. If you've already installed the certificate above, Firefox should work with no extra steps on those platforms.
+
On Linux (or if auto-trust is disabled), import manually:
+
+ - Open Firefox, go to Settings → Privacy & Security.
+ - Scroll to Certificates → click View Certificates.
+ - In the Authorities tab, click Import.
+ - Select the downloaded
wrolpi-ca.crt file.
+ - Check Trust this CA to identify websites → OK.
+
+
Alternatively, enable auto-trust: go to about:config and set security.enterprise_roots.enabled to true.
+
+
+
+
+
+
+
+
+
+
diff --git a/etc/raspberrypios/nginx.conf b/etc/raspberrypios/nginx.conf
deleted file mode 100644
index df0c1f61..00000000
--- a/etc/raspberrypios/nginx.conf
+++ /dev/null
@@ -1,101 +0,0 @@
-user www-data;
-
-events {
-
-}
-
-http {
- client_max_body_size 0;
-
- map $http_upgrade $connection_upgrade {
- default upgrade;
- '' close;
- }
- charset utf-8;
-
- include mime.types;
- types {
- # Custom types for ebooks.
- application/x-mobipocket-ebook mobi;
- application/epub+zip epub;
- # Configs
- text/yaml yaml yml;
- }
-
- error_page 502 /error/50x.html;
- error_page 503 /error/50x.html;
- error_page 504 /error/50x.html;
-
- server {
- listen 80;
- listen [::]:80;
- server_name _; # Default server for requests without "Host" header
-
- location / {
- return 301 https://$host$request_uri;
- }
- }
-
- server {
- listen 443 ssl;
- listen [::]:443 ssl;
- proxy_pass_header Host;
-
- # Self-signed cert.
- ssl_certificate /etc/nginx/cert.crt;
- ssl_certificate_key /etc/nginx/cert.key;
-
- # CORS headers for Controller direct access (http://host:8087 -> https://host/api)
- # Added at server level so they apply to error pages (502, etc.)
- add_header 'Access-Control-Allow-Origin' '*' always;
- add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
- add_header 'Access-Control-Allow-Headers' 'Content-Type, Accept' always;
-
- # Override http-level error_page to use named location with CORS
- error_page 502 503 504 @error_with_cors;
-
- location @error_with_cors {
- add_header 'Access-Control-Allow-Origin' '*' always;
- add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
- add_header 'Access-Control-Allow-Headers' 'Content-Type, Accept' always;
- root /var/www;
- try_files /50x.html =502;
- }
-
- include /etc/nginx/conf.d/*;
- }
-
- # HTTPS Map is served by Apache2. This is because mod_tile.conf is required to render the map. renderd does not
- # support nginx.
-
- server {
- # HTTPS Kiwix.
- listen 8085 ssl;
- listen [::]:8085 ssl;
- proxy_pass_header Host;
- proxy_set_header 'Access-Control-Allow-Origin' '*';
-
- ssl_certificate /etc/nginx/cert.crt;
- ssl_certificate_key /etc/nginx/cert.key;
-
- location / {
- proxy_pass http://0.0.0.0:9085;
- }
- }
-
- server {
- # HTTPS Help.
- listen 8086 ssl;
- listen [::]:8086 ssl;
- proxy_pass_header Host;
- add_header 'Access-Control-Allow-Origin' '*';
-
- ssl_certificate /etc/nginx/cert.crt;
- ssl_certificate_key /etc/nginx/cert.key;
-
- location / {
- proxy_pass http://0.0.0.0:9086;
- }
- }
-
-}
diff --git a/etc/raspberrypios/ports.conf b/etc/raspberrypios/ports.conf
index 0f8b9773..3fccdebe 100644
--- a/etc/raspberrypios/ports.conf
+++ b/etc/raspberrypios/ports.conf
@@ -1 +1 @@
-Listen 8084
\ No newline at end of file
+Listen 9084
\ No newline at end of file
diff --git a/etc/raspberrypios/wrolpi-cert-renew.service b/etc/raspberrypios/wrolpi-cert-renew.service
new file mode 100644
index 00000000..a0d3016f
--- /dev/null
+++ b/etc/raspberrypios/wrolpi-cert-renew.service
@@ -0,0 +1,8 @@
+[Unit]
+Description=Renew WROLPi TLS certificates if expiring soon
+After=network.target
+
+[Service]
+Type=oneshot
+ExecStart=/opt/wrolpi/scripts/renew_certificates.sh
+StandardOutput=journal
diff --git a/etc/raspberrypios/wrolpi-cert-renew.timer b/etc/raspberrypios/wrolpi-cert-renew.timer
new file mode 100644
index 00000000..4e597944
--- /dev/null
+++ b/etc/raspberrypios/wrolpi-cert-renew.timer
@@ -0,0 +1,10 @@
+[Unit]
+Description=Weekly check for WROLPi TLS certificate renewal
+
+[Timer]
+OnCalendar=weekly
+Persistent=true
+RandomizedDelaySec=3600
+
+[Install]
+WantedBy=timers.target
diff --git a/etc/raspberrypios/wrolpi.conf b/etc/raspberrypios/wrolpi.conf
deleted file mode 100644
index 718e0824..00000000
--- a/etc/raspberrypios/wrolpi.conf
+++ /dev/null
@@ -1,100 +0,0 @@
-# Nginx config for the WROLPi services.
-
-#
-# Error pages
-#
-
-location /error/ {
- alias /var/www/;
- internal;
-}
-
-#
-# App
-#
-
-location /favicon.ico {
- alias /opt/wrolpi/icon.ico;
-}
-
-location /apple-touch-icon.png {
- alias /opt/wrolpi/icon.png;
-}
-
-location / {
- # React APP with Controller fallback
- proxy_pass http://0.0.0.0:3000;
- proxy_intercept_errors on;
- error_page 502 503 504 = @react_fallback;
- rewrite $1 break;
-}
-
-location @react_fallback {
- return 302 /controller/?fallback=true;
-}
-
-location /ws {
- proxy_pass http://0.0.0.0:3000;
- proxy_http_version 1.1;
- proxy_set_header Upgrade $http_upgrade;
- proxy_set_header Connection $connection_upgrade;
-}
-
-#
-# Controller - system management API and UI
-# All Controller endpoints are under /controller/*
-#
-location /controller {
- rewrite ^/controller(.*)$ $1 break;
- proxy_pass http://0.0.0.0:8087;
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
-}
-
-#
-# Main API - handled by the API service (port 8081)
-#
-
-location /api {
- proxy_pass http://0.0.0.0:8081;
- rewrite /^api(.*)$ $1 break;
-}
-
-location /docs {
- proxy_pass http://0.0.0.0:8081;
- rewrite /^docs(.*)$ $1 break;
-}
-
-location /media {
- # The media files mounted in the docker volume
- sendfile on;
- sendfile_max_chunk 1m;
-
- tcp_nopush on;
- tcp_nodelay on;
- keepalive_timeout 65;
-
- autoindex on;
- autoindex_exact_size off;
- alias /media/wrolpi;
-}
-
-location /download {
- # Assume any request here is to download a file from the media directory.
- if ( $request_filename ~ '^.*/(.+)$' ) {
- # Always include the filename for downloads.
- add_header Content-Disposition 'attachment; filename="$1"';
- }
-
- sendfile on;
- sendfile_max_chunk 1m;
-
- tcp_nopush on;
- tcp_nodelay on;
- keepalive_timeout 65;
-
- autoindex on;
- autoindex_exact_size off;
- alias /media/wrolpi;
-}
diff --git a/etc/raspberrypios/wrolpi.target b/etc/raspberrypios/wrolpi.target
index a7e61fdb..e048be8b 100644
--- a/etc/raspberrypios/wrolpi.target
+++ b/etc/raspberrypios/wrolpi.target
@@ -1,3 +1,3 @@
[Unit]
Description=WROLPi services group
-Wants=wrolpi-controller.service wrolpi-api.service wrolpi-app.service nginx.service postgresql.service wrolpi-kiwix.service renderd.service apache2.service postgresql@15-map.service
+Wants=wrolpi-controller.service wrolpi-api.service wrolpi-app.service caddy.service postgresql.service wrolpi-kiwix.service renderd.service apache2.service postgresql@15-map.service
diff --git a/help.sh b/help.sh
index 712479c8..88475ba3 100755
--- a/help.sh
+++ b/help.sh
@@ -85,7 +85,7 @@ systemctl status postgresql.service >/dev/null &&
echo "OK: Postgres found" ||
echo "FAILED: Postgres service does not exist"
-if netstat -ant | grep LISTEN | grep 127.0.0.1:5432 >/dev/null; then
+if netstat -ant | grep LISTEN | grep -E '[:\.]5432\b' >/dev/null; then
echo "OK: Port 5432 is occupied"
else
echo "FAILED: Port 5432 is not occupied"
@@ -238,22 +238,59 @@ else
echo "FAILED: WROLPi app did not respond with ebook interface"
fi
-if netstat -ant | grep LISTEN | grep 0.0.0.0:80 >/dev/null; then
- echo "OK: Port 80 is occupied"
+echo
+# Caddy
+
+if command -v caddy &>/dev/null; then
+ echo "OK: Caddy is installed ($(caddy version 2>/dev/null | head -1))"
else
- echo "FAILED: Port 80 is not occupied"
+ echo "FAILED: Caddy is not installed"
fi
-if systemctl list-unit-files "*nginx*" >/dev/null; then
- echo "OK: nginx systemd exists"
+if systemctl list-unit-files "*caddy*" >/dev/null 2>&1; then
+ echo "OK: Caddy systemd exists"
+ if systemctl status caddy >/dev/null 2>&1; then
+ echo "OK: Caddy service is running"
+ else
+ echo "FAILED: Caddy service is not running"
+ fi
else
- echo "FAILED: nginx does not exist"
+ echo "FAILED: Caddy systemd does not exist"
+fi
+
+check_file /etc/caddy/Caddyfile "Caddyfile exists" "Caddyfile does not exist at /etc/caddy/Caddyfile"
+check_file /etc/ssl/wrolpi/cert.crt "TLS certificate exists" "TLS certificate does not exist at /etc/ssl/wrolpi/cert.crt"
+check_file /etc/ssl/wrolpi/cert.key "TLS key exists" "TLS key does not exist at /etc/ssl/wrolpi/cert.key"
+check_file ${MEDIA_DIRECTORY}/config/ssl/ca.crt "Root CA certificate exists" "Root CA certificate does not exist at ${MEDIA_DIRECTORY}/config/ssl/ca.crt"
+check_file ${MEDIA_DIRECTORY}/config/ssl/ca.key "Root CA key exists" "Root CA key does not exist at ${MEDIA_DIRECTORY}/config/ssl/ca.key"
+check_file /var/www/landing.html "Landing page exists" "Landing page does not exist at /var/www/landing.html"
+
+if [ -f ${MEDIA_DIRECTORY}/config/ssl/ca.crt ] && [ -f /etc/ssl/wrolpi/cert.crt ]; then
+ if openssl verify -CAfile ${MEDIA_DIRECTORY}/config/ssl/ca.crt /etc/ssl/wrolpi/cert.crt >/dev/null 2>&1; then
+ echo "OK: Leaf certificate is signed by root CA"
+ else
+ echo "FAILED: Leaf certificate is NOT signed by root CA (regenerate with /opt/wrolpi/scripts/generate_certificates.sh)"
+ fi
+fi
+
+for port in 80 443 8084 8085 8086; do
+ if netstat -ant | grep LISTEN | grep -E "(0\.0\.0\.0|:::)${port}\b" >/dev/null; then
+ echo "OK: Caddy port ${port} is occupied"
+ else
+ echo "FAILED: Caddy port ${port} is not occupied"
+ fi
+done
+
+if curl -k -s https://0.0.0.0/ --max-time 5 | grep -i wrolpi >/dev/null; then
+ echo "OK: Caddy HTTPS proxy responded on :443"
+else
+ echo "FAILED: Caddy HTTPS proxy did not respond on :443"
fi
echo
# Map
-if netstat -ant | grep LISTEN | grep 127.0.0.1:5433 >/dev/null; then
+if netstat -ant | grep LISTEN | grep -E '[:\.]5433\b' >/dev/null; then
echo "OK: Port 5433 is occupied"
else
echo "FAILED: Port 5433 is not occupied"
@@ -314,10 +351,10 @@ else
echo "FAILED: Media directory is not a mounted drive. (This is fine if you don't have an external drive.)"
fi
-if curl -k -s https://0.0.0.0/media/ --max-time 5 | grep "Index of /media/" >/dev/null; then
- echo "OK: Media directory files are served by nginx"
- if curl -k -s -I https://0.0.0.0/media/config/wrolpi.yaml --max-time 5 | grep '200 OK' >/dev/null; then
- echo "OK: Config can be fetched from nginx"
+if curl -k -s https://0.0.0.0/media/ --max-time 5 | grep -i "media" >/dev/null; then
+ echo "OK: Media directory files are served by caddy"
+ if curl -k -s -I https://0.0.0.0/media/config/wrolpi.yaml --max-time 5 | grep -E '200( OK)?' >/dev/null; then
+ echo "OK: Config can be fetched from caddy"
else
echo "FAILED: Media directory files are not being served"
fi
@@ -325,10 +362,10 @@ else
echo "FAILED: Media directory files are not being served"
fi
-if curl -k -s https://0.0.0.0/download/ --max-time 5 | grep "Index of /download/" >/dev/null; then
- echo "OK: Media download directory files are served by nginx"
- if curl -k -s -I https://0.0.0.0/download/config/wrolpi.yaml --max-time 5 | grep '200 OK' >/dev/null; then
- echo "OK: Config can be downloaded from nginx"
+if curl -k -s -I https://0.0.0.0/download/ --max-time 5 | grep -E '200( OK)?' >/dev/null; then
+ echo "OK: Media download directory files are served by caddy"
+ if curl -k -s -I https://0.0.0.0/download/config/wrolpi.yaml --max-time 5 | grep -E '200( OK)?' >/dev/null; then
+ echo "OK: Config can be downloaded from caddy"
else
echo "FAILED: Media download directory files are not being served"
fi
diff --git a/pi-gen/stage2/04-wrolpi/01-packages-nr b/pi-gen/stage2/04-wrolpi/01-packages-nr
index 1f96d62c..05e27f2b 100644
--- a/pi-gen/stage2/04-wrolpi/01-packages-nr
+++ b/pi-gen/stage2/04-wrolpi/01-packages-nr
@@ -65,8 +65,6 @@ lua5.1
mapnik-utils
netcat-openbsd
network-manager
-nginx-doc
-nginx-full
nicstat
nodejs
npm
diff --git a/pi-gen/stage2/04-wrolpi/03-run-chroot.sh b/pi-gen/stage2/04-wrolpi/03-run-chroot.sh
index c04a9e3e..f46fc7ef 100755
--- a/pi-gen/stage2/04-wrolpi/03-run-chroot.sh
+++ b/pi-gen/stage2/04-wrolpi/03-run-chroot.sh
@@ -59,14 +59,17 @@ echo "Controller pre-installed"
chown -R wrolpi:wrolpi /opt/wrolpi
-# Configure nginx.
-cp /opt/wrolpi/etc/raspberrypios/nginx.conf /etc/nginx/nginx.conf
+# Install Caddy from official apt repo.
+curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
+curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
+apt-get update
+apt-get install -y caddy
+
+# Configure Caddy.
+mkdir -p /etc/caddy
+cp /opt/wrolpi/etc/raspberrypios/Caddyfile /etc/caddy/Caddyfile
cp /opt/wrolpi/etc/raspberrypios/50x.html /var/www/50x.html
-# Copy wrolpi.conf which defines the location blocks for reverse proxy.
-[ -f /etc/nginx/conf.d/default.conf ] && rm /etc/nginx/conf.d/default.conf
-cp /opt/wrolpi/etc/raspberrypios/wrolpi.conf /etc/nginx/conf.d/wrolpi.conf
-# Remove default site (WROLPi nginx.conf doesn't use sites-enabled).
-rm -f /etc/nginx/sites-enabled/default
+cp /opt/wrolpi/etc/raspberrypios/landing.html /var/www/landing.html
# WROLPi needs a few privileged commands.
cp /opt/wrolpi/etc/raspberrypios/90-wrolpi /etc/sudoers.d/90-wrolpi
@@ -78,11 +81,14 @@ mkdir /media/wrolpi
chown wrolpi:wrolpi /media/wrolpi
cp /opt/wrolpi/etc/raspberrypios/wrolpi-*.service /etc/systemd/system/
+cp /opt/wrolpi/etc/raspberrypios/wrolpi-*.timer /etc/systemd/system/
cp /opt/wrolpi/etc/raspberrypios/wrolpi.target /etc/systemd/system/
# Enable first startup script.
systemctl enable wrolpi-first-startup.service
# Enable Controller service to start on boot.
systemctl enable wrolpi-controller.service
+# Enable weekly certificate renewal check.
+systemctl enable wrolpi-cert-renew.timer
# Copy MOTD scripts, delete original.
cp /opt/wrolpi/etc/raspberrypios/motd/20-wrolpi.motd /etc/update-motd.d/20-wrolpi
diff --git a/repair.sh b/repair.sh
index d1171922..f3d00a87 100755
--- a/repair.sh
+++ b/repair.sh
@@ -22,7 +22,7 @@ systemctl stop wrolpi-controller.service || :
systemctl stop wrolpi-api.service || :
systemctl stop wrolpi-app.service || :
systemctl stop wrolpi-kiwix.service || :
-systemctl stop nginx || :
+systemctl stop caddy || :
systemctl stop renderd || :
systemctl stop apache2 || :
@@ -49,16 +49,16 @@ git reset HEAD --hard
chown -R wrolpi:wrolpi /opt/wrolpi
# Copy configs to system.
-cp /opt/wrolpi/etc/raspberrypios/nginx.conf /etc/nginx/nginx.conf
-[ -f /etc/nginx/conf.d/default.conf ] && rm /etc/nginx/conf.d/default.conf
-cp /opt/wrolpi/etc/raspberrypios/wrolpi.conf /etc/nginx/conf.d/wrolpi.conf
+mkdir -p /etc/caddy
+cp /opt/wrolpi/etc/raspberrypios/Caddyfile /etc/caddy/Caddyfile
cp /opt/wrolpi/etc/raspberrypios/50x.html /var/www/50x.html
+cp /opt/wrolpi/etc/raspberrypios/landing.html /var/www/landing.html
-# Generate nginx certificate for HTTPS.
+# Generate certificate for HTTPS.
/opt/wrolpi/scripts/generate_certificates.sh
-# Start nginx quickly so user can access Controller from React UI
-systemctl start nginx || :
+# Start Caddy quickly so user can access Controller from React UI
+systemctl start caddy || :
# WROLPi needs a few privileged commands.
cp /opt/wrolpi/etc/raspberrypios/90-wrolpi /etc/sudoers.d/90-wrolpi
@@ -68,12 +68,14 @@ visudo -c -f /etc/sudoers.d/90-wrolpi
# Install the systemd services
cp /opt/wrolpi/etc/raspberrypios/wrolpi*.service /etc/systemd/system/
+cp /opt/wrolpi/etc/raspberrypios/wrolpi-*.timer /etc/systemd/system/
cp /opt/wrolpi/etc/raspberrypios/wrolpi.target /etc/systemd/system/
systemctl enable wrolpi-controller.service
systemctl enable wrolpi-api.service
systemctl enable wrolpi-app.service
systemctl enable wrolpi-kiwix.service
systemctl enable wrolpi-help.service
+systemctl enable wrolpi-cert-renew.timer
# Repair Controller (offline-safe - no pip install)
repair_controller() {
@@ -128,7 +130,6 @@ cp /opt/wrolpi/etc/raspberrypios/index.html \
/opt/wrolpi/modules/map/leaflet.js \
/opt/wrolpi/modules/map/leaflet.css /var/www/html/
chmod 644 /var/www/html/*
-a2enmod ssl
a2enmod headers
systemctl enable renderd
diff --git a/scripts/generate_certificates.sh b/scripts/generate_certificates.sh
index e205bf60..ca366f6e 100755
--- a/scripts/generate_certificates.sh
+++ b/scripts/generate_certificates.sh
@@ -1,19 +1,94 @@
#!/usr/bin/env bash
-# Generate self-signed SSL certificates for nginx if they don't exist.
+# Generate a root CA and signed leaf certificate for WROLPi.
+#
+# Root CA: stored in ${MEDIA_DIRECTORY}/config/ssl/ — persists forever.
+# Leaf cert: stored in ${CERT_DIR}/ — regenerated each run to pick up hostname/IP changes.
+#
+# Users trust the root CA once; leaf certs can be regenerated without re-trusting.
set -e
-CERT_FILE="/etc/nginx/cert.crt"
-KEY_FILE="/etc/nginx/cert.key"
+MEDIA_DIRECTORY="${MEDIA_DIRECTORY:-/media/wrolpi}"
+CERT_DIR="${CERT_DIR:-/etc/ssl/wrolpi}"
-if [[ -f "${CERT_FILE}" && -f "${KEY_FILE}" ]]; then
- echo "Certificates already exist at ${CERT_FILE} and ${KEY_FILE}"
- exit 0
+CA_DIR="${MEDIA_DIRECTORY}/config/ssl"
+CA_KEY="${CA_DIR}/ca.key"
+CA_CERT="${CA_DIR}/ca.crt"
+
+LEAF_KEY="${CERT_DIR}/cert.key"
+LEAF_CERT="${CERT_DIR}/cert.crt"
+
+mkdir -p "${CA_DIR}" "${CERT_DIR}"
+
+# --- Root CA (only created once) ---
+if [[ -f "${CA_CERT}" && -f "${CA_KEY}" ]]; then
+ echo "Root CA already exists at ${CA_CERT}"
+else
+ echo "Generating WROLPi Root CA..."
+ openssl genrsa -out "${CA_KEY}" 4096
+ openssl req -new -x509 -nodes -key "${CA_KEY}" -out "${CA_CERT}" -days 36500 \
+ -subj "/C=US/ST=State/L=City/O=WROLPi/CN=WROLPi Root CA" \
+ -addext "basicConstraints = critical, CA:TRUE" \
+ -addext "keyUsage = critical, keyCertSign, cRLSign" \
+ -addext "nameConstraints = critical, permitted;DNS:.local, permitted;DNS:localhost, permitted;IP:127.0.0.0/255.0.0.0, permitted;IP:10.0.0.0/255.0.0.0, permitted;IP:172.16.0.0/255.240.0.0, permitted;IP:192.168.0.0/255.255.0.0, permitted;IP:::1/ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff, permitted;IP:fc00::/fe00::, permitted;IP:fe80::/ffc0::"
+ chmod 600 "${CA_KEY}"
+ chmod 644 "${CA_CERT}"
+ echo "Root CA generated."
fi
-echo "Generating nginx certificates..."
-openssl genrsa -out "${KEY_FILE}" 2048
-openssl req -new -x509 -nodes -key "${KEY_FILE}" -out "${CERT_FILE}" -days 3650 \
- -subj "/C=US/ST=State/L=City/O=Org/OU=WROLPi/CN=$(hostname).local"
-chmod 640 "${KEY_FILE}" "${CERT_FILE}"
-echo "Certificates generated successfully."
+# --- Leaf certificate (always regenerated) ---
+echo "Generating WROLPi leaf certificate..."
+
+# Build SAN list dynamically.
+SAN="DNS:localhost,DNS:wrolpi.local"
+
+HOSTNAME_SHORT="$(hostname)"
+if [[ -n "${HOSTNAME_SHORT}" ]]; then
+ SAN="${SAN},DNS:${HOSTNAME_SHORT}.local"
+fi
+
+SAN="${SAN},IP:127.0.0.1"
+
+# Add all local IPs.
+for ip in $(hostname -I 2>/dev/null || true); do
+ SAN="${SAN},IP:${ip}"
+done
+
+# Append any extra SANs (e.g. host IP when running in Docker).
+if [[ -n "${EXTRA_SANS:-}" ]]; then
+ SAN="${SAN},${EXTRA_SANS}"
+fi
+
+echo "SANs: ${SAN}"
+
+# Create CSR and sign with root CA.
+openssl genrsa -out "${LEAF_KEY}" 2048
+
+openssl req -new -key "${LEAF_KEY}" \
+ -subj "/C=US/ST=State/L=City/O=WROLPi/CN=${HOSTNAME_SHORT:-wrolpi}.local" \
+ -out "${CERT_DIR}/cert.csr"
+
+openssl x509 -req -in "${CERT_DIR}/cert.csr" \
+ -CA "${CA_CERT}" -CAkey "${CA_KEY}" -CAcreateserial \
+ -days 825 \
+ -extfile <(printf "subjectAltName=%s\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth" "${SAN}") \
+ -out "${LEAF_CERT}"
+
+rm -f "${CERT_DIR}/cert.csr"
+chmod 640 "${LEAF_KEY}" "${LEAF_CERT}"
+
+# Allow the caddy user to read the leaf cert/key (native installs).
+if id caddy >/dev/null 2>&1; then
+ chown root:caddy "${LEAF_KEY}" "${LEAF_CERT}"
+fi
+
+# Validate the chain.
+openssl verify -CAfile "${CA_CERT}" "${LEAF_CERT}"
+echo "Leaf certificate generated and verified."
+
+# Install root CA into system trust store so the device trusts its own certs.
+if command -v update-ca-certificates >/dev/null 2>&1; then
+ cp "${CA_CERT}" /usr/local/share/ca-certificates/wrolpi-ca.crt
+ update-ca-certificates
+ echo "Root CA installed in system trust store."
+fi
diff --git a/scripts/install_debian_12.sh b/scripts/install_debian_12.sh
index 3e6dc017..35aedc9d 100755
--- a/scripts/install_debian_12.sh
+++ b/scripts/install_debian_12.sh
@@ -39,6 +39,14 @@ if [[ ! -f /opt/wrolpi-blobs/map-db-gis.dump || ! -s /opt/wrolpi-blobs/map-db-gi
wget https://wrolpi.nyc3.cdn.digitaloceanspaces.com/map-db-gis.dump -O /opt/wrolpi-blobs/map-db-gis.dump
fi
+# Install Caddy from official apt repo if not already installed.
+if ! command -v caddy &>/dev/null; then
+ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
+ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
+ apt-get update
+ apt-get install -y caddy
+fi
+
# Install WROLPi Help.
/opt/wrolpi/scripts/install_help_service.sh
diff --git a/scripts/install_raspberrypios.sh b/scripts/install_raspberrypios.sh
index d4bbfa27..16f38fb9 100755
--- a/scripts/install_raspberrypios.sh
+++ b/scripts/install_raspberrypios.sh
@@ -39,6 +39,14 @@ if [[ ! -f /opt/wrolpi-blobs/map-db-gis.dump || ! -s /opt/wrolpi-blobs/map-db-gi
wget https://wrolpi.nyc3.cdn.digitaloceanspaces.com/map-db-gis.dump -O /opt/wrolpi-blobs/map-db-gis.dump
fi
+# Install Caddy from official apt repo if not already installed.
+if ! command -v caddy &>/dev/null; then
+ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
+ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
+ apt-get update
+ apt-get install -y caddy
+fi
+
# Install WROLPi Help.
/opt/wrolpi/scripts/install_help_service.sh
diff --git a/scripts/renew_certificates.sh b/scripts/renew_certificates.sh
new file mode 100644
index 00000000..432fb436
--- /dev/null
+++ b/scripts/renew_certificates.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# Check if the WROLPi leaf certificate is expired or expiring within 30 days.
+# If so, regenerate it and reload Caddy.
+
+set -e
+
+CERT_DIR="${CERT_DIR:-/etc/ssl/wrolpi}"
+LEAF_CERT="${CERT_DIR}/cert.crt"
+
+if [ ! -f "${LEAF_CERT}" ]; then
+ echo "Leaf certificate not found at ${LEAF_CERT}, generating..."
+ /opt/wrolpi/scripts/generate_certificates.sh
+ systemctl reload caddy || systemctl restart caddy || echo "Warning: failed to reload Caddy"
+ exit 0
+fi
+
+# Check if cert expires within 30 days.
+if openssl x509 -checkend 2592000 -noout -in "${LEAF_CERT}" 2>/dev/null; then
+ echo "Certificate is still valid for more than 30 days, no renewal needed."
+ exit 0
+fi
+
+echo "Certificate is expired or expiring within 30 days, renewing..."
+/opt/wrolpi/scripts/generate_certificates.sh
+
+# Reload Caddy to pick up new cert.
+systemctl reload caddy || systemctl restart caddy || echo "Warning: failed to reload Caddy"
+echo "Certificate renewed and Caddy reloaded."
diff --git a/scripts/start_kiwix_serve.sh b/scripts/start_kiwix_serve.sh
index d94927fd..59c47b4b 100755
--- a/scripts/start_kiwix_serve.sh
+++ b/scripts/start_kiwix_serve.sh
@@ -23,5 +23,5 @@ find /media/wrolpi/zims -iname '*.zim' -exec kiwix-manage ${LIBRARY} add {} \;
[ ! -f ${LIBRARY} ] && echo "Could not find any Zim files to import" && exit 2
-# Serve HTTP kiwix on 9085. nginx will serve HTTPS on 8085.
+# Serve HTTP kiwix on 9085. Caddy will serve HTTPS on 8085.
kiwix-serve --library ${LIBRARY} --address 0.0.0.0 --port 9085
diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh
index 2e971401..a0f43f65 100755
--- a/scripts/upgrade.sh
+++ b/scripts/upgrade.sh
@@ -104,5 +104,18 @@ fi
# Migrate map DB if necessary. Do this before repair because it will reset map if map db is empty.
/opt/wrolpi/wrolpi/scripts/migrate_map_db.sh || echo "Map DB migration failed."
+# Migrate from nginx to Caddy if necessary.
+if ! command -v caddy &>/dev/null; then
+ echo "Installing Caddy (replacing nginx)..."
+ # Disable and stop nginx if it was previously used.
+ systemctl stop nginx 2>/dev/null || :
+ systemctl disable nginx 2>/dev/null || :
+ # Install Caddy from official apt repo.
+ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
+ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
+ apt-get update
+ apt-get install -y caddy
+fi
+
# Install any configs, restart services.
/opt/wrolpi/repair.sh
diff --git a/wrolpi/scripts/first_startup.sh b/wrolpi/scripts/first_startup.sh
index 52bcc27c..1d877498 100755
--- a/wrolpi/scripts/first_startup.sh
+++ b/wrolpi/scripts/first_startup.sh
@@ -3,20 +3,22 @@
set -x
-# Generate nginx certificate for HTTPS if it doesn't exist.
+# Generate certificate for HTTPS.
/opt/wrolpi/scripts/generate_certificates.sh
-# Ensure nginx config files are in place (safeguard in case build didn't complete this).
-if [ ! -f /etc/nginx/conf.d/wrolpi.conf ]; then
- echo "wrolpi.conf missing, copying from /opt/wrolpi..."
- cp /opt/wrolpi/etc/raspberrypios/wrolpi.conf /etc/nginx/conf.d/wrolpi.conf
-fi
-# Remove default site if it exists (WROLPi nginx.conf doesn't use sites-enabled).
-rm -f /etc/nginx/sites-enabled/default
+# Copy landing page for HTTP certificate download.
+cp /opt/wrolpi/etc/raspberrypios/landing.html /var/www/landing.html
-# Enable and start nginx now that certificates exist.
-systemctl enable nginx
-systemctl start nginx || echo "Warning: nginx failed to start"
+# Ensure Caddy config is in place (safeguard in case build didn't complete this).
+if [ ! -f /etc/caddy/Caddyfile ]; then
+ echo "Caddyfile missing, copying from /opt/wrolpi..."
+ mkdir -p /etc/caddy
+ cp /opt/wrolpi/etc/raspberrypios/Caddyfile /etc/caddy/Caddyfile
+fi
+
+# Enable and start Caddy now that certificates exist.
+systemctl enable caddy
+systemctl start caddy || echo "Warning: caddy failed to start"
# Copy skeleton files to first users home directory.
USER_HOME=$(getent passwd 1000 | cut -d: -f6)