From b87a8ef70e38b1a1379fc647651a84436d36310c Mon Sep 17 00:00:00 2001 From: pavel444-byte Date: Sat, 5 Sep 2026 17:09:53 +0500 Subject: [PATCH] DUNGEONS-30: add access gateway with Telegram and Discord approvals Co-authored-by: multica-agent --- .dockerignore | 8 ++ .env.example | 42 ++++++++ .gitignore | 9 ++ .nvmrc | 1 + Dockerfile | 13 +++ README.md | 119 +++++++++++++++++++++ compose.yaml | 21 ++++ package-lock.json | 204 ++++++++++++++++++++++++++++++++++++ package.json | 21 ++++ public/app.js | 73 +++++++++++++ public/index.html | 69 ++++++++++++ public/mark.svg | 1 + public/style.css | 94 +++++++++++++++++ scripts/check.js | 16 +++ scripts/register-discord.js | 16 +++ scripts/revoke-sessions.js | 22 ++++ src/bots/api.js | 22 ++++ src/bots/discord.js | 73 +++++++++++++ src/bots/service.js | 71 +++++++++++++ src/bots/telegram.js | 76 ++++++++++++++ src/config.js | 69 ++++++++++++ src/http.js | 47 +++++++++ src/index.js | 40 +++++++ src/server.js | 146 ++++++++++++++++++++++++++ src/store.js | 139 ++++++++++++++++++++++++ test/bots.test.js | 83 +++++++++++++++ test/config.test.js | 35 +++++++ test/gateway.test.js | 141 +++++++++++++++++++++++++ test/helpers.js | 21 ++++ test/store.test.js | 146 ++++++++++++++++++++++++++ 30 files changed, 1838 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 compose.yaml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/mark.svg create mode 100644 public/style.css create mode 100644 scripts/check.js create mode 100644 scripts/register-discord.js create mode 100644 scripts/revoke-sessions.js create mode 100644 src/bots/api.js create mode 100644 src/bots/discord.js create mode 100644 src/bots/service.js create mode 100644 src/bots/telegram.js create mode 100644 src/config.js create mode 100644 src/http.js create mode 100644 src/index.js create mode 100644 src/server.js create mode 100644 src/store.js create mode 100644 test/bots.test.js create mode 100644 test/config.test.js create mode 100644 test/gateway.test.js create mode 100644 test/helpers.js create mode 100644 test/store.test.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6612042 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.env +.env.* +node_modules +data +artifacts +test +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3bfc6c8 --- /dev/null +++ b/.env.example @@ -0,0 +1,42 @@ +# The private Dungeons-Ground origin. Keep it inaccessible from the public internet. +UPSTREAM_URL=http://127.0.0.1:3000 +# The public gateway origin; HTTPS is required with NODE_ENV=production. +PUBLIC_URL=http://localhost:8080 +HOST=0.0.0.0 +PORT=8080 +NODE_ENV=development +# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +SESSION_SECRET=replace-with-a-random-secret-at-least-32-characters +DATA_FILE=./data/access.json + +CODE_LENGTH=10 +CODE_TTL_DAYS=15 +SESSION_TTL_HOURS=360 +REQUEST_TTL_HOURS=24 +REQUEST_COOLDOWN_SECONDS=300 +LOGIN_ATTEMPTS=5 +LOGIN_WINDOW_SECONDS=900 +GLOBAL_LOGIN_ATTEMPTS=100 +# Set only when the proxy is reachable exclusively through your trusted edge. +# The edge must OVERWRITE X-Forwarded-For with the single client IP. +TRUST_PROXY=false + +# Either or both bots can be enabled. Empty tokens disable a provider. +# Telegram uses long polling. Users and admins must first /start the bot in DM. +TELEGRAM_BOT_TOKEN= +TELEGRAM_BOT_USERNAME= +TELEGRAM_ADMIN_IDS= + +# Discord uses a signed HTTP interactions endpoint at /_access/discord. +DISCORD_BOT_TOKEN= +DISCORD_APPLICATION_ID= +DISCORD_PUBLIC_KEY= +DISCORD_ADMIN_IDS= +# Channel/server link where users can invoke /access (shown on the gate). +DISCORD_ACCESS_URL= +# Optional: register /access immediately in one server instead of globally. +DISCORD_GUILD_ID= + +# Comma-separated request details sent to admins. platform, user_id and request_id +# are always included. Names/handles are informational, never authorization IDs. +ADMIN_REQUEST_FIELDS=username,display_name,language_code,guild_id,created_at diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ef4507 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +.env +.env.* +!.env.example +data/ +coverage/ +artifacts/ +*.log +.DS_Store diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..713d3c5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-alpine +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --no-fund && mkdir -p /app/data && chown node:node /app/data +COPY --chown=node:node src ./src +COPY --chown=node:node public ./public +COPY --chown=node:node scripts ./scripts +ENV NODE_ENV=production HOST=0.0.0.0 PORT=8080 DATA_FILE=/app/data/access.json +USER node +EXPOSE 8080 +VOLUME ["/app/data"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s CMD node -e "fetch('http://127.0.0.1:'+process.env.PORT+'/_access/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" +CMD ["node", "src/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3a3b6af --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# Dungeons & Ground · Access gateway + +A private-alpha reverse proxy with an access page matching Dungeons-Ground’s dark grid, lime accents, Unbounded/Manrope/DM Mono typography, and D&G mark. All implementation lives in **Dungeons-Proxy**; Dungeons-Ground was used as a read-only reference. + +Users open the Telegram or Discord bot and press **Get access**. Configured administrators receive the requester's numeric user ID, selected profile details, and **Approve / Deny** buttons. Approval sends a randomly generated numeric access code to the requester’s DM. The same number can be used on multiple devices for **15 days** by default. Reusing it does not extend its expiry. Once it expires, all associated browser sessions stop working and the user must request a new number and approval. + +The gateway forwards the configured Dungeons-Ground site on the same public origin after entry. It protects HTML, API routes, static assets, uploads, and WebSocket upgrades. Deep links return to their original route after entry. Existing application login remains separate from the alpha access gate. + +## Local start + +Use Node.js **22.15+ on the Node 22 line** (the `.nvmrc` selects Node 22), Node 24, or Node 26+. + +```sh +nvm use +npm ci +cp .env.example .env +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +``` + +Paste the generated value into `SESSION_SECRET` in `.env`. Set `UPSTREAM_URL` to the Dungeons-Ground origin, set `PUBLIC_URL` to the gateway's public origin, and configure one or both bots below. Start the existing Dungeons-Ground site separately, then: + +```sh +npm start +``` + +With the default local URLs, open `http://localhost:8080`; the upstream is `http://127.0.0.1:3000`. `npm run dev` restarts on source changes. Environment variables supplied by a host override `.env`. The server reads `.env` from the directory in which it starts. + +For a visual preview, development mode may run with both tokens empty. It displays an honest unavailable message in place of bot links; there is no built-in bypass number. Production requires at least one configured provider and an HTTPS `PUBLIC_URL`. + +## Telegram setup + +1. Create a bot with BotFather and put its token in `TELEGRAM_BOT_TOKEN`. +2. Set `TELEGRAM_BOT_USERNAME` to its username without `@`. +3. Set `TELEGRAM_ADMIN_IDS` to comma-separated **numeric user IDs**, not usernames or group IDs. Admins receive requests in their private chats. +4. Every admin must open the bot and send `/start` so the bot can DM them. Requesters do the same using the Telegram button on the gate. +5. Run the gateway. It uses `getUpdates` long polling, so no Telegram webhook or public callback endpoint is needed. Run only one poller per token. If an existing webhook is configured, remove it before starting this poller. + +`/start` and `/access` show **Get access** in a private chat. The bot acknowledges button presses, checks the acting admin's actual Telegram ID, and delivers approved numbers privately. The last processed update offset is persisted. + +## Discord setup + +1. Create an application and bot in the Discord Developer Portal. Put the bot token, application ID, and application's public key into `DISCORD_BOT_TOKEN`, `DISCORD_APPLICATION_ID`, and `DISCORD_PUBLIC_KEY`. +2. Put comma-separated numeric admin user IDs into `DISCORD_ADMIN_IDS`. The bot and admins should share the server, and admins and requesters must allow direct messages from it. +3. Invite the bot to your server with the `bot` and `applications.commands` OAuth scopes. No privileged gateway intents are required; this integration receives signed HTTP interactions. +4. Set `DISCORD_ACCESS_URL` to your Discord channel link or server invite. This is where the gate sends users to invoke `/access`. +5. Expose the gateway over HTTPS and set its **Interactions Endpoint URL** to `https://YOUR-GATEWAY/_access/discord`. The server answers Discord's signed verification ping. Never put a separate authentication wall in front of this endpoint. +6. Run `npm run discord:register` to register `/access`. Optional `DISCORD_GUILD_ID` registers it in one server for immediate availability; otherwise it is global and may need time to propagate. The script only upserts `/access`, preserving unrelated commands. + +`/access` responds with a **Get access** button. Request and review operations defer their replies while DMs are sent. The endpoint verifies the Ed25519 signature against the original request bytes and rejects stale timestamps. Only configured Discord admin IDs can approve or deny. No bot secrets are exposed to the browser. + +## Configuration + +`.env.example` contains every setting with defaults. + +| Variable | Purpose / default | +| --- | --- | +| `UPSTREAM_URL` | Private HTTP(S) origin of the Dungeons-Ground site; no path or credentials | +| `PUBLIC_URL` | Public gateway origin, used for links, cookies and origin checks | +| `HOST`, `PORT` | Listen address and port; `0.0.0.0:8080` | +| `SESSION_SECRET` | Random secret of at least 32 characters; keep stable across restarts | +| `DATA_FILE` | Durable state file; `./data/access.json` | +| `CODE_LENGTH` | Numeric code length; 10 digits, configurable from 8–12 | +| `CODE_TTL_DAYS` | Code validity from approval; **15 days**, configurable from 1–90 | +| `SESSION_TTL_HOURS` | Browser session maximum; 360 hours, always capped at code expiry | +| `REQUEST_TTL_HOURS` | How long a pending request can be approved; 24 hours | +| `REQUEST_COOLDOWN_SECONDS` | Minimum interval between replacement requests; 300 seconds | +| `LOGIN_ATTEMPTS`, `LOGIN_WINDOW_SECONDS` | Per-IP entry limit; 5 attempts per 900 seconds | +| `GLOBAL_LOGIN_ATTEMPTS` | Aggregate entry limit per window; 100, including successful entries | +| `TRUST_PROXY` | Trust a single IP in `X-Forwarded-For`; false by default | +| `ADMIN_REQUEST_FIELDS` | Optional details shown to admins: `username,display_name,language_code,guild_id,created_at` | + +Platform, numeric user ID, and request ID always appear in the admin message. Missing optional details show as `—`. Display names and usernames are informational; only configured numeric IDs authorize reviews. These details are collected from the bot interaction, not submitted by the website. + +## Deployment + +Run one gateway instance behind an HTTPS edge such as Caddy or nginx. Keep the upstream reachable **only** from the gateway or private network; an independently public upstream can be accessed directly. Configure the upstream application's generated links and authentication callback URLs to use the public gateway origin. The proxy preserves request paths, query strings, methods, bodies, application cookies, and authorization headers; it rewrites upstream-origin redirects and cookie domains for the public gateway. Arbitrary absolute URLs inside upstream HTML/JavaScript are not rewritten. + +The reserved `/_access` namespace contains the gate, its assets, login/logout endpoints, health endpoint and Discord interactions. Do not cache authenticated proxy responses at the edge. The gateway marks them `private, no-store`. Leave `TRUST_PROXY=false` unless the gateway is reachable exclusively through a trusted edge that **overwrites** `X-Forwarded-For` with one client IP. Comma-separated forwarding chains are deliberately not trusted. + +For Docker, edit `.env` to set an HTTPS `PUBLIC_URL` and bot credentials, then: + +```sh +docker compose up --build -d +docker compose logs -f gateway +``` + +Compose binds the gateway to host loopback for your TLS edge and stores data in a named volume. When the upstream is on the Docker host, use `UPSTREAM_URL=http://host.docker.internal:3000`, not `localhost` (which means the gateway container). When it is another container, connect both to the same Docker network and use that service's hostname. `PORT` controls the host binding; the container always listens on 8080. The image runs as an unprivileged user. No live bot credentials are included in this repository. + +## Persistence and operation + +- Requests, approvals, hashed codes, hashed session tokens, hashed-IP rate limits, and the Telegram offset persist in an atomically replaced, fsynced JSON file with mode `0600`. Plain access numbers exist only during generation and DM delivery; they are not stored or logged. +- This is a small private-alpha **single-process** store, capped at 10,000 retained requests and 10,000 active sessions. It is not a distributed database. Use one instance and a durable local volume; do not use autoscaling, clustered workers, or multiple replicas sharing this file. Expired sessions and old request records are pruned periodically; requests are retained for 30 days beyond their last expiry. +- A `.lock` file prevents simultaneous writers. Normal SIGINT/SIGTERM shutdown releases it. After an abrupt kill or host crash, verify the recorded PID belongs to the stopped gateway before removing the stale lock; never delete a live instance's lock. In Docker, inspect the container state before restarting with a stale lock. +- Back up the data file and secret together while the gateway is stopped. Corrupt state fails startup instead of silently discarding access controls. Rotating the secret invalidates existing numbers and browser sessions. +- If a code DM fails, the request stays pending and no code is activated; the admin can press Approve again after the user enables DMs. Undelivered admin notifications can be retried with Get access. Duplicate requests/reviews do not create duplicate approvals. After a crash during delivery, the request returns to pending and the admin should approve it again; an uncertain delivery is never activated automatically. +- While a number remains valid, pressing Get access points the user to their existing DM. Lost numbers cannot be recovered from hashes: revoke that user's access below, then let them request a fresh approval. +- Numbers are bearer access credentials and can be entered on multiple devices. Browser cookies are HttpOnly and SameSite=Lax, with Secure enabled for HTTPS. Number entry and logout require the configured same-origin page. Both per-IP and aggregate limits apply to entry attempts, even after a process restart. WebSockets are checked at upgrade and closed within one second of session/code expiry or revocation. + +To revoke all numbers, pending requests and sessions belonging to a user, stop the gateway first and run: + +```sh +npm run sessions:revoke -- telegram 123456789 +# Or: npm run sessions:revoke -- discord 123456789012345678 +``` + +Then restart it. The user may request again after the configured request cooldown. To leave one browser session, send `POST /_access/logout` from the same gateway origin; it clears that session without invalidating the reusable number. + +`GET /_access/health` reports that the HTTP process is serving requests; it does not certify upstream or bot availability. Polling/transport failures use redacted logs. The page uses Google Fonts, with system font fallbacks if unavailable. + +## Optional local verification + +```sh +npm run check +npm test +``` + +The suite covers both bot adapters with mocked provider APIs, administrator authorization, failed DM recovery, duplicate reviews, reusable codes and expiry/renewal, persistent sessions, rate limits, signed Discord interactions, unauthenticated HTTP/WebSocket denial, streaming proxy requests, redirects, cookies, logout, and upstream outages. It requires no external credentials. Live Telegram/Discord delivery still needs an installation with real tokens, configured admins and working DMs. + +Protocol references: [Telegram Bot API](https://core.telegram.org/bots/api), [Discord interactions](https://docs.discord.com/developers/interactions/receiving-and-responding), [Discord application commands](https://docs.discord.com/developers/interactions/application-commands), [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware). diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..e1afae2 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,21 @@ +services: + gateway: + build: . + init: true + restart: unless-stopped + env_file: .env + environment: + NODE_ENV: production + HOST: 0.0.0.0 + PORT: 8080 + DATA_FILE: /app/data/access.json + ports: + - "127.0.0.1:${PORT:-8080}:8080" + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - access-data:/app/data + stop_grace_period: 45s + +volumes: + access-data: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..35e5be7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,204 @@ +{ + "name": "dungeons-proxy", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dungeons-proxy", + "version": "1.0.0", + "dependencies": { + "dotenv": "17.4.2", + "http-proxy-middleware": "4.2.0" + }, + "devDependencies": { + "ws": "8.21.0" + }, + "engines": { + "node": "^22.15.0 || ^24.0.0 || >=26.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/http-proxy-middleware": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-4.2.0.tgz", + "integrity": "sha512-ZA+oNOoM+GLoFTIzhkJptVQov73Srep2LBqhF8hG8CIPKO3nam1jonXVQ/QUH8RbwsmaaVz2SOJdzBNBHNtKbw==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "httpxy": "^0.5.4", + "is-glob": "^4.0.3", + "is-plain-obj": "^4.1.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": "^22.15.0 || ^24.0.0 || >=26.0.0" + } + }, + "node_modules/httpxy": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/httpxy/-/httpxy-0.5.5.tgz", + "integrity": "sha512-uDjmnPyp1q4Sgzf3w+J/Fc6UqcCEj0x4Wjp7OqK5dGhNeDgpyrAmnS6ey8QWrX3SWDon2DMKf9sBa5X9+CVyMA==", + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..eadf763 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "dungeons-proxy", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Dungeons & Ground private-alpha access gateway with Telegram and Discord approval", + "engines": { "node": "^22.15.0 || ^24.0.0 || >=26.0.0" }, + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js", + "test": "node --test test/*.test.js", + "check": "node scripts/check.js", + "discord:register": "node scripts/register-discord.js", + "sessions:revoke": "node scripts/revoke-sessions.js" + }, + "dependencies": { + "dotenv": "17.4.2", + "http-proxy-middleware": "4.2.0" + }, + "devDependencies": { "ws": "8.21.0" } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..7ad3428 --- /dev/null +++ b/public/app.js @@ -0,0 +1,73 @@ +const form = document.querySelector('#access-form'); +const input = document.querySelector('#access-number'); +const button = document.querySelector('#enter-button'); +const label = document.querySelector('#button-label'); +const status = document.querySelector('#form-status'); +const codeLength = Number(form.dataset.codeLength); +let waiting = false; +let cooldown; + +function announce(message, success = false) { + status.textContent = message; + status.classList.toggle('success', success); + status.hidden = false; +} + +input.addEventListener('input', () => { + input.removeAttribute('aria-invalid'); + if (!waiting) status.hidden = true; +}); + +form.addEventListener('submit', async event => { + event.preventDefault(); + if (waiting) return; + const code = input.value.replace(/\s/g, ''); + if (!new RegExp(`^\\d{${codeLength}}$`).test(code)) { + input.setAttribute('aria-invalid', 'true'); + announce(`Enter the ${codeLength}-digit number from your bot DM.`); + input.focus(); + return; + } + waiting = true; + button.disabled = true; + form.setAttribute('aria-busy', 'true'); + label.textContent = 'CHECKING YOUR NUMBER…'; + status.hidden = true; + let retryAfter = 0; + let approved = false; + try { + const params = new URLSearchParams(location.search); + const next = location.pathname.startsWith('/_access') ? params.get('next') || '/' : location.pathname + location.search + location.hash; + const response = await fetch('/_access/login', { + method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ code, next }), signal: AbortSignal.timeout(15000), + }); + const data = await response.json(); + if (!response.ok) { + retryAfter = response.status === 429 ? Number(data.retryAfter) || 60 : 0; + if (response.status === 401) input.setAttribute('aria-invalid', 'true'); + throw new Error(data.error || 'We could not check your number. Please try again.'); + } + approved = true; + input.value = ''; + announce('Access granted. Your next chapter is opening…', true); + label.textContent = 'OPENING THE GROUND…'; + location.assign(data.next); + } catch (error) { + announce(error.name === 'TimeoutError' || error instanceof TypeError ? 'Connection interrupted. Check your connection and try again.' : error.message); + status.focus(); + } finally { + form.removeAttribute('aria-busy'); + if (retryAfter > 0) { + const deadline = Date.now() + retryAfter * 1000; + const tick = () => { + const seconds = Math.max(0, Math.ceil((deadline - Date.now()) / 1000)); + label.textContent = `TRY AGAIN IN ${seconds}s`; + if (seconds === 0) { clearInterval(cooldown); waiting = false; button.disabled = false; label.textContent = 'ENTER THE ALPHA'; } + }; + tick(); cooldown = setInterval(tick, 1000); + } else if (!approved) { + waiting = false; button.disabled = false; label.textContent = 'ENTER THE ALPHA'; + } + } +}); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..452b3a5 --- /dev/null +++ b/public/index.html @@ -0,0 +1,69 @@ + + + + + + + + Dungeons & Ground — Your next chapter + + + + + + + + + + +
+
+
+

A WORLD WORTH GETTING INTO

+

YOUR NEXT
CHAPTER
AWAITS.

+

Beyond this gate, a universe of your own. Real players. AI companions. Stories that keep moving, even when life gets in the way.

+
+ +
A MESSAGE FROM THE GROUNDKEEPER

“Every great adventure begins with
someone opening a door.”

+
+
PRIVATE WORLDSYOUR PACEINFINITE POSSIBILITIES
+
+
+
THE GATE / ACCESS CONTROL
+
+

YOU’RE ONE STEP AWAY

+

ENTER THE
GROUND.

+

Already approved? Enter the number
our bot sent to your direct messages.

+
+ + +

Your number is reusable for {{CODE_DAYS}} days after approval.

+ + +
+ +
+

DON’T HAVE A NUMBER?

01 → 02 → 03
+

Open a bot, choose Get access, and wait for an administrator to approve your request.

+
{{PROVIDERS}}
+

Keep your DMs open. Your invitation will arrive there.

+
+
+
ADMIN-APPROVED ACCESS D&G / ALPHA
+
+
+
+
01

MAKE CONTACT

Find us on Telegram or Discord.
Press Get access in the bot.

+
02

GET THE GREEN LIGHT

An administrator reviews your request.
Your number arrives in a private message.

+
03

WRITE YOUR NEXT CHAPTER

Enter your number and step inside.
Request a new one after {{CODE_DAYS}} days.

+
+
+
STORIES THAT WAIT FOR NO ONE.DUNGEONS & GROUND © PRIVATE ALPHA
+ + diff --git a/public/mark.svg b/public/mark.svg new file mode 100644 index 0000000..50921ef --- /dev/null +++ b/public/mark.svg @@ -0,0 +1 @@ +D&G diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..297811d --- /dev/null +++ b/public/style.css @@ -0,0 +1,94 @@ +:root { + --acid: #d9f75f; --ink: #eeeee7; --muted: #9a9a91; --line: #292926; + --display: 'Unbounded', sans-serif; --body: 'Manrope', sans-serif; --mono: 'DM Mono', monospace; + color-scheme: dark; font-family: var(--body); color: var(--ink); background: #0a0a0a; +} +* { box-sizing: border-box; } +body { margin: 0; background-image: linear-gradient(#ffffff03 1px, transparent 1px), linear-gradient(90deg, #ffffff03 1px, transparent 1px); background-size: 34px 34px; } +a { color: inherit; text-decoration: none; } +button, input { font: inherit; } +button, a { -webkit-tap-highlight-color: transparent; } +button:not(:disabled), a { cursor: pointer; } +button, a, input { transition: background .2s, color .2s, border-color .2s; } +button:focus-visible, a:focus-visible, input:focus-visible { outline: 2px solid var(--acid); outline-offset: 4px; } +::selection { background: var(--acid); color: #080808; } +[hidden] { display: none !important; } +.skip-link { position: fixed; top: -100px; left: 20px; padding: 15px; background: var(--acid); color: #080808; z-index: 5; } +.skip-link:focus { top: 20px; } +.nav { height: 98px; padding: 0 clamp(22px, 5vw, 80px); border-bottom: 1px solid var(--line); display: flex; justify-content: space-between; align-items: center; } +.mark { display: inline-flex; gap: 13px; align-items: center; } +.mark-icon { display: grid; place-items: center; width: 44px; height: 44px; border: 1px solid var(--acid); font: 600 14px/1 var(--display); letter-spacing: -1px; clip-path: polygon(8px 0,100% 0,100% calc(100% - 8px),calc(100% - 8px) 100%,0 100%,0 8px); } +.mark-icon span { color: var(--acid); font-size: 9px; } +.mark-copy { display: flex; flex-direction: column; gap: 3px; font-family: var(--display); letter-spacing: .08em; } +.mark-copy strong { font-size: 12px; } +.mark-copy small { font-size: 8px; color: var(--muted); letter-spacing: .32em; } +.alpha-tag { font: 500 9px var(--mono); letter-spacing: .12em; color: var(--muted); display: flex; align-items: center; gap: 12px; } +.alpha-tag i, .card-bottom i { width: 5px; height: 5px; background: var(--acid); border-radius: 50%; box-shadow: 0 0 10px #d9f75f40; } +.tag-divider { color: #48483d; margin: 0 5px; } +.hero { max-width: 1536px; margin: auto; display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(385px, .8fr); gap: clamp(40px, 7vw, 110px); padding: 72px clamp(22px, 5vw, 80px) 76px; align-items: center; } +.eyebrow { font: 500 9px var(--mono); letter-spacing: .15em; color: var(--muted); display: flex; align-items: center; gap: 12px; } +.eyebrow > span { display: inline-block; width: 26px; height: 1px; background: var(--acid); } +.hero h1 { font: 600 clamp(52px, 6.3vw, 98px)/1.04 var(--display); letter-spacing: -.065em; margin: 29px 0 27px; } +.hero h1 em { font-style: normal; color: transparent; -webkit-text-stroke: 1px var(--acid); } +.lede { max-width: 490px; color: #adada5; font-size: 15px; line-height: 1.85; margin: 0; } +.transmission { display: flex; gap: 23px; align-items: center; margin-top: 38px; } +.transmission small { font: 500 8px var(--mono); letter-spacing: .1em; color: var(--acid); } +.transmission p { font-size: 12px; line-height: 1.8; color: #bcbcb2; margin: 10px 0 0; } +.signal { position: relative; flex: 0 0 83px; width: 83px; height: 83px; display: grid; place-items: center; border: 1px solid #36372c; border-radius: 50%; } +.signal:before, .signal:after { content: ''; position: absolute; inset: 10px; border: 1px solid #2c2e22; border-radius: 50%; } +.signal:after { inset: 23px; border-color: #464d27; } +.signal b { font: 600 10px var(--display); color: var(--acid); } +.signal i { position: absolute; background: var(--acid); border-radius: 50%; width: 4px; height: 4px; top: 12px; left: 11px; } +.signal i:nth-child(2) { top: 61px; left: 69px; } +.trust-row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-top: 38px; font: 400 8px var(--mono); color: var(--muted); letter-spacing: .07em; } +.trust-row i { width: 3px; height: 3px; border-radius: 50%; background: #6b7545; } +.access-card { border: 1px solid #383b2b; background: linear-gradient(140deg, #14150f, #0d0d0b 35%, #0b0b0a); box-shadow: 0 30px 100px #0006; position: relative; } +.access-card:before { content: ''; position: absolute; height: 2px; width: 46px; background: var(--acid); top: -1px; left: -1px; } +.card-top { display: flex; align-items: center; justify-content: space-between; min-height: 47px; padding: 0 25px; border-bottom: 1px solid var(--line); font: 400 8px var(--mono); letter-spacing: .12em; color: var(--muted); } +.lock { color: var(--acid); font-size: 21px; } +.card-content { padding: 28px 32px 27px; } +.acid { color: var(--acid); font-size: 8px; } +.access-card h2 { font: 600 clamp(29px, 3vw, 44px)/1.15 var(--display); letter-spacing: -.055em; margin: 17px 0 15px; } +.card-lede { font-size: 12px; line-height: 1.8; color: var(--muted); margin: 0 0 27px; } +form label { display: flex; justify-content: space-between; gap: 10px; font: 500 8px var(--mono); letter-spacing: .09em; margin-bottom: 10px; } +form label span { color: var(--muted); } +input { display: block; width: 100%; height: 59px; background: #090909; border: 1px solid #45473a; color: var(--ink); padding: 0 16px; font: 400 21px var(--mono); letter-spacing: .16em; border-radius: 0; } +input::placeholder { color: #8a8c7c; font-size: 13px; letter-spacing: .05em; } +input:focus { border-color: var(--acid); } +input[aria-invalid="true"] { border-color: #f1a48a; } +.hint { font-size: 10px; line-height: 1.7; color: var(--muted); margin: 10px 0 20px; } +.enter-button { display: flex; align-items: center; justify-content: space-between; width: 100%; min-height: 53px; padding: 0 19px; background: var(--acid); color: #0c1002; border: 1px solid var(--acid); font: 500 10px var(--mono); letter-spacing: .09em; } +.enter-button > span:last-child { font-size: 23px; } +.enter-button:hover { background: #e6ff91; } +.enter-button:disabled { opacity: .65; cursor: wait; } +.form-status { margin: 0 0 15px; padding: 12px; font: 400 11px/1.7 var(--body); color: #ffc0a8; border: 1px solid #724b3b; background: #221813; } +.form-status.success { color: var(--acid); border-color: #596522; background: #171b0c; } +.request-access { border-top: 1px solid var(--line); margin-top: 27px; padding-top: 22px; } +.request-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.request-heading h3 { font: 500 8px var(--mono); letter-spacing: .07em; margin: 0; } +.request-heading > span { font: 400 8px var(--mono); color: #777e58; } +.request-access p { font-size: 11px; color: var(--muted); line-height: 1.8; margin: 12px 0 17px; } +.request-access strong { color: #dbdcd1; font-weight: 500; } +.providers { display: flex; gap: 10px; } +.provider { display: flex; flex: 1; align-items: center; justify-content: space-between; gap: 8px; border: 1px solid #35372e; min-height: 44px; padding: 0 12px; font: 500 9px var(--mono); letter-spacing: .04em; } +.provider > span:first-child { font-size: 14px; color: var(--acid); } +.provider > span:last-child { color: #90937e; } +.provider:hover { background: #1a1e0f; border-color: var(--acid); } +.request-access .dm-note { font-size: 9px; margin: 13px 0 0; } +.providers .unavailable { border: 1px solid var(--line); padding: 12px; margin: 0; } +.card-bottom { min-height: 37px; padding: 0 24px; display: flex; align-items: center; gap: 8px; border-top: 1px solid var(--line); color: var(--muted); font: 400 7px var(--mono); letter-spacing: .05em; } +.card-bottom > span { margin-left: auto; color: #888e6e; } +.steps { display: grid; grid-template-columns: repeat(3, 1fr); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); background: #0b0b0af0; padding: 0 clamp(22px, 5vw, 80px); } +.steps article { position: relative; display: flex; gap: 22px; padding: 31px 28px 33px 0; } +.steps article + article { border-left: 1px solid var(--line); padding-left: 32px; } +.step-number { font: 500 11px var(--mono); color: var(--acid); padding-top: 2px; } +.steps h2 { font: 500 10px/1.6 var(--display); letter-spacing: -.02em; margin: 0 0 10px; } +.steps p { font-size: 11px; line-height: 1.8; margin: 0; color: var(--muted); } +.step-arrow { margin-left: auto; font: 20px var(--mono); color: #5b6144; } +.footer { display: flex; justify-content: space-between; gap: 20px; padding: 24px clamp(22px, 5vw, 80px); font: 400 7px var(--mono); letter-spacing: .11em; color: var(--muted); } +.footer i { color: #737765; font-style: normal; padding: 0 7px; } +@media (min-width: 1600px) { .hero { padding-top: 100px; padding-bottom: 100px; } } +@media (max-width: 1000px) { .hero { gap: 35px; grid-template-columns: minmax(0, 1fr) minmax(340px, .95fr); padding-top: 45px; padding-bottom: 50px; } .hero h1 { font-size: 54px; } .card-content { padding: 24px; } .trust-row { font-size: 7px; gap: 7px; } .steps article, .steps article + article { gap: 12px; padding: 25px 18px; } .steps article:first-child { padding-left: 0; } .step-arrow { display: none; } .steps h2 { font-size: 9px; } } +@media (max-width: 760px) { .nav { height: 80px; } .alpha-tag { font-size: 8px; } .alpha-tag .tag-divider, .alpha-tag { gap: 7px; } .hero { grid-template-columns: 1fr; max-width: 620px; padding-top: 38px; gap: 35px; } .hero h1 { font-size: clamp(49px, 10vw, 70px); margin: 23px 0 22px; } .lede { font-size: 13px; } .transmission { margin-top: 24px; } .trust-row { margin-top: 25px; font-size: 7px; gap: 12px; } .card-content { padding: 27px; } .access-card h2 { font-size: 36px; } .steps { grid-template-columns: 1fr; } .steps article, .steps article + article { padding: 25px 0; border-left: 0; gap: 22px; } .steps article + article { border-top: 1px solid var(--line); } .steps h2 { font-size: 10px; } .steps br { display: none; } .step-arrow { display: block; } .footer { line-height: 1.9; } .footer > span:last-child { text-align: right; } } +@media (max-width: 480px) { .alpha-tag { max-width: 105px; line-height: 1.6; } .tag-divider { display: none; } .alpha-tag i { flex: 0 0 5px; } .hero h1 { font-size: clamp(39px, 10vw, 49px); } .hero h1 br:first-child { display: initial; } .hero .eyebrow { font-size: 7px; gap: 8px; } .hero-copy .eyebrow > span { width: 17px; } .card-content { padding: 25px 20px; } .card-top { padding: 0 20px; } .card-bottom { padding: 0 16px; font-size: 6px; } .trust-row { font-size: 6px; gap: 8px; } .transmission { gap: 15px; } .transmission small { font-size: 7px; } .transmission p { font-size: 11px; } .desktop-break { display: none; } .footer { font-size: 6px; } } +@media (prefers-reduced-motion: reduce) { *, *:before, *:after { transition: none !important; scroll-behavior: auto !important; } } diff --git a/scripts/check.js b/scripts/check.js new file mode 100644 index 0000000..220e194 --- /dev/null +++ b/scripts/check.js @@ -0,0 +1,16 @@ +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +function check(directory) { + for (const file of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, file.name); + if (file.isDirectory()) check(path); + else if (file.name.endsWith('.js')) { + const result = spawnSync(process.execPath, ['--check', path], { stdio: 'inherit' }); + if (result.status !== 0) process.exit(1); + } + } +} +for (const directory of ['src', 'public', 'scripts', 'test']) check(directory); +console.log('JavaScript syntax checks passed.'); diff --git a/scripts/register-discord.js b/scripts/register-discord.js new file mode 100644 index 0000000..80e1117 --- /dev/null +++ b/scripts/register-discord.js @@ -0,0 +1,16 @@ +import 'dotenv/config'; +import { loadConfig } from '../src/config.js'; +import { DiscordBot } from '../src/bots/discord.js'; + +try { + const config = loadConfig(); + if (!config.discord.token) throw new Error('Configure Discord in .env first.'); + const bot = new DiscordBot(config); + const scope = config.discord.guildId ? `/guilds/${config.discord.guildId}` : ''; + // POST upserts only /access, preserving unrelated commands registered by the owner. + await bot.api(`/applications/${config.discord.applicationId}${scope}/commands`, { + name: 'access', description: 'Request private-alpha access to Dungeons & Ground', type: 1, + ...(scope ? {} : { integration_types: [0], contexts: [0, 1] }), + }); + console.log(`/access registered ${scope ? 'in the configured server' : 'globally (propagation can take time)'}.`); +} catch (error) { console.error(error.message); process.exitCode = 1; } diff --git a/scripts/revoke-sessions.js b/scripts/revoke-sessions.js new file mode 100644 index 0000000..c52bd67 --- /dev/null +++ b/scripts/revoke-sessions.js @@ -0,0 +1,22 @@ +import 'dotenv/config'; +import { loadConfig } from '../src/config.js'; +import { AccessStore } from '../src/store.js'; + +const [platform, userId] = process.argv.slice(2); +if (!['telegram', 'discord'].includes(platform) || !/^\d+$/.test(userId || '')) { + console.error('Stop the gateway first, then run: npm run sessions:revoke -- telegram|discord USER_ID'); + process.exitCode = 1; +} else { + let store; + try { + store = new AccessStore(loadConfig()); + store.change(state => { + state.sessions = state.sessions.filter(s => s.platform !== platform || s.userId !== userId); + for (const request of state.requests) { + if (request.platform === platform && request.user_id === userId) { request.status = 'revoked'; delete request.codeHash; } + } + }); + console.log('Access numbers, pending requests and sessions revoked for the specified user. Restart the gateway.'); + } catch (error) { console.error(error.message); process.exitCode = 1; } + finally { store?.close(); } +} diff --git a/src/bots/api.js b/src/bots/api.js new file mode 100644 index 0000000..c8ccacd --- /dev/null +++ b/src/bots/api.js @@ -0,0 +1,22 @@ +import { setTimeout as delay } from 'node:timers/promises'; + +// Never include provider URLs, tokens, payloads, or raw responses in errors/logs. +export async function botFetch(url, options = {}, { fetchImpl = fetch, signal, timeout = 15000 } = {}) { + for (let attempt = 0; attempt < 3; attempt++) { + let response; + try { + response = await fetchImpl(url, { ...options, signal: AbortSignal.any([AbortSignal.timeout(timeout), ...(signal ? [signal] : [])]) }); + } catch { throw new Error('Bot connection failed. Please try again later.'); } + let body; + try { body = await response.json(); } catch { throw new Error('The bot provider returned an invalid response.'); } + if (response.status === 429 && attempt < 2) { + const seconds = Number(body.retry_after ?? body.parameters?.retry_after ?? 1); + if (!Number.isFinite(seconds) || seconds > 5) break; + await delay(Math.max(1, seconds) * 1000, undefined, { signal }); + continue; + } + if (!response.ok || body.ok === false) throw new Error(`The bot provider could not complete the request (HTTP ${response.status}). Check bot permissions and enable direct messages.`); + return body; + } + throw new Error('The bot provider is rate limited. Please try again later.'); +} diff --git a/src/bots/discord.js b/src/bots/discord.js new file mode 100644 index 0000000..3e4ad6c --- /dev/null +++ b/src/bots/discord.js @@ -0,0 +1,73 @@ +import { createPublicKey, verify } from 'node:crypto'; +import { botFetch } from './api.js'; + +export class DiscordBot { + constructor(config, { fetchImpl = fetch, now = Date.now, log = console } = {}) { + this.config = config; + this.fetchImpl = fetchImpl; + this.now = now; + this.log = log; + this.jobs = new Set(); + this.seen = new Map(); + this.key = createPublicKey({ key: Buffer.from(`302a300506032b6570032100${config.discord.publicKey}`, 'hex'), format: 'der', type: 'spki' }); + } + + api(path, body, method = 'POST', authenticate = true) { + return botFetch(`https://discord.com/api/v10${path}`, { + method, headers: { 'content-type': 'application/json', ...(authenticate ? { authorization: `Bot ${this.config.discord.token}` } : {}) }, body: JSON.stringify(body), + }, { fetchImpl: this.fetchImpl }); + } + + async sendUser(userId, content, buttons = []) { + const channel = await this.api('/users/@me/channels', { recipient_id: userId }); + return this.api(`/channels/${channel.id}/messages`, { + content, allowed_mentions: { parse: [] }, + ...(buttons.length ? { components: [{ type: 1, components: buttons.map(b => ({ type: 2, style: b.id.startsWith('deny:') ? 4 : 3, label: b.label, custom_id: b.id })) }] } : {}), + }); + } + + validSignature(headers, raw) { + const signature = headers['x-signature-ed25519']; + const timestamp = headers['x-signature-timestamp']; + if (typeof signature !== 'string' || !/^[a-f\d]{128}$/i.test(signature) || typeof timestamp !== 'string' || !/^\d{1,12}$/.test(timestamp)) return false; + if (Math.abs(this.now() - Number(timestamp) * 1000) > 300_000) return false; + try { return verify(null, Buffer.concat([Buffer.from(timestamp), raw]), this.key, Buffer.from(signature, 'hex')); } + catch { return false; } + } + + respond(interaction, service) { + if (interaction.type === 1) return { type: 1 }; + if (interaction.application_id !== this.config.discord.applicationId) return { type: 4, data: { content: 'Unknown application.', flags: 64 } }; + if (interaction.type === 2 && interaction.data?.name === 'access') { + return { type: 4, data: { content: 'Dungeons & Ground · Private alpha\nAn administrator will review your Discord ID and profile details. Enable DMs to receive your number after approval.', flags: 64, + components: [{ type: 1, components: [{ type: 2, style: 3, label: 'Get access', custom_id: 'request' }] }] } }; + } + const user = interaction.member?.user || interaction.user; + const action = interaction.data?.custom_id; + if (interaction.type !== 3 || !user?.id || (action !== 'request' && !/^(approve|deny):[a-f\d-]{36}$/.test(action || ''))) { + return { type: 4, data: { content: 'Use /access to request an access number.', flags: 64 } }; + } + for (const [id, expires] of this.seen) if (expires <= this.now()) this.seen.delete(id); + if (!this.seen.has(interaction.id)) { + this.seen.set(interaction.id, this.now() + 300_000); + // Defer the interaction immediately; bot API calls can take longer than 3 seconds. + const job = Promise.resolve().then(async () => { + let content; + try { + if (action === 'request') { + content = await service.request({ platform: 'discord', user_id: user.id, username: user.username, + display_name: user.global_name || user.username, language_code: interaction.locale, guild_id: interaction.guild_id }); + } else { + const [decision, requestId] = action.split(':'); + content = await service.decide('discord', user.id, requestId, decision); + } + } catch (error) { content = error.message; } + await this.api(`/webhooks/${this.config.discord.applicationId}/${interaction.token}/messages/@original`, { content, allowed_mentions: { parse: [] }, components: [] }, 'PATCH', false); + }).catch(() => this.log.error('Discord reply failed; the request or decision remains saved.')).finally(() => this.jobs.delete(job)); + this.jobs.add(job); + } + return { type: 5, data: { flags: 64 } }; + } + + async drain() { await Promise.allSettled([...this.jobs]); } +} diff --git a/src/bots/service.js b/src/bots/service.js new file mode 100644 index 0000000..99a4251 --- /dev/null +++ b/src/bots/service.js @@ -0,0 +1,71 @@ +const clean = value => String(value ?? '—').replace(/[\r\n\u0000-\u001f\u007f]/g, ' ').slice(0, 160); + +export class AccessService { + constructor(config, store, providers) { + this.config = config; + this.store = store; + this.providers = providers; + this.pending = new Map(); + } + + request(identity) { + const key = `${identity.platform}:${identity.user_id}`; + if (this.pending.has(key)) return this.pending.get(key); + const task = this.createRequest(identity).finally(() => this.pending.delete(key)); + this.pending.set(key, task); + return task; + } + + async createRequest(identity) { + const provider = this.providers[identity.platform]; + if (!provider) return 'This access provider is unavailable.'; + const { request, fresh } = this.store.request(identity); + if (request.status === 'approved') return `Your existing number is reusable until ${new Date(request.codeExpiresAt).toISOString()}. Check your DMs. If you lost it, contact an administrator.`; + if (request.status === 'delivering') return 'An administrator is sending your number. Check your DMs shortly.'; + // Confirm DMs work before asking admins to approve the request. + await provider.sendUser(identity.user_id, fresh + ? 'Your Dungeons & Ground access request has been recorded. An administrator will review it. Your number will arrive here after approval.' + : 'Your request is still pending. We are checking that an administrator has received it.'); + const lines = ['Dungeons & Ground · Access request', `platform: ${request.platform}`, `user_id: ${request.user_id}`, `request_id: ${request.id}`]; + for (const field of this.config.adminFields) { + lines.push(`${field}: ${clean(field === 'created_at' ? new Date(request.createdAt).toISOString() : request[field])}`); + } + const buttons = [{ label: 'Approve', id: `approve:${request.id}` }, { label: 'Deny', id: `deny:${request.id}` }]; + for (const adminId of this.config[identity.platform].admins) { + if (this.store.getRequest(request.id).notifiedAdmins.includes(adminId)) continue; + try { + await provider.sendUser(adminId, lines.join('\n'), buttons); + this.store.updateRequest(request.id, r => r.notifiedAdmins.push(adminId)); + } catch { /* Keep undelivered admins retryable when the user presses Get access again. */ } + } + if (!this.store.getRequest(request.id).notifiedAdmins.length) return 'Your request is saved, but we could not contact an administrator. Please try Get access again later.'; + return 'Request sent. You will receive your access number in a DM after an administrator approves it.'; + } + + async decide(platform, adminId, requestId, decision) { + if (!this.config[platform]?.admins.includes(adminId)) return 'Only configured administrators can review access requests.'; + const request = this.store.getRequest(requestId); + if (!request || request.platform !== platform) return 'Request not found.'; + if (request.status !== 'pending') return `This request is already ${request.status}.`; + if (request.expiresAt <= this.store.now()) return 'This request has expired. The user needs to request access again.'; + const provider = this.providers[platform]; + if (decision === 'deny') { + this.store.updateRequest(requestId, r => { r.status = 'denied'; r.adminId = adminId; }); + try { await provider.sendUser(request.user_id, 'Your Dungeons & Ground access request was declined. Contact an administrator if you have questions.'); } + catch { return 'Request denied. The user could not be reached by DM.'; } + return 'Request denied. The user has been notified.'; + } + if (decision !== 'approve') return 'Unknown action.'; + const code = this.store.reserveCode(requestId, adminId); + if (!code) return 'This request is already being reviewed.'; + const expiresAt = this.store.getRequest(requestId).codeExpiresAt; + try { + await provider.sendUser(request.user_id, `Access approved!\n\nYour number: ${code}\n\nEnter it at ${this.config.publicUrl.origin}\nIt is reusable until ${new Date(expiresAt).toISOString()} (UTC). Keep it private. After it expires, use Get access to request a new number.`); + } catch { + this.store.updateRequest(requestId, r => { r.status = 'pending'; delete r.codeHash; delete r.codeExpiresAt; delete r.adminId; }); + return 'Could not deliver the number. Ask the user to enable DMs or /start the bot, then press Approve again. No number was activated.'; + } + this.store.updateRequest(requestId, r => { r.status = 'approved'; r.approvedAt = this.store.now(); }); + return 'Approved. The access number was sent privately to the user.'; + } +} diff --git a/src/bots/telegram.js b/src/bots/telegram.js new file mode 100644 index 0000000..3e955b1 --- /dev/null +++ b/src/bots/telegram.js @@ -0,0 +1,76 @@ +import { setTimeout as delay } from 'node:timers/promises'; +import { botFetch } from './api.js'; + +export class TelegramBot { + constructor(config, store, { fetchImpl = fetch, log = console } = {}) { + this.config = config; + this.store = store; + this.fetchImpl = fetchImpl; + this.log = log; + this.controller = new AbortController(); + } + + call(method, body, timeout = 15000) { + return botFetch(`https://api.telegram.org/bot${this.config.telegram.token}/${method}`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), + }, { fetchImpl: this.fetchImpl, signal: this.controller.signal, timeout }).then(result => result.result); + } + + sendUser(chatId, text, buttons = []) { + return this.call('sendMessage', { + chat_id: chatId, text, link_preview_options: { is_disabled: true }, + ...(buttons.length ? { reply_markup: { inline_keyboard: [buttons.map(b => ({ text: b.label, callback_data: b.id }))] } } : {}), + }); + } + + async handle(update, service) { + const message = update.message; + if (message?.chat.type === 'private' && /^\/(start|access)(?:@\w+)?(?:\s|$)/.test(message.text || '') && !message.from?.is_bot) { + await this.sendUser(String(message.from.id), 'Dungeons & Ground · Private alpha\n\nRequest access below. An administrator will review your Telegram ID and profile details. After approval, your reusable access number will be sent here.', [{ label: 'Get access', id: 'request' }]); + } + const callback = update.callback_query; + if (!callback || callback.from?.is_bot) return; + await this.call('answerCallbackQuery', { callback_query_id: callback.id }); + if (callback.message?.chat.type !== 'private') return; + const userId = String(callback.from.id); + let result; + if (callback.data === 'request') { + result = await service.request({ platform: 'telegram', user_id: userId, username: callback.from.username, + display_name: [callback.from.first_name, callback.from.last_name].filter(Boolean).join(' '), language_code: callback.from.language_code }); + } else { + const match = /^(approve|deny):([a-f\d-]{36})$/.exec(callback.data || ''); + if (!match) return; + result = await service.decide('telegram', userId, match[2], match[1]); + } + await this.sendUser(userId, result); + } + + async run(service) { + try { + const webhook = await this.call('getWebhookInfo', {}); + if (webhook.url) throw new Error('Telegram has a webhook configured. Remove it before using long polling.'); + } catch (error) { this.log.error(error.message); return; } + while (!this.controller.signal.aborted) { + try { + const updates = await this.call('getUpdates', { offset: this.store.state.telegramOffset, timeout: 25, allowed_updates: ['message', 'callback_query'] }, 35000); + for (const update of updates) { + if (this.controller.signal.aborted) break; + try { await this.handle(update, service); } + catch (error) { + this.log.error('Telegram update failed; the user can retry Get access or the review button.'); + const userId = update.callback_query?.from.id || update.message?.from.id; + if (userId) await this.sendUser(String(userId), error.message).catch(() => {}); + } + this.store.change(state => { state.telegramOffset = update.update_id + 1; }); + } + } catch { + if (!this.controller.signal.aborted) { + this.log.error('Telegram polling failed; retrying in five seconds. Check the token and ensure only one poller runs.'); + await delay(5000, undefined, { signal: this.controller.signal }).catch(() => {}); + } + } + } + } + + stop() { this.controller.abort(); } +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..804f72e --- /dev/null +++ b/src/config.js @@ -0,0 +1,69 @@ +import { resolve } from 'node:path'; + +const allowedFields = ['username', 'display_name', 'language_code', 'guild_id', 'created_at']; + +export function loadConfig(env = process.env) { + const integer = (key, fallback, min, max) => { + const value = env[key] ?? String(fallback); + if (!/^\d+$/.test(value) || Number(value) < min || Number(value) > max) { + throw new Error(`${key} must be an integer from ${min} to ${max}`); + } + return Number(value); + }; + const origin = (key, fallback) => { + let url; + try { url = new URL(env[key] || fallback); } catch { throw new Error(`${key} must be a valid HTTP(S) origin`); } + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) { + throw new Error(`${key} must be an HTTP(S) origin without credentials, path, query or fragment`); + } + return url; + }; + const ids = (key) => { + const values = [...new Set((env[key] || '').split(',').map(v => v.trim()).filter(Boolean))]; + if (values.some(value => !/^[1-9]\d{0,24}$/.test(value))) throw new Error(`${key} must contain comma-separated numeric user IDs`); + return values; + }; + const publicUrl = origin('PUBLIC_URL', 'http://localhost:8080'); + const upstreamUrl = origin('UPSTREAM_URL', 'http://127.0.0.1:3000'); + if (publicUrl.origin === upstreamUrl.origin) throw new Error('UPSTREAM_URL cannot point to PUBLIC_URL'); + if (env.NODE_ENV === 'production' && publicUrl.protocol !== 'https:') throw new Error('PUBLIC_URL must use HTTPS in production'); + const secret = env.SESSION_SECRET || ''; + if (secret.length < 32 || /replace-with|change.?me/i.test(secret)) throw new Error('Set SESSION_SECRET to a random secret of at least 32 characters'); + if (env.TRUST_PROXY && !['true', 'false'].includes(env.TRUST_PROXY)) throw new Error('TRUST_PROXY must be true or false'); + const telegram = { token: env.TELEGRAM_BOT_TOKEN || '', username: env.TELEGRAM_BOT_USERNAME || '', admins: ids('TELEGRAM_ADMIN_IDS') }; + if (telegram.token && (!/^\d+:[A-Za-z0-9_-]+$/.test(telegram.token) || !/^[A-Za-z0-9_]{5,32}$/.test(telegram.username) || !telegram.admins.length)) { + throw new Error('Telegram requires TELEGRAM_BOT_TOKEN, TELEGRAM_BOT_USERNAME (without @), and TELEGRAM_ADMIN_IDS'); + } + const discord = { + token: env.DISCORD_BOT_TOKEN || '', applicationId: env.DISCORD_APPLICATION_ID || '', + publicKey: env.DISCORD_PUBLIC_KEY || '', admins: ids('DISCORD_ADMIN_IDS'), + accessUrl: env.DISCORD_ACCESS_URL || '', guildId: env.DISCORD_GUILD_ID || '', + }; + if (discord.token) { + if (!/^\d{1,25}$/.test(discord.applicationId) || !/^[a-f\d]{64}$/i.test(discord.publicKey) || !discord.admins.length) { + throw new Error('Discord requires DISCORD_APPLICATION_ID, DISCORD_PUBLIC_KEY and DISCORD_ADMIN_IDS'); + } + let link; + try { link = new URL(discord.accessUrl); } catch { throw new Error('Set DISCORD_ACCESS_URL to your Discord channel or invite'); } + if (link.protocol !== 'https:' || !['discord.com', 'discord.gg'].includes(link.hostname) || link.username || link.password) { + throw new Error('DISCORD_ACCESS_URL must be an HTTPS discord.com or discord.gg link'); + } + if (discord.guildId && !/^\d{1,25}$/.test(discord.guildId)) throw new Error('DISCORD_GUILD_ID must be numeric'); + } + if (env.NODE_ENV === 'production' && !telegram.token && !discord.token) throw new Error('Enable at least one bot in production'); + const fields = (env.ADMIN_REQUEST_FIELDS ?? allowedFields.join(',')).split(',').map(v => v.trim()).filter(Boolean); + if (fields.some(field => !allowedFields.includes(field))) throw new Error(`ADMIN_REQUEST_FIELDS may contain: ${allowedFields.join(', ')}`); + return { + publicUrl, upstreamUrl, secret, telegram, discord, adminFields: fields, + host: env.HOST || '0.0.0.0', port: integer('PORT', 8080, 1, 65535), + dataFile: resolve(env.DATA_FILE || './data/access.json'), secureCookie: publicUrl.protocol === 'https:', + trustProxy: env.TRUST_PROXY === 'true', codeLength: integer('CODE_LENGTH', 10, 8, 12), + codeTtlMs: integer('CODE_TTL_DAYS', 15, 1, 90) * 86400_000, + sessionTtlMs: integer('SESSION_TTL_HOURS', 360, 1, 8760) * 3_600_000, + requestTtlMs: integer('REQUEST_TTL_HOURS', 24, 1, 168) * 3_600_000, + requestCooldownMs: integer('REQUEST_COOLDOWN_SECONDS', 300, 1, 86400) * 1000, + loginAttempts: integer('LOGIN_ATTEMPTS', 5, 1, 100), + globalLoginAttempts: integer('GLOBAL_LOGIN_ATTEMPTS', 100, 1, 10000), + loginWindowMs: integer('LOGIN_WINDOW_SECONDS', 900, 1, 86400) * 1000, + }; +} diff --git a/src/http.js b/src/http.js new file mode 100644 index 0000000..f1f5b79 --- /dev/null +++ b/src/http.js @@ -0,0 +1,47 @@ +import { isIP } from 'node:net'; + +export const COOKIE = 'dng_access'; + +export function readCookie(req) { + const matches = (req.headers.cookie || '').split(';').map(v => v.trim()).filter(v => v.startsWith(`${COOKIE}=`)); + return matches.length === 1 ? matches[0].slice(COOKIE.length + 1) : ''; +} + +export function sessionCookie(config, token, maxAge = 0) { + return `${COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${config.secureCookie ? '; Secure' : ''}`; +} + +export function safeNext(value) { + if (typeof value !== 'string' || value.length > 4096 || !value.startsWith('/') || value.startsWith('//') || /[\\\u0000-\u0020]/.test(value)) return '/'; + try { + const decoded = decodeURIComponent(value); + if (/[\\\u0000-\u001f]/.test(decoded)) return '/'; + const url = new URL(value, 'http://gateway.local'); + if (url.origin !== 'http://gateway.local' || url.pathname.startsWith('/_access')) return '/'; + return url.pathname + url.search + url.hash; + } catch { return '/'; } +} + +export function clientIp(req, config) { + const forwarded = req.headers['x-forwarded-for']; + // Accept a single IP only. Forwarded chains require explicit edge normalization. + if (config.trustProxy && typeof forwarded === 'string' && isIP(forwarded.trim())) return forwarded.trim(); + return req.socket.remoteAddress || 'unknown'; +} + +export function sendJson(res, status, body, headers = {}) { + res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', ...headers }); + res.end(JSON.stringify(body)); +} + +export async function readBody(req, maximum = 4096) { + if (Number(req.headers['content-length']) > maximum) { const error = new Error('Request is too large.'); error.status = 413; throw error; } + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > maximum) { const error = new Error('Request is too large.'); error.status = 413; throw error; } + chunks.push(chunk); + } + return Buffer.concat(chunks); +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..32059a8 --- /dev/null +++ b/src/index.js @@ -0,0 +1,40 @@ +import 'dotenv/config'; +import { loadConfig } from './config.js'; +import { AccessStore } from './store.js'; +import { AccessService } from './bots/service.js'; +import { TelegramBot } from './bots/telegram.js'; +import { DiscordBot } from './bots/discord.js'; +import { createGateway } from './server.js'; + +async function main() { + const config = loadConfig(); + const store = new AccessStore(config); + const providers = {}; + if (config.telegram.token) providers.telegram = new TelegramBot(config, store); + if (config.discord.token) providers.discord = new DiscordBot(config); + const service = new AccessService(config, store, providers); + const gateway = createGateway(config, store, { discord: providers.discord, service }); + try { + await new Promise((resolve, reject) => { gateway.server.once('error', reject); gateway.server.listen(config.port, config.host, resolve); }); + } catch (error) { store.close(); throw error; } + console.log(`Dungeons & Ground access gateway listening on ${config.publicUrl.origin}`); + if (!Object.keys(providers).length) console.log('Preview mode: no bots configured. Access cannot be granted until a bot is enabled.'); + const poller = providers.telegram?.run(service); + const maintenance = setInterval(() => { + try { store.prune(); } catch { console.error('Access store maintenance failed. Check disk space and permissions.'); } + }, 60000).unref(); + let stopping = false; + const stop = async () => { + if (stopping) return; + stopping = true; + clearInterval(maintenance); + providers.telegram?.stop(); + await gateway.close(); + await Promise.allSettled([poller, providers.discord?.drain()]); + store.close(); + }; + process.once('SIGINT', () => void stop()); + process.once('SIGTERM', () => void stop()); +} + +main().catch(error => { console.error(`Startup failed: ${error.message}`); process.exitCode = 1; }); diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..0f7b396 --- /dev/null +++ b/src/server.js @@ -0,0 +1,146 @@ +import { createServer } from 'node:http'; +import { readFileSync } from 'node:fs'; +import { createProxyMiddleware } from 'http-proxy-middleware'; +import { clientIp, COOKIE, readBody, readCookie, safeNext, sendJson, sessionCookie } from './http.js'; + +const publicFile = name => readFileSync(new URL(`../public/${name}`, import.meta.url)); +const escape = value => String(value).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char])); + +export function createGateway(config, store, { discord, service, log = console } = {}) { + const links = []; + if (config.telegram.token) links.push(`↗ TELEGRAM `); + if (config.discord.token) links.push(`◈ DISCORD `); + const page = publicFile('index.html').toString().replaceAll('{{CODE_LENGTH}}', String(config.codeLength)) + .replaceAll('{{CODE_DAYS}}', String(config.codeTtlMs / 86400_000)) + .replace('{{PROVIDERS}}', links.length ? links.join('') : '

Access requests are not open yet. Please contact the administrator.

'); + const assets = new Map([ + ['/_access/assets/style.css', [publicFile('style.css'), 'text/css; charset=utf-8']], + ['/_access/assets/app.js', [publicFile('app.js'), 'text/javascript; charset=utf-8']], + ['/_access/assets/mark.svg', [publicFile('mark.svg'), 'image/svg+xml']], + ]); + const sockets = new Set(); + const websocketTimers = new Map(); + const gatewayHeaders = { + 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer', + 'content-security-policy': "default-src 'none'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'", + 'x-frame-options': 'DENY', ...(config.secureCookie ? { 'strict-transport-security': 'max-age=31536000' } : {}), + }; + function cleanProxyHeaders(req) { + const ip = clientIp(req, config); + for (const header of Object.keys(req.headers)) { + if (header === 'forwarded' || header.startsWith('x-forwarded-') || header.startsWith('x-dng-') || header === 'x-real-ip') delete req.headers[header]; + } + req.headers['x-forwarded-for'] = ip; + req.headers['x-forwarded-host'] = config.publicUrl.host; + req.headers['x-forwarded-proto'] = config.publicUrl.protocol.slice(0, -1); + const cookie = (req.headers.cookie || '').split(';').map(v => v.trim()).filter(v => v && !v.startsWith(`${COOKIE}=`)).join('; '); + if (cookie) req.headers.cookie = cookie; else delete req.headers.cookie; + } + const proxy = createProxyMiddleware({ + target: config.upstreamUrl.origin, changeOrigin: true, + // Do not set ws:true: its automatic upgrade listener would bypass the auth check. + cookieDomainRewrite: '', proxyTimeout: 120000, + on: { + proxyRes(response) { + const location = response.headers.location; + if (location) { + try { + const url = new URL(location, config.upstreamUrl); + if (url.origin === config.upstreamUrl.origin) response.headers.location = config.publicUrl.origin + url.pathname + url.search + url.hash; + } catch { /* Keep upstream non-URL Location values unchanged. */ } + } + // The upstream cannot replace our access cookie. Application cookies pass through. + if (response.headers['set-cookie']) response.headers['set-cookie'] = response.headers['set-cookie'].filter(cookie => !cookie.trim().startsWith(`${COOKIE}=`)); + response.headers['cache-control'] = 'private, no-store'; + }, + error(_error, _req, res) { + log.error('Upstream connection failed.'); + if (typeof res.writeHead === 'function' && !res.headersSent) sendJson(res, 502, { error: 'The Groundkeeper is temporarily unreachable. Please try again shortly.' }); + else res.destroy(); + }, + }, + }); + async function route(req, res) { + if (!req.url?.startsWith('/') || req.url.startsWith('//')) return sendJson(res, 400, { error: 'Invalid request target.' }); + let url; + try { url = new URL(req.url, config.publicUrl); } catch { return sendJson(res, 400, { error: 'Invalid URL.' }); } + const path = url.pathname; + if (path.startsWith('/_access')) { + for (const [key, value] of Object.entries(gatewayHeaders)) res.setHeader(key, value); + if (path === '/_access/health' && req.method === 'GET') return sendJson(res, 200, { status: 'ok' }); + if (path === '/_access/discord' && req.method === 'POST' && discord) { + const raw = await readBody(req, 65536); + if (!discord.validSignature(req.headers, raw)) return sendJson(res, 401, { error: 'Invalid signature.' }); + let interaction; + try { interaction = JSON.parse(raw.toString()); } catch { return sendJson(res, 400, { error: 'Invalid JSON.' }); } + if (!interaction || typeof interaction !== 'object') return sendJson(res, 400, { error: 'Invalid interaction.' }); + return sendJson(res, 200, discord.respond(interaction, service)); + } + if (path === '/_access/login' || path === '/_access/logout') { + if (req.method !== 'POST') return sendJson(res, 405, { error: 'Use POST.' }, { allow: 'POST' }); + if (req.headers.origin !== config.publicUrl.origin) return sendJson(res, 403, { error: 'Please submit from the access page.' }); + if (path.endsWith('/logout')) { + const token = readCookie(req); + store.logout(token); + for (const [socket, record] of websocketTimers) if (record.token === token) socket.destroy(); + return sendJson(res, 200, { next: '/_access' }, { 'set-cookie': sessionCookie(config, '') }); + } + if (!(req.headers['content-type'] || '').toLowerCase().startsWith('application/json')) return sendJson(res, 415, { error: 'Use application/json.' }); + const retry = store.attempt(clientIp(req, config)); + if (retry) return sendJson(res, 429, { error: 'Too many attempts. Please wait before trying again.', retryAfter: retry }, { 'retry-after': String(retry) }); + let body; + try { body = JSON.parse((await readBody(req)).toString()); } catch (error) { return sendJson(res, error.status || 400, { error: error.status ? error.message : 'Invalid JSON.' }); } + const token = store.redeem(body?.code); + if (!token) return sendJson(res, 401, { error: 'That number is invalid or expired. Check your DM, or request a new number from the bot.' }); + const session = store.session(token); + return sendJson(res, 200, { next: safeNext(body.next) }, { 'set-cookie': sessionCookie(config, token, Math.max(0, Math.floor((session.expiresAt - store.now()) / 1000))) }); + } + if (assets.has(path) && ['GET', 'HEAD'].includes(req.method)) { + const [body, type] = assets.get(path); + res.writeHead(200, { 'content-type': type }); return res.end(req.method === 'HEAD' ? undefined : body); + } + if (!['/_access', '/_access/'].includes(path)) return sendJson(res, 404, { error: 'Not found.' }); + if (!['GET', 'HEAD'].includes(req.method)) return sendJson(res, 405, { error: 'Use GET.' }, { allow: 'GET, HEAD' }); + if (store.session(readCookie(req))) { res.writeHead(303, { location: safeNext(url.searchParams.get('next')) }); return res.end(); } + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(req.method === 'HEAD' ? undefined : page); + } + if (!store.session(readCookie(req))) { + if (['GET', 'HEAD'].includes(req.method) && path === '/') { + res.writeHead(200, { ...gatewayHeaders, 'content-type': 'text/html; charset=utf-8' }); return res.end(req.method === 'HEAD' ? undefined : page); + } + if (['GET', 'HEAD'].includes(req.method) && (req.headers.accept || '').includes('text/html')) { + res.writeHead(303, { ...gatewayHeaders, location: `/_access?next=${encodeURIComponent(safeNext(req.url))}` }); return res.end(); + } + return sendJson(res, 401, { error: 'An access number is required.', login: '/_access' }); + } + cleanProxyHeaders(req); + await proxy(req, res); + } + const server = createServer((req, res) => { + void route(req, res).catch(error => { + log.error('Gateway request failed.'); + if (!res.headersSent && !res.destroyed) sendJson(res, error.status || 500, { error: error.status ? error.message : 'Unable to complete this request. Please try again.' }); + else res.destroy(); + }); + }); + server.requestTimeout = 30000; + server.headersTimeout = 15000; + server.on('connection', socket => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)); }); + server.on('upgrade', (req, socket, head) => { + const token = readCookie(req); + const session = store.session(token); + if (!session || !req.url?.startsWith('/') || req.url.startsWith('//') || new URL(req.url, config.publicUrl).pathname.startsWith('/_access') || (req.headers.origin && req.headers.origin !== config.publicUrl.origin)) { + socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n'); return; + } + cleanProxyHeaders(req); + // Recheck long-running sockets so an expired number cannot retain access. + const timer = setInterval(() => { if (!store.session(token)) socket.destroy(); }, 1000).unref(); + websocketTimers.set(socket, { timer, token }); + socket.on('close', () => { clearInterval(timer); websocketTimers.delete(socket); }); + Promise.resolve(proxy.upgrade(req, socket, head)).catch(() => socket.destroy()); + }); + return { server, close: () => new Promise(resolve => { + server.close(resolve); + for (const socket of sockets) socket.destroy(); + }) }; +} diff --git a/src/store.js b/src/store.js new file mode 100644 index 0000000..03e3075 --- /dev/null +++ b/src/store.js @@ -0,0 +1,139 @@ +import { createHmac, randomBytes, randomInt, randomUUID } from 'node:crypto'; +import { mkdirSync, readFileSync, writeFileSync, renameSync, openSync, closeSync, fsyncSync, unlinkSync } from 'node:fs'; +import { dirname } from 'node:path'; + +// One process, one durable volume. Every mutation is committed before it is visible. +// A lock prevents two instances from overwriting approvals or rate limits. +export class AccessStore { + constructor(config, { now = Date.now, memory = false } = {}) { + this.config = config; + this.now = now; + this.memory = memory; + this.state = { version: 1, requests: [], sessions: [], limits: {}, telegramOffset: 0 }; + if (!memory) { + mkdirSync(dirname(config.dataFile), { recursive: true, mode: 0o700 }); + this.lockFile = `${config.dataFile}.lock`; + try { + const fd = openSync(this.lockFile, 'wx', 0o600); + writeFileSync(fd, String(process.pid)); + closeSync(fd); + } catch (error) { + if (error.code !== 'EEXIST') throw error; + throw new Error('Access store is locked. Stop the other instance; after a crash, verify its PID before removing the .lock file.'); + } + try { + try { this.state = JSON.parse(readFileSync(config.dataFile, 'utf8')); } + catch (error) { if (error.code !== 'ENOENT') throw error; } + if (this.state.version !== 1 || !Array.isArray(this.state.requests) || !Array.isArray(this.state.sessions) || !this.state.limits) { + throw new Error('Invalid access store; restore a backup instead of starting with empty data'); + } + // Delivery interrupted by a crash must never leave a usable, unconfirmed code. + this.change(state => { + for (const request of state.requests) { + if (request.status === 'delivering') { request.status = 'pending'; delete request.codeHash; } + } + }); + } catch (error) { this.close(); throw error; } + } + } + + hash(kind, value) { return createHmac('sha256', this.config.secret).update(`${kind}:${value}`).digest('hex'); } + + change(mutate) { + const next = structuredClone(this.state); + const result = mutate(next); + if (!this.memory) { + const temporary = `${this.config.dataFile}.tmp`; + const fd = openSync(temporary, 'w', 0o600); + try { writeFileSync(fd, JSON.stringify(next)); fsyncSync(fd); } finally { closeSync(fd); } + renameSync(temporary, this.config.dataFile); + const directory = openSync(dirname(this.config.dataFile), 'r'); + try { fsyncSync(directory); } finally { closeSync(directory); } + } + this.state = next; + return structuredClone(result); + } + + close() { if (this.lockFile) { unlinkSync(this.lockFile); this.lockFile = null; } } + + prune() { + const now = this.now(); + this.change(state => { + state.sessions = state.sessions.filter(session => session.expiresAt > now); + state.requests = state.requests.filter(request => Math.max(request.expiresAt, request.codeExpiresAt || 0) + 30 * 86400_000 > now); + for (const [key, limit] of Object.entries(state.limits)) if (limit.resetAt <= now) delete state.limits[key]; + }); + } + + request(identity) { + const now = this.now(); + const previous = this.state.requests.findLast(r => r.platform === identity.platform && r.user_id === identity.user_id); + if (previous) { + if (['pending', 'delivering'].includes(previous.status) && previous.expiresAt > now) return { request: structuredClone(previous), fresh: false }; + if (previous.status === 'approved' && previous.codeExpiresAt > now) return { request: structuredClone(previous), fresh: false }; + if (previous.createdAt + this.config.requestCooldownMs > now) throw new Error('Please wait a few minutes before requesting access again.'); + } + if (this.state.requests.length >= 10000) throw new Error('The request queue is full. Please contact the administrator.'); + const request = { ...identity, id: randomUUID(), status: 'pending', createdAt: now, expiresAt: now + this.config.requestTtlMs, notifiedAdmins: [] }; + this.change(state => state.requests.push(request)); + return { request, fresh: true }; + } + + getRequest(id) { return structuredClone(this.state.requests.find(r => r.id === id)); } + updateRequest(id, mutate) { return this.change(state => { const request = state.requests.find(r => r.id === id); if (request) mutate(request); return request; }); } + + reserveCode(id, adminId) { + const request = this.getRequest(id); + if (!request || request.status !== 'pending' || request.expiresAt <= this.now()) return null; + let code; + do { code = String(randomInt(10 ** this.config.codeLength)).padStart(this.config.codeLength, '0'); } + while (this.state.requests.some(r => r.codeHash === this.hash('code', code))); + this.updateRequest(id, r => { + r.status = 'delivering'; r.codeHash = this.hash('code', code); + r.codeExpiresAt = this.now() + this.config.codeTtlMs; r.adminId = adminId; + }); + return code; + } + + redeem(code) { + if (typeof code !== 'string' || !new RegExp(`^\\d{${this.config.codeLength}}$`).test(code)) return null; + const hash = this.hash('code', code); + const request = this.state.requests.find(r => r.status === 'approved' && r.codeExpiresAt > this.now() && r.codeHash === hash); + if (!request) return null; + if (this.state.sessions.length >= 10000) return null; + const token = randomBytes(32).toString('base64url'); + this.change(state => { + const record = state.requests.find(r => r.id === request.id); + record.lastUsedAt = this.now(); + state.sessions.push({ hash: this.hash('session', token), requestId: request.id, platform: request.platform, userId: request.user_id, expiresAt: Math.min(request.codeExpiresAt, this.now() + this.config.sessionTtlMs) }); + }); + return token; + } + + session(token) { + if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(token)) return null; + const session = this.state.sessions.find(s => s.hash === this.hash('session', token) && s.expiresAt > this.now()); + if (!session) return null; + const request = this.getRequest(session.requestId); + return request?.status === 'approved' && request.codeExpiresAt > this.now() ? session : null; + } + + logout(token) { if (token) this.change(state => { state.sessions = state.sessions.filter(s => s.hash !== this.hash('session', token)); }); } + + attempt(ip) { + const now = this.now(); + const keys = [this.hash('ip', ip), 'global']; + const maximums = [this.config.loginAttempts, this.config.globalLoginAttempts]; + for (const [index, key] of keys.entries()) { + const limit = this.state.limits[key]; + if (limit?.resetAt > now && limit.count >= maximums[index]) return Math.ceil((limit.resetAt - now) / 1000); + } + this.change(state => { + for (const key of keys) { + if (!state.limits[key] || state.limits[key].resetAt <= now) state.limits[key] = { count: 0, resetAt: now + this.config.loginWindowMs }; + state.limits[key].count++; + } + }); + return 0; + } +} diff --git a/test/bots.test.js b/test/bots.test.js new file mode 100644 index 0000000..a0ff645 --- /dev/null +++ b/test/bots.test.js @@ -0,0 +1,83 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { generateKeyPairSync, sign } from 'node:crypto'; +import { once } from 'node:events'; +import { DiscordBot } from '../src/bots/discord.js'; +import { TelegramBot } from '../src/bots/telegram.js'; +import { createGateway } from '../src/server.js'; +import { fixture } from './helpers.js'; + +test('Telegram /start offers Get access; private callback creates request and admin callback delivers number', async () => { + const f = fixture(); + const calls = []; + const bot = new TelegramBot(f.config, f.store, { fetchImpl: async (url, options) => { + calls.push({ method: url.split('/').at(-1), body: JSON.parse(options.body) }); + return Response.json({ ok: true, result: {} }); + } }); + const user = { id: 5, first_name: 'Player', username: 'player', language_code: 'en' }; + await bot.handle({ message: { from: user, chat: { type: 'private' }, text: '/start access' } }, f.service); + assert.equal(calls[0].body.reply_markup.inline_keyboard[0][0].callback_data, 'request'); + await bot.handle({ callback_query: { id: 'cb1', from: user, message: { chat: { type: 'private' } }, data: 'request' } }, f.service); + assert.equal(f.store.state.requests[0].user_id, '5'); + assert.equal(f.store.state.requests[0].language_code, 'en'); + const id = f.store.state.requests[0].id; + await bot.handle({ callback_query: { id: 'cb2', from: { id: 100 }, message: { chat: { type: 'private' } }, data: `approve:${id}` } }, f.service); + assert.equal(f.store.getRequest(id).status, 'approved'); + assert.ok(f.messages.some(m => m.userId === '5' && m.text.includes('Your number:'))); + assert.equal(calls.filter(c => c.method === 'answerCallbackQuery').length, 2); + await bot.handle({ message: { from: user, chat: { type: 'group' }, text: '/start' } }, f.service); + assert.equal(calls.filter(c => c.method === 'sendMessage').length, 3); +}); + +test('Discord HTTP endpoint validates the exact signed body, rejects stale/forged signatures, and defers approvals', async t => { + const f = fixture(); + const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + f.config.discord.publicKey = publicKey.export({ type: 'spki', format: 'der' }).subarray(-32).toString('hex'); + f.config.discord.applicationId = '321'; + const edits = []; + const bot = new DiscordBot(f.config, { now: f.now, fetchImpl: async (url, options) => { + edits.push({ url, body: JSON.parse(options.body) }); return Response.json({ id: 'dm' }); + } }); + const gateway = createGateway(f.config, f.store, { discord: bot, service: f.service }); + gateway.server.listen(0, '127.0.0.1'); await once(gateway.server, 'listening'); + t.after(async () => { await bot.drain(); await gateway.close(); }); + const endpoint = `http://127.0.0.1:${gateway.server.address().port}/_access/discord`; + const post = async (payload, overrides = {}) => { + const raw = JSON.stringify(payload); + const timestamp = String(Math.floor(f.now() / 1000)); + const signature = sign(null, Buffer.from(timestamp + raw), privateKey).toString('hex'); + return fetch(endpoint, { method: 'POST', headers: { 'content-type': 'application/json', 'x-signature-timestamp': timestamp, 'x-signature-ed25519': signature, ...overrides }, body: raw }); + }; + assert.deepEqual(await (await post({ type: 1 })).json(), { type: 1 }); + assert.equal((await post({ type: 1 }, { 'x-signature-ed25519': '00'.repeat(64) })).status, 401); + assert.equal((await post({ type: 1 }, { 'x-signature-timestamp': '1' })).status, 401); + assert.equal((await fetch(endpoint, { method: 'POST', body: '{}' })).status, 401); + const start = { type: 2, application_id: '321', data: { name: 'access' } }; + assert.equal((await (await post(start)).json()).data.components[0].components[0].custom_id, 'request'); + const interaction = { type: 3, id: '111', token: 'interaction-token', application_id: '321', user: { id: '7', username: 'player' }, data: { custom_id: 'request' } }; + assert.deepEqual(await (await post(interaction)).json(), { type: 5, data: { flags: 64 } }); + await bot.drain(); + assert.equal(f.store.state.requests[0].platform, 'discord'); + assert.equal(f.store.state.requests[0].user_id, '7'); + await post(interaction); await bot.drain(); + assert.equal(edits.length, 1, 'replayed interaction must not re-run the action'); + const requestId = f.store.state.requests[0].id; + await post({ ...interaction, id: '112', user: { id: '999' }, data: { custom_id: `approve:${requestId}` } }); + await bot.drain(); + assert.equal(f.store.getRequest(requestId).status, 'pending'); + await post({ ...interaction, id: '113', user: { id: '200' }, data: { custom_id: `approve:${requestId}` } }); + await bot.drain(); + assert.equal(f.store.getRequest(requestId).status, 'approved'); + assert.equal(f.messages.filter(m => m.userId === '7' && m.text.includes('Your number:')).length, 1); +}); + +test('Discord DMs disable mentions and render separate approval buttons', async () => { + const f = fixture(); + f.config.discord.publicKey = generateKeyPairSync('ed25519').publicKey.export({ type: 'spki', format: 'der' }).subarray(-32).toString('hex'); + const bodies = []; + const bot = new DiscordBot(f.config, { fetchImpl: async (_url, options) => { bodies.push(JSON.parse(options.body)); return Response.json({ id: '123' }); } }); + await bot.sendUser('100', '@everyone malicious display name', [{ label: 'Approve', id: 'approve:123' }, { label: 'Deny', id: 'deny:123' }]); + assert.deepEqual(bodies[0], { recipient_id: '100' }); + assert.deepEqual(bodies[1].allowed_mentions, { parse: [] }); + assert.equal(bodies[1].components[0].components.length, 2); +}); diff --git a/test/config.test.js b/test/config.test.js new file mode 100644 index 0000000..7c7f29b --- /dev/null +++ b/test/config.test.js @@ -0,0 +1,35 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { loadConfig } from '../src/config.js'; +import { safeNext, readCookie, clientIp } from '../src/http.js'; + +const base = { SESSION_SECRET: 'a-secure-test-secret-longer-than-32-chars' }; + +test('configuration rejects unsafe origins, weak secrets, invalid numbers, and incomplete bots', () => { + for (const options of [ + { SESSION_SECRET: '' }, { SESSION_SECRET: 'replace-with-a-random-secret-at-least-32-characters' }, + { UPSTREAM_URL: 'file:///tmp/secret' }, { UPSTREAM_URL: 'https://example.com/path' }, + { UPSTREAM_URL: 'https://user:password@example.com' }, { UPSTREAM_URL: 'http://localhost:8080' }, + { CODE_LENGTH: '7' }, { CODE_TTL_DAYS: '0' }, { PORT: '3000x' }, { TRUST_PROXY: 'yes' }, + { TELEGRAM_BOT_TOKEN: '123:token' }, { DISCORD_BOT_TOKEN: 'secret' }, + { TELEGRAM_ADMIN_IDS: '@admin' }, { ADMIN_REQUEST_FIELDS: 'password' }, + { NODE_ENV: 'production' }, { NODE_ENV: 'production', PUBLIC_URL: 'https://gate.example' }, + ]) assert.throws(() => loadConfig({ ...base, ...options })); + const config = loadConfig({ ...base, NODE_ENV: 'production', PUBLIC_URL: 'https://gate.example', TELEGRAM_BOT_TOKEN: '123:token', TELEGRAM_BOT_USERNAME: 'example_bot', TELEGRAM_ADMIN_IDS: '1,2,1' }); + assert.deepEqual(config.telegram.admins, ['1', '2']); + assert.equal(config.secureCookie, true); +}); + +test('return paths cannot redirect outside the gateway or back into its controls', () => { + for (const value of [undefined, 'https://evil.example', '//evil.example', '/\\evil.example', '/%5cevil.example', '/_access/login', '/a/../_access', '/%00', '/x\ny', '/%']) assert.equal(safeNext(value), '/'); + assert.equal(safeNext('/campaigns/123?tab=party#round'), '/campaigns/123?tab=party#round'); +}); + +test('ambiguous cookies and untrusted forwarding cannot grant access or bypass rate limiting', () => { + assert.equal(readCookie({ headers: { cookie: 'dng_access=first; dng_access=second' } }), ''); + const req = { headers: { 'x-forwarded-for': '192.0.2.1' }, socket: { remoteAddress: '127.0.0.1' } }; + assert.equal(clientIp(req, { trustProxy: false }), '127.0.0.1'); + assert.equal(clientIp(req, { trustProxy: true }), '192.0.2.1'); + req.headers['x-forwarded-for'] = '192.0.2.1, 198.51.100.1'; + assert.equal(clientIp(req, { trustProxy: true }), '127.0.0.1'); +}); diff --git a/test/gateway.test.js b/test/gateway.test.js new file mode 100644 index 0000000..968079c --- /dev/null +++ b/test/gateway.test.js @@ -0,0 +1,141 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { WebSocket, WebSocketServer } from 'ws'; +import { createGateway } from '../src/server.js'; +import { fixture, grant } from './helpers.js'; + +async function setup(t, options) { + const f = fixture(options); + const received = []; + const upstream = createServer(async (req, res) => { + let body = ''; + for await (const chunk of req) body += chunk; + received.push({ url: req.url, headers: req.headers, method: req.method, body }); + if (req.url === '/redirect') { res.writeHead(302, { location: `${f.config.upstreamUrl.origin}/dashboard?tab=party` }); return res.end(); } + res.writeHead(200, { 'content-type': 'application/json', 'set-cookie': ['app=abc; Domain=127.0.0.1; Path=/; HttpOnly', 'dng_access=forged; Path=/'], 'cache-control': 'public, max-age=3600' }); + res.end(JSON.stringify(received.at(-1))); + }); + const wss = new WebSocketServer({ server: upstream }); + wss.on('connection', (ws, req) => { received.push({ ws: true, headers: req.headers }); ws.on('message', data => ws.send(data)); }); + upstream.listen(0, '127.0.0.1'); await once(upstream, 'listening'); + f.config.upstreamUrl = new URL(`http://127.0.0.1:${upstream.address().port}`); + const gateway = createGateway(f.config, f.store, { log: { error() {} } }); + gateway.server.listen(0, '127.0.0.1'); await once(gateway.server, 'listening'); + f.config.publicUrl = new URL(`http://127.0.0.1:${gateway.server.address().port}`); + const url = f.config.publicUrl.origin; + t.after(async () => { + await gateway.close(); + for (const ws of wss.clients) ws.terminate(); + wss.close(); + upstream.closeAllConnections(); + await new Promise(resolve => upstream.close(resolve)); + }); + return { ...f, url, received, upstream, gateway, + login: (code, next = '/', headers = {}) => fetch(`${url}/_access/login`, { method: 'POST', headers: { origin: url, 'content-type': 'application/json', ...headers }, body: JSON.stringify({ code, next }) }), + }; +} + +async function deniedSocket(url, headers = {}) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url.replace('http:', 'ws:') + '/socket', { headers }); + ws.on('unexpected-response', (_req, res) => { res.resume(); ws.terminate(); resolve(res.statusCode); }); + ws.on('open', () => { ws.terminate(); reject(new Error('Unauthenticated websocket was accepted')); }); + ws.on('error', () => {}); + }); +} + +test('unauthenticated pages, API, assets, HEAD, uploads and websocket upgrades never reach upstream', async t => { + const f = await setup(t); + const root = await fetch(f.url); + assert.equal(root.status, 200); + assert.match(await root.text(), /YOUR NEXT/); + const page = await fetch(`${f.url}/campaigns/5?tab=round`, { headers: { accept: 'text/html' }, redirect: 'manual' }); + assert.equal(page.status, 303); + assert.equal(page.headers.get('location'), '/_access?next=%2Fcampaigns%2F5%3Ftab%3Dround'); + for (const [method, path] of [['GET', '/api/v1/campaigns'], ['HEAD', '/_nuxt/file.js'], ['POST', '/upload'], ['GET', '/private?x=1']]) { + assert.equal((await fetch(f.url + path, { method })).status, 401); + } + assert.equal((await fetch(`${f.url}/_access/unknown`)).status, 404); + assert.equal(await deniedSocket(f.url), 401); + assert.equal(f.received.length, 0); + assert.equal((await fetch(`${f.url}/_access/assets/style.css`)).status, 200); +}); + +test('a reusable code unlocks deep links and streams methods, bodies, application cookies and bearer auth', async t => { + const f = await setup(t); + const { code } = await grant(f); + const result = await f.login(code, '/campaigns/123?tab=party'); + assert.equal(result.status, 200); + assert.deepEqual(await result.json(), { next: '/campaigns/123?tab=party' }); + const setCookie = result.headers.get('set-cookie'); + assert.match(setCookie, /HttpOnly; SameSite=Lax; Max-Age=1296000/); + const cookie = setCookie.split(';')[0]; + const response = await fetch(`${f.url}/api/v1/campaigns?x=1`, { method: 'PATCH', body: '{"name":"A new world"}', headers: { + cookie: `${cookie}; app=existing`, authorization: 'Bearer upstream-token', 'content-type': 'application/json', + 'x-forwarded-for': '192.0.2.1', 'x-forwarded-host': 'evil.example', 'x-forwarded-proto': 'ftp', 'x-dng-user': 'forged', + } }); + const body = await response.json(); + assert.equal(body.url, '/api/v1/campaigns?x=1'); + assert.equal(body.method, 'PATCH'); + assert.equal(body.body, '{"name":"A new world"}'); + assert.equal(body.headers.cookie, 'app=existing'); + assert.equal(body.headers.authorization, 'Bearer upstream-token'); + assert.equal(body.headers['x-forwarded-host'], f.config.publicUrl.host); + assert.equal(body.headers['x-forwarded-proto'], 'http'); + assert.equal(body.headers['x-forwarded-for'], '127.0.0.1'); + assert.equal(body.headers['x-dng-user'], undefined); + assert.equal(response.headers.get('cache-control'), 'private, no-store'); + assert.match(response.headers.get('set-cookie'), /app=abc/); + assert.doesNotMatch(response.headers.get('set-cookie'), /Domain|dng_access/); + const redirect = await fetch(`${f.url}/redirect`, { headers: { cookie }, redirect: 'manual' }); + assert.equal(redirect.headers.get('location'), `${f.url}/dashboard?tab=party`); + assert.equal((await f.login(code)).status, 200); + assert.equal(await deniedSocket(f.url), 401, 'proxy must not attach an unguarded listener after HTTP traffic'); + assert.equal((await fetch(`${f.url}/_access/logout`, { method: 'POST', headers: { origin: f.url, cookie } })).status, 200); + assert.equal((await fetch(`${f.url}/api`, { headers: { cookie } })).status, 401); +}); + +test('WebSockets authenticate, preserve application cookies, reject cross-origin access, and close at expiry', async t => { + const f = await setup(t); + const { code } = await grant(f); + const cookie = (await f.login(code)).headers.get('set-cookie').split(';')[0]; + assert.equal(await deniedSocket(f.url, { cookie, origin: 'https://evil.example' }), 401); + const ws = new WebSocket(f.url.replace('http:', 'ws:') + '/socket', { headers: { cookie: `${cookie}; app=ws`, origin: f.url } }); + await once(ws, 'open'); + const echo = once(ws, 'message'); ws.send('round:ready'); + assert.equal((await echo)[0].toString(), 'round:ready'); + assert.equal(f.received.find(r => r.ws).headers.cookie, 'app=ws'); + const closed = once(ws, 'close'); + f.advance(15 * 86400_000); + await closed; + assert.equal((await f.login(code)).status, 401); +}); + +test('login rejects cross-origin posts, malformed bodies, forged cookies and rate-limit evasion', async t => { + const f = await setup(t, { loginAttempts: 2 }); + assert.equal((await f.login('0000000000', '/', { origin: 'https://evil.example' })).status, 403); + assert.equal((await fetch(`${f.url}/_access/login`)).status, 405); + assert.equal((await fetch(`${f.url}/_access/login`, { method: 'POST', headers: { origin: f.url, 'content-type': 'text/plain' }, body: '{}' })).status, 415); + const malformed = await fetch(`${f.url}/_access/login`, { method: 'POST', headers: { origin: f.url, 'content-type': 'application/json' }, body: '{' }); + assert.equal(malformed.status, 400); + assert.equal((await f.login('0000000000')).status, 401); + const limited = await f.login('0000000000', '/', { 'x-forwarded-for': '203.0.113.1' }); + assert.equal(limited.status, 429); + assert.equal(limited.headers.get('retry-after'), '900'); + assert.equal((await fetch(`${f.url}/api`, { headers: { cookie: `dng_access=${'a'.repeat(43)}` } })).status, 401); +}); + +test('oversized login requests are bounded and upstream outages return a usable 502', async t => { + const f = await setup(t); + const oversized = await fetch(`${f.url}/_access/login`, { method: 'POST', headers: { origin: f.url, 'content-type': 'application/json' }, body: JSON.stringify({ code: '1'.repeat(5000) }) }); + assert.equal(oversized.status, 413); + const { code } = await grant(f); + const cookie = (await f.login(code, '//evil.example')).headers.get('set-cookie').split(';')[0]; + f.upstream.closeAllConnections(); + await new Promise(resolve => f.upstream.close(resolve)); + const response = await fetch(`${f.url}/dashboard`, { headers: { cookie } }); + assert.equal(response.status, 502); + assert.match((await response.json()).error, /temporarily unreachable/); +}); diff --git a/test/helpers.js b/test/helpers.js new file mode 100644 index 0000000..89c0f05 --- /dev/null +++ b/test/helpers.js @@ -0,0 +1,21 @@ +import { loadConfig } from '../src/config.js'; +import { AccessStore } from '../src/store.js'; +import { AccessService } from '../src/bots/service.js'; + +export function fixture(options = {}) { + let time = Date.now(); + const config = { ...loadConfig({ SESSION_SECRET: 'test-secret-with-at-least-32-characters', TELEGRAM_ADMIN_IDS: '100,101', DISCORD_ADMIN_IDS: '200' }), ...options }; + const store = new AccessStore(config, { memory: true, now: () => time }); + const messages = []; + const provider = { async sendUser(userId, text, buttons = []) { messages.push({ userId, text, buttons }); } }; + const service = new AccessService(config, store, { telegram: provider, discord: provider }); + return { config, store, service, messages, provider, advance: ms => { time += ms; }, now: () => time }; +} + +export async function grant(f, userId = '5') { + await f.service.request({ platform: 'telegram', user_id: userId, username: 'adventurer' }); + const request = f.store.state.requests.at(-1); + await f.service.decide('telegram', '100', request.id, 'approve'); + const code = f.messages.findLast(m => m.text.includes('Your number:')).text.match(/Your number: (\d+)/)[1]; + return { code, request: f.store.getRequest(request.id) }; +} diff --git a/test/store.test.js b/test/store.test.js new file mode 100644 index 0000000..1bab141 --- /dev/null +++ b/test/store.test.js @@ -0,0 +1,146 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { AccessStore } from '../src/store.js'; +import { fixture, grant } from './helpers.js'; + +test('the same number works on multiple devices for 15 days; all sessions stop at code expiry', async () => { + const f = fixture(); + const { code, request } = await grant(f); + assert.equal(code.length, 10); + assert.equal(request.codeExpiresAt - request.approvedAt, 15 * 86400_000); + const first = f.store.redeem(code); + f.advance(14 * 86400_000); + const second = f.store.redeem(code); + assert.notEqual(first, second); + assert.ok(f.store.session(first)); + assert.equal(f.store.session(second).expiresAt, request.codeExpiresAt); + assert.equal(JSON.stringify(f.store.state).includes(code), false); + assert.equal(JSON.stringify(f.store.state).includes(first), false); + f.advance(86400_000); + assert.equal(f.store.redeem(code), null); + assert.equal(f.store.session(first), null); + assert.equal(f.store.session(second), null); + await f.service.request({ platform: 'telegram', user_id: '5' }); + assert.equal(f.store.state.requests.length, 2); + assert.equal(f.store.state.requests[1].status, 'pending'); +}); + +test('only configured admins from the correct platform can approve; repeated reviews do not send another code', async () => { + const f = fixture(); + await f.service.request({ platform: 'telegram', user_id: '5' }); + const id = f.store.state.requests[0].id; + assert.match(await f.service.decide('telegram', '999', id, 'approve'), /Only configured/); + assert.match(await f.service.decide('discord', '200', id, 'approve'), /not found/); + assert.equal(f.store.getRequest(id).status, 'pending'); + const results = await Promise.all([f.service.decide('telegram', '100', id, 'approve'), f.service.decide('telegram', '101', id, 'approve')]); + assert.equal(results.filter(r => r.startsWith('Approved.')).length, 1); + assert.equal(f.messages.filter(m => m.text.includes('Your number:')).length, 1); + assert.match(await f.service.decide('telegram', '100', id, 'deny'), /already approved/); +}); + +test('failed code delivery leaves the request retryable and the attempted number unusable', async () => { + const f = fixture(); + await f.service.request({ platform: 'telegram', user_id: '5' }); + const id = f.store.state.requests[0].id; + let attemptedCode; + const original = f.provider.sendUser; + f.provider.sendUser = async (_id, message) => { + attemptedCode = message.match(/Your number: (\d+)/)[1]; + assert.equal(f.store.redeem(attemptedCode), null); + throw new Error('DM blocked'); + }; + assert.match(await f.service.decide('telegram', '100', id, 'approve'), /No number was activated/); + assert.equal(f.store.getRequest(id).status, 'pending'); + assert.equal(f.store.getRequest(id).codeHash, undefined); + assert.equal(f.store.redeem(attemptedCode), null); + f.provider.sendUser = original; + assert.match(await f.service.decide('telegram', '100', id, 'approve'), /Approved/); +}); + +test('duplicate requests are coalesced; failed admin notifications can be retried', async () => { + const f = fixture(); + const send = f.provider.sendUser; + f.provider.sendUser = async (id, ...args) => { if (id === '100' || id === '101') throw new Error('Unavailable'); return send(id, ...args); }; + const identity = { platform: 'telegram', user_id: '5' }; + assert.match(await f.service.request(identity), /could not contact/); + f.provider.sendUser = send; + await Promise.all([f.service.request(identity), f.service.request(identity)]); + assert.equal(f.store.state.requests.length, 1); + assert.deepEqual(f.store.state.requests[0].notifiedAdmins, ['100', '101']); + assert.equal(f.messages.filter(m => m.buttons.length === 2).length, 2); +}); + +test('denial, pending expiry, malformed numbers, logout and revocation fail closed', async () => { + const f = fixture(); + await f.service.request({ platform: 'telegram', user_id: '5' }); + const id = f.store.state.requests[0].id; + await f.service.decide('telegram', '100', id, 'deny'); + assert.equal(f.store.getRequest(id).status, 'denied'); + await assert.rejects(f.service.request({ platform: 'telegram', user_id: '5' }), /wait a few minutes/); + await f.service.request({ platform: 'telegram', user_id: '6' }); + f.advance(f.config.requestTtlMs); + assert.match(await f.service.decide('telegram', '100', f.store.state.requests[1].id, 'approve'), /expired/); + for (const code of [null, {}, 1234567890, '', '1'.repeat(11), 'abcd123456']) assert.equal(f.store.redeem(code), null); + const { code, request } = await grant(f, '7'); + const token = f.store.redeem(code); + f.store.logout(token); + assert.equal(f.store.session(token), null); + const other = f.store.redeem(code); + f.store.updateRequest(request.id, r => { r.status = 'revoked'; }); + assert.equal(f.store.session(other), null); + assert.equal(f.store.redeem(code), null); +}); + +test('per-IP and global login limits use fixed windows and do not store raw IPs', () => { + const f = fixture({ loginAttempts: 2, globalLoginAttempts: 3 }); + assert.equal(f.store.attempt('192.0.2.1'), 0); + assert.equal(f.store.attempt('192.0.2.1'), 0); + assert.equal(f.store.attempt('192.0.2.1'), 900); + assert.equal(f.store.attempt('192.0.2.2'), 0); + assert.equal(f.store.attempt('192.0.2.3'), 900); + assert.equal(JSON.stringify(f.store.state).includes('192.0.2.'), false); + f.advance(900_000); + assert.equal(f.store.attempt('192.0.2.1'), 0); +}); + +test('atomic storage survives restart, preserves limits, locks a second writer, and rejects corrupt data', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dng-store-')); + const f = fixture({ dataFile: join(dir, 'access.json') }); + let disk; + try { + disk = new AccessStore(f.config); + const { request } = disk.request({ platform: 'telegram', user_id: '5' }); + const code = disk.reserveCode(request.id, '100'); + disk.updateRequest(request.id, r => { r.status = 'approved'; }); + const token = disk.redeem(code); + disk.attempt('192.0.2.1'); + assert.throws(() => new AccessStore(f.config), /locked/); + assert.equal(statSync(f.config.dataFile).mode & 0o777, 0o600); + assert.equal(readFileSync(f.config.dataFile, 'utf8').includes(code), false); + disk.close(); + disk = new AccessStore(f.config); + assert.ok(disk.session(token)); + assert.ok(disk.redeem(code)); + assert.equal(disk.state.limits.global.count, 1); + disk.close(); + writeFileSync(f.config.dataFile, 'invalid json'); + assert.throws(() => new AccessStore(f.config)); + } finally { disk?.close(); rmSync(dir, { recursive: true, force: true }); } +}); + +test('interrupted approval is reset on restart without activating the undelivered code', () => { + const dir = mkdtempSync(join(tmpdir(), 'dng-recovery-')); + const f = fixture({ dataFile: join(dir, 'access.json') }); + let disk = new AccessStore(f.config); + try { + const { request } = disk.request({ platform: 'telegram', user_id: '5' }); + const code = disk.reserveCode(request.id, '100'); + disk.close(); + disk = new AccessStore(f.config); + assert.equal(disk.getRequest(request.id).status, 'pending'); + assert.equal(disk.redeem(code), null); + } finally { disk.close(); rmSync(dir, { recursive: true, force: true }); } +});