feat(rsvp): deploy rsvp.debyl.io, the invite-only party RSVP app
A single Go binary with SQLite, built and loaded as localhost/rsvpd:<VERSION> by make deploy-remote in ~/src/rsvp-debylio. Both of its listeners are published on 127.0.0.1 only: Caddy proxies the public one to everyone and the admin one (/admin, no login) only to caddy_local_networks. A Caddyfile mistake alone cannot expose admin, and neither can a port mistake alone. Guests' invite links are the credential and they sit in the URL path, which shapes the vhost: - It does not import common_headers. That snippet sets Referrer-Policy same-origin, which would replace the app's no-referrer and let a token leak in a Referer header. - Its access log rewrites request>uri to /i/REDACTED and drops the Location response header, since every POST 303s back to /i/<token>. - Caddy's error logger is separate from the site's and wrote the raw URI to caddy.log when the upstream was down. The global log now excludes http.log.error.rsvp and a filtered rsvp-errors logger takes it instead. Verified with zero token occurrences in both logs, locally and live. The data directory is owned directly by the host uid of the container's uid 10001 (subuid + 10000). Setting it to the podman user and chowning back each run flipped ownership on every deploy and briefly locked the app out of its database. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a9f51b77e1
commit
f59ade748b
@@ -27,6 +27,12 @@ hass_path: "{{ podman_volumes }}/hass"
|
|||||||
partsy_path: "{{ podman_volumes }}/partsy"
|
partsy_path: "{{ podman_volumes }}/partsy"
|
||||||
partsy_skudak_path: "{{ podman_volumes }}/partsy-skudak"
|
partsy_skudak_path: "{{ podman_volumes }}/partsy-skudak"
|
||||||
photos_path: "{{ podman_volumes }}/photos"
|
photos_path: "{{ podman_volumes }}/photos"
|
||||||
|
# rsvp.debyl.io -- invite-only RSVP app (~/src/rsvp-debylio). Both listeners are
|
||||||
|
# published on loopback only: Caddy proxies the public one, and the admin one
|
||||||
|
# only for LAN clients. Caddy runs with network: host, so 127.0.0.1 reaches them.
|
||||||
|
rsvp_path: "{{ podman_volumes }}/rsvp"
|
||||||
|
rsvp_public_port: 9080
|
||||||
|
rsvp_admin_port: 9081
|
||||||
# Named rather than hardcoded so the ML service can be stood up beside a broken
|
# Named rather than hardcoded so the ML service can be stood up beside a broken
|
||||||
# one without editing tasks. On 2026-08-28 a worker thread wedged in
|
# one without editing tasks. On 2026-08-28 a worker thread wedged in
|
||||||
# uninterruptible sleep (D state) in exit_mmap, which made the container
|
# uninterruptible sleep (D state) in exit_mmap, which made the container
|
||||||
@@ -185,6 +191,7 @@ home_server_name_io: home.debyl.io
|
|||||||
parts_server_name_io: parts.debyl.io
|
parts_server_name_io: parts.debyl.io
|
||||||
photos_server_name_io: photos.debyl.io
|
photos_server_name_io: photos.debyl.io
|
||||||
gitea_debyl_server_name: git.debyl.io
|
gitea_debyl_server_name: git.debyl.io
|
||||||
|
rsvp_server_name: rsvp.debyl.io
|
||||||
|
|
||||||
# skudak.com domains (migration from skudakrennsport.com)
|
# skudak.com domains (migration from skudakrennsport.com)
|
||||||
parts_skudak_server_name: parts.skudak.com
|
parts_skudak_server_name: parts.skudak.com
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
# The image runs as uid 10001 (scratch, no passwd file). Rootless podman maps
|
||||||
|
# container uid N to host uid subuid_start + N - 1, so own the directory as
|
||||||
|
# that host uid directly. Setting the podman user here and chowning back
|
||||||
|
# afterwards would flip ownership on every deploy and briefly lock the running
|
||||||
|
# app out of its own database directory.
|
||||||
|
- name: create rsvp host directory volumes
|
||||||
|
become: true
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ podman_subuid.stdout | int + 10000 }}"
|
||||||
|
group: "{{ podman_subuid.stdout | int + 10000 }}"
|
||||||
|
mode: 0750
|
||||||
|
notify: restorecon podman
|
||||||
|
loop:
|
||||||
|
- "{{ rsvp_path }}/data"
|
||||||
|
|
||||||
|
- name: flush handlers
|
||||||
|
ansible.builtin.meta: flush_handlers
|
||||||
|
|
||||||
|
- import_tasks: podman/podman-check.yml
|
||||||
|
vars:
|
||||||
|
container_name: rsvp
|
||||||
|
container_image: "{{ image }}"
|
||||||
|
|
||||||
|
- name: create rsvp container
|
||||||
|
become: true
|
||||||
|
become_user: "{{ podman_user }}"
|
||||||
|
containers.podman.podman_container:
|
||||||
|
name: rsvp
|
||||||
|
image: "{{ image }}"
|
||||||
|
restart_policy: on-failure:3
|
||||||
|
log_driver: journald
|
||||||
|
env:
|
||||||
|
RSVP_DB_PATH: /data/rsvp.db
|
||||||
|
RSVP_BASE_URL: "https://{{ rsvp_server_name }}"
|
||||||
|
RSVP_TZ: America/New_York
|
||||||
|
# Client IPs for the invite-link miss limiter come from Caddy's
|
||||||
|
# X-Forwarded-For. Safe only because both ports are loopback-only.
|
||||||
|
RSVP_TRUST_FORWARDED: "1"
|
||||||
|
volumes:
|
||||||
|
- "{{ rsvp_path }}/data:/data"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:{{ rsvp_public_port }}:8080"
|
||||||
|
- "127.0.0.1:{{ rsvp_admin_port }}:8081"
|
||||||
|
|
||||||
|
- name: create systemd startup job for rsvp
|
||||||
|
include_tasks: podman/systemd-generate.yml
|
||||||
|
vars:
|
||||||
|
container_name: rsvp
|
||||||
@@ -126,6 +126,13 @@
|
|||||||
image: localhost/greg-time-bot:3.17.3
|
image: localhost/greg-time-bot:3.17.3
|
||||||
tags: gregtime
|
tags: gregtime
|
||||||
|
|
||||||
|
# Built and loaded by `make deploy-remote` in ~/src/rsvp-debylio; bump this to
|
||||||
|
# the VERSION it loaded. The Caddy vhost ships with the caddy-config tag.
|
||||||
|
- import_tasks: containers/home/rsvp.yml
|
||||||
|
vars:
|
||||||
|
image: localhost/rsvpd:1.0.1
|
||||||
|
tags: rsvp
|
||||||
|
|
||||||
# Gated on zomboid_enabled (roles/podman/defaults/main.yml) so it can be taken
|
# Gated on zomboid_enabled (roles/podman/defaults/main.yml) so it can be taken
|
||||||
# down for a CI-heavy stretch without losing the world.
|
# down for a CI-heavy stretch without losing the world.
|
||||||
- import_tasks: containers/home/zomboid.yml
|
- import_tasks: containers/home/zomboid.yml
|
||||||
|
|||||||
@@ -23,6 +23,26 @@
|
|||||||
}
|
}
|
||||||
format {{ caddy_log_format }}
|
format {{ caddy_log_format }}
|
||||||
level {{ caddy_log_level }}
|
level {{ caddy_log_level }}
|
||||||
|
# See rsvp-errors below.
|
||||||
|
exclude http.log.error.rsvp
|
||||||
|
}
|
||||||
|
|
||||||
|
# {{ rsvp_server_name }}: when a proxied request fails, Caddy's error log
|
||||||
|
# records the raw request URI -- which for /i/<token> is a working invite
|
||||||
|
# link. Send those through the same redaction as the site's access log
|
||||||
|
# instead of the default log above.
|
||||||
|
log rsvp-errors {
|
||||||
|
output file /var/log/caddy/rsvp.log {
|
||||||
|
roll_size {{ caddy_log_roll_size }}
|
||||||
|
roll_keep {{ caddy_log_roll_keep }}
|
||||||
|
roll_keep_for {{ caddy_log_roll_keep_for }}
|
||||||
|
}
|
||||||
|
format filter {
|
||||||
|
wrap json
|
||||||
|
request>uri regexp ^/i/[^/?#]+ /i/REDACTED
|
||||||
|
request>headers>Referer delete
|
||||||
|
}
|
||||||
|
include http.log.error.rsvp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +213,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# RSVP - {{ rsvp_server_name }} (public invite links, LAN-only /admin)
|
||||||
|
#
|
||||||
|
# Does NOT import common_headers: its Referrer-Policy "same-origin" would
|
||||||
|
# replace the app's "no-referrer", and invite tokens live in the URL path.
|
||||||
|
{{ rsvp_server_name }} {
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
|
X-Robots-Tag "noindex, nofollow"
|
||||||
|
Referrer-Policy "no-referrer"
|
||||||
|
Content-Security-Policy "default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
-Server
|
||||||
|
}
|
||||||
|
|
||||||
|
# Admin is a second listener with no login. It is only published on
|
||||||
|
# loopback, and only proxied here for LAN clients -- two independent controls.
|
||||||
|
@admin_local {
|
||||||
|
path /admin /admin/*
|
||||||
|
remote_ip {{ caddy_local_networks | join(' ') }}
|
||||||
|
}
|
||||||
|
handle @admin_local {
|
||||||
|
reverse_proxy 127.0.0.1:{{ rsvp_admin_port }}
|
||||||
|
}
|
||||||
|
handle /admin* {
|
||||||
|
respond "Not found" 404
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
reverse_proxy 127.0.0.1:{{ rsvp_public_port }}
|
||||||
|
}
|
||||||
|
|
||||||
|
# The invite token is the credential and it is in the URL path, so redact
|
||||||
|
# it from the request URI and drop the redirect Location (POSTs 303 back to
|
||||||
|
# /i/<token>) before anything is written. Named "rsvp" so its error logger
|
||||||
|
# (http.log.error.rsvp) can be redirected in the global options.
|
||||||
|
log rsvp {
|
||||||
|
output file /var/log/caddy/rsvp.log {
|
||||||
|
roll_size {{ caddy_log_roll_size }}
|
||||||
|
roll_keep {{ caddy_log_roll_keep }}
|
||||||
|
roll_keep_for {{ caddy_log_roll_keep_for }}
|
||||||
|
}
|
||||||
|
format filter {
|
||||||
|
wrap json
|
||||||
|
request>uri regexp ^/i/[^/?#]+ /i/REDACTED
|
||||||
|
request>headers>Referer delete
|
||||||
|
resp_headers>Location delete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Uptime Kuma (Debyltech) - {{ uptime_kuma_server_name }}
|
# Uptime Kuma (Debyltech) - {{ uptime_kuma_server_name }}
|
||||||
{{ uptime_kuma_server_name }} {
|
{{ uptime_kuma_server_name }} {
|
||||||
{{ ip_restricted_site() }}
|
{{ ip_restricted_site() }}
|
||||||
|
|||||||
Reference in New Issue
Block a user