2 Commits

Author SHA1 Message Date
jacobh2 4852803cdb Pi bundle: Kvitt on port 443 through NPM (D-124)
Build Kvitt images / build (push) Successful in 2h51m36s
KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST} with no port; the health check
through NPM prints the port-less URL; publish-apk.sh's summary drops :8443.
README: no new router rule (the existing external 443 forward to the Home
Assistant Pi serves Kvitt, NPM selects by name), NPM listens on 443, the
fallback, operations and APK-abuse notes, and every :8443 URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-19 20:23:18 +02:00
jacobh2 a86ad241f1 Server and contracts: public URL on port 443 (D-124)
Kvitt is served on the default HTTPS port through Nginx Proxy Manager, so the
OpenAPI server is https://{host} (contracts/openapi.yaml and openapi.rs
PUBLIC_SERVER_URL), examples E-7 public_url and E-12 link are
https://kvitt.example.invalid without a port, tools/contracts-check.py expects
the portless header, and the server's tests, config message and README name
no port.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-19 20:22:09 +02:00
16 changed files with 48 additions and 43 deletions
@@ -3,7 +3,7 @@
"kind": "partner",
"target_user_id": null,
"code": "4N8RVT2KW6ZC",
"link": "https://kvitt.example.invalid:8443/i/4N8RVT2KW6ZC?from=Jacob",
"link": "https://kvitt.example.invalid/i/4N8RVT2KW6ZC?from=Jacob",
"created_at": "2026-10-17T06:30:02.004Z",
"expires_at": "2026-10-20T06:30:02.004Z"
}
+1 -1
View File
@@ -39,5 +39,5 @@
"server_version": 16
}
],
"public_url": "https://kvitt.example.invalid:8443"
"public_url": "https://kvitt.example.invalid"
}
+1 -1
View File
@@ -23,7 +23,7 @@ info:
`protocol_unsupported`, `rate_limited`, `internal`, `unavailable`; unauthenticated ones `rate_limited`,
`internal`, `unavailable`. `not_implemented` (501) exists only during milestones M2a to M4.
servers:
- url: https://{host}:8443
- url: https://{host}
variables:
host:
default: kvitt.example.invalid
+21 -17
View File
@@ -165,10 +165,10 @@ Copying the key to the Pi (§10.7 step 7, mode 0440, group 10001) is done by `pi
| Owner's Mac | `infra/build-images.sh [tag]` (fallback image build), `infra/publish-apk.sh` (release APK into `infra/pi/out/apk/`) | The owner (or an agent, for the APK build only) |
| Kvitt Pi, `/opt/kvitt/` | `compose.yaml`, `.env`, `secrets/`, `import/`, `apk/`, and the scripts `deploy.sh`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh` (copied there by `install.sh`) | The owner, in their own SSH session |
| Home Assistant Pi | Nginx Proxy Manager (NPM): a proxy host for `<KVITT_PUBLIC_HOST>` with its own Let's Encrypt certificate, TLS on 443, plain HTTP to `<KVITT_PI_HOST>:8080` over the LAN (D-120) | The owner, in NPM's web UI (step 6) |
| Router (ASUS) | External TCP 8443 to the Home Assistant Pi's 443 (NPM); external 80 already reaches NPM for Let's Encrypt | The owner (step 7) |
| Router (ASUS) | No new rule: the existing external TCP 443 forward to the Home Assistant Pi (NPM, for Home Assistant) serves Kvitt too, NPM picks the proxy host by name; external 80 already reaches NPM for Let's Encrypt; nothing is forwarded to the Kvitt Pi (D-124) | The owner checks it (step 7) |
Stack (`pi/compose.yaml`, project name `kvitt`): `server` (publishes `8080:8080` on the Pi's LAN interfaces, the
only published port; `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}:8443`, `KVITT_TRUSTED_PROXY=${HA_HOST}`) and
only published port; `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}` (default port 443, D-124), `KVITT_TRUSTED_PROXY=${HA_HOST}`) and
`backup`; volumes `data`, `backups`; network `kvitt`; every service `restart: unless-stopped`, json-file logs
10 MB x 3, `TZ=Europe/Stockholm`. Images are `${KVITT_REGISTRY}/kvitt-server|kvitt-backup:${KVITT_TAG}` with
`KVITT_REGISTRY=gitea.lan/jacobh2` (D-104; the names the workflow pushes). Caddy and cert-sync were removed by
@@ -385,7 +385,7 @@ starts and waits. If an earlier bundle with Caddy and cert-sync was installed, i
`kvitt_certs` and `kvitt_caddy_data`) and says so. Expected end: `server: healthy`, a `docker compose ps` table with
`server` (ports `0.0.0.0:8080->8080/tcp`) and `backup`, the line `GET http://localhost:8080/health: {"status":"ok",...}`,
then "Not reachable through Nginx Proxy Manager yet" (expected on the first install, before step 6) or
`GET https://<KVITT_PUBLIC_HOST>:443/health (via NPM at <HA_HOST>): {"status":"ok",...}`, then "Kvitt is running.".
`GET https://<KVITT_PUBLIC_HOST>/health (via NPM at <HA_HOST>:443): {"status":"ok",...}`, then "Kvitt is running.".
Then `rm -r ~/kvitt-pi/secrets` (the script reminds you). Running `install.sh` again is safe. Re-running
`install.sh` keeps the `KVITT_TAG` that `deploy.sh` set; change the tag only with `deploy.sh <tag>`.
@@ -396,7 +396,8 @@ HTTP over the home LAN to the Kvitt Pi's port 8080 (plain HTTP between the two P
sure `<KVITT_PUBLIC_HOST>` points at the home's public address (your DNS or DuckDNS page shows the same address as
`icehack.asuscomm.com`). Let's Encrypt's HTTP-01 challenge reaches NPM through external port 80, which already
works: it is how NPM got the certificate for Home Assistant. Serving Kvitt under a path of `icehack.asuscomm.com`
(for example `/kvitt/`) is not supported.
(for example `/kvitt/`) is not supported. NPM listens on the default HTTPS port 443 for every proxy host and
picks the host by the name the client asks for, so Kvitt and Home Assistant share that port (D-124).
Open NPM's web UI (Home Assistant, Settings, Add-ons, Nginx Proxy Manager, Open Web UI), then Hosts, Proxy Hosts,
Add Proxy Host.
@@ -509,13 +510,14 @@ the connection comes from 10/8, 172.16/12, 192.168/16 (and the CDN ranges it dow
LAN can choose its own client address for the rate limits. Internet clients arrive with their public address (the
router's forward keeps the source address), so their `X-Real-IP` is ignored.
### 7. Router forward
### 7. Router: no new rule
ASUS router web UI, WAN, Virtual Server / Port Forwarding: add a rule: Service Name `Kvitt`, Protocol TCP, External
Port `8443`, Internal Port `443` (NPM's HTTPS port; if the add-on's Network settings map `443/tcp` to another host
port, use that one), Internal IP Address `<HA_HOST>`; Apply. No rule forwards to the Kvitt Pi (remove one to
`<KVITT_PI_HOST>` if you made it for the Caddy version). Then on the phone with Wi-Fi off, open
`https://<KVITT_PUBLIC_HOST>:8443/health` in Chrome: it shows `"status":"ok"` without a certificate warning (yes/no).
No new rule (D-124): external TCP 443 already reaches the Home Assistant Pi (NPM) for Home Assistant, and NPM picks
Kvitt's proxy host by name on the same port. Verify it exists: ASUS router web UI, WAN, Virtual Server / Port
Forwarding: the list has a TCP rule with External Port `443` and Internal IP Address `<HA_HOST>` (yes/no). Nothing is
forwarded to the Kvitt Pi: remove a rule to `<KVITT_PI_HOST>` if you made one for the Caddy version, and a rule with
External Port `8443` if you made one for the earlier NPM version (D-120). Then on the phone with Wi-Fi off, open
`https://<KVITT_PUBLIC_HOST>/health` in Chrome: it shows `"status":"ok"` without a certificate warning (yes/no).
### 8. First backup and bootstrap
@@ -526,9 +528,10 @@ On the Pi:
/opt/kvitt/bootstrap.sh # first install only: prints the admin invite code, link and expiry
```
The invite link starts with `https://<KVITT_PUBLIC_HOST>:8443/i/`. The release app opens such links and connects to
that host only once an Android build carries the new host name (SPEC §8.5; until then the app knows
`icehack.asuscomm.com`): tell the orchestrator before pairing a release build.
The invite link starts with `https://<KVITT_PUBLIC_HOST>/i/` (no port). The release app opens such links and connects to
that host only once its Android build carries the host name (`kvitt.publicHost`, D-121) and the default port 443
(D-124; builds made for port 8443 do not match a link without a port): tell the orchestrator before pairing a
release build.
### 9. Deploying a new version
@@ -552,8 +555,8 @@ copying the bundle again (step 4, without the secrets) and running `./install.sh
### Fallback (SPEC §10.5.3): when NPM is down
* NPM or the Home Assistant Pi down means Kvitt is unreachable from outside, and also from home over
`https://<KVITT_PUBLIC_HOST>:8443` (that path goes through NPM too). The server on the Kvitt Pi keeps running and
nothing there needs to change. The phones keep working on their full local copy and sync when NPM is back.
`https://<KVITT_PUBLIC_HOST>` (that path goes through NPM too); Home Assistant is then unreachable from outside as
well (same NPM, same port 443). The server on the Kvitt Pi keeps running and nothing there needs to change. The phones keep working on their full local copy and sync when NPM is back.
* On the home Wi-Fi only **debug builds** can reach the server directly, at `http://<KVITT_PI_HOST>:8080` (AC-2.8
allows `http://` to private LAN addresses in debug builds only). **Release builds require HTTPS with a publicly
trusted certificate (SPEC §8.5) and cannot use that address: there is no LAN fallback for the release app.** A
@@ -580,12 +583,13 @@ copying the bundle again (step 4, without the secrets) and running `./install.sh
| Memory | `docker stats --no-stream` (server RSS under 100 MB with both phones syncing) |
| Disk | `df -h /` and `docker system df`; SQLite a few MB, pictures under 1 MB, APKs about 10 MB each since R8, 5 kept |
| Image digests deployed | `tail -n 8 /opt/kvitt/deployed-digests` |
| Client IPs are real (once, after the router forward) | Open `https://<KVITT_PUBLIC_HOST>:8443/health` on the phone with Wi-Fi off, then within a minute on the Kvitt Pi `sudo apt install -y conntrack` (once) and `sudo conntrack -L -p tcp --orig-port-dst 8080`: in every line the first `src=` is `<HA_HOST>` and the second half (from the second `src=`) has `dst=<HA_HOST>`, no `172.*` there, so the server sees NPM's address and trusts its `X-Forwarded-For`. If the second half shows a `172.*` address instead, every internet client shares one per-IP limit (SPEC §8.6): tell the orchestrator |
| Client IPs are real (once, after step 7) | Open `https://<KVITT_PUBLIC_HOST>/health` on the phone with Wi-Fi off, then within a minute on the Kvitt Pi `sudo apt install -y conntrack` (once) and `sudo conntrack -L -p tcp --orig-port-dst 8080`: in every line the first `src=` is `<HA_HOST>` and the second half (from the second `src=`) has `dst=<HA_HOST>`, no `172.*` there, so the server sees NPM's address and trusts its `X-Forwarded-For`. If the second half shows a `172.*` address instead, every internet client shares one per-IP limit (SPEC §8.6): tell the orchestrator |
| Restore | `infra/RESTORE.md` |
**APK downloads (security review L-3, D-119).** `GET /apk/files/<name>` is public (the landing page links it) and
about 11 MB per response; the server allows 10 per minute per client IP. If one address still fetches
`/apk/files/` over and over (NPM's access log), remove the router forward (step 7) until it stops.
`/apk/files/` over and over (NPM's access log), disable Kvitt's proxy host in NPM (Hosts, Proxy Hosts, the row's
menu, Disable) until it stops; the router's 443 forward also serves Home Assistant, so leave it in place.
### Owner decisions (open)
+3 -3
View File
@@ -10,9 +10,9 @@ KVITT_REGISTRY=gitea.lan/jacobh2
# or the short git SHA that infra/build-images.sh printed.
KVITT_TAG=CHANGE_ME_TAG
# Kvitt's own public host name (D-120), owner-chosen: a domain or DuckDNS name pointing at the home's public address.
# NPM holds its Let's Encrypt certificate; the router forwards external 8443 to NPM's 443. Invite links and the
# landing page use https://<KVITT_PUBLIC_HOST>:8443 (KVITT_PUBLIC_URL). Not icehack.asuscomm.com: that name is
# NPM's proxy host for Home Assistant.
# NPM holds its Let's Encrypt certificate and serves it on the default HTTPS port 443, which the router already
# forwards to NPM for Home Assistant (D-124). Invite links and the landing page use https://<KVITT_PUBLIC_HOST>
# (KVITT_PUBLIC_URL, no port). Not icehack.asuscomm.com: that name is NPM's proxy host for Home Assistant.
KVITT_PUBLIC_HOST=CHANGE_ME_KVITT_PUBLIC_HOST
# LAN IPv4 address of the Home Assistant Pi, where Nginx Proxy Manager terminates TLS and forwards to this Pi's port
# 8080 (D-120). The server trusts X-Forwarded-For only from this address (KVITT_TRUSTED_PROXY), so it must be the
+1 -1
View File
@@ -30,7 +30,7 @@ services:
environment:
TZ: Europe/Stockholm
KVITT_DATA_DIR: /data
KVITT_PUBLIC_URL: https://${KVITT_PUBLIC_HOST:?set KVITT_PUBLIC_HOST in .env}:8443
KVITT_PUBLIC_URL: https://${KVITT_PUBLIC_HOST:?set KVITT_PUBLIC_HOST in .env}
# NPM's LAN address: only a request whose TCP peer is this address may set X-Forwarded-For, whose last entry
# is then the client IP of the rate limits (SPEC §8.5, §8.6, D-120). An IP literal (install.sh checks it).
KVITT_TRUSTED_PROXY: ${HA_HOST:?set HA_HOST in .env}
+5 -4
View File
@@ -6,7 +6,8 @@
KVITT_DIR=${KVITT_DIR:-/opt/kvitt}
# The server's port, published on the Pi's LAN interfaces; Nginx Proxy Manager forwards to it (D-120).
SERVER_URL=http://localhost:8080
# Nginx Proxy Manager's HTTPS port on the Home Assistant Pi (the router forwards external 8443 to it, D-120).
# Nginx Proxy Manager's HTTPS port on the Home Assistant Pi, the default HTTPS port that Kvitt's public URL uses
# (the router's existing external 443 forward reaches it, D-124).
NPM_HTTPS_PORT=443
say() { printf '%s\n' "$*"; }
@@ -76,11 +77,11 @@ print_proxy_health() {
local out ha_host host
ha_host=$(env_value HA_HOST)
host=$(env_value KVITT_PUBLIC_HOST)
if out=$(curl -fsS --max-time 15 --resolve "$host:$NPM_HTTPS_PORT:$ha_host" "https://$host:$NPM_HTTPS_PORT/health" 2>&1); then
say "GET https://$host:$NPM_HTTPS_PORT/health (via NPM at $ha_host): $out"
if out=$(curl -fsS --max-time 15 --resolve "$host:$NPM_HTTPS_PORT:$ha_host" "https://$host/health" 2>&1); then
say "GET https://$host/health (via NPM at $ha_host:$NPM_HTTPS_PORT): $out"
return 0
fi
say "GET https://$host:$NPM_HTTPS_PORT/health (via NPM at $ha_host) failed: $out"
say "GET https://$host/health (via NPM at $ha_host:$NPM_HTTPS_PORT) failed: $out"
return 1
}
+2 -2
View File
@@ -35,7 +35,7 @@ KVITT_PUBLIC_HOST="$(sed -n 's/^KVITT_PUBLIC_HOST=//p' "$env_file" | tail -n 1)"
[[ "$KVITT_PUBLIC_HOST" != icehack.asuscomm.com ]] \
|| { echo "publish-apk.sh: KVITT_PUBLIC_HOST cannot be icehack.asuscomm.com, NPM's proxy host for Home Assistant (D-120)." >&2; exit 1; }
echo "Release $version_name (version_code $version_code) for https://$KVITT_PUBLIC_HOST:8443"
echo "Release $version_name (version_code $version_code) for https://$KVITT_PUBLIC_HOST"
PATH="/opt/homebrew/opt/openjdk@25/bin:$PATH" tools/gradle-serial.sh :app:assembleRelease \
"-Pkvitt.versionCode=$version_code" "-Pkvitt.versionName=$version_name" "-Pkvitt.publicHost=$KVITT_PUBLIC_HOST"
@@ -94,7 +94,7 @@ done | sort -n -r | tail -n +6 | while read -r old; do
done
echo "Staged in infra/pi/out/apk/: $file_name ($size_bytes bytes, sha256 $sha256) and latest.json."
echo "Built for the release server https://$KVITT_PUBLIC_HOST:8443 (KVITT_PUBLIC_HOST in infra/pi/.env)."
echo "Built for the release server https://$KVITT_PUBLIC_HOST (KVITT_PUBLIC_HOST in infra/pi/.env)."
echo "Copy to the Pi and install (infra/README.md, Publishing an APK):"
echo " scp infra/pi/out/apk/latest.json infra/pi/out/apk/$file_name infra/pi/out/apk/kvitt-$version_code.mapping.txt <ssh user>@<Kvitt Pi address>:/opt/kvitt/apk/"
echo " ssh <ssh user>@<Kvitt Pi address> /opt/kvitt/install-apk.sh"
+1 -1
View File
@@ -30,7 +30,7 @@ Configuration is environment variables only (SPEC §10.4, exhaustive):
|---|---|---|
| `KVITT_DATA_DIR` | `/data` | Holds `kvitt.sqlite` (+ `-wal`, `-shm`), `pictures/`, `apk/`. Created if missing. |
| `KVITT_BIND` | `0.0.0.0:8080` | Listen address. |
| `KVITT_PUBLIC_URL` | none: required | Base URL in invite links and the landing page, `https://<KVITT_PUBLIC_HOST>:8443` on the Pi (set by compose). Every command refuses to start without it. |
| `KVITT_PUBLIC_URL` | none: required | Base URL in invite links and the landing page, `https://<KVITT_PUBLIC_HOST>` on the Pi (set by compose). Every command refuses to start without it. |
| `KVITT_FCM_CREDENTIALS` | `/secrets/fcm-service-account.json` | FCM key; missing file = push disabled (warning at start). |
| `KVITT_FCM_PROJECT_ID` | `kvitt-1337` | FCM project. |
| `KVITT_TRUSTED_PROXY` | `none` | IP literal or host name allowed to set `X-Forwarded-For` (on the Pi `HA_HOST`, NPM's LAN address); `none` or a name that does not resolve means no proxy. |
+2 -2
View File
@@ -69,7 +69,7 @@ impl Config {
AppError::new(
crate::error::ErrorCode::Internal,
"KVITT_PUBLIC_URL is not set: it must be the Kvitt base URL, \
https://<KVITT_PUBLIC_HOST>:8443 (SPEC §10.4)",
https://<KVITT_PUBLIC_HOST> (SPEC §10.4)",
)
})?;
Ok(Self {
@@ -102,7 +102,7 @@ impl Config {
mod tests {
use super::*;
const PUBLIC_URL: &str = "https://kvitt.example.invalid:8443";
const PUBLIC_URL: &str = "https://kvitt.example.invalid";
#[test]
fn defaults_when_unset_or_empty() {
+1 -1
View File
@@ -37,7 +37,7 @@ strings. Common rules: SPEC §14.3.1.";
/// The production base URL (SPEC §7, §14.3): the host is the server variable [`HOST_VARIABLE`], because the Kvitt
/// host name is owner-chosen (SPEC §10.5).
pub const PUBLIC_SERVER_URL: &str = "https://{host}:8443";
pub const PUBLIC_SERVER_URL: &str = "https://{host}";
/// The server variable in [`PUBLIC_SERVER_URL`].
pub const HOST_VARIABLE: &str = "host";
/// The default of [`HOST_VARIABLE`]: a reserved name that never resolves (RFC 6761), not a real host.
@@ -23,7 +23,7 @@ use tower::ServiceExt;
use uuid::Uuid;
/// `KVITT_PUBLIC_URL` of the harness, on the reserved `.invalid` name of the examples (E-7, E-12).
pub const PUBLIC_URL: &str = "https://kvitt.example.invalid:8443";
pub const PUBLIC_URL: &str = "https://kvitt.example.invalid";
pub const ADMIN_ID: &str = "0192a000-0000-7000-8000-000000000001";
pub const MEMBER_ID: &str = "0192a000-0000-7000-8000-000000000002";
pub const ADMIN_DEVICE_ID: &str = "0192a001-0000-7000-8000-000000000001";
@@ -12,7 +12,7 @@ fn run(dir: &std::path::Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_kvitt-server"))
.args(args)
.env("KVITT_DATA_DIR", dir)
.env("KVITT_PUBLIC_URL", "https://kvitt.example:8443")
.env("KVITT_PUBLIC_URL", "https://kvitt.example")
.env("RUST_LOG", "warn")
.output()
.unwrap()
@@ -26,7 +26,7 @@ fn printed_code(output: &Output) -> String {
.find_map(|l| l.strip_prefix("Link:"))
.unwrap()
.trim();
let code = link.strip_prefix("https://kvitt.example:8443/i/").unwrap();
let code = link.strip_prefix("https://kvitt.example/i/").unwrap();
assert_eq!(code.len(), 12);
let display = stdout
.lines()
@@ -131,7 +131,7 @@ fn printed_partner_code(output: &Output, from: &str) -> String {
.find_map(|l| l.strip_prefix("Link:"))
.unwrap()
.trim();
let rest = link.strip_prefix("https://kvitt.example:8443/i/").unwrap();
let rest = link.strip_prefix("https://kvitt.example/i/").unwrap();
let (code, query) = rest.split_once('?').unwrap();
assert_eq!(query, format!("from={from}"));
assert_eq!(code.len(), 12);
@@ -34,9 +34,9 @@ async fn e4_english_by_default_with_both_links_and_the_grouped_code() {
assert!(html.contains(">Open in Kvitt</a>"));
assert!(html.contains("7K3M-QX9D-2HPA"));
assert!(html.contains(
"href=\"kvitt://invite?server=https%3A%2F%2Fkvitt%2Eexample%2Einvalid%3A8443&amp;code=7K3MQX9D2HPA\""
"href=\"kvitt://invite?server=https%3A%2F%2Fkvitt%2Eexample%2Einvalid&amp;code=7K3MQX9D2HPA\""
));
assert!(html.contains("href=\"https://kvitt.example.invalid:8443/apk/files/kvitt-412.apk\""));
assert!(html.contains("href=\"https://kvitt.example.invalid/apk/files/kvitt-412.apk\""));
}
#[tokio::test]
@@ -90,7 +90,7 @@ async fn e4_passes_the_admin_name_of_an_e12_link_into_the_app_link_only() {
assert_eq!(reply.status, 200);
let html = reply.text();
assert!(html.contains(
"href=\"kvitt://invite?server=https%3A%2F%2Fkvitt%2Eexample%2Einvalid%3A8443&amp;code=7K3MQX9D2HPA&amp;from=%C3%85sa%20Berg\""
"href=\"kvitt://invite?server=https%3A%2F%2Fkvitt%2Eexample%2Einvalid&amp;code=7K3MQX9D2HPA&amp;from=%C3%85sa%20Berg\""
), "{html}");
// The name is not shown on the page itself.
assert!(!html.contains("Åsa"));
@@ -49,7 +49,7 @@ impl TestApp {
let dir = TempDir::new();
let config = Config::from_lookup(|k| match k {
"KVITT_DATA_DIR" => Some(dir.0.to_string_lossy().into_owned()),
"KVITT_PUBLIC_URL" => Some("https://kvitt.example.invalid:8443".to_owned()),
"KVITT_PUBLIC_URL" => Some("https://kvitt.example.invalid".to_owned()),
_ => None,
})
.unwrap();
+1 -1
View File
@@ -1471,7 +1471,7 @@ def check_openapi():
lines = f.read().splitlines()
text = "\n".join(lines)
head_ok = (re.search(r"^openapi: 3\.1\.0$", text, re.M) and re.search(r"^ title: Kvitt$", text, re.M)
and re.search(r"^ version: '1'$", text, re.M) and re.search(r"^ - url: https://\{host\}:8443\n variables:\n host:\n default: kvitt\.example\.invalid$", text, re.M))
and re.search(r"^ version: '1'$", text, re.M) and re.search(r"^ - url: https://\{host\}\n variables:\n host:\n default: kvitt\.example\.invalid$", text, re.M))
report(bool(head_ok), "openapi header (3.1.0, title Kvitt, version 1, one server)", "" if head_ok else "header fields differ from §14.3")
sec_ok = re.search(r"^ device:\n type: http\n scheme: bearer$", text, re.M) is not None
report(sec_ok, "openapi security scheme device = HTTP bearer (§8.2)", "" if sec_ok else "components.securitySchemes.device not found")