Skip to content
Merged
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
32 changes: 32 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# 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/
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.env
.env.*
!.env.example

# The credential also lands outside secrets/: docker-compose.yml bind-mounts it
# from the context root, and the file Firebase hands you is named after the
# project. The Dockerfile COPYs none of these, but the context still travels.
firebase-service-account.json
**/*service-account*.json
**/*adminsdk*.json

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

*.md
docs/
tests/
deploy-fly.sh
docker-compose.yml
fly.toml
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ TRUSTED_WHITELIST_ENABLED=false
SERVER_PRIVATE_KEY=

# Firebase Configuration (optional, for FCM support)
# The credential is not baked into the container image. Supply it at runtime
# through exactly one of the two forms below; the inline one takes precedence.
FIREBASE_PROJECT_ID=mostro-test
# FIREBASE_SERVICE_ACCOUNT_JSON={"client_email":"...","private_key":"..."}
FIREBASE_SERVICE_ACCOUNT_PATH=/path/to/service-account.json

# UnifiedPush Configuration
Expand Down
34 changes: 31 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM rust:1.90 as builder
FROM rust:1.90 AS builder

WORKDIR /usr/src/app
COPY Cargo.toml Cargo.lock ./
Expand All @@ -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"]
52 changes: 50 additions & 2 deletions deploy-fly.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,33 @@ REQUIRED_SECRETS=(
NOSTR_RELAYS
SERVER_PRIVATE_KEY
FIREBASE_PROJECT_ID
FIREBASE_SERVICE_ACCOUNT_PATH
)

FLY_CONFIG="${FLY_CONFIG:-fly.toml}"

# The Firebase credential no longer ships inside the image, so it must arrive at
# runtime. On Fly a secret already *is* an environment variable, so the inline
# form needs nothing else, and it is what this wrapper requires.
#
# FIREBASE_SERVICE_ACCOUNT_PATH is deliberately not accepted here. It only names
# a file, and nothing this script can read proves a file will exist there:
# `flyctl secrets list` returns names, never values, so the path the secret
# holds cannot be compared against anything fly.toml declares. A [[files]] entry
# is not evidence either — it may well write something unrelated. Guessing wrong
# means FCM starts disabled and every push is dropped in silence, which is the
# failure this check exists to catch, and it is exactly what a PATH secret left
# over from when the image carried the credential would do today.
#
# The path form stays first-class everywhere the file is genuinely under the
# operator's control — docker-compose, systemd, Kubernetes — none of which
# deploy through this script. If you do provision one on Fly via [[files]], set
# FLY_ALLOW_CREDENTIAL_PATH=1 to assert that its guest_path is the path the
# secret names. That is an assertion the operator makes, not one verified here.
CREDENTIAL_SECRETS=(FIREBASE_SERVICE_ACCOUNT_JSON)
if [[ "${FLY_ALLOW_CREDENTIAL_PATH:-}" == 1 ]]; then
CREDENTIAL_SECRETS+=(FIREBASE_SERVICE_ACCOUNT_PATH)
fi

die() {
echo "Error: $*" >&2
exit 1
Expand Down Expand Up @@ -48,8 +72,32 @@ 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 thread
AndreaDiazCorreia marked this conversation as resolved.
fi
done

if [[ "${credential_present}" != true ]]; then
echo "No usable Firebase credential secret is set for ${APP_NAME}." >&2
echo "Set one of:" >&2
printf ' - %s\n' "${CREDENTIAL_SECRETS[@]}" >&2
if (( ${#CREDENTIAL_SECRETS[@]} == 1 )); then
echo "FIREBASE_SERVICE_ACCOUNT_PATH is not accepted for Fly deploys: nothing here" >&2
echo "can prove a file exists at the path it names. If ${FLY_CONFIG} provisions one" >&2
echo "through [[files]], re-run with FLY_ALLOW_CREDENTIAL_PATH=1." >&2
fi
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}"
# Explicit, so the config named in the messages above is the one deployed.
flyctl deploy -a "${APP_NAME}" -c "${FLY_CONFIG}"

echo "Deploy complete."
echo ""
Expand Down
31 changes: 24 additions & 7 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,34 @@ services:
- SERVER_HOST=0.0.0.0
- SERVER_PORT=8080
- FCM_ENABLED=true
# The Dockerfile bakes secrets/ into the image at /secrets. Only the
# file name is configurable: the path must stay inside the container,
# so it must not be interpolated from a host-side path.
- FIREBASE_SERVICE_ACCOUNT_PATH=/secrets/${FIREBASE_SERVICE_ACCOUNT_FILE:-firebase-service-account.json}
# Opt-in: the dispatch path POSTs to the client-supplied device
# token treated as a URL. Keep it false unless you accept that.
- 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Bare key: forwarded from the host shell only when set there, and it
# wins over the path above. This is what makes the mount optional --
# FIREBASE_SERVICE_ACCOUNT_JSON="$(cat firebase-service-account.json)" docker-compose up -d
# Without this line Compose never passes it in and FCM starts disabled.
- FIREBASE_SERVICE_ACCOUNT_JSON
- RUST_LOG=info
volumes:
# The binary runs with / as its working directory and writes the
# UnifiedPush endpoint store to data/unifiedpush_endpoints.json.
- ./data:/data
# The binary runs with /app as its working directory and writes the
# UnifiedPush endpoint store to data/unifiedpush_endpoints.json. A bind
# mount keeps the host's ownership, so ./data must be WRITABLE by UID
# 10001 (`chown 10001:10001 data`). Only matters once UnifiedPush is on.
- ./data:/app/data
# A private key: give it to UID 10001 and nobody else, rather than
# widening the mode until the container can read it.
# chmod 0600 firebase-service-account.json
# sudo chown 10001:10001 firebase-service-account.json
# Mode first: after the chown the file is UID 10001's, not yours.
# If host-side ownership is awkward, drop this mount and use the inline
# form above instead.
- ./firebase-service-account.json:/app/secrets/firebase-service-account.json:ro
# The image already declares a HEALTHCHECK, and it reads SERVER_PORT from
# the container environment. Redeclaring it here would hard-code the port
# and silently stop matching the moment SERVER_PORT above changes.
restart: unless-stopped
15 changes: 11 additions & 4 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` | `false` | Enable UnifiedPush backend. Opt-in on purpose: the dispatch path POSTs to the client-supplied device token treated as a URL, so the backend stays off unless set explicitly. |
| `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 Down Expand Up @@ -184,6 +185,12 @@ Full detail, including the known DNS-rebinding limitation, is in
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).

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.
The container runs as UID 10001, so a bind-mounted file must be readable by
that UID on the host.

If FCM init fails (no credential configured, JSON invalid, OAuth refusal) the server logs at `error` and runs without FCM. UnifiedPush, if enabled, continues to work.
Loading
Loading