mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 06:32:59 +00:00
store: document both sites and the egress rule
This commit is contained in:
1 parent
462181e1d9
commit
10f6772456
1 file changed
+203
-5
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user