Replace nginx with Caddy for TLS termination and reverse proxying.

- Replace nginx with Caddy as the web server for both Docker and native
  (RPi/Debian) deployments. Caddy handles HTTPS termination, reverse
  proxying to all services (API, React app, Controller, Kiwix, Help, Map),
  and serves the media/download file browsers.
- Generate a Root CA and signed leaf certificates for HTTPS. The Root CA
  uses Name Constraints to restrict signing to local addresses only
  (.local, localhost, and RFC 1918 private IP ranges), mitigating MITM
  risk if the CA key is compromised.
- Add an HTTP landing page that guides users through one-time CA
  certificate installation on iOS, Android, macOS, Windows, Linux, and
  Firefox, with auto-redirect when the cert is already trusted.
- Add trailing-slash redirects for /controller, /docs, /media, and
  /download so bare paths don't fall through to the React catch-all.
- Add a Root CA download link on the Settings page under Configs.
- Update help.sh diagnostics for IPv6 listener detection and HTTP/2
  status codes.
- Remove all nginx config files and the https_proxy Docker container.
- Bump wrolpi-help submodule with certificate documentation.
This commit is contained in:
Roland 2026-03-07 12:08:27 -07:00 • committed by lrnselfreliance
parent dd270b37dc
commit fbb71e012d
43 changed files with 881 additions and 583 deletions

View file

@ -27,6 +27,7 @@ network so that any user with a laptop/tablet/phone can connect and use the libr
* [Demo](#demo)
* [Download](#download)
* [Try WROLPi](#try-wrolpi)
* [Docker Documentation](docker/README.md)
* [Debian 12 Install](#debian-12-install)
* [Raspberry Pi Install](#raspberrypi-install)
* [Charter](#charter)
@ -93,6 +94,8 @@ You can try out WROLPi by running the docker containers.
10. Browse to WROLPi.
* https://0.0.0.0:8443
More Docker documentation is available in [docker/README.md](docker/README.md)
# Debian 12 Install
Steps necessary to initialize your WROLPi after installing the Debian image from wrolpi.org

View file

@ -814,6 +814,15 @@ export function SettingsPage() {
saveConfig={saveConfig}
fetchConfigs={fetchConfigs}
/>
<Divider/>
<Header as='h3'>Root CA Certificate</Header>
<p>Install the WROLPi Root CA certificate on your devices to trust all HTTPS connections to this WROLPi.
You only need to do this once per device.</p>
<a href='/ca.crt' download='wrolpi-ca.crt'>
<Button primary><Icon name='download'/> Download Root CA Certificate</Button>
</a>
</Segment>;
return <Container fluid>

View file

@ -102,8 +102,8 @@ DEFAULT_CONFIG = {
"description": "PostgreSQL database",
},
{
"name": "nginx",
"systemd_name": "nginx",
"name": "caddy",
"systemd_name": "caddy",
"port": 80,
"viewable": True,
"use_https": False,

View file

@ -26,7 +26,7 @@ CONTAINER_PREFIX = os.environ.get("COMPOSE_PROJECT_NAME", "wrolpi")
# Containers that should not have a "View" button (non-HTTP services)
NON_VIEWABLE_CONTAINERS = {"db", "postgres", "postgresql", "redis", "memcached"}
# Containers that should use HTTPS (in addition to *_https suffix detection)
# Containers that should use HTTPS
HTTPS_CONTAINERS = {"web"}

View file

@ -66,7 +66,7 @@ class TestManagedServicesConfig:
{"systemd_name": "wrolpi-api-dev", "port": 8081, "viewable": True, "show_only_when_running": True}),
("wrolpi-app", {"port": 3000, "viewable": False}),
("wrolpi-app-dev", {"port": 3000, "viewable": False, "show_only_when_running": True}),
("nginx", {"port": 80}),
("caddy", {"port": 80}),
("postgresql", {"port": 5432, "viewable": False}),
("wrolpi-upgrade", {"port": None, "viewable": False, "show_only_when_running": True}),
])

View file

@ -196,7 +196,7 @@ class TestGetAllContainersStatus:
assert result[0].get("use_https", False) is False
def test_web_container_uses_https(self):
"""Web container (nginx proxy) should have use_https=True."""
"""Web container (Caddy proxy) should have use_https=True."""
mock_container = mock.Mock()
mock_container.name = f"{CONTAINER_PREFIX}-web-1"
mock_container.status = "running"

View file

@ -56,14 +56,17 @@ python3 -m venv /opt/wrolpi/controller/venv
/opt/wrolpi/controller/venv/bin/pip install -r /opt/wrolpi/controller/requirements.txt
echo "Controller pre-installed"
# 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
@ -88,6 +91,7 @@ mkdir /media/wrolpi
/opt/wrolpi/scripts/install_help_service.sh
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/
cp /opt/wrolpi/etc/raspberrypios/renderd.conf /etc/renderd.conf
@ -102,10 +106,14 @@ systemctl enable NetworkManager
# Enable iperf3 for speed testing.
systemctl enable iperf3
# Enable first startup script to generate certificates and start nginx.
# Enable first startup script to generate certificates and start Caddy.
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
# Enable weekly certificate renewal check.
systemctl enable wrolpi-cert-renew.timer
# Protect blob files from accidental modification or deletion.
# These files are critical for initializing WROLPi.

View file

@ -66,8 +66,6 @@ lua5.1
mapnik-utils
netcat-openbsd
network-manager
nginx-doc
nginx-full
nicstat
nodejs
npm

View file

@ -102,7 +102,7 @@ services:
interval: 1m
user: '${UID-1000}:${GID-1000}'
environment:
# Force React to use nginx port to connect to it's websocket.
# Force React to use Caddy port to connect to it's websocket.
- WDS_SOCKET_PORT=0
map: # https://github.com/Overv/openstreetmap-tile-server
@ -119,39 +119,33 @@ services:
test: [ 'CMD-SHELL', 'curl http://127.0.0.1:80' ]
interval: 1m
map_https: # HTTPS for map tile server
depends_on:
- map
environment:
- UPSTREAM_HOST=map
build:
context: .
dockerfile: docker/https_proxy/Dockerfile
ports:
- '8084:443'
volumes:
- './docker/map/nginx.map.conf:/etc/nginx/nginx.conf'
healthcheck:
test: [ 'CMD-SHELL', 'curl http://127.0.0.1:443' ]
interval: 1m
web:
web: # Caddy reverse proxy - handles HTTPS for all services.
depends_on:
- app
- zim
- help
- map
build:
context: .
dockerfile: docker/web/Dockerfile
ports:
- ${WEB_HOST-0.0.0.0}:${WEB_PORT-8080}:80
- ${WEB_HOST-0.0.0.0}:${WEB_HTTPS_PORT-8443}:443
- '8084:8084'
- '8085:8085'
- '8086:8086'
volumes:
- './docker/web/nginx.dev.conf:/etc/nginx/nginx.conf'
- './docker/web/wrolpi.dev.conf:/etc/nginx/conf.d/wrolpi.conf'
- './icon.ico:/etc/nginx/favicon.ico'
- './icon.png:/etc/nginx/icon.png'
- './docker/web/Caddyfile:/etc/caddy/Caddyfile'
- './icon.ico:/etc/caddy/favicon.ico'
- './icon.png:/etc/caddy/icon.png'
- './test:/opt/media'
environment:
- MEDIA_DIRECTORY=/opt/media
# Set EXTRA_SANS to include the host's LAN IP in the TLS certificate.
# Example: EXTRA_SANS=IP:10.0.0.5
- EXTRA_SANS=${EXTRA_SANS:-}
healthcheck:
test: [ 'CMD-SHELL', 'curl http://127.0.0.1:80' ]
test: [ 'CMD-SHELL', 'wget --spider http://127.0.0.1:80 || exit 1' ]
interval: 1m
zim: # kiwix-serve
@ -165,22 +159,6 @@ services:
test: [ 'CMD-SHELL', 'curl http://127.0.0.1:80' ]
interval: 1m
zim_https: # HTTPS for Zim service.
depends_on:
- zim
environment:
- UPSTREAM_HOST=zim
build:
context: .
dockerfile: docker/https_proxy/Dockerfile
ports:
- '8085:443'
volumes:
- './docker/zim/nginx.zim.conf:/etc/nginx/nginx.conf'
healthcheck:
test: [ 'CMD-SHELL', 'curl http://127.0.0.1:443' ]
interval: 1m
help: # The WROLPi Help documentation.
build:
context: .
@ -191,22 +169,6 @@ services:
test: [ 'CMD-SHELL', 'curl http://127.0.0.1:8086' ]
interval: 1m
help_https: # HTTPS for Help service.
depends_on:
- help
environment:
- UPSTREAM_HOST=help
build:
context: .
dockerfile: docker/https_proxy/Dockerfile
ports:
- '8086:443'
volumes:
- './docker/help/nginx.help.conf:/etc/nginx/nginx.conf'
healthcheck:
test: [ 'CMD-SHELL', 'curl http://127.0.0.1:443' ]
interval: 1m
volumes:
openstreetmap-data:
external: true

127
docker/README.md Normal file
View file

@ -0,0 +1,127 @@
# WROLPi Docker
Development and testing environment using Docker containers.
## Quick Start
```bash
git clone https://github.com/lrnselfreliance/wrolpi.git
cd wrolpi
git submodule update --init
docker-compose build --parallel
docker volume create --name=openstreetmap-data
docker volume create --name=openstreetmap-rendered-tiles
docker-compose up -d db
docker-compose run --rm api db upgrade
docker-compose up
```
Browse to https://localhost:8443
## Services
| Service | Port(s) | Description |
|----------------|--------------------------------------|-----------------------------|
| **web** | 8080 (HTTP), 8443 (HTTPS), 8084-8086 | Caddy reverse proxy |
| **api** | 8081 | Sanic API server |
| **app** | (via web) | React frontend |
| **controller** | 8087 | System management (FastAPI) |
| **db** | 5432 | PostgreSQL 12 |
| **archive** | 8083 | SingleFile/Readability |
| **zim** | (via web:8085) | Kiwix |
| **help** | (via web:8086) | MkDocs documentation |
| **map** | (via web:8084) | OpenStreetMap tile server |
## TLS Certificates
WROLPi uses a two-tier PKI for HTTPS. A **root CA** is generated once and persists
in the media directory. A **leaf certificate** is regenerated on every container start
to pick up hostname and IP changes.
### How it works
1. On first start, the `web` container generates a root CA at `/opt/media/config/ssl/`.
2. A leaf cert signed by the root CA is created at `/etc/ssl/caddy/` inside the container.
3. Caddy serves HTTPS on ports 443, 8084, 8085, and 8086 using the leaf cert.
4. Port 80 serves an HTTP landing page where users can download the root CA.
### Trusting the certificate
1. Browse to `http://<host-ip>:8080` to see the landing page with download link and
platform-specific trust instructions.
2. Download and install the root CA on your device.
3. All HTTPS ports will work without browser warnings.
The root CA only needs to be trusted once. Leaf certs can be regenerated (hostname
change, container rebuild) without needing to re-trust.
### Name Constraints
The root CA is restricted to local/private networks only via X.509 Name Constraints.
Even if the CA key were compromised, it cannot sign certificates for public domains.
Permitted:
- DNS: `*.local`, `localhost`
- IPv4: `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`
- IPv6: `::1`, `fc00::/7` (unique local), `fe80::/10` (link-local)
### EXTRA_SANS
The Docker container cannot discover the host's real LAN IP. Set `EXTRA_SANS` in a
`.env` file so the leaf certificate includes your host's IP and hostname:
```bash
# .env
EXTRA_SANS=IP:10.0.0.5,DNS:myhost.local
```
Each entry must have a type prefix (`IP:` or `DNS:`). Multiple entries are
comma-separated. After changing `EXTRA_SANS`, restart the web container:
```bash
docker compose up -d --build web
```
### Regenerating the root CA
If you need to regenerate the root CA (e.g. after updating the certificate
generation script), delete the old CA files and rebuild:
```bash
rm test/config/ssl/ca.crt test/config/ssl/ca.key
docker compose up -d --build web
```
All devices will need to re-download and re-trust the new CA from
`http://<host-ip>:8080/ca.crt`.
## Volumes
The `test/` directory is mounted to `/opt/media` inside containers, serving as the
media directory for development. Certificate files, configs, and downloaded content
are stored here.
Two external volumes are required for the map service:
```bash
docker volume create --name=openstreetmap-data
docker volume create --name=openstreetmap-rendered-tiles
```
## Environment Variables
| Variable | Default | Description |
|-------------------|----------------|----------------------------------------------|
| `MEDIA_DIRECTORY` | `/opt/media` | Media directory inside containers |
| `EXTRA_SANS` | (empty) | Additional SANs for the TLS leaf certificate |
| `WEB_HOST` | `0.0.0.0` | Host bind address for the web service |
| `WEB_PORT` | `8080` | HTTP port |
| `WEB_HTTPS_PORT` | `8443` | HTTPS port |
| `REACT_APP_API` | `0.0.0.0:8081` | API bind address |
| `UID` | `1000` | User ID for api/app containers |
| `GID` | `1000` | Group ID for api/app containers |
## Upgrading
See [UPGRADE.md](../UPGRADE.md) for upgrade instructions.

View file

@ -1,21 +0,0 @@
events {}
http {
server {
listen 443 ssl;
ssl_certificate /cert.crt;
ssl_certificate_key /cert.key;
add_header 'Access-Control-Allow-Origin' '*';
location / {
proxy_pass http://help:80;
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;
proxy_set_header 'Access-Control-Allow-Origin' '*';
}
}
}

@ -1 +1 @@
Subproject commit 0bc20aa33e03f35f2da62ecd9460636fc8398b1c
Subproject commit f8164d905310670b95f63d08bb9254f7d4359f7d

View file

@ -1,16 +0,0 @@
# This Dockerfile is used as a reverse proxy to serve a HTTPS nieve service with HTTPS.
FROM nginx:latest AS https_proxy
# Create a self-signed cert and key pair for HTTPS.
RUN openssl genrsa -out /cert.key 2048
RUN openssl req -new -x509 -nodes -key /cert.key -out /cert.crt -days 3650 \
-subj "/C=US/ST=State/L=City/O=Org/OU=WROLPi/CN=wrolpi.local"
RUN chmod 777 /cert.key /cert.crt
# Apply docker-compose environment variables to config
ENV UPSTREAM_HOST=you-must-change-this-in-docker-compose
ENV UPSTREAM_PORT=80
COPY docker/https_proxy/nginx.proxy.conf.template /etc/nginx/nginx.conf
RUN envsubst < /etc/nginx/nginx.conf > /etc/nginx/nginx.conf
CMD ["nginx", "-g", "daemon off;"]

View file

@ -1,18 +0,0 @@
events {}
http {
server {
listen 443 ssl;
ssl_certificate /cert.crt;
ssl_certificate_key /cert.key;
location / {
proxy_pass http://${UPSTREAM_HOST}:${UPSTREAM_PORT};
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;
}
}
}

View file

@ -1,21 +0,0 @@
events {}
http {
server {
listen 443 ssl;
ssl_certificate /cert.crt;
ssl_certificate_key /cert.key;
add_header 'Access-Control-Allow-Origin' '*';
location / {
proxy_pass http://map:80;
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;
proxy_set_header 'Access-Control-Allow-Origin' '*';
}
}
}

119
docker/web/Caddyfile Normal file
View file

@ -0,0 +1,119 @@
# Caddy config for WROLPi services (Docker).
{
# 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 * /opt/media/config/ssl
header Content-Type "application/x-x509-ca-cert"
header Content-Disposition "attachment; filename=\"wrolpi-ca.crt\""
file_server
}
handle {
redir https://{host}:8443{uri} permanent
}
}
:443 {
tls /etc/ssl/caddy/cert.crt /etc/ssl/caddy/cert.key
handle /favicon.ico {
root * /etc/caddy
rewrite * /favicon.ico
file_server
}
handle /apple-touch-icon.png {
root * /etc/caddy
rewrite * /icon.png
file_server
}
# WebSocket for React dev server.
handle /ws {
reverse_proxy app:3000
}
# Controller - system management API and UI.
handle_path /controller/* {
reverse_proxy controller:8087
}
handle /controller {
redir * /controller/ 301
}
# Main API.
handle /api/* {
reverse_proxy api:8081
}
# API docs.
handle /docs/* {
reverse_proxy api:8081
}
handle /docs {
redir * /docs/ 301
}
# Static media files with directory browsing.
handle_path /media/* {
root * /opt/media
file_server browse
}
handle /media {
redir * /media/ 301
}
# Downloads with Content-Disposition header.
handle_path /download/* {
root * /opt/media
header Content-Disposition "attachment"
file_server browse
}
handle /download {
redir * /download/ 301
}
# React app with fallback to Controller.
handle {
reverse_proxy app:3000 {
@bad status 502 503 504
handle_response @bad {
header Location /controller/?fallback=true
respond "" 302
}
}
}
}
# Kiwix HTTPS proxy (replaces zim_https container).
:8085 {
tls /etc/ssl/caddy/cert.crt /etc/ssl/caddy/cert.key
header ?Access-Control-Allow-Origin "*"
header ?Access-Control-Allow-Methods "GET, OPTIONS"
reverse_proxy zim:80
}
# Help HTTPS proxy (replaces help_https container).
:8086 {
tls /etc/ssl/caddy/cert.crt /etc/ssl/caddy/cert.key
header ?Access-Control-Allow-Origin "*"
header ?Access-Control-Allow-Methods "GET, OPTIONS"
reverse_proxy help:80
}
# Map HTTPS proxy (replaces map_https container).
:8084 {
tls /etc/ssl/caddy/cert.crt /etc/ssl/caddy/cert.key
header ?Access-Control-Allow-Origin "*"
header ?Access-Control-Allow-Methods "GET, OPTIONS"
reverse_proxy map:80
}

View file

@ -1,11 +1,13 @@
FROM nginx:latest
FROM caddy:latest
# Create a self-signed cert and key pair for HTTPS.
RUN openssl genrsa -out /cert.key 2048
RUN openssl req -new -x509 -nodes -key /cert.key -out /cert.crt -days 3650 \
-subj "/C=US/ST=State/L=City/O=Org/OU=WROLPi/CN=wrolpi.local"
RUN chmod 777 /cert.key /cert.crt
RUN apk add --no-cache openssl bash
RUN rm /etc/nginx/conf.d/default.conf
COPY docker/web/nginx.dev.conf /etc/nginx/nginx.conf
COPY docker/web/wrolpi.dev.conf /etc/nginx/conf.d/wrolpi.conf
# Copy scripts and static files.
COPY docker/web/Caddyfile /etc/caddy/Caddyfile
COPY docker/web/entrypoint.sh /entrypoint.sh
COPY scripts/generate_certificates.sh /opt/wrolpi/scripts/generate_certificates.sh
COPY etc/raspberrypios/landing.html /var/www/landing.html
RUN chmod +x /entrypoint.sh /opt/wrolpi/scripts/generate_certificates.sh
ENTRYPOINT ["/entrypoint.sh"]

11
docker/web/entrypoint.sh Normal file
View file

@ -0,0 +1,11 @@
#!/bin/sh
export MEDIA_DIRECTORY="${MEDIA_DIRECTORY:-/opt/media}"
export CERT_DIR="/etc/ssl/caddy"
# EXTRA_SANS can be set to include additional SANs in the leaf certificate.
# This is needed in Docker because the container cannot discover the host's
# real LAN IP. Example: EXTRA_SANS=IP:10.0.0.5,DNS:myhost.local
export EXTRA_SANS="${EXTRA_SANS:-}"
/opt/wrolpi/scripts/generate_certificates.sh
exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile

View file

@ -1,46 +0,0 @@
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;
}
server {
listen 80;
listen [::]:80;
server_name _; # Default server for requests without "Host" header
location / {
return 301 https://$host:8443$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
proxy_pass_header Host;
# Self-signed cert from Dockerfile build.
ssl_certificate /cert.crt;
ssl_certificate_key /cert.key;
include /etc/nginx/conf.d/*;
}
}

View file

@ -1,91 +0,0 @@
# Nginx config for the WROLPi services.
#
# App
#
location /favicon.ico {
alias /etc/nginx/favicon.ico;
}
location /apple-touch-icon.png {
alias /etc/nginx/icon.png;
}
location / {
# React APP with Controller fallback
proxy_pass http://app: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://app: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://controller: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://api:8081;
rewrite /^api(.*)$ $1 break;
}
location /docs {
proxy_pass http://api: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 /opt/media;
}
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 /opt/media;
}

View file

@ -1,19 +0,0 @@
events {}
http {
server {
listen 443 ssl;
ssl_certificate /cert.crt;
ssl_certificate_key /cert.key;
location / {
proxy_pass http://zim:80;
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;
proxy_set_header 'Access-Control-Allow-Origin' '*';
}
}
}

View file

@ -1,4 +1,4 @@
<VirtualHost *:8084>
<VirtualHost *:9084>
# 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"

View file

@ -1,4 +1,4 @@
<VirtualHost *:8084>
<VirtualHost *:9084>
# 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"

View file

@ -4,7 +4,7 @@
<h1>Error!</h1>
<p>nginx could not find WROLPi! Are your services running?</p>
<p>The web proxy could not find WROLPi! Are your services running?</p>
<p>Start by viewing status of your services at the
<a id="controller_link" href="http://127.0.0.1:8087">Controller</a>

132
etc/raspberrypios/Caddyfile Normal file
View file

@ -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
}

View file

@ -0,0 +1,174 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome to WROLPi</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #1a1a2e; color: #eee; min-height: 100vh;
display: flex; justify-content: center; padding: 2rem 1rem;
}
.container { max-width: 640px; width: 100%; }
h1 { font-size: 2rem; margin-bottom: 0.5rem; color: #fff; }
.subtitle { color: #aaa; margin-bottom: 2rem; }
.card {
background: #16213e; border-radius: 12px; padding: 1.5rem;
margin-bottom: 1.5rem; border: 1px solid #1a3a5c;
}
.card h2 { font-size: 1.1rem; margin-bottom: 0.75rem; color: #4cc9f0; }
.card p { line-height: 1.6; color: #ccc; margin-bottom: 1rem; }
.btn {
display: inline-block; padding: 0.75rem 1.5rem; border-radius: 8px;
text-decoration: none; font-weight: 600; font-size: 1rem;
cursor: pointer; border: none; text-align: center;
}
.btn-primary { background: #4cc9f0; color: #1a1a2e; }
.btn-primary:hover { background: #3ab8df; }
.btn-continue { background: #2ecc71; color: #fff; width: 100%; font-size: 1.1rem; }
.btn-continue:hover { background: #27ae60; }
details { margin-bottom: 0.75rem; }
summary {
cursor: pointer; padding: 0.5rem 0; font-weight: 600;
color: #4cc9f0; list-style: none;
}
summary::before { content: "\25b6\00a0"; font-size: 0.8rem; }
details[open] > summary::before { content: "\25bc\00a0"; }
.instructions { padding: 0.75rem 0 0.25rem 0.5rem; color: #bbb; line-height: 1.7; }
.instructions ol { padding-left: 1.25rem; }
.instructions li { margin-bottom: 0.3rem; }
code { background: #0f3460; padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.9em; }
#auto-redirect { display: none; text-align: center; color: #2ecc71; margin-bottom: 1rem; }
</style>
</head>
<body>
<div class="container">
<h1>Welcome to WROLPi</h1>
<p class="subtitle">Your offline library needs a one-time certificate setup.</p>
<div id="auto-redirect" class="card">
<p>Certificate already trusted! Redirecting...</p>
</div>
<div class="card">
<h2>Step 1: Download the Root CA Certificate</h2>
<p>Install this certificate on your device to trust all WROLPi HTTPS services permanently.</p>
<a class="btn btn-primary" href="/ca.crt" download="wrolpi-ca.crt">Download WROLPi Root CA</a>
</div>
<div class="card">
<h2>Step 2: Trust the Certificate</h2>
<p>Follow the instructions for your device:</p>
<details>
<summary>iOS / iPadOS</summary>
<div class="instructions">
<ol>
<li>Tap the download button above, then tap <strong>Allow</strong> when prompted.</li>
<li>Open <strong>Settings</strong> &rarr; <strong>General</strong> &rarr; <strong>VPN &amp; Device Management</strong>.</li>
<li>Tap <strong>WROLPi Root CA</strong> &rarr; <strong>Install</strong> &rarr; enter your passcode.</li>
<li>Go to <strong>Settings</strong> &rarr; <strong>General</strong> &rarr; <strong>About</strong> &rarr; <strong>Certificate Trust Settings</strong>.</li>
<li>Enable full trust for <strong>WROLPi Root CA</strong>.</li>
</ol>
</div>
</details>
<details>
<summary>Android</summary>
<div class="instructions">
<ol>
<li>Tap the download button above.</li>
<li>Open <strong>Settings</strong> &rarr; <strong>Security &amp; privacy</strong> &rarr; <strong>More security settings</strong> &rarr; <strong>Encryption &amp; credentials</strong> &rarr; <strong>Install a certificate</strong> &rarr; <strong>CA certificate</strong>.</li>
<li>Select the downloaded <code>wrolpi-ca.crt</code> file and confirm.</li>
</ol>
<p style="margin-top:0.5rem;font-size:0.9em;color:#999;">Menu paths vary by manufacturer. If you can't find it, search for <em>"certificate"</em> in Settings.</p>
</div>
</details>
<details>
<summary>macOS</summary>
<div class="instructions">
<ol>
<li>Double-click the downloaded <code>wrolpi-ca.crt</code> file to open Keychain Access.</li>
<li>Add it to the <strong>System</strong> or <strong>login</strong> keychain.</li>
<li>Find <strong>WROLPi Root CA</strong> in the keychain, double-click it.</li>
<li>Expand <strong>Trust</strong>, set <em>When using this certificate</em> to <strong>Always Trust</strong>.</li>
<li>Close and enter your password to confirm.</li>
</ol>
</div>
</details>
<details>
<summary>Windows</summary>
<div class="instructions">
<ol>
<li>Double-click the downloaded <code>wrolpi-ca.crt</code> file.</li>
<li>Click <strong>Install Certificate</strong> &rarr; <strong>Local Machine</strong> &rarr; <strong>Next</strong>.</li>
<li>Select <strong>Place all certificates in the following store</strong> &rarr; <strong>Browse</strong> &rarr; <strong>Trusted Root Certification Authorities</strong>.</li>
<li>Click <strong>Finish</strong>.</li>
</ol>
</div>
</details>
<details>
<summary>Linux (Debian / Ubuntu)</summary>
<div class="instructions">
<ol>
<li>Copy the certificate (the <code>.crt</code> extension is required): <code>sudo cp wrolpi-ca.crt /usr/local/share/ca-certificates/</code></li>
<li>Update the trust store: <code>sudo update-ca-certificates</code></li>
<li>Restart your browser. Chromium-based browsers (Chrome, Edge) will use the system store automatically.</li>
</ol>
<p style="margin-top:0.5rem;font-size:0.9em;color:#999;">Firefox on Linux does <em>not</em> use the system trust store by default &mdash; see the Firefox section below.</p>
</div>
</details>
<details>
<summary>Firefox (all platforms)</summary>
<div class="instructions">
<p style="margin-bottom:0.5rem;">Since Firefox 120, Firefox <strong>automatically trusts</strong> root certificates installed in the OS certificate store on <strong>Windows, macOS, and Android</strong>. If you've already installed the certificate above, Firefox should work with no extra steps on those platforms.</p>
<p style="margin-bottom:0.5rem;">On <strong>Linux</strong> (or if auto-trust is disabled), import manually:</p>
<ol>
<li>Open Firefox, go to <strong>Settings</strong> &rarr; <strong>Privacy &amp; Security</strong>.</li>
<li>Scroll to <strong>Certificates</strong> &rarr; click <strong>View Certificates</strong>.</li>
<li>In the <strong>Authorities</strong> tab, click <strong>Import</strong>.</li>
<li>Select the downloaded <code>wrolpi-ca.crt</code> file.</li>
<li>Check <strong>Trust this CA to identify websites</strong> &rarr; <strong>OK</strong>.</li>
</ol>
<p style="margin-top:0.5rem;font-size:0.9em;color:#999;">Alternatively, enable auto-trust: go to <code>about:config</code> and set <code>security.enterprise_roots.enabled</code> to <code>true</code>.</p>
</div>
</details>
</div>
<div class="card" style="text-align:center;">
<a class="btn btn-continue" id="continue-btn" href="#">Continue to WROLPi</a>
</div>
</div>
<script>
(function() {
var host = window.location.hostname;
var port = window.location.port;
// Docker maps HTTP 8080 → HTTPS 8443; native uses default 443.
var httpsPort = (port === '8080') ? ':8443' : '';
var httpsBase = 'https://' + host + httpsPort;
document.getElementById('continue-btn').href = httpsBase;
// Auto-redirect if cert is already trusted.
var controller = new AbortController();
var timeout = setTimeout(function() { controller.abort(); }, 3000);
fetch(httpsBase + '/api/echo', { signal: controller.signal })
.then(function(r) {
if (r.ok) {
document.getElementById('auto-redirect').style.display = 'block';
setTimeout(function() { window.location.href = httpsBase; }, 1500);
}
})
.catch(function() {})
.finally(function() { clearTimeout(timeout); });
})();
</script>
</body>
</html>

View file

@ -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;
}
}
}

View file

@ -1 +1 @@
Listen 8084
Listen 9084

View file

@ -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

View file

@ -0,0 +1,10 @@
[Unit]
Description=Weekly check for WROLPi TLS certificate renewal
[Timer]
OnCalendar=weekly
Persistent=true
RandomizedDelaySec=3600
[Install]
WantedBy=timers.target

View file

@ -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;
}

View file

@ -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

69
help.sh
View file

@ -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

View file

@ -65,8 +65,6 @@ lua5.1
mapnik-utils
netcat-openbsd
network-manager
nginx-doc
nginx-full
nicstat
nodejs
npm

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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."

View file

@ -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

View file

@ -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

View file

@ -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)