diff --git a/.gitignore b/.gitignore index b45f41f9..78ef489a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ build /repositories repo/ db_backups +/secrets/mongo-replica-keyfile message.txt # macOS diff --git a/README.md b/README.md index 0540c749..2911a4a8 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,9 @@ docker-compose up -d **4. Open** . The port can be changed in `docker-compose.yml`; putting Anonymous GitHub behind nginx is recommended for HTTPS. +For an optional remote, hidden, delayed MongoDB replica and backup source, see +the [MongoDB replication guide](docs/mongodb-replication.md). + ## Scope of anonymization diff --git a/docker-compose.replica-primary.yml b/docker-compose.replica-primary.yml new file mode 100644 index 00000000..2805c5fd --- /dev/null +++ b/docker-compose.replica-primary.yml @@ -0,0 +1,17 @@ +services: + mongodb: + entrypoint: + - /bin/bash + - /opt/anonymous-github/mongodb-replica-entrypoint.sh + command: + - --quiet + - --replSet + - rs0 + - --keyFile + - /tmp/mongo-replica-keyfile + - --bind_ip_all + environment: + MONGO_REPLICA_KEYFILE_PATH: /run/secrets/mongo-replica-keyfile + volumes: + - ${MONGO_REPLICA_KEYFILE:-./secrets/mongo-replica-keyfile}:/run/secrets/mongo-replica-keyfile:ro + - ./scripts/mongodb-replica-entrypoint.sh:/opt/anonymous-github/mongodb-replica-entrypoint.sh:ro diff --git a/docker-compose.replica-secondary.yml b/docker-compose.replica-secondary.yml new file mode 100644 index 00000000..c32f9a0d --- /dev/null +++ b/docker-compose.replica-secondary.yml @@ -0,0 +1,38 @@ +name: anonymous-github-mongo-secondary + +services: + mongodb-secondary: + image: ${MONGO_IMAGE:-mongo:latest} + restart: unless-stopped + entrypoint: + - /bin/bash + - /opt/anonymous-github/mongodb-replica-entrypoint.sh + command: + - --quiet + - --replSet + - rs0 + - --keyFile + - /tmp/mongo-replica-keyfile + - --bind_ip_all + environment: + MONGO_REPLICA_KEYFILE_PATH: /run/secrets/mongo-replica-keyfile + volumes: + - mongodb_secondary_data:/data/db + - ${MONGO_REPLICA_KEYFILE:-./secrets/mongo-replica-keyfile}:/run/secrets/mongo-replica-keyfile:ro + - ./scripts/mongodb-replica-entrypoint.sh:/opt/anonymous-github/mongodb-replica-entrypoint.sh:ro + ports: + - ${MONGO_BIND_ADDRESS:-127.0.0.1}:27017:27017 + healthcheck: + test: + - CMD + - mongosh + - --quiet + - --eval + - "db.adminCommand('ping')" + interval: 10s + timeout: 10s + retries: 12 + start_period: 10s + +volumes: + mongodb_secondary_data: diff --git a/docker-compose.yml b/docker-compose.yml index d904957c..7db5a209 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,7 +79,7 @@ services: retries: 5 mongodb: - image: mongo:latest + image: ${MONGO_IMAGE:-mongo:latest} restart: on-failure environment: MONGO_INITDB_ROOT_USERNAME: $DB_USERNAME @@ -87,7 +87,7 @@ services: volumes: - mongodb_data_container:/data/db ports: - - 127.0.0.1:27017:27017 + - ${MONGO_BIND_ADDRESS:-127.0.0.1}:27017:27017 command: --quiet healthcheck: test: diff --git a/docs/mongodb-replication.md b/docs/mongodb-replication.md new file mode 100644 index 00000000..6bcda600 --- /dev/null +++ b/docs/mongodb-replication.md @@ -0,0 +1,200 @@ +# Optional remote MongoDB replica + +This deployment option converts the existing standalone MongoDB container into +a replica set named `rs0` and adds a remote, hidden, non-voting secondary. +Normal single-node deployments continue to use `docker-compose.yml` alone. + +The remote member is designed as a recovery and backup source: + +- It cannot become primary. +- Its availability does not affect production majority writes. +- It is hidden from application traffic. +- It is delayed by one hour by default, providing a short recovery window for + accidental deletes or a bad migration. + +Replication is not a backup. Destructive changes eventually reach every +member. Continue making encrypted, off-host `mongodump` archives from the +secondary. + +## Requirements + +1. Take and verify a `mongodump` backup before converting production. +2. Install this repository on both servers. +3. Connect the servers through a trusted private network such as WireGuard, + Tailscale, a private VLAN, or a cloud private network. +4. Create stable DNS names that resolve from both servers and from the + application container, for example: + + - `mongo-primary.internal` + - `mongo-secondary.internal` + +5. Permit TCP port `27017` only between the required private-network hosts. + Do not expose MongoDB to the public internet. +6. Run the exact same MongoDB version on both servers. Find the current + production version with: + + ```bash + docker compose exec mongodb mongod --version + ``` + + Set the matching image in both servers' `.env` files: + + ```env + MONGO_IMAGE=mongo: + ``` + +## 1. Generate the shared member key + +On the primary server: + +```bash +./scripts/mongodb-replica.sh generate-key +``` + +This creates `secrets/mongo-replica-keyfile` without overwriting an existing +key. Copy that exact file securely to the same repository-relative path on the +secondary server: + +```bash +scp secrets/mongo-replica-keyfile \ + secondary-server:/path/to/anonymous_github/secrets/mongo-replica-keyfile +``` + +Keep this key outside source control and backups that are accessible to +untrusted users. Every replica-set member must share the same key. + +## 2. Configure production + +Add these values to the primary server's `.env`: + +```env +# Use the exact version already running in production. +MONGO_IMAGE=mongo: + +# The primary server's private/VPN interface. +MONGO_BIND_ADDRESS= +MONGO_REPLICA_KEYFILE=./secrets/mongo-replica-keyfile + +# Make the replica overlay the default for future Compose commands. +COMPOSE_FILE=docker-compose.yml:docker-compose.replica-primary.yml + +# URL-encode special characters in the username and password. +MONGODB_URI=mongodb://:@mongo-primary.internal:27017/production?authSource=admin&replicaSet=rs0&retryWrites=true&w=majority +``` + +Stop application writers, leaving MongoDB available: + +```bash +docker compose stop anonymous_github streamer +``` + +Start MongoDB with replica-set support and initialize the existing database as +the primary: + +```bash +./scripts/mongodb-replica.sh primary-up \ + mongo-primary.internal:27017 +``` + +`primary-up` is idempotent. If `rs0` is already initialized, it reports that +state instead of replacing the replica-set configuration. + +## 3. Start the remote secondary + +On the secondary server, create a minimal `.env`: + +```env +MONGO_IMAGE=mongo: +MONGO_BIND_ADDRESS= +MONGO_REPLICA_KEYFILE=./secrets/mongo-replica-keyfile +``` + +Start its empty MongoDB data volume: + +```bash +./scripts/mongodb-replica.sh secondary-up +``` + +Do not copy the primary's live WiredTiger volume. MongoDB performs the initial +sync after the member is added. + +## 4. Add and verify the secondary + +Back on the primary server, add the remote member with the default one-hour +delay: + +```bash +./scripts/mongodb-replica.sh add-secondary \ + mongo-secondary.internal:27017 +``` + +To keep the remote copy current instead, explicitly set a zero-second delay: + +```bash +./scripts/mongodb-replica.sh add-secondary \ + mongo-secondary.internal:27017 0 +``` + +Check initial-sync and replication state: + +```bash +./scripts/mongodb-replica.sh status +``` + +Wait until the remote member reports `SECONDARY`, then restart the application: + +```bash +docker compose up -d anonymous_github streamer redis +``` + +## Operations + +Use the primary overlay for every future operation on the production stack. +Keeping `COMPOSE_FILE` in `.env` makes ordinary commands such as +`docker compose up -d` and `docker compose logs mongodb` use it automatically. + +Check replica status: + +```bash +./scripts/mongodb-replica.sh status +``` + +If a destructive production operation occurs and the secondary is delayed, +stop the secondary container immediately before the delay window elapses: + +```bash +docker compose -f docker-compose.replica-secondary.yml stop mongodb-secondary +``` + +Then take a copy or logical dump of the delayed data before attempting +recovery. + +For automatic failover, use three voting data-bearing members instead of +turning this two-server recovery topology into a two-voter replica set. Two +voters require both servers to acknowledge a majority and can make production +unwritable during a network outage. + +## Troubleshooting + +- All members must use the same replica-set name (`rs0`), MongoDB version, and + shared keyfile. +- The hostnames stored in `rs.conf()` must resolve from every member. +- The application container must also resolve `mongo-primary.internal`. Use + private DNS or a Compose `extra_hosts` entry if host DNS is not propagated + into Docker. +- Check container logs with: + + ```bash + docker compose logs mongodb + docker compose -f docker-compose.replica-secondary.yml logs mongodb-secondary + ``` + +- Re-run `primary-up`, `add-secondary`, or `status` safely; the management + operations do not replace existing replica-set members. + +## MongoDB references + +- [Convert a standalone server to a replica set](https://www.mongodb.com/docs/manual/tutorial/convert-standalone-to-replica-set/) +- [Deploy a replica set with member authentication](https://www.mongodb.com/docs/v8.0/tutorial/deploy-replica-set-with-keyfile-access-control/) +- [Configure a delayed member](https://www.mongodb.com/docs/manual/tutorial/configure-a-delayed-replica-set-member/) +- [Hidden replica-set members](https://www.mongodb.com/docs/manual/core/replica-set-hidden-member/) diff --git a/scripts/mongodb-replica-entrypoint.sh b/scripts/mongodb-replica-entrypoint.sh new file mode 100755 index 00000000..438499c8 --- /dev/null +++ b/scripts/mongodb-replica-entrypoint.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +source_keyfile="${MONGO_REPLICA_KEYFILE_PATH:-/run/secrets/mongo-replica-keyfile}" +runtime_keyfile="/tmp/mongo-replica-keyfile" + +if [[ ! -s "$source_keyfile" ]]; then + echo "MongoDB replica keyfile is missing or empty: $source_keyfile" >&2 + exit 1 +fi + +# Bind-mounted secrets commonly have host ownership or permissive modes that +# mongod rejects. Copy to an ephemeral location with the required ownership +# and permissions before the official image entrypoint drops privileges. +install -m 400 -o mongodb -g mongodb "$source_keyfile" "$runtime_keyfile" + +exec /usr/local/bin/docker-entrypoint.sh "$@" diff --git a/scripts/mongodb-replica.sh b/scripts/mongodb-replica.sh new file mode 100755 index 00000000..1e2bd7aa --- /dev/null +++ b/scripts/mongodb-replica.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +primary_files=( + -f "$repository_root/docker-compose.yml" + -f "$repository_root/docker-compose.replica-primary.yml" +) +secondary_files=( + -f "$repository_root/docker-compose.replica-secondary.yml" +) + +compose_prefix=(docker compose) +if [[ -n "${COMPOSE_ENV_FILE:-}" ]]; then + compose_prefix+=(--env-file "$COMPOSE_ENV_FILE") +fi + +keyfile="${MONGO_REPLICA_KEYFILE:-$repository_root/secrets/mongo-replica-keyfile}" +if [[ "$keyfile" != /* ]]; then + keyfile="$repository_root/${keyfile#./}" +fi + +usage() { + cat <<'USAGE' +Usage: + ./scripts/mongodb-replica.sh generate-key + ./scripts/mongodb-replica.sh primary-up + ./scripts/mongodb-replica.sh secondary-up + ./scripts/mongodb-replica.sh add-secondary [delay-seconds] + ./scripts/mongodb-replica.sh status + +Environment: + COMPOSE_ENV_FILE Optional Compose environment file. + MONGO_REPLICA_KEYFILE Shared keyfile path (default: ./secrets/mongo-replica-keyfile). + +The primary and secondary hostnames must resolve from both MongoDB servers. +The remote member is added hidden, priority 0, and non-voting so a WAN outage +cannot block production majority writes. Set delay-seconds to 0 for no delay. +USAGE +} + +validate_host() { + if [[ ! "$1" =~ ^[A-Za-z0-9.-]+:[0-9]{1,5}$ ]]; then + echo "Expected a resolvable hostname and port, for example mongo-primary.internal:27017" >&2 + exit 2 + fi +} + +validate_delay() { + if [[ ! "$1" =~ ^[0-9]+$ ]]; then + echo "Delay must be a non-negative number of seconds" >&2 + exit 2 + fi +} + +require_keyfile() { + if [[ ! -s "$keyfile" ]]; then + echo "Replica keyfile not found: $keyfile" >&2 + echo "Run ./scripts/mongodb-replica.sh generate-key first." >&2 + exit 2 + fi +} + +primary_compose() { + "${compose_prefix[@]}" "${primary_files[@]}" "$@" +} + +secondary_compose() { + "${compose_prefix[@]}" "${secondary_files[@]}" "$@" +} + +mongo_eval() { + local javascript="$1" + primary_compose exec -T -e "MONGO_RS_EVAL=$javascript" mongodb sh -eu -c \ + 'mongosh --quiet \ + --username="$MONGO_INITDB_ROOT_USERNAME" \ + --password="$MONGO_INITDB_ROOT_PASSWORD" \ + --authenticationDatabase=admin \ + --eval "$MONGO_RS_EVAL"' +} + +wait_for_primary_container() { + local attempt + for attempt in $(seq 1 60); do + if primary_compose exec -T mongodb sh -eu -c \ + 'mongosh --quiet \ + --username="$MONGO_INITDB_ROOT_USERNAME" \ + --password="$MONGO_INITDB_ROOT_PASSWORD" \ + --authenticationDatabase=admin \ + --eval "db.adminCommand({ ping: 1 }).ok"' >/dev/null 2>&1; then + return + fi + sleep 2 + done + echo "MongoDB did not become ready within 120 seconds" >&2 + exit 1 +} + +command="${1:-}" +case "$command" in + generate-key) + if [[ -e "$keyfile" ]]; then + echo "Refusing to overwrite existing keyfile: $keyfile" >&2 + exit 2 + fi + mkdir -p "$(dirname "$keyfile")" + umask 077 + openssl rand -base64 756 > "$keyfile" + chmod 600 "$keyfile" + echo "Created $keyfile" + echo "Copy this exact file securely to the secondary server." + ;; + + primary-up) + primary_host="${2:-}" + validate_host "$primary_host" + require_keyfile + primary_compose up -d mongodb + wait_for_primary_container + mongo_eval " + try { + const status = rs.status(); + print('Replica set already initialized as ' + status.set); + } catch (error) { + if (error.code !== 94 && error.codeName !== 'NotYetInitialized') throw error; + printjson(rs.initiate({ + _id: 'rs0', + members: [{ _id: 0, host: '$primary_host', priority: 1, votes: 1 }] + })); + } + " + ;; + + secondary-up) + require_keyfile + secondary_compose up -d mongodb-secondary + ;; + + add-secondary) + secondary_host="${2:-}" + delay="${3:-3600}" + validate_host "$secondary_host" + validate_delay "$delay" + mongo_eval " + const host = '$secondary_host'; + const existing = rs.conf().members.find((member) => member.host === host); + if (existing) { + print('Secondary already configured: ' + host); + } else { + printjson(rs.add({ + host, + priority: 0, + votes: 0, + hidden: true, + secondaryDelaySecs: $delay + })); + } + " + ;; + + status) + mongo_eval " + printjson(rs.status().members.map((member) => ({ + name: member.name, + state: member.stateStr, + health: member.health, + replicationTime: member.optimeDate + }))); + " + ;; + + *) + usage + [[ -n "$command" ]] && exit 2 + ;; +esac diff --git a/src/config.ts b/src/config.ts index 32297002..da265784 100644 --- a/src/config.ts +++ b/src/config.ts @@ -26,6 +26,12 @@ interface Config { DB_USERNAME: string; DB_PASSWORD: string; DB_HOSTNAME: string; + /** + * Complete MongoDB connection string. When set, this takes precedence over + * DB_USERNAME, DB_PASSWORD, and DB_HOSTNAME and supports replica-set seed + * lists and options. + */ + MONGODB_URI: string; FOLDER: string; additionalExtensions: string[]; S3_BUCKET: string | null; @@ -73,6 +79,7 @@ const config: Config = { DB_USERNAME: "admin", DB_PASSWORD: "password", DB_HOSTNAME: "mongodb", + MONGODB_URI: "", REDIS_HOSTNAME: "redis", REDIS_PORT: 6379, FOLDER: resolve(__dirname, "..", "repositories"), @@ -142,8 +149,10 @@ if (isProduction) { const insecureDefaults: [string, string][] = [ ["CLIENT_ID", "CLIENT_ID"], ["CLIENT_SECRET", "CLIENT_SECRET"], - ["DB_PASSWORD", "password"], ]; + if (!config.MONGODB_URI) { + insecureDefaults.push(["DB_PASSWORD", "password"]); + } for (const [key, badValue] of insecureDefaults) { if ((config as unknown as Record)[key] === badValue) { throw new Error( diff --git a/src/server/database.ts b/src/server/database.ts index 6f1144d3..36980ee9 100644 --- a/src/server/database.ts +++ b/src/server/database.ts @@ -8,7 +8,12 @@ import PullRequest from "../core/PullRequest"; import AnonymizedGistModel from "../core/model/anonymizedGists/anonymizedGists.model"; import Gist from "../core/Gist"; -const MONGO_URL = `mongodb://${config.DB_USERNAME}:${config.DB_PASSWORD}@${config.DB_HOSTNAME}:27017/`; +export function getMongoUrl(): string { + return ( + config.MONGODB_URI || + `mongodb://${config.DB_USERNAME}:${config.DB_PASSWORD}@${config.DB_HOSTNAME}:27017/production` + ); +} export const database = mongoose.connection; @@ -16,11 +21,12 @@ export let isConnected = false; export async function connect() { mongoose.set("strictQuery", false); - await mongoose.connect(MONGO_URL + "production", { - authSource: "admin", + const options: ConnectOptions = { appName: "Anonymous GitHub Server", compressors: "zstd", - } as ConnectOptions); + }; + if (!config.MONGODB_URI) options.authSource = "admin"; + await mongoose.connect(getMongoUrl(), options); isConnected = true; return database; diff --git a/test/database-connection.test.js b/test/database-connection.test.js new file mode 100644 index 00000000..81aefb10 --- /dev/null +++ b/test/database-connection.test.js @@ -0,0 +1,38 @@ +require("ts-node/register/transpile-only"); +const { expect } = require("chai"); + +const config = require("../src/config").default; +const { getMongoUrl } = require("../src/server/database"); + +describe("MongoDB connection configuration", function () { + const original = { + MONGODB_URI: config.MONGODB_URI, + DB_USERNAME: config.DB_USERNAME, + DB_PASSWORD: config.DB_PASSWORD, + DB_HOSTNAME: config.DB_HOSTNAME, + }; + + afterEach(function () { + Object.assign(config, original); + }); + + it("uses a complete replica-set URI when configured", function () { + const uri = + "mongodb://user:password@mongo-primary.internal:27017/production" + + "?authSource=admin&replicaSet=rs0&w=majority"; + config.MONGODB_URI = uri; + + expect(getMongoUrl()).to.equal(uri); + }); + + it("retains the existing single-node connection fallback", function () { + config.MONGODB_URI = ""; + config.DB_USERNAME = "user"; + config.DB_PASSWORD = "password"; + config.DB_HOSTNAME = "mongodb"; + + expect(getMongoUrl()).to.equal( + "mongodb://user:password@mongodb:27017/production" + ); + }); +});