From 10f677245692a3caa221be7436b1716cc8bf1e77 Mon Sep 17 00:00:00 2001 From: shum Date: Thu, 22 Oct 2026 04:59:09 +0000 Subject: [PATCH] store: document both sites and the egress rule --- apps/simplex-store-service/README.md | 208 ++++++++++++++++++++++++++- 1 file changed, 203 insertions(+), 5 deletions(-) diff --git a/apps/simplex-store-service/README.md b/apps/simplex-store-service/README.md index 6ff25c0549..2a02d85737 100644 --- a/apps/simplex-store-service/README.md +++ b/apps/simplex-store-service/README.md @@ -25,7 +25,7 @@ At this stage the service: - does not accept contact requests: the address is for RPC only, - in service mode with `[group]` in the ini, manages one SimpleX group and serves `/issue`, `/bulk` and `/revoke` in it (see [Issuing codes](#issuing-codes)), -- in service mode with `--service-config`, also serves the built web app (`npm run build` in `web/`), `POST /api/invoice` and `GET /api/invoice/:id`, the BTCPay and Stripe webhook routes, and a payment poller, seeding its price/offer catalog on every start, +- in service mode with `--service-config`, also serves the built web app (`npm run build` in `web/`) and, on the hosts in `names_hosts`, the names site (see [Deployment](#deployment)), `POST /api/invoice` and `GET /api/invoice/:id`, the BTCPay and Stripe webhook routes, and a payment poller, seeding its price/offer catalog on every start, - owns the `sx_badge_service_`-prefixed tables and its own migrations table (`sx_badge_service_migrations`). Every other command answers `unsupported_version`, or `unknown_purchase_key` when the key that @@ -153,7 +153,8 @@ absent `[btcpay]` does for Bitcoin and Monero. Its keys: The service fills `publishable_key` into the shell at boot: the built `index.html` ships with an empty `` (`web/public/index.html`), and the listener serves a copy with the configured key -substituted in, so the ini is the only place the key is set. With no `[stripe]` section the +substituted in, so the ini is the only place the key is set. The names site's shell carries the +same meta without the `id`, and is filled the same way. With no `[stripe]` section the pristine (empty) shell is served and the page shows its development stand-in card form. The dev mock (`web/mock/server.py`) does the same substitution from `$STRIPE_PUBLISHABLE_KEY`, since it runs without an ini. @@ -172,6 +173,8 @@ script-src 'self' https://js.stripe.com https://*.js.stripe.com; frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com ``` +The full policies of both sites are in [Caddy](#caddy). + **Stripe Link must be disabled in the Dashboard.** Left on, it needs `link.com` in the CSP above and reintroduces an email prompt of its own, which this design otherwise avoids. @@ -208,6 +211,192 @@ the exception: each answers 200, 400 or 413 with an empty body, because its prov caller and nothing it could read would change what the route does. A wrong verb on any route, those two included, answers `method_not_allowed`. +## Deployment + +`scripts/store-service/` holds the production deployment: the image (`Dockerfile`, built from +the repository root), `compose.yml`, the ini template `store_service.ini.example` and the egress +rule `egress.sh`. The service runs with `+client_postgres` and host networking; one Caddy serves +both sites from the folders the service exports at boot and proxies the API to it. + +### Upgrading from the badge service + +The rename to StoreService changed what the operator runs: + +| Before | After | +|---|---| +| `scripts/badge-service/` | `scripts/store-service/` | +| compose service `badge-service`, image `simplex-badge-service` | compose service `store-service`, image `simplex-store-service` | +| `badge_service.ini`, mounted at `/etc/simplex-badge-service/badge_service.ini` | `store_service.ini`, mounted at `/etc/simplex-store-service/store_service.ini` | +| the binary `simplex-badge-service` | `simplex-store-service` | + +Move `badge_service.ini` to `scripts/store-service/store_service.ini`, and point Caddy's badges +root at `scripts/store-service/web`, which the service fills at boot. Then start the new service +with + +``` +docker compose up -d --build --remove-orphans +``` + +from `scripts/store-service/`. Without `--remove-orphans` the old `badge-service` container keeps +running beside the new one, on the same port and database. The database `badge_service`, +`BADGE_DB_CONN`, the `sx_badge_service_` tables and the chat database prefix +`simplex_badge_service` are unchanged. + +### Two sites + +The image carries the badges build at `/srv/web` (`static_dir`). The names site (simplex.domains) +is configured beside it in `[listener]`. The image does not build the names site yet, so mount a +names build into the container for `names_static_dir` until it does. + +- `names_static_dir`: the names build, the directory `npm run build` writes in the names webapp + (`dist/`); +- `names_export_dir`: where the service writes the names site at boot, with the Stripe + publishable key in its shell, as `webapp_export_dir` is for the badges site. `compose.yml` + bind-mounts `./names` there (`/srv/names-export`), and `./web` at `/srv/webapp`; +- `names_hosts`: the comma-separated hosts the names site is served on when `serve_webapp = on`. + +The service refuses to start with `names_hosts` or `names_export_dir` but no `names_static_dir`. + +With `serve_webapp = on` (all in one), the listener picks the site by the request's `Host`, without +its port: a host in `names_hosts` gets the names site, any other host the badges site. Every path +outside `/api/` and `/webhooks/` is a file of the chosen site, `/` and `/index.html` its shell. The +API answers on every host. + +### Caddy + +The Caddyfile is not in this repository. Its two host blocks, with the exported folders at +`/srv/store-service/web` and `/srv/store-service/names` and the listener at `127.0.0.1:8080`: + +``` +badges.simplex.chat { + encode zstd gzip + handle /api/* { + reverse_proxy 127.0.0.1:8080 + } + handle /webhooks/* { + reverse_proxy 127.0.0.1:8080 + } + handle { + root * /srv/store-service/web + file_server + } + @shell path / /index.html /sw.js + header @shell Cache-Control "no-cache" + header /assets/* { + Cache-Control "public, max-age=31536000, immutable" + match status 2xx + } + header Content-Security-Policy "default-src 'self'; script-src 'self' https://js.stripe.com https://*.js.stripe.com; frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com; connect-src 'self' https://api.stripe.com; img-src 'self' https://*.stripe.com; frame-ancestors 'self' https://simplex.chat https://*.simplex.chat" +} + +simplex.domains { + encode zstd gzip + handle /api/* { + reverse_proxy 127.0.0.1:8080 + } + handle { + root * /srv/store-service/names + file_server + } + @shell path / /index.html /sw.js + header @shell Cache-Control "no-cache" + header /assets/* { + Cache-Control "public, max-age=31536000, immutable" + match status 2xx + } + header /manifest.webmanifest Content-Type "application/manifest+json" + @media path /fonts/* /img/* + header @media { + Cache-Control "public, max-age=86400" + match status 2xx + } + header { + Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-i5ar+ITNdQiucbmin4/AN6DAY5LbAWeq3at+RiFoUoY=' 'sha256-Jhsw0wf9pI4icAxqab9mIjf34YdecDZeLVN+/KVZW+4=' https://js.stripe.com https://*.js.stripe.com; frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com; connect-src 'self' https://api.stripe.com; img-src 'self' data: https://*.stripe.com; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'" + Strict-Transport-Security "max-age=63072000; includeSubDomains" + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin-when-cross-origin" + Cross-Origin-Opener-Policy "same-origin-allow-popups" + } +} +``` + +- Both sites call `/api/*` on their own origin, so there is no CORS. +- `/webhooks/*` is proxied on badges.simplex.chat only. BTCPay and Stripe send every event there, + names payments included. +- The shell and `/sw.js` are `no-cache`, since they name the build's hashed files, and Caddy's + `file_server` sends no `Cache-Control` of its own. `/assets/*` file names carry a content hash. + The long lifetimes apply to found files only, so a missing asset is not cached for a year. +- `file_server` sends no type for `.webmanifest`, which the names site's manifest needs. +- The badges policy is `web/README.md`'s. The names policy is the names webapp's (its README, + "Hosting"), with `https://*.stripe.com` added to `img-src` beside `data:`, which the link + profiles' pictures need. Its two hashes are those of the inline theme and font-preload scripts + in the names `index.html`, and change with them. It allows no inline style. +- Stripe.js runs on simplex.domains itself, in the document that keeps the owner codes and prepaid + codes in `localStorage`. The owner accepted this on 2026-10-20, rather than a Checkout redirect + or a separate card origin. Scripts and frames are allowed only from this origin and Stripe's + hosts. + +### Egress rule + +The link previews check a link's hosts before the chat core fetches it, but the core resolves the +host again when it connects, so a name that changes its answer in between (DNS rebinding) can +reach a private address. The guard is a firewall rule: the service's new outbound connections to +unspecified, private, CGNAT, loopback, link-local, benchmarking, multicast and reserved IPv4 +ranges, and to unique-local, link-local, site-local and multicast IPv6 ranges (with IPv4-mapped, +IPv4-compatible and NAT64 forms of the IPv4 ones), are rejected. The exceptions are the +endpoints the service is configured to use, on their ports: the RPC node (`[chain] rpc_url`), +BTCPay (`[btcpay] host`), the database and the DNS resolvers in `/etc/resolv.conf`. Replies on +connections already open, such as Caddy's requests, pass. + +`egress.sh` writes this as the chain `STORE_SERVICE_EGRESS` in `iptables` and `ip6tables`, and +rebuilds it on every run. With compose's host networking it matches the service's user in +`OUTPUT` (`-m owner --uid-owner`). That rule covers every process of that user on the host, so +set `USER_UID` (and `USER_GID`) in compose's environment to ids no other process runs as. On a +Docker bridge network instead, `--bridge ` matches traffic from that network in +`DOCKER-USER` and `INPUT`. + +Install it as root from `scripts/store-service/`. `--uid` and `--db` default to the environment's +`USER_UID` and `BADGE_DB_CONN`, else to compose's defaults (1000 and +`postgresql://simplex@127.0.0.1:5432/badge_service`). `sudo` drops the environment, so pass them +when compose runs with other values, here a uid of 1001: + +``` +sudo ./egress.sh --dry-run --uid 1001 +sudo ./egress.sh --uid 1001 +sudo ./egress.sh --check --uid 1001 +``` + +- `--dry-run` prints the commands and changes nothing; read the allowed endpoints in it. +- `--check` exits 1 and names what differs unless every rule and jump is in place and the chain + holds nothing else. +- Names are resolved when the rule is installed: run it again after changing the ini, the + database string or the resolvers, and after a private endpoint's address changes. A name the + host cannot resolve (a container name) stops the install; pass its address with + `--allow ADDRESS:PORT`, as for any other endpoint the service needs, such as a SOCKS proxy. +- The rules do not survive a reboot. Run the script from a boot unit ordered before Docker, or + save them with `netfilter-persistent`. + +Check it by hand from inside the container, once the service runs: + +``` +docker compose exec store-service bash -c ': [months] [paid|unpaid|free] //issue supporter 12 +//issue name [years ] [paid|unpaid|free] +//issue name 7 years 5 ``` `months` defaults to 1 and must be between 1 and 255; the status defaults to `free` and records @@ -228,10 +419,14 @@ whether the code was sold (`paid`), is awaiting payment (`unpaid`), or was issue (`free`). Redemption refuses an `unpaid` code with `payment_pending`: the web checkout writes the code row when the invoice is created, and settlement is what marks it paid. +`//issue name` makes a prepaid name code (`SN-…`): it pays for one name of at least +`` letters, 6 to 63, for `years` years, 2 to 10 (default 2). The status is as above: an +order refuses an `unpaid` name code with `payment_pending`. + The code is printed once and only its `SHA-256` is stored, so a code that is not copied when it is shown cannot be recovered. -A code that leaked, or that was refunded, is withdrawn the same way: +A code that leaked, or that was refunded, is withdrawn the same way, badge and name codes alike: ``` //revoke @@ -252,8 +447,11 @@ why issuing codes lives in the service rather than in core. With `[group]` in `store_service.ini`, the service manages one group and serves three commands in it: `/issue [months ] [uses ]` and `/bulk [months ] count ` for moderators and above, `/revoke ` for admins and owners. `months` is 1 to 255, `uses` 1 to 1000 and -`count` 1 to 100; a value outside these gets the usage reply. A member's role is checked as the -service last saw it, so a command sent by a moderator just demoted or removed can still run if it +`count` 1 to 100; a value outside these gets the usage reply. The name forms are +`/issue name [years ]` and `/bulk name [years ] count `, with the +bounds of `//issue name`; a name code issued in the group is single-use and `free`. `/revoke` +takes badge codes only: withdraw a name code with `//revoke` in `--run-cli` mode. A member's role +is checked as the service last saw it, so a command sent by a moderator just demoted or removed can still run if it reaches the service first; revoke any code the service posts for them after the change. `uses` above 1 makes a multi-use code, tracked by a group message showing its remaining uses and the time of the last one; when every use is redeemed, the same message says so. Every reply carrying a code is read by every member,