store: document both sites and the egress rule

This commit is contained in:
shum committed 2026-10-22 04:59:09 +00:00
1 parent 462181e1d9
commit 10f6772456
1 file changed
+203 -5
+203 -5
View File
@@ -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 `<meta id="stripe-publishable-key" name="stripe-publishable-key" content="">`
(`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 <interface>` 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 ': </dev/tcp/10.255.255.1/80'
docker compose exec store-service bash -c ': </dev/tcp/127.0.0.1/8080'
docker compose exec store-service bash -c ': </dev/tcp/127.0.0.1/5432 && echo allowed'
```
- The first two answer `Connection refused` at once, sent by the rule. Without it the second would
reach the service's own listener.
- The third prints `allowed`. With the RPC node on this host, the same command with its port
(`8545` for a local reth) does too.
To remove it, delete the jump and the chain, in `iptables` and in `ip6tables` alike:
```
sudo iptables -D OUTPUT -m owner --uid-owner 1001 -j STORE_SERVICE_EGRESS
sudo iptables -F STORE_SERVICE_EGRESS
sudo iptables -X STORE_SERVICE_EGRESS
```
## Issuing codes
Operators issue codes two ways: from the service's own command line in `--run-cli` mode, and from the
@@ -221,6 +410,8 @@ The command is sent to the running service in `--run-cli` mode, not a way to sta
```
//issue <badge_type> [months] [paid|unpaid|free]
//issue supporter 12
//issue name <length> [years <Y>] [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<length>-…`): it pays for one name of at least
`<length>` 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 <code>
@@ -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 <type> [months <M>] [uses <N>]` and `/bulk <type> [months <M>] count <B>` for moderators
and above, `/revoke <code>` 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 <length> [years <Y>]` and `/bulk name <length> [years <Y>] count <B>`, 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,