Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Everything here would otherwise be uploaded to the Docker daemon on every
# build. That matters more than usual for this repo: Fly.io builds on a remote
# builder by default, so the build context leaves the machine.
#
# The Dockerfile only COPYs Cargo.toml, Cargo.lock, src/ and config/, so
# nothing below is needed to build.

secrets/
.env
.env.*
!.env.example

data/
target/
.git/
.github/
.claude/
.planning/

*.md
docs/
tests/
deploy-fly.sh
docker-compose.yml
fly.toml
32 changes: 30 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,41 @@ RUN cargo build --release

FROM debian:bookworm-slim

RUN apt-get update && apt-get install -y \
# curl is here only for HEALTHCHECK, which has no other way to speak HTTP in a
# slim image. It buys nothing on Fly.io, which ignores Docker health checks and
# runs the ones declared in fly.toml, but docker-compose and plain `docker run`
# rely on it.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*

# Unprivileged runtime user. A fixed high UID and GID keep ownership predictable
# for bind mounts on the host. The group is created explicitly: `useradd --uid`
# alone picks the GID from the system range, so `USER 10001:10001` below would
# otherwise name a group that does not exist in /etc/group.
RUN groupadd --system --gid 10001 mostro \
&& useradd --system --no-create-home --shell /usr/sbin/nologin \
--uid 10001 --gid 10001 mostro

COPY --from=builder /usr/src/app/target/release/mostro-push-backend /usr/local/bin/
COPY secrets/ /secrets/

# The token store is in memory, so the only thing the process ever writes is
# the UnifiedPush endpoint file, resolved relative to the working directory.
WORKDIR /app
RUN mkdir -p /app/data && chown -R 10001:10001 /app

# The Firebase service account is deliberately NOT copied in. Baking it into a
# layer publishes it to anyone who can pull the image, `docker save` included,
# with no need to run the container. Provide it at runtime instead, through
# FIREBASE_SERVICE_ACCOUNT_JSON or a file mounted at
# FIREBASE_SERVICE_ACCOUNT_PATH. See docs/deployment.md.

ENV RUST_LOG=info

USER 10001:10001

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -fsS "http://127.0.0.1:${SERVER_PORT:-8080}/api/health" || exit 1

CMD ["mostro-push-backend"]
26 changes: 26 additions & 0 deletions deploy-fly.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ REQUIRED_SECRETS=(
NOSTR_RELAYS
SERVER_PRIVATE_KEY
FIREBASE_PROJECT_ID
)

# The Firebase credential no longer ships inside the image, so it must arrive
# at runtime. Either form works and exactly one is enough:
# FIREBASE_SERVICE_ACCOUNT_JSON - the credential itself (preferred on Fly)
# FIREBASE_SERVICE_ACCOUNT_PATH - a path to a file mounted into the machine
CREDENTIAL_SECRETS=(
FIREBASE_SERVICE_ACCOUNT_JSON
FIREBASE_SERVICE_ACCOUNT_PATH
)

Expand Down Expand Up @@ -48,6 +56,24 @@ if (( ${#missing_secrets[@]} > 0 )); then
exit 1
fi

# Without one of these the server still starts, but FCM is disabled and every
# push is silently dropped. Fail here rather than discover it in the logs.
credential_present=false
for secret in "${CREDENTIAL_SECRETS[@]}"; do
if grep -qx "${secret}" <<< "${configured_secret_names}"; then
credential_present=true
break
Comment on lines +63 to +65

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require an inline credential for standard Fly deploys

When an existing Fly app has only the previously required FIREBASE_SERVICE_ACCOUNT_PATH secret, this check still marks the credential as present and proceeds. However, the updated Dockerfile no longer copies the referenced file, and fly.toml defines no file or volume mount, so the server starts with FCM disabled and silently drops all FCM pushes. The Fly wrapper should require FIREBASE_SERVICE_ACCOUNT_JSON, unless it can verify that an external file mount has actually been provisioned.

Useful? React with 👍 / 👎.

fi
done

if [[ "${credential_present}" != true ]]; then
echo "No Firebase credential secret is set for ${APP_NAME}." >&2
echo "Set exactly one of:" >&2
printf ' - %s\n' "${CREDENTIAL_SECRETS[@]}" >&2
echo "The credential is no longer baked into the image. See docs/deployment.md." >&2
exit 1
fi

echo "Deploying..."
flyctl deploy -a "${APP_NAME}"

Expand Down
18 changes: 16 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,23 @@ services:
- SERVER_HOST=0.0.0.0
- SERVER_PORT=8080
- FCM_ENABLED=true
- UNIFIEDPUSH_ENABLED=true
# Opt-in: the dispatch path POSTs to the device token treated as a URL.
- UNIFIEDPUSH_ENABLED=false
- FIREBASE_PROJECT_ID=mostro-test
# The credential is not in the image. This path must match the mount
# below; without it FCM starts disabled and every push is dropped.
- FIREBASE_SERVICE_ACCOUNT_PATH=/app/secrets/firebase-service-account.json
- RUST_LOG=info
volumes:
- ./firebase-service-account.json:/app/firebase-service-account.json:ro
# The container runs as UID 10001, so this file must be readable by that
# UID on the host (e.g. `chmod 0644`). If that is awkward, drop the mount
# and pass the credential inline instead:
# FIREBASE_SERVICE_ACCOUNT_JSON=$(cat firebase-service-account.json)
- ./firebase-service-account.json:/app/secrets/firebase-service-account.json:ro
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/api/health"]
interval: 30s
timeout: 3s
start_period: 5s
retries: 3
restart: unless-stopped
13 changes: 10 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ To turn the filter on/off without rebuilding, flip
| `FCM_ENABLED` | `true` | Enable Firebase Cloud Messaging backend |
| `UNIFIEDPUSH_ENABLED` | `true` | Enable UnifiedPush backend |
| `FIREBASE_PROJECT_ID` | - | Firebase project ID, required when `FCM_ENABLED=true` |
| `FIREBASE_SERVICE_ACCOUNT_PATH` | - | Absolute path to the Firebase service-account JSON. If missing or unreadable, FCM is disabled at startup with a warning; the server keeps running. |
| `FIREBASE_SERVICE_ACCOUNT_JSON` | - | The Firebase service-account JSON itself. Takes precedence over the path form; an empty value is treated as absent. |
| `FIREBASE_SERVICE_ACCOUNT_PATH` | - | Absolute path to the Firebase service-account JSON. Used when the JSON form is unset. If neither resolves, FCM is disabled at startup with an `error` log and the server keeps running. |
| `BATCH_DELAY_MS` | `5000` | Reserved (declared on `PushConfig`; not currently consumed) |
| `COOLDOWN_MS` | `60000` | Reserved (declared on `PushConfig`; not currently consumed) |

Expand Down Expand Up @@ -141,7 +142,7 @@ SERVER_PORT=8080
FCM_ENABLED=true
UNIFIEDPUSH_ENABLED=false
FIREBASE_PROJECT_ID=mostro-mobile
FIREBASE_SERVICE_ACCOUNT_PATH=/secrets/mostro-mobile-firebase-adminsdk.json
FIREBASE_SERVICE_ACCOUNT_PATH=/app/secrets/firebase-service-account.json

# Token store
TOKEN_TTL_HOURS=48
Expand All @@ -163,6 +164,12 @@ RUST_LOG=info
1. [Firebase Console](https://console.firebase.google.com/) → your project → Project Settings → Service accounts.
2. Click **Generate new private key**, save the JSON file outside the repo.
3. Mount it into the runtime (Docker volume, Fly.io secret file, or a path on disk for systemd).
4. Set `FIREBASE_SERVICE_ACCOUNT_PATH` to the path the binary will read at startup.
4. Supply it at runtime with either `FIREBASE_SERVICE_ACCOUNT_JSON` (the JSON
itself) or `FIREBASE_SERVICE_ACCOUNT_PATH` (a path to a mounted file). It is
deliberately not baked into the container image; see
[deployment.md](./deployment.md#provisioning-the-firebase-service-account).

The container runs as UID 10001, so a bind-mounted file must be readable by
that UID on the host.

If FCM init fails (file missing, JSON invalid, OAuth refusal) the server logs a warning and runs without FCM. UnifiedPush, if enabled, continues to work.
51 changes: 47 additions & 4 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ flyctl secrets set -a mostro-push-server \
SERVER_PRIVATE_KEY="${server_private_key}" \
NOSTR_RELAYS="wss://relay.mostro.network" \
FIREBASE_PROJECT_ID="your-project-id" \
FIREBASE_SERVICE_ACCOUNT_PATH="/secrets/firebase-service-account.json" \
FIREBASE_SERVICE_ACCOUNT_JSON="$(cat /path/to/firebase-service-account.json)" \
FCM_ENABLED="true" \
UNIFIEDPUSH_ENABLED="false" \
SERVER_HOST="0.0.0.0" \
Expand All @@ -55,7 +55,7 @@ unset server_private_key
- `NOSTR_RELAYS`
- `SERVER_PRIVATE_KEY`
- `FIREBASE_PROJECT_ID`
- `FIREBASE_SERVICE_ACCOUNT_PATH`
- one of `FIREBASE_SERVICE_ACCOUNT_JSON` or `FIREBASE_SERVICE_ACCOUNT_PATH`

Deploy after the secrets exist:

Expand All @@ -65,7 +65,50 @@ Deploy after the secrets exist:

`NOTIFY_TRUST_PROXY_HEADERS=true` is correct on Fly because requests reach the app behind the Fly edge proxy, which sets `Fly-Client-IP`. On any deployment where the app is reachable directly, leave this `false`; otherwise an attacker can rotate that header per request and defeat the per-IP limiter.

The Firebase service account JSON is bundled into the Docker image at the path specified by `FIREBASE_SERVICE_ACCOUNT_PATH`. Provision it before the build (the `Dockerfile` copies the `secrets/` directory).
### Provisioning the Firebase service account

The credential is **not** in the image. It used to be: the `Dockerfile` copied
`secrets/` into a layer, which published the private key to anyone able to pull
the image — `docker save` and `docker history` reach it without ever running the
container. `.dockerignore` now also keeps `secrets/` out of the build context
entirely, which matters because Fly builds on a remote builder by default.

Two ways to supply it at runtime, and exactly one is needed:

| Variable | Use when |
|---|---|
| `FIREBASE_SERVICE_ACCOUNT_JSON` | The credential itself. Preferred on Fly.io, where a secret already *is* an environment variable. |
| `FIREBASE_SERVICE_ACCOUNT_PATH` | A path to a file mounted into the runtime. Preferred for docker-compose, systemd and Kubernetes. |

`FIREBASE_SERVICE_ACCOUNT_JSON` takes precedence when both are set. An empty
value is treated as absent, so a half-configured deployment falls back to the
path form instead of failing.

The inline form is the default on Fly for a specific reason: the container now
runs as UID 10001, and a file the platform mounts carries ownership and mode
this project does not control. An environment variable is readable by the
process whatever its UID.

```bash
flyctl secrets set -a mostro-push-server \
FIREBASE_SERVICE_ACCOUNT_JSON="$(cat /path/to/firebase-service-account.json)"
```

**Sequencing matters.** If neither variable is set the server still starts:
`main.rs` logs the failure and runs without FCM, because a listener and an HTTP
API without push are more useful than no server at all. The result is an
instance that accepts registrations and delivers nothing. `deploy-fly.sh`
refuses to deploy when no credential secret exists, but if you deploy by other
means, set the secret **before** rolling out an image built from this Dockerfile.

Confirm it took after the first deploy:

```bash
flyctl logs -a mostro-push-server | grep -i "FCM service initialized"
```

An `FCM notifications are DISABLED` line at `error` level means the credential
did not arrive.

### Rotate `SERVER_PRIVATE_KEY`

Expand Down Expand Up @@ -216,7 +259,7 @@ The only on-disk state is `data/unifiedpush_endpoints.json`, written atomically

There is no database to back up. Operationally important inputs are:

- `FIREBASE_SERVICE_ACCOUNT_PATH` JSON file (regenerate via Firebase Console if lost)
- The Firebase service account JSON, held in `FIREBASE_SERVICE_ACCOUNT_JSON` or at `FIREBASE_SERVICE_ACCOUNT_PATH` (regenerate via Firebase Console if lost)
- The contents of `flyctl secrets list` (or the `.env` file on bare-metal)
- `data/unifiedpush_endpoints.json` if you want UnifiedPush registrations to survive a host migration; clients will re-register on next use otherwise

Expand Down
9 changes: 9 additions & 0 deletions fly.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ primary_region = 'gru'
auto_start_machines = true
min_machines_running = 1

# Fly ignores the Dockerfile HEALTHCHECK and runs these instead. Without one,
# a machine that is up but no longer serving stays in rotation.
[[http_service.checks]]
grace_period = '10s'
interval = '15s'
method = 'GET'
timeout = '2s'
path = '/api/health'

[[services]]
protocol = 'tcp'
internal_port = 8080
Expand Down
12 changes: 10 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,16 @@ async fn main() -> std::io::Result<()> {
push_services.push((Arc::clone(&fcm_service) as Arc<dyn PushService>, "fcm"));
}
Err(e) => {
log::warn!("Failed to initialize FCM service: {}", e);
log::warn!("FCM notifications will be disabled. Set FIREBASE_SERVICE_ACCOUNT_PATH to enable.");
// error!, not warn!: the credential no longer ships inside the
// image, so a misconfigured deployment is now the likely cause
// rather than a corner case. The server keeps running — the
// Nostr listener and the HTTP API are still useful — but this
// must not scroll past as routine noise.
log::error!("Failed to initialize FCM service: {}", e);
log::error!(
"FCM notifications are DISABLED. Provide the credential via \
FIREBASE_SERVICE_ACCOUNT_JSON or FIREBASE_SERVICE_ACCOUNT_PATH."
);
}
}
}
Expand Down
Loading
Loading