From a2485f16a6f62baf5d0f5e0ec1f866db2acbf2d4 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Thu, 10 Sep 2026 21:05:59 +0400 Subject: [PATCH] feat: deploy calendar service with Podman and nginx --- README.md | 3 ++ deploy/README.md | 59 ++++++++++++++++++++++++ deploy/index.html | 50 ++++++++++++++++++++ deploy/vacationplanner.conf | 84 ++++++++++++++++++++++++++++++++++ deploy/vacationplanner.service | 36 +++++++++++++++ 5 files changed, 232 insertions(+) create mode 100644 deploy/README.md create mode 100644 deploy/index.html create mode 100644 deploy/vacationplanner.conf create mode 100644 deploy/vacationplanner.service diff --git a/README.md b/README.md index b443611..a3ae986 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,9 @@ The image serves HTTP only. Its `scratch` filesystem contains a static musl bina and CA certificates for outgoing HTTPS requests, with no shell or package manager. It runs as an unprivileged user. +The maintainer's [VPS deployment files](deploy/README.md) provide a systemd +unit for Podman, an nginx configuration and a plain HTML usage page. + ## Calendar subscription and privacy **Self-hosting is recommended. Do not blindly trust any hosted instance, diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..32da8f5 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,59 @@ +# VPS deployment + +These files configure the maintainer's Debian 12 server with Podman 4.3, +systemd and nginx. The nginx configuration uses the existing wildcard +certificate at `/etc/letsencrypt/live/popov.link/` and the server's TLS settings. + +| Source | Installed path | +| --- | --- | +| `vacationplanner.service` | `/etc/systemd/system/vacationplanner.service` | +| `vacationplanner.conf` | `/etc/nginx/sites-available/vacationplanner.conf` | +| `index.html` | `/var/www/vacationplanner/index.html` | + +Enable the nginx site with a symlink in `/etc/nginx/sites-enabled/`. Validate +the unit with `systemd-analyze verify`, run `systemctl daemon-reload`, then +`systemctl enable --now vacationplanner.service`. Run `nginx -t` before +`systemctl reload nginx`. Back up existing configuration before replacing it. + +The container listens on `127.0.0.1:8080` using host networking. It runs as +`65532:65532`, with a read-only filesystem, no capabilities or additional +privileges, and limits of 128 MiB without swap, 0.5 CPU and 64 processes. +systemd restarts it after an exit and checks `/healthz` before marking the +start successful. Graceful shutdown allows 65 seconds. + +Only `/calendar.ics` is proxied. Other valid GET/HEAD paths serve the same +HTML 4.01 Strict page; unsupported methods return `405`. Calendar limits are +6 requests/minute per IP with a burst of 4, 2 requests/second overall with a +burst of 16, and 4 concurrent requests per IP or 16 overall. Rejections return +`429` and `Retry-After: 60`. Client addresses come from nginx's existing +trusted proxy configuration. Both virtual hosts disable access and error +logging; calendar responses are not cached or buffered to disk. + +## Updates and recovery + +The existing `podman-auto-update.timer` checks the registry daily. The +`io.containers.autoupdate=registry` label and `PODMAN_SYSTEMD_UNIT` connect +the container to its unit. Podman's default rollback restores the previous +image if restarting the updated service fails, including its HTTP readiness +check. Ordinary restarts use the saved image with `--pull=missing`. + +```sh +sudo systemctl status vacationplanner.service podman-auto-update.timer +curl --fail http://127.0.0.1:8080/healthz +sudo podman auto-update --dry-run +``` + +Before an update, retain a known-good image under a separate local tag and +record its digest. This also protects it from the existing timer's image +pruning. To recover manually, tag that saved image as +`code.popov.link/valentineus/vacationplanner2ics:latest` and restart +`vacationplanner.service`. If the registry image is still faulty, temporarily +remove the container's auto-update label from this unit before restarting; +restore it when a fixed image is available. Restore nginx files from the +backup, validate them and reload nginx if the proxy change must be reverted. + +HTML validation uses OpenSP with the W3C HTML 4.01 Strict DTD, HTML Tidy and +Lynx in Docker. Deployment checks use a local mock API to exercise query +forwarding, routes, headers, rate limits and concurrent request limits. +Use fake credentials for these checks and verify logging before making a +real calendar request. diff --git a/deploy/index.html b/deploy/index.html new file mode 100644 index 0000000..4be3ccc --- /dev/null +++ b/deploy/index.html @@ -0,0 +1,50 @@ + + + + + Vacationplanner calendar subscriptions + + +

Vacationplanner calendar subscriptions

+

This service turns Vacationplanner vacations into an iCalendar + subscription for Apple Calendar and other calendar applications.

+ +

Usage

+

Add a calendar subscription using the following URL. Replace + YOUR_API_TOKEN with your Vacationplanner API token and + URL-encode its value.

+
https://vacationplanner.popov.link/calendar.ics?token=YOUR_API_TOKEN
+
+
token
+
Required: your Vacationplanner API token.
+
years
+
Optional: comma-separated years. The default is the current UTC + year and the next year. Up to 10 distinct years from 1 to 9998 are + accepted; duplicate years are removed.
+
+

To select years explicitly:

+
https://vacationplanner.popov.link/calendar.ics?token=YOUR_API_TOKEN&years=2026,2027,2028
+

The calendar application controls the refresh interval. Occasional + requests from several devices are supported; frequent requests may be + rate limited. Keep the subscription URL private.

+ +

Privacy and self-hosting

+

Self-hosting is recommended. Do not blindly trust + any hosted instance, including this one. The subscription URL contains + your API token. Service operators and providers that terminate HTTPS + can read it. HTTPS does not hide the token from those operators.

+

The service does not store calendars or log requests. Query-string + logging is disabled on this server, but this does not guarantee privacy + across the infrastructure. Prefer a server you control and replace + the host in the example URLs with your own.

+

The repositories below contain source code, ready-made container + image links and self-hosting instructions.

+ +
+

Repository locations: + canonical source + · github + · read-only mirror

+ + diff --git a/deploy/vacationplanner.conf b/deploy/vacationplanner.conf new file mode 100644 index 0000000..50e3c19 --- /dev/null +++ b/deploy/vacationplanner.conf @@ -0,0 +1,84 @@ +limit_req_zone $binary_remote_addr zone=vacationplanner_ip_rate:1m rate=6r/m; +limit_req_zone $server_name zone=vacationplanner_total_rate:1m rate=2r/s; +limit_conn_zone $binary_remote_addr zone=vacationplanner_ip_conn:1m; +limit_conn_zone $server_name zone=vacationplanner_total_conn:1m; + +map $status $vacationplanner_retry_after { + default ""; + 429 60; +} + +map $status $vacationplanner_allow { + default ""; + 405 "GET, HEAD"; +} + +server { + listen 80; + listen [::]:80; + server_name vacationplanner.popov.link; + + access_log off; + error_log /dev/null; + add_header Referrer-Policy "no-referrer" always; + + return 301 https://vacationplanner.popov.link$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name vacationplanner.popov.link; + + ssl_certificate /etc/letsencrypt/live/popov.link/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/popov.link/privkey.pem; + ssl_trusted_certificate /etc/letsencrypt/live/popov.link/chain.pem; + + root /var/www/vacationplanner; + access_log off; + error_log /dev/null; + + # Defining a header here replaces the inherited add_header directives. + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "no-referrer" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header Retry-After $vacationplanner_retry_after always; + add_header Allow $vacationplanner_allow always; + + if ($request_method !~ ^(GET|HEAD)$) { + return 405; + } + + location = /calendar.ics { + limit_req zone=vacationplanner_ip_rate burst=4 nodelay; + limit_req zone=vacationplanner_total_rate burst=16 nodelay; + limit_req_status 429; + limit_conn vacationplanner_ip_conn 4; + limit_conn vacationplanner_total_conn 16; + limit_conn_status 429; + + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_hide_header Referrer-Policy; + proxy_hide_header X-Content-Type-Options; + proxy_read_timeout 65s; + proxy_buffering off; + proxy_cache off; + proxy_max_temp_file_size 0; + proxy_intercept_errors off; + } + + location / { + try_files /index.html =404; + } +} diff --git a/deploy/vacationplanner.service b/deploy/vacationplanner.service new file mode 100644 index 0000000..349e39f --- /dev/null +++ b/deploy/vacationplanner.service @@ -0,0 +1,36 @@ +[Unit] +Description=Vacationplanner calendar subscriptions +Wants=network-online.target +After=network-online.target +RequiresMountsFor=/var/lib/containers/storage +StartLimitIntervalSec=0 + +[Service] +Type=notify +NotifyAccess=all +Environment=PODMAN_SYSTEMD_UNIT=%n +RuntimeDirectory=vacationplanner +RuntimeDirectoryMode=0700 +Restart=always +RestartSec=5 +TimeoutStartSec=180 +TimeoutStopSec=80 +ExecStart=/usr/bin/podman run --detach --rm --replace \ + --name vacationplanner --cidfile=/run/vacationplanner/container.cid \ + --cgroups=no-conmon --sdnotify=conmon --pull=missing \ + --label=io.containers.autoupdate=registry \ + --network=host --env=BIND_ADDR=127.0.0.1:8080 \ + --user=65532:65532 --read-only --read-only-tmpfs=false \ + --cap-drop=ALL --security-opt=no-new-privileges \ + --memory=128m --memory-swap=128m --cpus=0.5 --pids-limit=64 \ + --stop-timeout=65 --log-driver=none \ + code.popov.link/valentineus/vacationplanner2ics:latest +ExecStartPost=/usr/bin/curl --fail --silent --show-error \ + --retry 20 --retry-all-errors --retry-delay 1 --retry-max-time 45 \ + --connect-timeout 1 --max-time 2 --output /dev/null \ + http://127.0.0.1:8080/healthz +ExecStop=-/usr/bin/podman stop --ignore --time=65 --cidfile=/run/vacationplanner/container.cid +ExecStopPost=-/usr/bin/podman rm --force --ignore --cidfile=/run/vacationplanner/container.cid + +[Install] +WantedBy=multi-user.target