diff --git a/.buildkite/docker-compose.postgres.yml b/.buildkite/docker-compose.postgres.yml new file mode 100644 index 000000000..294731686 --- /dev/null +++ b/.buildkite/docker-compose.postgres.yml @@ -0,0 +1,20 @@ +version: '3.1' + +services: + postgres: + image: postgres:11 + environment: + POSTGRES_PASSWORD: postgres + + testenv: + image: node:12 + depends_on: + - postgres + environment: + IRCBRIDGE_TEST_PGDB: "ircbridge_integtest" + IRCBRIDGE_TEST_PGURL: "postgresql://postgres:postgres@postgres" + IRCBRIDGE_TEST_ENABLEPG: "yes" + working_dir: /app + volumes: + - ..:/app + command: "bash -c 'npm install && npm run build && npm run test'" \ No newline at end of file diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml deleted file mode 100644 index 0a703170f..000000000 --- a/.buildkite/pipeline.yml +++ /dev/null @@ -1,32 +0,0 @@ -steps: - - label: ":eslint: Lint" - command: - - "npm install" - - "npm run lint" - plugins: - - docker#v3.0.1: - image: "node:12" - - - label: ":jasmine: Tests Node 10" - command: - - "npm install" - - "npm run test" - plugins: - - docker#v3.0.1: - image: "node:10" - - - label: ":jasmine: Tests Node 12" - command: - - "npm install" - - "npm run test" - plugins: - - docker#v3.0.1: - image: "node:12" - - - label: ":nyc: Coverage" - command: - - "npm install" - - "npm run ci-test" - plugins: - - docker#v3.0.1: - image: "node:12" diff --git a/.eslintrc b/.eslintrc index 8dd4a6f21..31694c6a1 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,21 +1,22 @@ { + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint"], "parserOptions": { "ecmaVersion": 9, "ecmaFeatures": { - "jsx": true + "jsx": false } }, "env": { "node": true, "jasmine": true }, + "extends": ["plugin:@typescript-eslint/recommended"], "rules": { - "strict": 0, "consistent-return": 2, "curly": 1, "default-case": 2, "guard-for-in": 2, - "no-alert": 2, "no-caller": 2, "no-cond-assign": 2, @@ -53,13 +54,11 @@ "no-unreachable": 2, "no-unexpected-multiline": 2, "no-unused-expressions": 2, - "no-unused-vars": [2, {"vars": "all", "args": "none"}], "no-use-before-define": [1, "nofunc"], "use-isnan": 2, "valid-typeof": 2, - "array-bracket-spacing": [1, "never"], - "max-len": [1, 100], + "max-len": [1, 120], "brace-style": [1, "stroustrup", { "allowSingleLine": true }], "comma-spacing": [1, {"before": false, "after": true}], "comma-style": [1, "last"], @@ -73,6 +72,12 @@ "no-spaced-func": 1, "no-trailing-spaces": 1, "keyword-spacing": [1, {"before": true, "after": true}], - "space-before-blocks": [1, "always"] + "space-before-blocks": [1, "always"], + "@typescript-eslint/explicit-function-return-type": 0, + "@typescript-eslint/camelcase": ["error", { "properties": "never" }], + "@typescript-eslint/ban-ts-ignore": 0, + "no-unused-vars": 0, // covered by @typescript-eslint/no-unused-vars + "strict": ["error", "never" ], + "no-var": 2 } } diff --git a/.gitignore b/.gitignore index f79e38665..c84a56850 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,8 @@ node_modules #passwords passkey.pem + +# Typescript files +*.tsbuildinfo +*.map +lib/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ce2d95536..fbfe1340c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,128 @@ + + 0.14.0 (2020-01-20) +==================== + +Bugfixes +-------- + +- If a new DM room is created for a IRC user, discard the old room. ([\#919](https://github.com/matrix-org/matrix-appservice-irc/issues/919)) +- Fix missig config.schema.yml in the Docker image ([\#920](https://github.com/matrix-org/matrix-appservice-irc/issues/920)) +- Stop trying to use sentry when config.sentry.enabled is false ([\#921](https://github.com/matrix-org/matrix-appservice-irc/issues/921)) +- Improve reply matching logic for Matrix messages. ([\#925](https://github.com/matrix-org/matrix-appservice-irc/issues/925)) + + +Internal Changes +---------------- + +- Use Typescript 3.7 and fix build issues. ([\#931](https://github.com/matrix-org/matrix-appservice-irc/issues/931)) + + +0.14.0-rc4 (2019-12-18) +======================== + +Bugfixes +-------- + +- Massively speed up connection reaper by not syncing all rooms ([\#914](https://github.com/matrix-org/matrix-appservice-irc/issues/914)) +- Tweak DB migration script to handle duplicate PMs and DB inconsistencies ([\#917](https://github.com/matrix-org/matrix-appservice-irc/issues/917)) +- Handle replies that contain a displayname rather than a userid. ([\#918](https://github.com/matrix-org/matrix-appservice-irc/issues/918)) + + +0.14.0-rc3 (2019-12-06) +======================== + +Features +-------- + +- Maximum AS transaction size has been raised from 5MB to 10MB. You may also now specify this limit in the config. ([\#907](https://github.com/matrix-org/matrix-appservice-irc/issues/907)) + + +0.14.0-rc2 (2019-12-05) +======================== + +Internal Changes +---------------- + +- Ensure that joins and leaves are performed linearly per-room. ([\#905](https://github.com/matrix-org/matrix-appservice-irc/issues/905)) + + +0.14.0-rc1 (2019-11-29) +======================== + +Features +-------- + +- The project now uses Typescript for it's source code. ([\#808](https://github.com/matrix-org/matrix-appservice-irc/issues/808)) +- Add support for PostgreSQL ([\#815](https://github.com/matrix-org/matrix-appservice-irc/issues/815)) +- Add migration script for migrating NeDB databases to PostgreSQL. ([\#816](https://github.com/matrix-org/matrix-appservice-irc/issues/816)) +- Add config option `excludedUsers` to exclude users from bridging by regex. ([\#820](https://github.com/matrix-org/matrix-appservice-irc/issues/820)) +- Support room upgrades on PostgreSQL. ([\#824](https://github.com/matrix-org/matrix-appservice-irc/issues/824)) +- Delay ident responses until pending clients have connected. Thanks to @heftig for the initial PR. ([\#825](https://github.com/matrix-org/matrix-appservice-irc/issues/825)) +- Allow admins to specify a bind port and/or hostname in the config. ([\#857](https://github.com/matrix-org/matrix-appservice-irc/issues/857)) +- When !storepass is called, reconnect the user to ensure the password is set. ([\#864](https://github.com/matrix-org/matrix-appservice-irc/issues/864)) +- Track last seen times of users between restarts ([\#876](https://github.com/matrix-org/matrix-appservice-irc/issues/876)) +- Add dry run mode to the debugApi /reapUsers command. ([\#879](https://github.com/matrix-org/matrix-appservice-irc/issues/879)) +- The bridge now supports error tracing via sentry ([\#897](https://github.com/matrix-org/matrix-appservice-irc/issues/897)) + + +Bugfixes +-------- + +- Inviting the bridge bot to an existing bridged room will no longer cause the room to be bridged as an admin room. Invites must also use `is_direct`. ([\#846](https://github.com/matrix-org/matrix-appservice-irc/issues/846)) +- Fix counter for leaving users. ([\#855](https://github.com/matrix-org/matrix-appservice-irc/issues/855)) +- Replace calls to `/state` with more efficient calls, where possible. ([\#865](https://github.com/matrix-org/matrix-appservice-irc/issues/865)) +- Topic changes from Matrix no longer cause a ghost user to join the room. ([\#866](https://github.com/matrix-org/matrix-appservice-irc/issues/866)) +- Ensure bot clients stay connected after being disconnected. ([\#867](https://github.com/matrix-org/matrix-appservice-irc/issues/867)) +- Fix issue where the internal ipv6 counter would not be correctly set ([\#873](https://github.com/matrix-org/matrix-appservice-irc/issues/873)) +- Fix bug where users could not store or remove their password ([\#874](https://github.com/matrix-org/matrix-appservice-irc/issues/874)) +- Fix a bug where users could not generate registration files ([\#875](https://github.com/matrix-org/matrix-appservice-irc/issues/875)) +- Fix uploaded long message URL's not sent to IRC side. ([\#889](https://github.com/matrix-org/matrix-appservice-irc/issues/889)) +- Debug API is now correctly enabled on startup ([\#893](https://github.com/matrix-org/matrix-appservice-irc/issues/893)) +- Quit the app with exitcode 1 if it fails to start ([\#894](https://github.com/matrix-org/matrix-appservice-irc/issues/894)) +- The !storepass command now reconnects users with their new password. ([\#900](https://github.com/matrix-org/matrix-appservice-irc/issues/900)) + + +Deprecations and Removals +------------------------- + +- Statsd is deprecated in this release, and will be removed in the next. Users are encouraged to use prometheus instead, which has richer logging capabilites. ([\#837](https://github.com/matrix-org/matrix-appservice-irc/issues/837)) +- Remove warnings/hacks around `config.appservice`. Users should have upgraded to the new format by now. ([\#849](https://github.com/matrix-org/matrix-appservice-irc/issues/849)) + + +Internal Changes +---------------- + +- Refactor Datastore for Typescript ([\#809](https://github.com/matrix-org/matrix-appservice-irc/issues/809)) +- Add linting support for Typescript files. ([\#810](https://github.com/matrix-org/matrix-appservice-irc/issues/810)) +- Fatal exceptions are now logged to stdout in addition to logs. ([\#812](https://github.com/matrix-org/matrix-appservice-irc/issues/812)) +- Refactor Datastore code to be more generic. ([\#814](https://github.com/matrix-org/matrix-appservice-irc/issues/814)) +- Move schema.yml from /lib/config to / ([\#819](https://github.com/matrix-org/matrix-appservice-irc/issues/819)) +- Use [Towncrier](https://pypi.org/project/towncrier/) for changelog management ([\#821](https://github.com/matrix-org/matrix-appservice-irc/issues/821)) +- Internal conversions of model classes to Typescript ([\#822](https://github.com/matrix-org/matrix-appservice-irc/issues/822)) +- Convert ClientPool and associated dependencies to Typescript ([\#826](https://github.com/matrix-org/matrix-appservice-irc/issues/826)) +- Convert logging to Typescript ([\#827](https://github.com/matrix-org/matrix-appservice-irc/issues/827)) +- Convert DebugApi to Typescript ([\#829](https://github.com/matrix-org/matrix-appservice-irc/issues/829)) +- Typescriptify QuitDebouncer ([\#830](https://github.com/matrix-org/matrix-appservice-irc/issues/830)) +- Typescriptify BridgedClient and dependencies ([\#831](https://github.com/matrix-org/matrix-appservice-irc/issues/831)) +- Convert generator and formatter functions to Typescript ([\#832](https://github.com/matrix-org/matrix-appservice-irc/issues/832)) +- Typescriptify IrcEventBroker ([\#833](https://github.com/matrix-org/matrix-appservice-irc/issues/833)) +- Use seperate DBs for each integration test. ([\#834](https://github.com/matrix-org/matrix-appservice-irc/issues/834)) +- Typescriptify IrcBridge ([\#836](https://github.com/matrix-org/matrix-appservice-irc/issues/836)) +- Typescriptify irc syncer classes ([\#839](https://github.com/matrix-org/matrix-appservice-irc/issues/839)) +- Do not call keepalive() callbacks if the user doesn't need to be kept alive. ([\#844](https://github.com/matrix-org/matrix-appservice-irc/issues/844)) +- Typescriptify matrix handler class ([\#845](https://github.com/matrix-org/matrix-appservice-irc/issues/845)) +- Remove `crc` and `prom-client` packages. ([\#846](https://github.com/matrix-org/matrix-appservice-irc/issues/846)) +- Swap to using promises over timers for queuing messages on IRC connections. ([\#848](https://github.com/matrix-org/matrix-appservice-irc/issues/848)) +- Typescriptify irc handler class ([\#850](https://github.com/matrix-org/matrix-appservice-irc/issues/850)) +- Updates to Dockerfile to add multiple stages and support Typescript ([\#853](https://github.com/matrix-org/matrix-appservice-irc/issues/853)) +- Rewrite provisioner/* in Typescript ([\#861](https://github.com/matrix-org/matrix-appservice-irc/issues/861)) +- Refactor bot command handling into own class. ([\#863](https://github.com/matrix-org/matrix-appservice-irc/issues/863)) +- Move some IRC specific functions from IrcBridge to ClientPool ([\#877](https://github.com/matrix-org/matrix-appservice-irc/issues/877)) +- Use the DB to prefill some membership caches, reducing the number of HTTP calls made and speeding up bridge startup. ([\#881](https://github.com/matrix-org/matrix-appservice-irc/issues/881)) +- Room directory visibility state for bridged rooms is now cached in the database ([\#882](https://github.com/matrix-org/matrix-appservice-irc/issues/882)) +- Gracefully close irc connections on SIGTERM ([\#895](https://github.com/matrix-org/matrix-appservice-irc/issues/895)) +- Log when a newly discovered irc user's profile is updated. ([\#896](https://github.com/matrix-org/matrix-appservice-irc/issues/896)) + Changes in 0.13.1 (2019-11-07) ============================== diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b6e2a2c4a..239309a6c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,6 +38,8 @@ This project follows "git flow" semantics. In practice, this means: `npm run check`. - Create a pull request. If this PR fixes an issue, link to it by referring to its number. - PRs from community members must be signed off as per Synapse's [Attribution section](https://github.com/matrix-org/synapse/blob/master/CONTRIBUTING.rst#attribution) + - Create a changelog entry in `changelog.d`. A changelog filename should be `${GithubIssueNumber}.{bugfix|misc|feature|doc|removal}` + The change should include information that is useful to the user rather than the developer. ## Coding notes The IRC bridge is compatible on Node.js v10+. Buildkite is used to ensure that tests will run on diff --git a/Dockerfile b/Dockerfile index 9ea05363c..d35888991 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,36 +1,42 @@ -# Builder -FROM node:10-slim as builder +# Freebind build +# node:12-slim uses debian:stretch-slim as a base, so it's safe to build on here. +FROM debian:stretch-slim as freebind RUN apt-get update \ - && apt-get install -y git python build-essential libicu-dev + && apt-get install -y git build-essential -RUN git clone https://github.com/matrix-org/freebindfree.git \ - && cd freebindfree \ - && make +RUN git clone https://github.com/matrix-org/freebindfree.git +RUN cd freebindfree && make -COPY ./package.json ./package.json -RUN npm install +# Typescript build +FROM node:12-slim as builder + +WORKDIR /build + +RUN apt-get update && apt-get install -y git python3 libicu-dev build-essential + +COPY src/ /build/src/ +COPY types/ /build/types/ +COPY .eslintrc *json /build/ + +RUN npm ci +RUN npm run build # App -FROM node:10-slim +FROM node:12-slim -RUN apt-get update \ - && apt-get install -y sipcalc iproute2 openssl --no-install-recommends \ - && rm -rf /var/lib/apt/lists/* \ - && mkdir app +RUN apt-get update && apt-get install -y sipcalc iproute2 openssl --no-install-recommends +RUN rm -rf /var/lib/apt/lists/* +RUN mkdir app WORKDIR /app RUN mkdir ./data -RUN openssl genpkey -out ./data/passkey.pem -outform PEM -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -COPY --from=builder /node_modules /app/node_modules -COPY --from=builder /freebindfree/libfreebindfree.so /app/libfreebindfree.so +COPY --from=freebind /freebindfree/libfreebindfree.so /app/libfreebindfree.so +COPY --from=builder /build/node_modules /app/node_modules +COPY --from=builder /build/lib /app/lib -COPY config.yaml /app/config.yaml -COPY passkey.pem /app/passkey.pem -COPY appservice-registration-irc.yaml /app/appservice-registration-irc.yaml -COPY app.js /app/ -COPY lib /app/lib +COPY app.js config.schema.yml /app/ COPY docker /app/docker ENV LD_PRELOAD /app/libfreebindfree.so diff --git a/Dockerfile.README.md b/Dockerfile.README.md index 7486d3280..6a005438b 100644 --- a/Dockerfile.README.md +++ b/Dockerfile.README.md @@ -3,10 +3,15 @@ Ensure you have docker installed. The version this was tested with is `18.06.1-ce`. -Copy the `config.sample.yaml` to `config.yaml` and fill in as normal. Remember +Create `./dockerdata` + +Copy the `config.sample.yaml` to `./dockerdata/config.yaml` and fill in as normal. Remember that docker cannot access the host via `localhost`. -You should create a `appservice-registration-irc.yaml`: +You should use the `/data/` directory for storing configs and store files where appropriate. Ensure +your config is making use of this directory. + +You should create a `./dockerdata/appservice-registration-irc.yaml`: ```yaml id: irc_bridge # Can be any helpful identifier @@ -28,4 +33,10 @@ protocols: Build the image using `docker build .` +If you are storing passwords for users, you should also run: + +```sh +openssl genpkey -out ./dockerdata/passkey.pem -outform PEM -algorithm RSA -pkeyopt rsa_keygen_bits:2048 +``` + You can now run your shiny new image using `docker run -p 9995:1234 -v $PWD/dockerdata:/app/data`. diff --git a/app.js b/app.js index da9412cd1..5a73430bc 100644 --- a/app.js +++ b/app.js @@ -5,14 +5,14 @@ const main = require("./lib/main"); const path = require("path"); const REG_PATH = "appservice-registration-irc.yaml"; - new Cli({ registrationPath: REG_PATH, enableRegistration: true, enableLocalpart: true, + port: -1, // Set this here so we know if the port is a default bridgeConfig: { affectsRegistration: true, - schema: path.join(__dirname, "lib/config/schema.yml"), + schema: path.join(__dirname, "config.schema.yml"), defaults: { homeserver: { dropMatrixMessagesAfterSecs: 0, @@ -37,14 +37,34 @@ new Cli({ } }, generateRegistration: function(reg, callback) { - main.generateRegistration(reg, this.getConfig()).done(function(completeRegistration) { - callback(completeRegistration); - }); + try { + const completeReg = main.generateRegistration(reg, this.getConfig()); + callback(completeReg); + } + catch (ex) { + log.error("Failed to generate registration:", ex); + process.exit(1); + } }, run: function(port, config, reg) { - main.runBridge(port, config, reg).catch(function(err) { + if (port === -1) { + port = null; + } + const bridge = main.runBridge(port, config, reg).catch(function(err) { log.error("Failed to run bridge."); - throw err; + process.exit(1); + }); + + process.on("SIGTERM", async () => { + log.info("SIGTERM recieved, killing bridge"); + try { + await main.killBridge(await bridge); + } + catch (ex) { + log.error("Failed to killBridge:", ex); + process.exit(1); + } + process.exit(0); }); } }).run(); diff --git a/config.sample.yaml b/config.sample.yaml index 7dbbef3dc..b78ef2511 100644 --- a/config.sample.yaml +++ b/config.sample.yaml @@ -32,6 +32,14 @@ homeserver: # Default: true enablePresence: true + # Which port should the appservice bind to. Takes priority over the one provided in the + # command line! Optional. + # bindPort: 9999 + + # Use this option to force the appservice to listen on another hostname for transactions. + # This is NOT your synapse hostname. E.g. use 127.0.0.1 to only listen locally. Optional. + # bindHostname: 0.0.0.0 + # Configuration specific to the IRC service ircService: servers: @@ -203,6 +211,10 @@ ircService: # circumstances. # exclude: ["#foo", "#bar"] + # excludedUsers: + # - regex: "@.*:evilcorp.com" + # kickReason: "We don't like Evilcorp" + # Configuration for controlling how Matrix and IRC membership lists are # synced. membershipLists: @@ -378,8 +390,6 @@ ircService: # to rotations. maxFiles: 5 - # Optional. Enable Prometheus metrics. If this is enabled, you MUST install `prom-client`: - # $ npm install prom-client@6.3.0 # Metrics will then be available via GET /metrics on the bridge listening port (-p). metrics: # Whether to actually enable the metric endpoint. Default: false @@ -391,11 +401,6 @@ ircService: - "1d" - "1w" - # The nedb database URI to connect to. This is the name of the directory to - # dump .db files to. This is relative to the project directory. - # Required. - databaseUri: "nedb://data" - # Configuration options for the debug HTTP API. To access this API, you must # append ?access_token=$APPSERVICE_TOKEN (from the registration file) to the requests. # @@ -469,3 +474,23 @@ advanced: # accidentally overloading the homeserver. Defaults to 1000, which should be # enough for the vast majority of use cases. maxHttpSockets: 1000 + # Max size of an appservice transaction payload, in bytes. Defaults to 10Mb + maxTxnSize: 10000000 + +# Capture information to a sentry.io instance +sentry: + enabled: false + dsn: "https://@sentry.io/" + # Optional. A tag to specify the production environment. Not set by default + # environment: "" + # Optional. A tag to specify the server name. Not set by default + # serverName: "" + +# Use an external database to store bridge state. +database: + # database engine (must be 'postgres' or 'nedb'). Default: nedb + engine: "postgres" + # Either a PostgreSQL connection string, or a path to the NeDB storage directory. + # For postgres, it must start with postgres:// + # For NeDB, it must start with nedb://. The path is relative to the project directory. + connectionString: "postgres://username:password@host:port/databasename" \ No newline at end of file diff --git a/lib/config/schema.yml b/config.schema.yml similarity index 92% rename from lib/config/schema.yml rename to config.schema.yml index 8a102cd78..a41208258 100644 --- a/lib/config/schema.yml +++ b/config.schema.yml @@ -6,6 +6,29 @@ properties: properties: maxHttpSockets: type: "integer" + maxTxnSize: + type: "integer" + sentry: + type: "object" + required: ["enabled"] + properties: + enabled: + type: "boolean" + dsn: + type: "string" + environment: + type: "string" + serverName: + type: "string" + database: + type: "object" + required: ["engine", "connectionString"] + properties: + engine: + type: "string" + enum: ["postgres", "nedb"] + connectionString: + type: "string" homeserver: type: "object" properties: @@ -19,12 +42,14 @@ properties: type: "integer" enablePresence: type: "boolean" + bindHostname: + type: "string" + bindPort: + type: "integer" required: ["url", "domain"] ircService: type: "object" properties: - databaseUri: - type: "string" metrics: type: "object" properties: @@ -320,4 +345,12 @@ properties: type: "integer" userModes: type: "string" - required: ["databaseUri", "servers"] + excludeUsers: + type: "array" + properties: + regex: + type: "string" + kickReason: + type: "string" + required: ["regex"] + required: ["servers"] diff --git a/docker/start.sh b/docker/start.sh index 12d01012e..ac25e1b58 100755 --- a/docker/start.sh +++ b/docker/start.sh @@ -5,4 +5,4 @@ then ip route add local $PREFIX dev lo fi -exec node app.js -c config.yaml -p 9995 -f appservice-registration-irc.yaml -u http://localhost:9995 +exec node app.js -c /data/config.yaml -p 9995 -f /data/appservice-registration-irc.yaml -u http://localhost:9995 diff --git a/lib/DataStore.js b/lib/DataStore.js deleted file mode 100644 index 82efd859e..000000000 --- a/lib/DataStore.js +++ /dev/null @@ -1,619 +0,0 @@ -/*eslint no-invalid-this: 0*/ // eslint doesn't understand Promise.coroutine wrapping -"use strict"; - -const Promise = require("bluebird"); -const crypto = require('crypto'); - -const MatrixRoom = require("matrix-appservice-bridge").MatrixRoom; -const MatrixUser = require("matrix-appservice-bridge").MatrixUser; -const RemoteUser = require("matrix-appservice-bridge").RemoteUser; -const IrcRoom = require("./models/IrcRoom"); -const IrcClientConfig = require("./models/IrcClientConfig"); -const log = require("./logging").get("DataStore"); -const fs = require('fs'); - -function DataStore(userStore, roomStore, pkeyPath, bridgeDomain) { - this._roomStore = roomStore; - this._userStore = userStore; - this._serverMappings = {}; // { domain: IrcServer } - this._bridgeDomain = bridgeDomain; - - var errLog = function(fieldName) { - return function(err) { - if (err) { - log.error("Failed to ensure '%s' index on store: " + err, fieldName); - return; - } - log.info("Indexes checked on '%s' for store.", fieldName); - }; - }; - - // add some indexes - this._roomStore.db.ensureIndex({ - fieldName: "id", - unique: true, - sparse: false - }, errLog("id")); - this._roomStore.db.ensureIndex({ - fieldName: "matrix_id", - unique: false, - sparse: true - }, errLog("matrix_id")); - this._roomStore.db.ensureIndex({ - fieldName: "remote_id", - unique: false, - sparse: true - }, errLog("remote_id")); - this._userStore.db.ensureIndex({ - fieldName: "data.localpart", - unique: false, - sparse: true - }, errLog("localpart")); - this._userStore.db.ensureIndex({ - fieldName: "id", - unique: true, - sparse: false - }, errLog("user id")); - - this._privateKey = null; - - if (pkeyPath) { - try { - this._privateKey = fs.readFileSync(pkeyPath, "utf8").toString(); - - // Test whether key is a valid PEM key (publicEncrypt does internal validation) - try { - crypto.publicEncrypt( - this._privateKey, - new Buffer("This is a test!") - ); - } - catch (err) { - log.error(`Failed to validate private key: (${err.message})`); - throw err; - } - - log.info(`Private key loaded from ${pkeyPath} - IRC password encryption enabled.`); - } - catch (err) { - log.error(`Could not load private key ${err.message}.`); - throw err; - } - } - - // Cache as many mappings as possible for hot paths like message sending. - - // TODO: cache IRC channel -> [room_id] mapping (only need to remove them in - // removeRoom() which is infrequent) - // TODO: cache room_id -> [#channel] mapping (only need to remove them in - // removeRoom() which is infrequent) - -} - -DataStore.prototype.setServerFromConfig = Promise.coroutine(function*(server, serverConfig) { - this._serverMappings[server.domain] = server; - - var channels = Object.keys(serverConfig.mappings); - for (var i = 0; i < channels.length; i++) { - var channel = channels[i]; - for (var k = 0; k < serverConfig.mappings[channel].length; k++) { - var ircRoom = new IrcRoom(server, channel); - var mxRoom = new MatrixRoom( - serverConfig.mappings[channel][k] - ); - yield this.storeRoom(ircRoom, mxRoom, 'config'); - } - } - - // Some kinds of users may have the same user_id prefix so will cause ident code to hit - // getMatrixUserByUsername hundreds of times which can be slow: - // https://github.com/matrix-org/matrix-appservice-irc/issues/404 - let domainKey = server.domain.replace(/\./g, "_"); - this._userStore.db.ensureIndex({ - fieldName: "data.client_config." + domainKey + ".username", - unique: true, - sparse: true - }, function(err) { - if (err) { - log.error("Failed to ensure ident username index on users database!"); - return; - } - log.info("Indexes checked for ident username for " + server.domain + " on users database"); - }); -}); - -/** - * Persists an IRC <--> Matrix room mapping in the database. - * @param {IrcRoom} ircRoom : The IRC room to store. - * @param {MatrixRoom} matrixRoom : The Matrix room to store. - * @param {string} origin : "config" if this mapping is from the config yaml, - * "provision" if this mapping was provisioned, "alias" if it was created via - * aliasing and "join" if it was created during a join. - * @return {Promise} - */ -DataStore.prototype.storeRoom = function(ircRoom, matrixRoom, origin) { - if (typeof origin !== 'string') { - throw new Error('Origin must be a string = "config"|"provision"|"alias"|"join"'); - } - - log.info("storeRoom (id=%s, addr=%s, chan=%s, origin=%s)", - matrixRoom.getId(), ircRoom.get("domain"), ircRoom.channel, origin); - - let mappingId = createMappingId(matrixRoom.getId(), ircRoom.get("domain"), ircRoom.channel); - return this._roomStore.linkRooms(matrixRoom, ircRoom, { - origin: origin - }, mappingId); -}; - -/** - * Get an IRC <--> Matrix room mapping from the database. - * @param {string} roomId : The Matrix room ID. - * @param {string} ircDomain : The IRC server domain. - * @param {string} ircChannel : The IRC channel. - * @param {string} origin : (Optional) "config" if this mapping was from the config yaml, - * "provision" if this mapping was provisioned, "alias" if it was created via aliasing and - * "join" if it was created during a join. - * @return {Promise} A promise which resolves to a room entry, or null if one is not found. - */ -DataStore.prototype.getRoom = function(roomId, ircDomain, ircChannel, origin) { - if (typeof origin !== 'undefined' && typeof origin !== 'string') { - throw new Error(`If defined, origin must be a string = - "config"|"provision"|"alias"|"join"`); - } - let mappingId = createMappingId(roomId, ircDomain, ircChannel); - - return this._roomStore.getEntryById(mappingId).then( - (entry) => { - if (origin && entry && origin !== entry.data.origin) { - return null; - } - return entry; - }); -}; - -/** - * Get all Matrix <--> IRC room mappings from the database. - * @return {Promise} A promise which resolves to a map: - * $roomId => [{networkId: 'server #channel1', channel: '#channel2'} , ...] - */ -DataStore.prototype.getAllChannelMappings = Promise.coroutine(function*() { - let entries = yield this._roomStore.select( - { - matrix_id: {$exists: true}, - remote_id: {$exists: true}, - 'remote.type': "channel" - } - ); - - let mappings = {}; - - entries.forEach((e) => { - // drop unknown irc networks in the database - if (!this._serverMappings[e.remote.domain]) { - return; - } - if (!mappings[e.matrix_id]) { - mappings[e.matrix_id] = []; - } - mappings[e.matrix_id].push({ - networkId: this._serverMappings[e.remote.domain].getNetworkId(), - channel: e.remote.channel - }); - }); - - return mappings; -}); - -/** - * Get provisioned IRC <--> Matrix room mappings from the database where - * the matrix room ID is roomId. - * @param {string} roomId : The Matrix room ID. - * @return {Promise} A promise which resolves to a list - * of entries. - */ -DataStore.prototype.getProvisionedMappings = function(roomId) { - return this._roomStore.getEntriesByMatrixId(roomId).filter( - (entry) => { - return entry.data && entry.data.origin === 'provision' - }); -}; - -/** - * Remove an IRC <--> Matrix room mapping from the database. - * @param {string} roomId : The Matrix room ID. - * @param {string} ircDomain : The IRC server domain. - * @param {string} ircChannel : The IRC channel. - * @param {string} origin : "config" if this mapping was from the config yaml, - * "provision" if this mapping was provisioned, "alias" if it was created via - * aliasing and "join" if it was created during a join. - * @return {Promise} - */ -DataStore.prototype.removeRoom = function(roomId, ircDomain, ircChannel, origin) { - if (typeof origin !== 'string') { - throw new Error('Origin must be a string = "config"|"provision"|"alias"|"join"'); - } - - return this._roomStore.delete({ - id: createMappingId(roomId, ircDomain, ircChannel), - 'data.origin': origin - }); -}; - -/** - * Retrieve a list of IRC rooms for a given room ID. - * @param {string} roomId : The room ID to get mapped IRC channels. - * @return {Promise>} A promise which resolves to a list of - * rooms. - */ -DataStore.prototype.getIrcChannelsForRoomId = function(roomId) { - return this._roomStore.getLinkedRemoteRooms(roomId).then((remoteRooms) => { - return remoteRooms.filter((remoteRoom) => { - return Boolean(this._serverMappings[remoteRoom.get("domain")]); - }).map((remoteRoom) => { - let server = this._serverMappings[remoteRoom.get("domain")]; - return IrcRoom.fromRemoteRoom(server, remoteRoom); - }); - }); -}; - -/** - * Retrieve a list of IRC rooms for a given list of room IDs. This is significantly - * faster than calling getIrcChannelsForRoomId for each room ID. - * @param {string[]} roomIds : The room IDs to get mapped IRC channels. - * @return {Promise>} A promise which resolves to a map of - * room ID to an array of IRC rooms. - */ -DataStore.prototype.getIrcChannelsForRoomIds = function(roomIds) { - return this._roomStore.batchGetLinkedRemoteRooms(roomIds).then((roomIdToRemoteRooms) => { - Object.keys(roomIdToRemoteRooms).forEach((roomId) => { - // filter out rooms with unknown IRC servers and - // map RemoteRooms to IrcRooms - roomIdToRemoteRooms[roomId] = roomIdToRemoteRooms[roomId].filter((remoteRoom) => { - return Boolean(this._serverMappings[remoteRoom.get("domain")]); - }).map((remoteRoom) => { - let server = this._serverMappings[remoteRoom.get("domain")]; - return IrcRoom.fromRemoteRoom(server, remoteRoom); - }); - }); - return roomIdToRemoteRooms; - }); -}; - -/** - * Retrieve a list of Matrix rooms for a given server and channel. - * @param {IrcServer} server : The server to get rooms for. - * @param {string} channel : The channel to get mapped rooms for. - * @return {Promise>} A promise which resolves to a list of rooms. - */ -DataStore.prototype.getMatrixRoomsForChannel = function(server, channel) { - var ircRoom = new IrcRoom(server, channel); - return this._roomStore.getLinkedMatrixRooms( - IrcRoom.createId(ircRoom.getServer(), ircRoom.getChannel()) - ); -}; - -DataStore.prototype.getMappingsForChannelByOrigin = function(server, channel, origin, allowUnset) { - if (typeof origin === "string") { - origin = [origin]; - } - if (!Array.isArray(origin) || !origin.every((s) => typeof s === "string")) { - throw new Error("origin must be string or array of strings"); - } - let remoteId = IrcRoom.createId(server, channel); - return this._roomStore.getEntriesByRemoteId(remoteId).then((entries) => { - return entries.filter((e) => { - if (allowUnset) { - if (!e.data || !e.data.origin) { - return true; - } - } - return e.data && origin.indexOf(e.data.origin) !== -1; - }); - }); -}; - -DataStore.prototype.getModesForChannel = function (server, channel) { - log.info("getModesForChannel (server=%s, channel=%s)", - server.domain, channel - ); - let remoteId = IrcRoom.createId(server, channel); - return this._roomStore.getEntriesByRemoteId(remoteId).then((entries) => { - const mapping = {}; - entries.forEach((entry) => { - mapping[entry.matrix.getId()] = entry.remote.get("modes") || []; - }); - return mapping; - }); -}; - -DataStore.prototype.setModeForRoom = Promise.coroutine(function*(roomId, mode, enabled=True) { - log.info("setModeForRoom (mode=%s, roomId=%s, enabled=%s)", - mode, roomId, enabled - ); - return this._roomStore.getEntriesByMatrixId(roomId).then((entries) => { - entries.map((entry) => { - const modes = entry.remote.get("modes") || []; - const hasMode = modes.includes(mode); - - if (hasMode === enabled) { - return; - } - if (enabled) { - modes.push(mode); - } - else { - modes.splice(modes.indexOf(mode), 1); - } - - entry.remote.set("modes", modes); - - this._roomStore.upsertEntry(entry); - }); - }); -}); - -DataStore.prototype.setPmRoom = function(ircRoom, matrixRoom, userId, virtualUserId) { - log.info("setPmRoom (id=%s, addr=%s chan=%s real=%s virt=%s)", - matrixRoom.getId(), ircRoom.server.domain, ircRoom.channel, userId, - virtualUserId); - - return this._roomStore.linkRooms(matrixRoom, ircRoom, { - real_user_id: userId, - virtual_user_id: virtualUserId - }, createPmId(userId, virtualUserId)); -}; - -DataStore.prototype.getMatrixPmRoom = function(realUserId, virtualUserId) { - var id = createPmId(realUserId, virtualUserId); - return this._roomStore.getEntryById(id).then(function(entry) { - if (!entry) { - return null; - } - return entry.matrix; - }); -}; - -DataStore.prototype.getTrackedChannelsForServer = function(ircAddr) { - return this._roomStore.getEntriesByRemoteRoomData({ domain: ircAddr }).then( - (entries) => { - var channels = []; - entries.forEach((e) => { - let r = e.remote; - let server = this._serverMappings[r.get("domain")]; - if (!server) { - return; - } - let ircRoom = IrcRoom.fromRemoteRoom(server, r); - if (ircRoom.getType() === "channel") { - channels.push(ircRoom.getChannel()); - } - }); - return channels; - }); -}; - -DataStore.prototype.getRoomIdsFromConfig = function() { - return this._roomStore.getEntriesByLinkData({ - origin: 'config' - }).then(function(entries) { - return entries.map((e) => { - return e.matrix.getId(); - }); - }); -}; - -DataStore.prototype.removeConfigMappings = function() { - return this._roomStore.removeEntriesByLinkData({ - from_config: true // for backwards compatibility - }).then(() => { - return this._roomStore.removeEntriesByLinkData({ - origin: 'config' - }) - }); -}; - -DataStore.prototype.getIpv6Counter = Promise.coroutine(function*() { - let config = yield this._userStore.getRemoteUser("config"); - if (!config) { - config = new RemoteUser("config"); - config.set("ipv6_counter", 0); - yield this._userStore.setRemoteUser(config); - } - return config.get("ipv6_counter"); -}); - -DataStore.prototype.setIpv6Counter = Promise.coroutine(function*(counter) { - let config = yield this._userStore.getRemoteUser("config"); - if (!config) { - config = new RemoteUser("config"); - } - config.set("ipv6_counter", counter); - yield this._userStore.setRemoteUser(config); -}); - -/** - * Retrieve a stored admin room based on the room's ID. - * @param {String} roomId : The room ID of the admin room. - * @return {Promise} Resolved when the room is retrieved. - */ -DataStore.prototype.getAdminRoomById = function(roomId) { - return this._roomStore.getEntriesByMatrixId(roomId).then(function(entries) { - if (entries.length == 0) { - return null; - } - if (entries.length > 1) { - log.error("getAdminRoomById(" + roomId + ") has " + entries.length + " entries"); - } - if (entries[0].matrix.get("admin_id")) { - return entries[0].matrix; - } - return null; - }); -}; - -/** - * Stores a unique admin room for a given user ID. - * @param {MatrixRoom} room : The matrix room which is the admin room for this user. - * @param {String} userId : The user ID who is getting an admin room. - * @return {Promise} Resolved when the room is stored. - */ -DataStore.prototype.storeAdminRoom = function(room, userId) { - log.info("storeAdminRoom (id=%s, user_id=%s)", room.getId(), userId); - room.set("admin_id", userId); - return this._roomStore.upsertEntry({ - id: createAdminId(userId), - matrix: room, - }); -}; - -DataStore.prototype.upsertRoomStoreEntry = function(entry) { - return this._roomStore.upsertEntry(entry); -} - -DataStore.prototype.getAdminRoomByUserId = function(userId) { - return this._roomStore.getEntryById(createAdminId(userId)).then(function(entry) { - if (!entry) { - return null; - } - return entry.matrix; - }); -}; - -DataStore.prototype.storeMatrixUser = function(matrixUser) { - return this._userStore.setMatrixUser(matrixUser); -}; - -DataStore.prototype.getMatrixUserByLocalpart = function(localpart) { - return this._userStore.getMatrixUser(`@${localpart}:${this._bridgeDomain}`); -}; - -DataStore.prototype.getIrcClientConfig = function(userId, domain) { - return this._userStore.getMatrixUser(userId).then((matrixUser) => { - if (!matrixUser) { - return null; - } - var userConfig = matrixUser.get("client_config"); - if (!userConfig) { - return null; - } - // map back from _ to . - Object.keys(userConfig).forEach(function(domainWithUnderscores) { - let actualDomain = domainWithUnderscores.replace(/_/g, "."); - if (actualDomain !== domainWithUnderscores) { // false for 'localhost' - userConfig[actualDomain] = userConfig[domainWithUnderscores]; - delete userConfig[domainWithUnderscores]; - } - }) - var configData = userConfig[domain]; - if (!configData) { - return null; - } - let clientConfig = new IrcClientConfig(userId, domain, configData); - if (clientConfig.getPassword()) { - if (!this._privateKey) { - throw new Error(`Cannot decrypt password of ${userId} - no private key`); - } - let decryptedPass = crypto.privateDecrypt( - this._privateKey, - new Buffer(clientConfig.getPassword(), 'base64') - ).toString(); - // Extract the password by removing the prefixed salt and seperating space - decryptedPass = decryptedPass.split(' ')[1]; - clientConfig.setPassword(decryptedPass); - } - return clientConfig; - }); -}; - -DataStore.prototype.storeIrcClientConfig = function(config) { - return this._userStore.getMatrixUser(config.getUserId()).then((user) => { - if (!user) { - user = new MatrixUser(config.getUserId()); - } - var userConfig = user.get("client_config") || {}; - if (config.getPassword()) { - if (!this._privateKey) { - throw new Error( - 'Cannot store plaintext passwords' - ); - } - let salt = crypto.randomBytes(16).toString('base64'); - let encryptedPass = crypto.publicEncrypt( - this._privateKey, - new Buffer(salt + ' ' + config.getPassword()) - ).toString('base64'); - // Store the encrypted password, ready for the db - config.setPassword(encryptedPass); - } - userConfig[config.getDomain().replace(/\./g, "_")] = config.serialize(); - user.set("client_config", userConfig); - return this._userStore.setMatrixUser(user); - }); -}; - -DataStore.prototype.getUserFeatures = function(userId) { - return this._userStore.getMatrixUser(userId).then((matrixUser) => { - return matrixUser ? (matrixUser.get("features") || {}) : {}; - }); -}; - -DataStore.prototype.storeUserFeatures = function(userId, features) { - return this._userStore.getMatrixUser(userId).then((matrixUser) => { - if (!matrixUser) { - matrixUser = new MatrixUser(userId); - } - matrixUser.set("features", features); - return this._userStore.setMatrixUser(matrixUser); - }); -}; - -DataStore.prototype.storePass = Promise.coroutine( - function*(userId, domain, pass) { - let config = yield this.getIrcClientConfig(userId, domain); - if (!config) { - throw new Error(`${userId} does not have an IRC client configured for ${domain}`); - } - config.setPassword(pass); - yield this.storeIrcClientConfig(config); - } -); - -DataStore.prototype.removePass = Promise.coroutine( - function*(userId, domain) { - let config = yield this.getIrcClientConfig(userId, domain); - config.setPassword(undefined); - yield this.storeIrcClientConfig(config); - } -); - -DataStore.prototype.getMatrixUserByUsername = Promise.coroutine( -function*(domain, username) { - let domainKey = domain.replace(/\./g, "_"); - let matrixUsers = yield this._userStore.getByMatrixData({ - ["client_config." + domainKey + ".username"]: username - }); - - if (matrixUsers.length > 1) { - log.error( - "getMatrixUserByUsername return %s results for %s on %s", - matrixUsers.length, username, domain - ); - } - return matrixUsers[0]; -}); - -function createPmId(userId, virtualUserId) { - // space as delimiter as none of these IDs allow spaces. - return "PM_" + userId + " " + virtualUserId; // clobber based on this. -} - -function createAdminId(userId) { - return "ADMIN_" + userId; // clobber based on this. -} - -function createMappingId(roomId, ircDomain, ircChannel) { - // space as delimiter as none of these IDs allow spaces. - return roomId + " " + ircDomain + " " + ircChannel; // clobber based on this -} - -module.exports = DataStore; diff --git a/lib/DebugApi.js b/lib/DebugApi.js deleted file mode 100644 index 1ec455770..000000000 --- a/lib/DebugApi.js +++ /dev/null @@ -1,414 +0,0 @@ -/*eslint no-invalid-this: 0*/ // eslint doesn't understand Promise.coroutine wrapping -"use strict"; -const querystring = require("querystring"); -const Promise = require("bluebird"); -const BridgeRequest = require("./models/BridgeRequest"); -const log = require("./logging").get("DebugApi"); -const http = require("http"); - -function DebugApi(ircBridge, port, servers, pool, token) { - this.ircBridge = ircBridge; - this.port = port; - this.pool = pool; - this.servers = servers; - this.token = token; -} - -DebugApi.prototype._getClient = function(server, user) { - if (!user) { - return this.pool.getBot(server); - } - return this.pool.getBridgedClientByUserId(server, user); -}; - -DebugApi.prototype.getClientState = function(server, user) { - log.debug("getClientState(%s,%s)", server.domain, user); - let client = this._getClient(server, user); - if (!client) { - return "User " + user + " does not have a client on " + server.domain; - } - return require("util").inspect(client, {colors:true, depth:7}); -}; - -DebugApi.prototype.killUser = function(userId, reason) { - const req = new BridgeRequest(this.ircBridge._bridge.getRequestFactory().newRequest()); - const clients = this.pool.getBridgedClientsForUserId(userId); - return this.ircBridge.matrixHandler.quitUser(req, userId, clients, null, reason); -}; - -// returns a promise to allow a response buffer to be populated -DebugApi.prototype.sendIRCCommand = function(server, user, body) { - log.debug("sendIRCCommand(%s,%s,%s)", server.domain, user, body); - let client = this._getClient(server, user); - if (!client) { - return Promise.resolve( - "User " + user + " does not have a client on " + server.domain + "\n" - ); - } - if (!client.unsafeClient) { - return Promise.resolve( - "There is no underlying client instance.\n" - ); - } - - // store all received response strings - let buffer = []; - let listener = function(msg) { - buffer.push(JSON.stringify(msg)); - } - - client.unsafeClient.on("raw", listener); - // turn rn to n so if there are any new lines they are all n. - body = body.replace("\r\n", "\n"); - body.split("\n").forEach((c) => { - // IRC protocol require rn - client.unsafeClient.conn.write(c + "\r\n"); - buffer.push(c); - }); - - // wait 3s to pool responses - return Promise.delay(3000).then(function() { - // unhook listener to avoid leaking - if (client.unsafeClient) { - client.unsafeClient.removeListener("raw", listener); - } - return buffer.join("\n") + "\n"; - }); -} - -DebugApi.prototype.run = function() { - log.info("DEBUG API LISTENING ON :%d", this.port); - - http.createServer((req, response) => { - try { - let reqPath = req.url.split("?"); - let path = reqPath[0]; - let query = querystring.parse(reqPath[1]); - log.debug(req.method + " " + path); - - if (query["access_token"] !== this.token) { - response.writeHead(403, {"Content-Type": "text/plain"}); - response.write("Invalid or missing ?access_token=. " + - "The app service token is required from the registration.\n"); - response.end(); - log.warn("Failed attempt with token " + query["access_token"]); - return; - } - - if (path == "/killUser") { - let body = ""; - req.on("data", function(chunk) { - body += chunk; - }); - req.on("end", () => { - let promise = null; - try { - body = JSON.parse(body); - if (!body.user_id || !body.reason) { - promise = Promise.reject(new Error("Need user_id and reason")); - } - else { - promise = this.killUser(body.user_id, body.reason); - } - } - catch (err) { - promise = Promise.reject(err); - } - - promise.then(function(r) { - response.writeHead(200, {"Content-Type": "text/plain"}); - response.write(r + "\n"); - response.end(); - }, function(err) { - log.error(err.stack); - response.writeHead(500, {"Content-Type": "text/plain"}); - response.write(err + "\n"); - response.end(); - }); - }); - return; - } - else if (req.method === "POST" && path == "/reapUsers") { - const msgCb = (msg) => { - if (!response.headersSent) { - response.writeHead(200, {"Content-Type": "text/plain"}); - } - response.write(msg + "\n") - } - this.ircBridge.connectionReap( - msgCb, query["server"], parseInt(query["since"]), query["reason"] - ).catch((err) => { - log.error(err.stack); - if (!response.headersSent) { - response.writeHead(500, {"Content-Type": "text/plain"}); - } - response.write(err + "\n"); - }).finally(() => { - response.end(); - }); - return; - } - else if (req.method === "POST" && path == "/killPortal") { - this.killPortal(req, response); - return; - } - else if (req.method === "GET" && path === "/inspectUsers") { - this.inspectUsers(query["regex"], response); - return; - } - - // Looks like /irc/$domain/user/$user_id - let segs = path.split("/"); - if (segs.length !== 5 || segs[1] !== "irc" || segs[3] !== "user") { - response.writeHead(404, {"Content-Type": "text/plain"}); - response.write("Not a valid debug path.\n"); - response.end(); - return; - } - - let domain = segs[2]; - let user = segs[4]; - - log.debug("Domain: %s User: %s", domain, user); - - let server = null; - for (var i = 0; i < this.servers.length; i++) { - if (this.servers[i].domain === domain) { - server = this.servers[i]; - break; - } - } - if (server === null) { - response.writeHead(400, {"Content-Type": "text/plain"}); - response.write("Not a valid domain.\n"); - response.end(); - return; - } - - let body = ""; - req.on("data", function(chunk) { - body += chunk; - }); - - req.on("end", () => { - // Create a promise which resolves to a response string - let promise = null; - if (req.method === "GET") { - try { - let resBody = this.getClientState(server, user); - if (!resBody.endsWith("\n")) { - resBody += "\n"; - } - promise = Promise.resolve(resBody); - } - catch (err) { - promise = Promise.reject(err); - } - } - else if (req.method === "POST") { - promise = this.sendIRCCommand(server, user, body) - } - else { - promise = Promise.reject(new Error("Bad HTTP method")); - } - - promise.done(function(r) { - response.writeHead(200, {"Content-Type": "text/plain"}); - response.write(r); - response.end(); - }, function(err) { - log.error(err.stack); - response.writeHead(500, {"Content-Type": "text/plain"}); - response.write(err + "\n"); - response.end(); - }); - }); - } - catch (err) { - log.error(err.stack); - } - }).listen(this.port); -} - -DebugApi.prototype.killPortal = Promise.coroutine(function*(req, response) { - const result = { - error: [], // string|[string] containing a fatal error or minor errors. - stages: [] // stages completed for removing the room. It's possible it might only - // half complete, and we should make that obvious. - }; - const body = yield this._wrapJsonReq(req, response); - - // Room room_id to lookup and delete the alias from. - const roomId = body["room_id"]; - - // IRC server domain - const domain = body["domain"]; - - // IRC channel - const channel = body["channel"]; - - // Should we tell the room about the deletion. Defaults to true. - const notice = !(body["leave_notice"] === false); - - // Should we remove the alias from the room. Defaults to true. - const remove_alias = !(body["remove_alias"] === false); - - // These keys are required. - ["room_id", "channel", "domain"].forEach((key) => { - if (typeof(body[key]) !== "string") { - result.error.push(`'${key}' is missing from body or not a string`); - } - }); - if (result.error.length > 0) { - this._wrapJsonResponse(result.error, false, response); - return; - } - - log.warn( -`Requested deletion of portal room alias ${roomId} through debug API -Domain: ${domain} -Channel: ${channel} -Leave Notice: ${notice} -Remove Alias: ${remove_alias}`); - - // Find room - let room = yield this.ircBridge.getStore().getRoom( - roomId, - domain, - channel, - "alias" - ); - if (room === null) { - result.error = "Room not found"; - this._wrapJsonResponse(result, false, response); - return; - } - - const server = this.servers.find((srv) => srv.domain === domain); - if (server === null) { - result.error = "Server not found!"; - this._wrapJsonResponse(result, false, response); - return; - } - - // Drop room from room store. - this.ircBridge.getStore().removeRoom( - roomId, - domain, - channel, - "alias" - ); - result.stages.push("Removed room from store"); - - if (notice) { - try { - yield this.ircBridge.getAppServiceBridge().getIntent().sendEvent(roomId, "notice", - { - body: `This room has been unbridged from ${channel} (${server.getReadableName()})` - }); - result.stages.push("Left notice in room"); - } - catch (e) { - result.error.push("Failed to send a leave notice"); - } - } - - if (remove_alias) { - const roomAlias = server.getAliasFromChannel(channel); - try { - yield this.ircBridge.getAppServiceBridge().getIntent().client.deleteAlias(roomAlias); - result.stages.push("Deleted alias for room"); - } - catch (e) { - result.error.push("Failed to remove alias"); - } - } - - // Drop clients from room. - // The provisioner will only drop clients who are not in other rooms. - // It will also leave the MatrixBot. - try { - yield this.ircBridge.getProvisioner()._leaveIfUnprovisioned( - { log: log }, - roomId, - server, - channel - ); - } - catch (e) { - result.error.push("Failed to leave users from room"); - result.error.push(e); - this._wrapJsonResponse(result, false, response); - return; - } - - result.stages.push("Parted clients where applicable."); - this._wrapJsonResponse(result, true, response); -}); - -DebugApi.prototype.inspectUsers = function(regex, response) { - if (!regex) { - this._wrapJsonResponse({ - "error": "'regex' not provided", - }, false, response); - return; - } - try { - const userClients = this.ircBridge.getBridgedClientsForRegex(regex); - const clientsResponse = {}; - Object.keys(userClients).forEach((userId) => { - clientsResponse[userId] = userClients[userId].map((client) => { - if (!client) { - return undefined; - } - return { - channels: client.chanList, - dead: client.isDead(), - server: client.server.domain, - nick: client.nick, - }; - }); - }); - this._wrapJsonResponse({ - users: clientsResponse, - }, true, response); - } - catch (ex) { - this._wrapJsonResponse({ - "error": "Failed to fetch clients for user", - "info": String(ex), - }, false, response); - } -}; - -DebugApi.prototype._wrapJsonReq = function(req, response) { - let body = ""; - req.on("data", function(chunk) { - body += chunk; - }); - return new Promise((resolve, reject) => { - req.on("error", (err) => { - reject(err); - }); - req.on("end", () => { - if (body === "") { - reject({"error": "Body missing"}); - } - try { - body = JSON.parse(body); - resolve(body); - } - catch (err) { - reject(err); - } - }); - }); -} - -DebugApi.prototype._wrapJsonResponse = function(json, isOk, response) { - response.writeHead(isOk === true ? 200 : 500, {"Content-Type": "application/json"}); - response.write(JSON.stringify(json)); - response.end(); -} - -module.exports = DebugApi; diff --git a/lib/bridge/IrcBridge.js b/lib/bridge/IrcBridge.js deleted file mode 100644 index bc2e00333..000000000 --- a/lib/bridge/IrcBridge.js +++ /dev/null @@ -1,1168 +0,0 @@ -/*eslint no-invalid-this: 0*/ // eslint doesn't understand Promise.coroutine wrapping -"use strict"; -var Promise = require("bluebird"); -var extend = require("extend"); - -var promiseutil = require("../promiseutil"); -var IrcHandler = require("./IrcHandler.js"); -var MatrixHandler = require("./MatrixHandler.js"); -var MemberListSyncer = require("./MemberListSyncer.js"); -var IdentGenerator = require("../irc/IdentGenerator.js"); -var Ipv6Generator = require("../irc/Ipv6Generator.js"); -var IrcServer = require("../irc/IrcServer.js"); -var ClientPool = require("../irc/ClientPool"); -var IrcEventBroker = require("../irc/IrcEventBroker"); -var BridgedClient = require("../irc/BridgedClient"); -var IrcUser = require("../models/IrcUser"); -var IrcRoom = require("../models/IrcRoom"); -var IrcClientConfig = require("../models/IrcClientConfig"); -var BridgeRequest = require("../models/BridgeRequest"); -var stats = require("../config/stats"); -var DataStore = require("../DataStore"); -var log = require("../logging").get("IrcBridge"); -const { - Bridge, - MatrixUser, - MatrixRoom, - PrometheusMetrics, - Logging, -} = require("matrix-appservice-bridge"); -const MatrixActivityTracker = require("matrix-lastactive").MatrixActivityTracker; - -var DebugApi = require("../DebugApi"); -var Provisioner = require("../provisioning/Provisioner.js"); -var PublicitySyncer = require("./PublicitySyncer.js"); - -const DELAY_TIME_MS = 10 * 1000; -const DELAY_FETCH_ROOM_LIST_MS = 3 * 1000; -const DEAD_TIME_MS = 5 * 60 * 1000; -const ACTION_TYPE_TO_MSGTYPE = { - message: "m.text", - emote: "m.emote", - notice: "m.notice" -}; - -function IrcBridge(config, registration) { - this.config = config; - // TODO: Don't log this to stdout - Logging.configure({console: config.ircService.logging.level}); - this.registration = registration; - this.ircServers = []; - this.domain = null; // String - this.appServiceUserId = null; // String - this.memberListSyncers = { - // domain: MemberListSyncer - }; - this.joinedRoomList = []; - this.activityTracker = config.ircService.debugApi.enabled ? new MatrixActivityTracker( - this.config.homeserver.url, - registration.as_token, - this.config.homeserver.domain, - this.config.homeserver.enablePresence, - require("../logging").get("MxActivityTracker"), - ) : null; - - // Dependency graph - this.matrixHandler = new MatrixHandler(this, this.config.matrixHandler); - this.ircHandler = new IrcHandler(this, this.config.ircHandler); - this._clientPool = new ClientPool(this); - var dirPath = this.config.ircService.databaseUri.substring("nedb://".length); - let roomLinkValidation = undefined; - let provisioning = config.ircService.provisioning; - if (provisioning && provisioning.enabled && - typeof (provisioning.ruleFile) === "string") { - roomLinkValidation = { - ruleFile: provisioning.ruleFile, - triggerEndpoint: provisioning.enableReload - }; - } - - this._bridge = new Bridge({ - registration: this.registration, - homeserverUrl: this.config.homeserver.url, - domain: this.config.homeserver.domain, - controller: { - onEvent: this.onEvent.bind(this), - onUserQuery: this.onUserQuery.bind(this), - onAliasQuery: this.onAliasQuery.bind(this), - onAliasQueried: this.onAliasQueried ? - this.onAliasQueried.bind(this) : null, - onLog: this.onLog.bind(this), - - thirdPartyLookup: { - protocols: ["irc"], - getProtocol: this.getThirdPartyProtocol.bind(this), - getLocation: this.getThirdPartyLocation.bind(this), - getUser: this.getThirdPartyUser.bind(this), - }, - }, - roomStore: dirPath + "/rooms.db", - userStore: dirPath + "/users.db", - disableContext: true, - suppressEcho: false, // we use our own dupe suppress for now - logRequestOutcome: false, // we use our own which has better logging - queue: { - type: "none", - perRequest: false - }, - intentOptions: { - clients: { - dontCheckPowerLevel: true, - enablePresence: this.config.homeserver.enablePresence, - }, - bot: { - dontCheckPowerLevel: true, - enablePresence: this.config.homeserver.enablePresence, - } - }, - // See note below for ESCAPE_DEFAULT - escapeUserIds: false, - roomLinkValidation, - roomUpgradeOpts: { - consumeEvent: true, - // We want to handle this in _onRoomUpgrade - migrateGhosts: false, - onRoomMigrated: this._onRoomUpgrade.bind(this), - migrateEntry: this._roomUpgradeMigrateEntry.bind(this), - } - }); - - // By default the bridge will escape mxids, but the irc bridge isn't ready for this yet. - MatrixUser.ESCAPE_DEFAULT = false; - - this._timers = null; // lazy map of Histogram instances used as metrics - if (this.config.ircService.metrics && this.config.ircService.metrics.enabled) { - this._initialiseMetrics(); - } - - this._ircEventBroker = new IrcEventBroker( - this._bridge, this._clientPool, this.ircHandler - ); - this._dataStore = null; // requires Bridge to have loaded the databases - this._identGenerator = null; // requires inited data store - this._ipv6Generator = null; // requires inited data store - this._startedUp = false; - this._debugApi = ( - config.ircService.debugApi.enabled ? new DebugApi( - this, - config.ircService.debugApi.port, - this.ircServers, - this._clientPool, - registration.getAppServiceToken() - ) : null - ); - - this._provisioner = null; - - this.publicitySyncer = new PublicitySyncer(this); -} - -IrcBridge.prototype._initialiseMetrics = function() { - const zeroAge = new PrometheusMetrics.AgeCounters(); - - const metrics = this._bridge.getPrometheusMetrics(); - - this._bridge.registerBridgeGauges(() => { - const remoteUsersByAge = new PrometheusMetrics.AgeCounters( - this.config.ircService.metrics.remoteUserAgeBuckets || ["1h", "1d", "1w"] - ); - - this.ircServers.forEach((server) => { - this._clientPool.updateActiveConnectionMetrics(server.domain, remoteUsersByAge); - }); - - return { - // TODO(paul): actually fill these in - matrixRoomConfigs: 0, - remoteRoomConfigs: 0, - - remoteGhosts: this._clientPool.countTotalConnections(), - // matrixGhosts is provided automatically by the bridge - - // TODO(paul) IRC bridge doesn't maintain mtimes at the moment. - // Should probably make these metrics optional to most - // exporters - matrixRoomsByAge: zeroAge, - remoteRoomsByAge: zeroAge, - - matrixUsersByAge: zeroAge, - remoteUsersByAge, - }; - }); - - const timers = this._timers = {}; - - timers.matrix_request_seconds = metrics.addTimer({ - name: "matrix_request_seconds", - help: "Histogram of processing durations of received Matrix messages", - labels: ["outcome"], - }); - timers.remote_request_seconds = metrics.addTimer({ - name: "remote_request_seconds", - help: "Histogram of processing durations of received remote messages", - labels: ["outcome"], - }); - - // Custom IRC metrics - const reconnQueue = metrics.addGauge({ - name: "clientpool_reconnect_queue", - help: "Number of disconnected irc connections waiting to reconnect.", - labels: ["server"] - }); - - const memberListLeaveQueue = metrics.addGauge({ - name: "user_leave_queue", - help: "Number of leave requests queued up for virtual users on the bridge.", - labels: ["server"] - }); - - const memberListJoinQueue = metrics.addGauge({ - name: "user_join_queue", - help: "Number of join requests queued up for virtual users on the bridge.", - labels: ["server"] - }); - - const ircHandlerCalls = metrics.addCounter({ - name: "irchandler_calls", - help: "Track calls made to the IRC Handler", - labels: ["method"] - }); - - const matrixHandlerConnFailureKicks = metrics.addCounter({ - name: "matrixhandler_connection_failure_kicks", - help: "Track IRC connection failures resulting in kicks", - labels: ["server"] - }); - - metrics.addCollector(() => { - this.ircServers.forEach((server) => { - reconnQueue.set({server: server.domain}, - this._clientPool.totalReconnectsWaiting(server.domain) - ); - let mxMetrics = this.matrixHandler.getMetrics(server.domain); - matrixHandlerConnFailureKicks.inc( - {server: server.domain}, - mxMetrics["connection_failure_kicks"] || 0 - ); - }); - - Object.keys(this.memberListSyncers).forEach((server) => { - memberListLeaveQueue.set( - {server}, - this.memberListSyncers[server].getUsersWaitingToLeave() - ); - memberListJoinQueue.set( - {server}, - this.memberListSyncers[server].getUsersWaitingToJoin() - ); - }); - - const ircMetrics = this.ircHandler.getMetrics(); - Object.keys(ircMetrics).forEach((method) => { - const value = ircMetrics[method]; - ircHandlerCalls.inc({method}, value); - }); - }); -}; - -IrcBridge.prototype.getAppServiceUserId = function() { - return this.appServiceUserId; -}; - -IrcBridge.prototype.getStore = function() { - return this._dataStore; -}; - -IrcBridge.prototype.getAppServiceBridge = function() { - return this._bridge; -}; - -IrcBridge.prototype.getClientPool = function() { - return this._clientPool; -}; - -IrcBridge.prototype.getProvisioner = function() { - return this._provisioner; -}; - -IrcBridge.prototype.createBridgedClient = function(ircClientConfig, matrixUser, isBot) { - let server = this.ircServers.filter((s) => { - return s.domain === ircClientConfig.getDomain(); - })[0]; - if (!server) { - throw new Error( - "Cannot create bridged client for unknown server " + - ircClientConfig.getDomain() - ); - } - - return new BridgedClient( - server, ircClientConfig, matrixUser, isBot, - this._ircEventBroker, this._identGenerator, this._ipv6Generator - ); -}; - -IrcBridge.prototype.run = Promise.coroutine(function*(port) { - yield this._bridge.loadDatabases(); - - if (this._debugApi) { - // monkey patch inspect() values to avoid useless NeDB - // struct spam on the debug API. - this._bridge.getUserStore().inspect = function(depth) { - return "UserStore"; - } - this._bridge.getRoomStore().inspect = function(depth) { - return "RoomStore"; - } - this._debugApi.run(); - } - - let pkeyPath = this.config.ircService.passwordEncryptionKeyPath; - - this._dataStore = new DataStore( - this._bridge.getUserStore(), this._bridge.getRoomStore(), pkeyPath, - this.config.homeserver.domain - ); - yield this._dataStore.removeConfigMappings(); - this._identGenerator = new IdentGenerator(this._dataStore); - this._ipv6Generator = new Ipv6Generator(this._dataStore); - - // maintain a list of IRC servers in-use - let serverDomains = Object.keys(this.config.ircService.servers); - for (var i = 0; i < serverDomains.length; i++) { - let domain = serverDomains[i]; - let completeConfig = extend( - true, {}, IrcServer.DEFAULT_CONFIG, this.config.ircService.servers[domain] - ); - let server = new IrcServer( - domain, completeConfig, this.config.homeserver.domain, - this.config.homeserver.dropMatrixMessagesAfterSecs - ); - // store the config mappings in the DB to keep everything in one place. - yield this._dataStore.setServerFromConfig(server, completeConfig); - this.ircServers.push(server); - } - - if (this.ircServers.length === 0) { - throw new Error("No IRC servers specified."); - } - - // run the bridge (needs to be done prior to configure IRC side) - yield this._bridge.run(port); - this._addRequestCallbacks(); - - if (this.config.appService) { - console.warn( - `[DEPRECATED] Use of config field 'appService' is deprecated. Remove this - field from the config file to remove this warning. - - This release will use values from this config field. This will produce - a fatal error in a later release.` - ); - this.domain = this.config.appService.homeserver.domain; - this.appServiceUserId = ( - "@" + ( - this.config.appService.localpart || - this.registration.getSenderLocalpart() || - IrcBridge.DEFAULT_LOCALPART - ) + ":" + - this.domain - ); - } - else { - if (!this.registration.getSenderLocalpart() || - !this.registration.getAppServiceToken()) { - throw new Error( - "FATAL: Registration file is missing a sender_localpart and/or AS token." - ); - } - this.domain = this.config.homeserver.domain; - this.appServiceUserId = ( - "@" + this.registration.getSenderLocalpart() + ":" + - this.domain - ); - } - - log.info("Fetching Matrix rooms that are already joined to..."); - yield this._fetchJoinedRooms(); - - // start things going - log.info("Joining mapped Matrix rooms..."); - yield this._joinMappedMatrixRooms(); - log.info("Syncing relevant membership lists..."); - let memberlistPromises = []; - - // HACK: Remember reconnectIntervals to put them back later - // If memberlist-syncing 100s of connections, the scheduler will cause massive - // waiting times for connections to be created. - // We disable this scheduling manually to allow people to send messages through - // quickly when starting up (effectively prioritising them). This is just the - // quickest way to disable scheduler. - let reconnectIntervalsMap = Object.create(null); - - this.ircServers.forEach((server) => { - - reconnectIntervalsMap[server.domain] = server.getReconnectIntervalMs(); - server.config.ircClients.reconnectIntervalMs = 0; - - // TODO reduce deps required to make MemberListSyncers. - // TODO Remove injectJoinFn bodge - this.memberListSyncers[server.domain] = new MemberListSyncer( - this, this._bridge.getBot(), server, this.appServiceUserId, - (roomId, joiningUserId, displayName, isFrontier) => { - var req = new BridgeRequest( - this._bridge.getRequestFactory().newRequest(), false - ); - var target = new MatrixUser(joiningUserId); - // inject a fake join event which will do M->I connections and - // therefore sync the member list - return this.matrixHandler.onJoin(req, { - event_id: "$fake:membershiplist", - room_id: roomId, - state_key: joiningUserId, - user_id: joiningUserId, - content: { - membership: "join", - displayname: displayName, - }, - _injected: true, - _frontier: isFrontier - }, target); - } - ); - memberlistPromises.push( - this.memberListSyncers[server.domain].sync() - ); - }); - - let provisioningEnabled = this.config.ircService.provisioning.enabled; - let requestTimeoutSeconds = this.config.ircService.provisioning.requestTimeoutSeconds; - this._provisioner = new Provisioner(this, provisioningEnabled, requestTimeoutSeconds); - - log.info("Connecting to IRC networks..."); - yield this.connectToIrcNetworks(); - - promiseutil.allSettled(this.ircServers.map((server) => { - // Call MODE on all known channels to get modes of all channels - return this.publicitySyncer.initModes(server); - })).catch((err) => { - log.error('Could not init modes for publicity syncer'); - log.error(err.stack); - }); - - yield Promise.all(memberlistPromises); - - // Reset reconnectIntervals - this.ircServers.forEach((server) => { - server.config.ircClients.reconnectIntervalMs = reconnectIntervalsMap[server.domain]; - }); - - log.info("Startup complete."); - this._startedUp = true; -}); - -IrcBridge.prototype._logMetric = function(req, outcome) { - if (!this._timers) { - return; // metrics are disabled - } - const isFromIrc = Boolean((req.getData() || {}).isFromIrc); - const timer = this._timers[ - isFromIrc ? "remote_request_seconds" : "matrix_request_seconds" - ]; - if (timer) { - timer.observe({outcome: outcome}, req.getDuration() / 1000); - } -} - -IrcBridge.prototype._addRequestCallbacks = function() { - function logMessage(req, msg) { - const data = req.getData(); - const dir = data && data.isFromIrc ? "I->M" : "M->I"; - const duration = " (" + req.getDuration() + "ms)"; - log.info(`[${req.getId()}] [${dir}] ${msg} ${duration}`); - } - - // SUCCESS - this._bridge.getRequestFactory().addDefaultResolveCallback((req, res) => { - if (res === BridgeRequest.ERR_VIRTUAL_USER) { - logMessage(req, "IGNORE virtual user"); - return; // these aren't true successes so don't skew graphs - } - else if (res === BridgeRequest.ERR_NOT_MAPPED) { - logMessage(req, "IGNORE not mapped"); - return; // these aren't true successes so don't skew graphs - } - else if (res === BridgeRequest.ERR_DROPPED) { - logMessage(req, "IGNORE dropped"); - this._logMetric(req, "dropped"); - return; - } - logMessage(req, "SUCCESS"); - const isFromIrc = Boolean((req.getData() || {}).isFromIrc); - stats.request(isFromIrc, "success", req.getDuration()); - this._logMetric(req, "success"); - }); - // FAILURE - this._bridge.getRequestFactory().addDefaultRejectCallback((req) => { - var isFromIrc = Boolean((req.getData() || {}).isFromIrc); - logMessage(req, "FAILED"); - stats.request(isFromIrc, "fail", req.getDuration()); - this._logMetric(req, "fail"); - }); - // DELAYED - this._bridge.getRequestFactory().addDefaultTimeoutCallback((req) => { - logMessage(req, "DELAYED"); - var isFromIrc = Boolean((req.getData() || {}).isFromIrc); - stats.request(isFromIrc, "delay", req.getDuration()); - }, DELAY_TIME_MS); - // DEAD - this._bridge.getRequestFactory().addDefaultTimeoutCallback((req) => { - logMessage(req, "DEAD"); - var isFromIrc = Boolean((req.getData() || {}).isFromIrc); - stats.request(isFromIrc, "fail", req.getDuration()); - this._logMetric(req, "fail"); - }, DEAD_TIME_MS); -} - -// Kill the bridge by killing all IRC clients in memory. -// Killing a client means that it will disconnect forever -// and never do anything useful again. -// There is no guarentee that the bridge will do anything -// usefull once this has been called. -// -// See (BridgedClient.prototype.kill) -IrcBridge.prototype.kill = function() { - log.info("Killing all clients"); - return this._clientPool.killAllClients(); -} - -IrcBridge.prototype.isStartedUp = function() { - return this._startedUp; -}; - -IrcBridge.prototype._joinMappedMatrixRooms = Promise.coroutine(function*() { - let roomIds = yield this.getStore().getRoomIdsFromConfig(); - let promises = roomIds.map((roomId) => { - if (this.joinedRoomList.includes(roomId)) { - log.debug(`Not joining ${roomId} because we are marked as joined`); - return Promise.resolve(); - } - return this._bridge.getIntent().join(roomId); - }); - yield promiseutil.allSettled(promises); -}); - -IrcBridge.prototype.sendMatrixAction = function(room, from, action, req) { - let msgtype = ACTION_TYPE_TO_MSGTYPE[action.type]; - let intent = this._bridge.getIntent(from.userId); - if (msgtype) { - if (action.htmlText) { - return intent.sendMessage(room.getId(), { - msgtype: msgtype, - body: ( - action.text || action.htmlText.replace(/(<([^>]+)>)/ig, "") // strip html tags - ), - format: "org.matrix.custom.html", - formatted_body: action.htmlText - }); - } - return intent.sendMessage(room.getId(), { - msgtype: msgtype, - body: action.text - }); - } - else if (action.type === "topic") { - return intent.setRoomTopic(room.getId(), action.text); - } - return Promise.reject(new Error("Unknown action: " + action.type)); -}; - -IrcBridge.prototype.uploadTextFile = function(fileName, plaintext, req) { - return this._bridge.getIntent().getClient().uploadContent({ - stream: new Buffer(plaintext), - name: fileName, - type: "text/plain; charset=utf-8", - rawResponse: true, - }); -}; - -IrcBridge.prototype.getMatrixUser = Promise.coroutine(function*(ircUser) { - let matrixUser = null; - let userLocalpart = ircUser.server.getUserLocalpart(ircUser.nick); - let displayName = ircUser.server.getDisplayNameFromNick(ircUser.nick); - - try { - matrixUser = yield this.getStore().getMatrixUserByLocalpart(userLocalpart); - if (matrixUser) { - return matrixUser; - } - } - catch (e) { - // user does not exist. Fall through. - } - - let userIntent = this._bridge.getIntentFromLocalpart(userLocalpart); - yield userIntent.setDisplayName(displayName); // will also register this user - matrixUser = new MatrixUser(userIntent.getClient().credentials.userId); - matrixUser.setDisplayName(displayName); - yield this.getStore().storeMatrixUser(matrixUser); - return matrixUser; -}); - -IrcBridge.prototype.onEvent = function(request, context) { - request.outcomeFrom(this._onEvent(request, context)); -}; - -IrcBridge.prototype._onEvent = Promise.coroutine(function*(baseRequest, context) { - const event = baseRequest.getData(); - if (event.sender && this.activityTracker) { - this.activityTracker.bumpLastActiveTime(event.sender); - } - const request = new BridgeRequest(baseRequest, false); - if (event.type === "m.room.message") { - if (event.origin_server_ts && this.config.homeserver.dropMatrixMessagesAfterSecs) { - const now = Date.now(); - if ((now - event.origin_server_ts) > - (1000 * this.config.homeserver.dropMatrixMessagesAfterSecs)) { - log.info( - "Dropping old m.room.message event %s timestamped %d", - event.event_id, event.origin_server_ts - ); - return BridgeRequest.ERR_DROPPED; - } - } - yield this.matrixHandler.onMessage(request, event); - } - else if (event.type === "m.room.topic" && event.state_key === "") { - yield this.matrixHandler.onMessage(request, event); - } - else if (event.type === "m.room.member") { - if (!event.content || !event.content.membership) { - return BridgeRequest.ERR_NOT_MAPPED; - } - this.ircHandler.onMatrixMemberEvent(event); - const target = new MatrixUser(event.state_key); - const sender = new MatrixUser(event.user_id); - if (event.content.membership === "invite") { - yield this.matrixHandler.onInvite(request, event, sender, target); - } - else if (event.content.membership === "join") { - yield this.matrixHandler.onJoin(request, event, target); - } - else if (["ban", "leave"].indexOf(event.content.membership) !== -1) { - // Given a "self-kick" is a leave, and you can't ban yourself, - // if the 2 IDs are different then we know it is either a kick - // or a ban (or a rescinded invite) - var isKickOrBan = target.getId() !== sender.getId(); - if (isKickOrBan) { - yield this.matrixHandler.onKick(request, event, sender, target); - } - else { - yield this.matrixHandler.onLeave(request, event, target, sender); - } - } - } - else if (event.type === "m.room.power_levels" && event.state_key === "") { - this.ircHandler.roomAccessSyncer.onMatrixPowerlevelEvent(event); - } - return undefined; -}); - -IrcBridge.prototype.onUserQuery = Promise.coroutine(function*(matrixUser) { - var baseRequest = this._bridge.getRequestFactory().newRequest(); - var request = new BridgeRequest(baseRequest, false); - yield this.matrixHandler.onUserQuery(request, matrixUser.getId()); - // TODO: Lean on the bridge lib more - return null; // don't provision, we already do atm -}); - -IrcBridge.prototype.onAliasQuery = Promise.coroutine(function*(alias, aliasLocalpart) { - var baseRequest = this._bridge.getRequestFactory().newRequest(); - var request = new BridgeRequest(baseRequest, false); - yield this.matrixHandler.onAliasQuery(request, alias); - // TODO: Lean on the bridge lib more - return null; // don't provision, we already do atm -}); - -IrcBridge.prototype.onLog = function(line, isError) { - if (isError) { - log.error(line); - } - else { - log.info(line); - } -}; - -IrcBridge.prototype.getThirdPartyProtocol = function(protocol) { - var servers = this.getServers(); - - return Promise.resolve({ - user_fields: ["domain", "nick"], - location_fields: ["domain", "channel"], - field_types: { - domain: { - regexp: "[a-z0-9-_]+(\.[a-z0-9-_]+)*", - placeholder: "irc.example.com", - }, - nick: { - regexp: "[^#\\s]+", - placeholder: "SomeNick", - }, - channel: { - // TODO(paul): Declare & and + in this list sometime when the - // bridge can support them - regexp: "[#][^\\s]+", - placeholder: "#channel", - }, - }, - instances: servers.map((server) => { - return { - network_id: server.getNetworkId(), - bot_user_id: this.getAppServiceUserId(), - desc: server.config.name || server.domain, - icon: server.config.icon, - fields: { - domain: server.domain, - }, - }; - }), - }); -}; - -IrcBridge.prototype.getThirdPartyLocation = function(protocol, fields) { - if (!fields.domain) { - return Promise.reject({err: "Expected 'domain' field", code: 400}); - } - var domain = fields.domain.toLowerCase(); - - if (!fields.channel) { - return Promise.reject({err: "Expected 'channel' field", code: 400}); - } - // TODO(paul): this ought to use IRC network-specific casefolding (e.g. rfc1459) - var channel = fields.channel.toLowerCase(); - - var server = this.getServer(domain); - if (!server) { - return Promise.resolve([]); - } - - if (!server.config.dynamicChannels.enabled) { - return Promise.resolve([]); - } - - var alias = server.getAliasFromChannel(channel); - - return Promise.resolve([ - { - alias: alias, - protocol: "irc", - fields: { - domain: domain, - channel: channel, - } - } - ]); -}; - -IrcBridge.prototype.getThirdPartyUser = function(protocol, fields) { - if (!fields.domain) { - return Promise.reject({err: "Expected 'domain' field", code: 400}); - } - var domain = fields.domain.toLowerCase(); - - if (!fields.nick) { - return Promise.reject({err: "Expected 'nick' field", code: 400}); - } - // TODO(paul): this ought to use IRC network-specific casefolding (e.g. rfc1459) - var nick = fields.nick.toLowerCase(); - - var server = this.getServer(domain); - if (!server) { - return Promise.resolve([]); - } - - var userId = server.getUserIdFromNick(nick); - - return Promise.resolve([ - { - userid: userId, - protocol: "irc", - fields: { - domain: domain, - nick: nick, - } - } - ]); -}; - -IrcBridge.prototype.getIrcUserFromCache = function(server, userId) { - return this._clientPool.getBridgedClientByUserId(server, userId); -}; - -IrcBridge.prototype.getBridgedClientsForUserId = function(userId) { - return this._clientPool.getBridgedClientsForUserId(userId); -}; - -IrcBridge.prototype.getBridgedClientsForRegex = function(regex) { - return this._clientPool.getBridgedClientsForRegex(regex); -}; - -IrcBridge.prototype.getServer = function(domainName) { - for (var i = 0; i < this.ircServers.length; i++) { - var server = this.ircServers[i]; - if (server.domain === domainName) { - return server; - } - } - return null; -}; - -IrcBridge.prototype.getServers = function() { - return this.ircServers || []; -}; - -// TODO: Check how many of the below functions need to reside on IrcBridge still. - -IrcBridge.prototype.aliasToIrcChannel = function(alias) { - var ircServer = null; - var servers = this.getServers(); - for (var i = 0; i < servers.length; i++) { - var server = servers[i]; - if (server.claimsAlias(alias)) { - ircServer = server; - break; - } - } - if (!ircServer) { - return {}; - } - return { - server: ircServer, - channel: ircServer.getChannelFromAlias(alias) - }; -}; - -IrcBridge.prototype.getServerForUserId = function(userId) { - let servers = this.getServers(); - for (let i = 0; i < servers.length; i++) { - if (servers[i].claimsUserId(userId)) { - return servers[i]; - } - } - return null; -} - -IrcBridge.prototype.matrixToIrcUser = function(user) { - var server = this.getServerForUserId(user.getId()); - var ircInfo = { - server: server, - nick: server ? server.getNickFromUserId(user.getId()) : null - }; - if (!ircInfo.server || !ircInfo.nick) { - return Promise.reject( - new Error("User ID " + user.getId() + " doesn't map to a server/nick") - ); - } - return Promise.resolve(new IrcUser(ircInfo.server, ircInfo.nick, true)); -}; - -IrcBridge.prototype.trackChannel = function(server, channel, key) { - if (!server.isBotEnabled()) { - log.info("trackChannel: Bot is disabled."); - return Promise.resolve(new IrcRoom(server, channel)); - } - return this.getBotClient(server).then(function(client) { - return client.joinChannel(channel, key); - }).catch(log.logErr); -}; - -IrcBridge.prototype.connectToIrcNetworks = function() { - return promiseutil.allSettled(this.ircServers.map((server) => { - return this._loginToServer(server); - })); -}; - -IrcBridge.prototype._loginToServer = Promise.coroutine(function*(server) { - var uname = "matrixirc"; - var bridgedClient = this.getIrcUserFromCache(server, uname); - if (!bridgedClient) { - var botIrcConfig = server.createBotIrcClientConfig(uname); - bridgedClient = this._clientPool.createIrcClient(botIrcConfig, null, true); - log.debug( - "Created new bot client for %s : %s (bot enabled=%s)", - server.domain, bridgedClient._id, server.isBotEnabled() - ); - } - var chansToJoin = []; - if (server.isBotEnabled()) { - if (server.shouldJoinChannelsIfNoUsers()) { - chansToJoin = yield this.getStore().getTrackedChannelsForServer(server.domain); - } - else { - chansToJoin = yield this.memberListSyncers[server.domain].getChannelsToJoin(); - } - } - log.info("Bot connecting to %s (%s channels) => %s", - server.domain, chansToJoin.length, JSON.stringify(chansToJoin) - ); - try { - yield bridgedClient.connect(); - } - catch (err) { - log.error("Bot failed to connect to %s : %s - Retrying....", - server.domain, JSON.stringify(err)); - log.logErr(err); - return this._loginToServer(server); - } - this._clientPool.setBot(server, bridgedClient); - var num = 1; - chansToJoin.forEach((c) => { - // join a channel every 500ms. We stagger them like this to - // avoid thundering herds - setTimeout(function() { - // catch this as if this rejects it will hard-crash - // since this is a new stack frame which will bubble - // up as an uncaught exception. - bridgedClient.joinChannel(c).catch((e) => { - log.error("Failed to join channel:: %s", c); - log.error(e); - }); - }, 500 * num); - num += 1; - }); - return undefined; -}); - -IrcBridge.prototype.checkNickExists = function(server, nick) { - log.info("Querying for nick %s on %s", nick, server.domain); - return this.getBotClient(server).then(function(client) { - return client.whois(nick); - }); -}; - -IrcBridge.prototype.joinBot = function(ircRoom) { - if (!ircRoom.server.isBotEnabled()) { - log.info("joinBot: Bot is disabled."); - return Promise.resolve(); - } - return this.getBotClient(ircRoom.server).then((client) => { - return client.joinChannel(ircRoom.channel); - }).catch((e) => { - log.error("Bot failed to join channel %s", ircRoom.channel); - }); -}; - -IrcBridge.prototype.partBot = function(ircRoom) { - log.info( - "Parting bot from %s on %s", ircRoom.channel, ircRoom.server.domain - ); - return this.getBotClient(ircRoom.server).then((client) => { - return client.leaveChannel(ircRoom.channel); - }); -}; - -IrcBridge.prototype.getBridgedClient = Promise.coroutine(function*(server, userId, - displayName) { - let bridgedClient = this.getIrcUserFromCache(server, userId); - if (bridgedClient) { - log.debug("Returning cached bridged client %s", userId); - return bridgedClient; - } - - let mxUser = new MatrixUser(userId); - mxUser.setDisplayName(displayName); - - // check the database for stored config information for this irc client - // including username, custom nick, nickserv password, etc. - let ircClientConfig = IrcClientConfig.newConfig( - mxUser, server.domain, null - ); - let storedConfig = yield this.getStore().getIrcClientConfig(userId, server.domain); - if (storedConfig) { - log.debug("Configuring IRC user from store => " + storedConfig); - ircClientConfig = storedConfig; - } - - // recheck the cache: We just yield'ed to check the client config. We may - // be racing with another request to getBridgedClient. - bridgedClient = this.getIrcUserFromCache(server, userId); - if (bridgedClient) { - log.debug("Returning cached bridged client %s", userId); - return bridgedClient; - } - - log.debug( - "Creating virtual irc user with nick %s for %s (display name %s)", - ircClientConfig.getDesiredNick(), userId, displayName - ); - bridgedClient = this._clientPool.createIrcClient(ircClientConfig, mxUser, false); - - try { - yield bridgedClient.connect(); - if (!storedConfig) { - yield this.getStore().storeIrcClientConfig(ircClientConfig); - } - return bridgedClient; - } - catch (err) { - log.error("Couldn't connect virtual user %s to %s : %s", - ircClientConfig.getDesiredNick(), server.domain, JSON.stringify(err)); - throw err; - } -}); - -IrcBridge.prototype.sendIrcAction = function(ircRoom, bridgedClient, action) { - log.info( - "Sending IRC message in %s as %s (connected=%s)", - ircRoom.channel, bridgedClient.nick, Boolean(bridgedClient.unsafeClient) - ); - return bridgedClient.sendAction(ircRoom, action); -}; - -IrcBridge.prototype.getBotClient = function(server) { - var botClient = this._clientPool.getBot(server); - if (botClient) { - return Promise.resolve(botClient); - } - return this._loginToServer(server).then(() => { - return this._clientPool.getBot(server); - }); -} - -IrcBridge.prototype._fetchJoinedRooms = Promise.coroutine(function*() { - /** Fetching joined rooms is quicker on larger homeservers than trying to - * /join each room in the mappings list. To ensure we start quicker, - * the bridge will block on this call rather than blocking on all join calls. - * On the most overloaded servers even this call may take several attempts, - * so it will block indefinitely. - */ - const bot = this._bridge.getBot(); - if (bot.getJoinedRooms == undefined) { - return; - } - let gotRooms = false; - while (!gotRooms) { - try { - const roomIds = yield bot.getJoinedRooms(); - gotRooms = true; - this.joinedRoomList = roomIds; - log.info(`ASBot is in ${roomIds.length} rooms!`); - } - catch (ex) { - log.error(`Failed to fetch roomlist from joined_rooms: ${ex}. Retrying`); - yield Promise.delay(DELAY_FETCH_ROOM_LIST_MS); - } - } -}); - -// This function is used to ensure that room entries have their IDs modified -// so that the room ID contained within is kept up to date. -IrcBridge.prototype._roomUpgradeMigrateEntry = function(entry, newRoomId) { - const oldRoomId = entry.matrix.getId(); - // Often our IDs for entries depend upon the room, so replace them. - entry.id = entry.id.replace(oldRoomId, newRoomId); - entry.matrix = new MatrixRoom(newRoomId, { - name: entry.name, - topic: entry.topic, - extras: entry._extras, - }); - // matrix-appservice-bridge will know to remove the old room entry - // and insert the new room entry despite the differing IDs - return entry; -} - -IrcBridge.prototype._onRoomUpgrade = Promise.coroutine(function*(oldRoomId, newRoomId) { - log.info(`Room has been upgraded from ${oldRoomId} to ${newRoomId}, updating ghosts..`); - // Get the channels for the room_id - const rooms = yield this.getStore().getIrcChannelsForRoomId(newRoomId); - // Get users who we wish to leave. - const asBot = this._bridge.getBot(); - const stateEvents = yield asBot.getClient().roomState(oldRoomId); - const roomInfo = asBot._getRoomInfo(oldRoomId, { - state: { - events: stateEvents - } - }); - const bridgingEvent = stateEvents.find((ev) => ev.type === "m.room.bridging"); - if (bridgingEvent) { - // The room had a bridge state event, so try to stick it in the new one. - try { - yield this._bridge.getIntent().sendStateEvent( - newRoomId, - "m.room.bridging", - bridgingEvent.state_key, - bridgingEvent.content - ); - log.info("m.room.bridging event copied to new room"); - } - catch (ex) { - // We may not have permissions to do so, which means we are basically stuffed. - log.warn("Could not send m.room.bridging event to new room:", ex); - } - } - yield Promise.all(rooms.map((room) => { - return this.getBotClient(room.getServer()).then((bot) => { - // This will invoke NAMES and make members join the new room, - // so we don't need to await it. - bot.getNicks(room.getChannel()).catch(() => { - log.error("Failed to get nicks for upgraded room"); - }); - log.info( - `Leaving ${roomInfo.remoteJoinedUsers.length} users from old room ${oldRoomId}.` - ); - this.memberListSyncers[room.getServer().domain].addToLeavePool( - roomInfo.remoteJoinedUsers, - oldRoomId, - room - ); - }) - })); - log.info(`Ghost migration to ${newRoomId} complete`); -}); - -IrcBridge.prototype.connectionReap = Promise.coroutine(function*( - logCb, serverName, maxIdleHours, reason = "User is inactive") { - if (!maxIdleHours || maxIdleHours < 0) { - throw Error("'since' must be greater than 0"); - } - const maxIdleTime = maxIdleHours * 60 * 60 * 1000; - serverName = serverName ? serverName : Object.keys(this.memberListSyncers)[0]; - const server = this.memberListSyncers[serverName]; - if (!server) { - throw Error("Server not found"); - } - const req = new BridgeRequest(this._bridge.getRequestFactory().newRequest()); - logCb(`Connection reaping for ${serverName}`); - const rooms = yield server.getSyncableRooms(true); - const users = []; - for (const room of rooms) { - for (const u of room.realJoinedUsers) { - if (!users.includes(u)) { - users.push(u); - } - } - } - logCb(`Found ${users.length} real users for ${serverName}`); - let offlineCount = 0; - for (const userId of users) { - const status = yield this.activityTracker.isUserOnline(userId, maxIdleTime); - if (!status.online) { - const clients = this._clientPool.getBridgedClientsForUserId(userId); - const quitRes = yield this.matrixHandler.quitUser(req, userId, clients, null, reason); - if (!quitRes) { - logCb(`Quit ${userId}`); - // To avoid us catching them again for maxIdleHours - this.activityTracker.bumpLastActiveTime(userId); - offlineCount++; - } - else { - logCb(`Didn't quit ${userId}: ${quitRes}`); - } - } - } - logCb(`Quit ${offlineCount} *offline* real users for ${serverName}.`); -}); - -IrcBridge.DEFAULT_LOCALPART = "appservice-irc"; - -module.exports = IrcBridge; diff --git a/lib/bridge/IrcHandler.js b/lib/bridge/IrcHandler.js deleted file mode 100644 index 4589bd785..000000000 --- a/lib/bridge/IrcHandler.js +++ /dev/null @@ -1,1009 +0,0 @@ -/*eslint no-invalid-this: 0 consistent-return: 0*/ - -const Promise = require("bluebird"); -const stats = require("../config/stats"); -const BridgeRequest = require("../models/BridgeRequest"); -const IrcRoom = require("../models/IrcRoom"); -const MatrixRoom = require("matrix-appservice-bridge").MatrixRoom; -const MatrixUser = require("matrix-appservice-bridge").MatrixUser; -const MatrixAction = require("../models/MatrixAction"); -const Queue = require("../util/Queue.js"); -const QueuePool = require("../util/QueuePool.js"); -const QuitDebouncer = require("./QuitDebouncer.js"); -const RoomAccessSyncer = require("./RoomAccessSyncer.js"); - -const JOIN_DELAY_MS = 250; -const JOIN_DELAY_CAP_MS = 30 * 60 * 1000; // 30 mins - - -const NICK_USERID_CACHE_MAX = 512; -const LEAVE_CONCURRENCY = 10; -const LEAVE_DELAY_MS = 3000; -const LEAVE_DELAY_JITTER = 5000; -const LEAVE_MAX_ATTEMPTS = 10; - -function IrcHandler(ircBridge, config) { - config = config || {} - this.ircBridge = ircBridge; - // maintain a map of which user ID is in which PM room, so we know if we - // need to re-invite them if they bail. - this._roomIdToPrivateMember = { - // room_id: { user_id: $USER_ID, membership: "join|invite|leave|etc" } - }; - - // Used when a server is configured to debounce quits that could potentially - // be part of a net-split. - this.quitDebouncer = new QuitDebouncer(ircBridge); - - // Use per-channel queues to keep the setting of topics in rooms atomic in - // order to prevent races involving several topics being received from IRC - // in quick succession. If `(server, channel, topic)` are the same, an - // existing promise will be used, otherwise a new item is added to the queue. - this.topicQueues = { - //$channel : Queue - } - - // A map of promises that resolve to the PM room that has been created for the - // two users in the key. The $fromUserId is the user ID of the virtual IRC user - // and the $toUserId, the user ID of the recipient of the message. This is used - // to prevent races when many messages are sent as PMs at once and therefore - // prevent many pm rooms from being created. - this.pmRoomPromises = { - //'$fromUserId $toUserId' : Promise - }; - - this.nickUserIdMapCache = new Map(); // server:channel => mapping - - // Map which contains nicks we know have been registered/has display name - this._registeredNicks = Object.create(null); - - // QueuePool for leaving "concurrently" without slowing leaves to a crawl. - // Takes { - // rooms: MatrixRoom[], - // userId: string, - // shouldKick: boolean, - // kickReason: string, - // retry: boolean, - // req: Request, - // deop: boolean, - // attempts: number, - //} - this._leaveQueue = new QueuePool( - config.leaveConcurrency || LEAVE_CONCURRENCY, - this._handleLeaveQueue.bind(this), - ); - - /* - One of: - "on" - Defaults to enabled, users can choose to disable. - "off" - Defaults to disabled, users can choose to enable. - "force-off" - Disabled, cannot be enabled. - */ - this._mentionMode = config.mapIrcMentionsToMatrix || "on"; - - this.roomAccessSyncer = new RoomAccessSyncer(ircBridge); - - this.getMetrics(); -} - -IrcHandler.prototype.onMatrixMemberEvent = function(event) { - let priv = this._roomIdToPrivateMember[event.room_id]; - if (!priv) { - // _roomIdToPrivateMember only starts tracking AFTER one private message - // has been sent since the bridge started, so if we can't find it, no - // messages have been sent so we can ignore it (since when we DO start - // tracking we hit room state explicitly). - return; - } - if (priv.user_id !== event.state_key) { - return; // don't care about member changes for other users - } - - priv.membership = event.content.membership; -}; - -IrcHandler.prototype._ensureMatrixUserJoined = Promise.coroutine(function*(roomId, - userId, virtUserId, log) { - let priv = this._roomIdToPrivateMember[roomId]; - if (!priv) { - // create a brand new entry for this user. Set them to not joined initially - // since we'll be yielding in a moment and we assume not joined. - priv = { - user_id: userId, - membership: "leave" - }; - this._roomIdToPrivateMember[roomId] = priv; - - // query room state to see if the user is actually joined. - log.info("Querying PM room state (%s) between %s and %s", - roomId, userId, virtUserId); - let cli = this.ircBridge.getAppServiceBridge().getClientFactory().getClientAs( - virtUserId - ); - let stateEvents = yield cli.roomState(roomId); - for (let i = 0; i < stateEvents.length; i++) { - if (stateEvents[i].type === "m.room.member" && - stateEvents[i].state_key === userId) { - priv.membership = stateEvents[i].content.membership; - break; - } - } - } - - // we should have the latest membership state now for this user (either we just - // fetched it or it has been kept in sync via onMatrixMemberEvent calls) - - if (priv.membership !== "join" && priv.membership !== "invite") { // fix it! - log.info("Inviting %s to the existing PM room with %s (current membership=%s)", - userId, virtUserId, priv.membership); - let cli = this.ircBridge.getAppServiceBridge().getClientFactory().getClientAs( - virtUserId - ); - yield cli.invite(roomId, userId); - // this should also be echoed back to us via onMatrixMemberEvent but hey, - // let's do this now as well. - priv.membership = "invite"; - } -}); - -/** - * Create a new matrix PM room for an IRC user with nick `fromUserNick` and another - * matrix user with user ID `toUserId`. - * @param {string} toUserId : The user ID of the recipient. - * @param {string} fromUserId : The user ID of the sender. - * @param {string} fromUserNick : The nick of the sender. - * @param {IrcServer} server : The sending IRC server. - * @return {Promise} which is resolved when the PM room has been created. - */ -IrcHandler.prototype._createPmRoom = Promise.coroutine( - function*(toUserId, fromUserId, fromUserNick, server) { - let response = yield this.ircBridge.getAppServiceBridge().getIntent( - fromUserId - ).createRoom({ - createAsClient: true, - options: { - name: (fromUserNick + " (PM on " + server.domain + ")"), - visibility: "private", - preset: "trusted_private_chat", - invite: [toUserId], - creation_content: { - "m.federate": server.shouldFederatePMs() - }, - is_direct: true, - } - }); - let pmRoom = new MatrixRoom(response.room_id); - let ircRoom = new IrcRoom(server, fromUserNick); - - yield this.ircBridge.getStore().setPmRoom( - ircRoom, pmRoom, toUserId, fromUserId - ); - - return pmRoom; - } -); - -/** - * Called when the AS receives an IRC message event. - * @param {IrcServer} server : The sending IRC server. - * @param {IrcUser} fromUser : The sender. - * @param {IrcUser} toUser : The target. - * @param {Object} action : The IRC action performed. - * @return {Promise} which is resolved/rejected when the request - * finishes. - */ -IrcHandler.prototype.onPrivateMessage = Promise.coroutine(function*(req, server, fromUser, - toUser, action) { - this.incrementMetric("pm"); - if (fromUser.isVirtual) { - return BridgeRequest.ERR_VIRTUAL_USER; - } - - if (!toUser.isVirtual) { - req.log.error("Cannot route PM to %s", toUser); - return; - } - let bridgedIrcClient = this.ircBridge.getClientPool().getBridgedClientByNick( - toUser.server, toUser.nick - ); - if (!bridgedIrcClient) { - req.log.error("Cannot route PM to %s - no client", toUser); - return; - } - req.log.info("onPrivateMessage: %s from=%s to=%s action=%s", - server.domain, fromUser, toUser, - JSON.stringify(action).substring(0, 80) - ); - - if (bridgedIrcClient.isBot) { - if (action.type !== "message") { - req.log.info("Ignoring non-message PM"); - return; - } - req.log.debug("Rerouting PM directed to the bot from %s to provisioning", fromUser); - this.ircBridge.getProvisioner().handlePm(server, fromUser, action.text); - return; - } - - - if (!server.allowsPms()) { - req.log.error("Server %s disallows PMs.", server.domain); - return; - } - - let mxAction = MatrixAction.fromIrcAction(action); - - if (!mxAction) { - req.log.error("Couldn't map IRC action to matrix action"); - return; - } - - let virtualMatrixUser = yield this.ircBridge.getMatrixUser(fromUser); - req.log.info("Mapped to %s", JSON.stringify(virtualMatrixUser)); - let pmRoom = yield this.ircBridge.getStore().getMatrixPmRoom( - bridgedIrcClient.userId, virtualMatrixUser.getId() - ); - - if (!pmRoom) { - let pmRoomPromiseId = bridgedIrcClient.userId + ' ' + virtualMatrixUser.getId(); - let p = this.pmRoomPromises[pmRoomPromiseId]; - - // If a promise to create this PM room does not already exist, create one - if (!p || p.isRejected()) { - req.log.info("Creating a PM room with %s", bridgedIrcClient.userId); - this.pmRoomPromises[pmRoomPromiseId] = this._createPmRoom( - bridgedIrcClient.userId, virtualMatrixUser.getId(), fromUser.nick, server - ); - p = this.pmRoomPromises[pmRoomPromiseId]; - } - - // Yield on the PM room being created - pmRoom = yield p; - } - else { - // make sure that the matrix user is still in the room - try { - yield this._ensureMatrixUserJoined( - pmRoom.getId(), bridgedIrcClient.userId, virtualMatrixUser.getId(), req.log - ); - } - catch (err) { - // We still want to send the message into the room even if we can't check - - // maybe the room state API has blown up. - req.log.error( - "Failed to ensure matrix user %s was joined to the existing PM room %s : %s", - bridgedIrcClient.userId, pmRoom.getId(), err - ); - } - } - - req.log.info("Relaying PM in room %s", pmRoom.getId()); - yield this.ircBridge.sendMatrixAction(pmRoom, virtualMatrixUser, mxAction, req); -}); - -/** - * Called when the AS receives an IRC invite event. - * @param {IrcServer} server : The sending IRC server. - * @param {IrcUser} fromUser : The sender. - * @param {IrcUser} toUser : The target. - * @param {String} channel : The channel. - * @return {Promise} which is resolved/rejected when the request - * finishes. - */ -IrcHandler.prototype.onInvite = Promise.coroutine(function*(req, server, fromUser, - toUser, channel) { - this.incrementMetric("invite"); - if (fromUser.isVirtual) { - return BridgeRequest.ERR_VIRTUAL_USER; - } - - if (!toUser.isVirtual) { - req.log.error("Cannot route invite to %s", toUser); - return; - } - - let bridgedIrcClient = this.ircBridge.getClientPool().getBridgedClientByNick( - toUser.server, toUser.nick - ); - if (!bridgedIrcClient) { - req.log.error("Cannot route invite to %s - no client", toUser); - return; - } - - if (bridgedIrcClient.isBot) { - req.log.info("Ignoring invite send to the bot"); - return; - } - - let virtualMatrixUser = yield this.ircBridge.getMatrixUser(fromUser); - req.log.info("Mapped to %s", JSON.stringify(virtualMatrixUser)); - let matrixRooms = yield this.ircBridge.getStore().getMatrixRoomsForChannel(server, channel); - let roomAlias = server.getAliasFromChannel(channel); - - if (matrixRooms.length === 0) { - const initial_state = [ - { - type: "m.room.join_rules", - state_key: "", - content: { - join_rule: server.getJoinRule() - } - }, - { - type: "m.room.history_visibility", - state_key: "", - content: { - history_visibility: "joined" - } - } - ]; - if (ircServer.areGroupsEnabled()) { - initial_state.push({ - type: "m.room.related_groups", - state_key: "", - content: { - groups: [ircServer.getGroupId()] - } - }); - } - let ircRoom = yield this.ircBridge.trackChannel(server, channel, null); - let response = yield this.ircBridge.getAppServiceBridge().getIntent( - virtualMatrixUser.getId() - ).createRoom({ - options: { - room_alias_name: roomAlias.split(":")[0].substring(1), // localpart - name: channel, - visibility: "private", - preset: "public_chat", - creation_content: { - "m.federate": server.shouldFederate() - }, - initial_state - } - }); - - // store the mapping - let mxRoom = new MatrixRoom(response.room_id); - yield this.ircBridge.getStore().storeRoom( - ircRoom, mxRoom, 'join' - ); - - // /mode the channel AFTER we have created the mapping so we process +s and +i correctly. - this.ircBridge.publicitySyncer.initModeForChannel( - server, channel - ).catch((err) => { - req.log.error( - "Could not init mode channel %s on %s", - channel, server - ); - }); - - req.log.info( - "Created a room to track %s on %s and invited %s", - ircRoom.channel, server.domain, virtualMatrixUser.user_id - ); - matrixRooms.push(mxRoom); - } - - // send invite - let invitePromises = matrixRooms.map((room) => { - req.log.info( - "Inviting %s to room %s", bridgedIrcClient.userId, room.getId() - ); - return this.ircBridge.getAppServiceBridge().getIntent( - virtualMatrixUser.getId() - ).invite( - room.getId(), bridgedIrcClient.userId - ); - }); - yield Promise.all(invitePromises); -}); - -IrcHandler.prototype._serviceTopicQueue = Promise.coroutine(function*(item) { - let promises = item.entries.map((entry) => { - if (entry.matrix.topic === item.topic) { - item.req.log.info( - "Topic of %s already set to '%s'", - entry.matrix.getId(), - item.topic - ); - return Promise.resolve(); - } - return this.ircBridge.getAppServiceBridge().getIntent( - item.matrixUser.getId() - ).setRoomTopic( - entry.matrix.getId(), item.topic - ).catch(() => { - // Setter might not have powerlevels, trying again. - return this.ircBridge.getAppServiceBridge().getIntent() - .setRoomTopic(entry.matrix.getId(), item.topic); - }).then( - () => { - entry.matrix.topic = item.topic; - return this.ircBridge.getStore().upsertRoomStoreEntry(entry); - }, - (err) => { - item.req.log.error(`Error storing room ${entry.matrix.getId()} (${err.message})`); - } - ); - } - ); - try { - yield Promise.all(promises); - item.req.log.info( - `Topic: '${item.topic.substring(0, 20)}...' set in rooms: `, - item.entries.map((entry) => entry.matrix.getId()).join(",") - ); - } - catch (err) { - item.req.log.error(`Failed to set topic(s) ${err.message}`); - } -}); - -/** - * Called when the AS receives an IRC topic event. - * @param {IrcServer} server : The sending IRC server. - * @param {IrcUser} fromUser : The sender. - * @param {string} channel : The target channel. - * @param {Object} action : The IRC action performed. - * @return {Promise} which is resolved/rejected when the request finishes. - */ -IrcHandler.prototype.onTopic = Promise.coroutine(function*(req, server, fromUser, - channel, action) { - this.incrementMetric("topic"); - req.log.info("onTopic: %s from=%s to=%s action=%s", - server.domain, fromUser, channel, JSON.stringify(action).substring(0, 80) - ); - - const ALLOWED_ORIGINS = ["join", "alias"]; - const topic = action.text; - - // Only bridge topics for rooms created by the bridge, via !join or an alias - const entries = yield this.ircBridge.getStore().getMappingsForChannelByOrigin( - server, channel, ALLOWED_ORIGINS, true - ); - if (entries.length === 0) { - req.log.info( - "No mapped matrix rooms for IRC channel %s with origin = [%s]", - channel, - ALLOWED_ORIGINS - ); - return; - } - - req.log.info( - "New topic in %s - bot queing to set topic in %s", - channel, - entries.map((e) => e.matrix.getId()) - ); - - const matrixUser = new MatrixUser( - server.getUserIdFromNick(fromUser.nick) - ); - - if (!this.topicQueues[channel]) { - this.topicQueues[channel] = new Queue(this._serviceTopicQueue.bind(this)); - } - yield this.topicQueues[channel].enqueue( - server.domain + " " + channel + " " + topic, - {req: req, entries: entries, topic: topic, matrixUser} - ); -}); - -/** - * Called when the AS receives an IRC message event. - * @param {IrcServer} server : The sending IRC server. - * @param {IrcUser} fromUser : The sender. - * @param {string} channel : The target channel. - * @param {Object} action : The IRC action performed. - * @return {Promise} which is resolved/rejected when the request finishes. - */ -IrcHandler.prototype.onMessage = Promise.coroutine(function*(req, server, fromUser, - channel, action) { - this.incrementMetric("message"); - if (fromUser.isVirtual) { - return BridgeRequest.ERR_VIRTUAL_USER; - } - - req.log.info("onMessage: %s from=%s to=%s action=%s", - server.domain, fromUser, channel, JSON.stringify(action).substring(0, 80) - ); - - let mxAction = MatrixAction.fromIrcAction(action); - if (!mxAction) { - req.log.error("Couldn't map IRC action to matrix action"); - return; - } - - let mapping = null; - if (this.nickUserIdMapCache.has(`${server.domain}:${channel}`)) { - mapping = this.nickUserIdMapCache.get(`${server.domain}:${channel}`); - } - else if (this._mentionMode !== "force-off") { - // Some users want to opt out of being mentioned. - mapping = this.ircBridge.getClientPool().getNickUserIdMappingForChannel( - server, channel - ); - const store = this.ircBridge.getStore(); - const nicks = Object.keys(mapping); - for (let nick of nicks) { - if (nick === server.getBotNickname()) { - continue; - } - const userId = mapping[nick]; - const feature = (yield store.getUserFeatures(userId)).mentions; - const enabled = feature === true || - (feature === undefined && this._mentionMode === "on"); - if (!enabled) { - delete mapping[nick]; - // We MUST keep the userId in this mapping, because the user - // may enable the feature and we need to know which mappings - // need recalculating. This nick should hopefully never come - // up in the wild. - mapping["disabled-matrix-mentions-for-" + nick] = userId; - } - } - this.nickUserIdMapCache.set(`${server.domain}:${channel}`, mapping); - if (this.nickUserIdMapCache.size > NICK_USERID_CACHE_MAX) { - this.nickUserIdMapCache.delete(this.nickUserIdMapCache.keys()[0]); - } - } - - if (mapping !== null) { - yield mxAction.formatMentions( - mapping, - this.ircBridge.getAppServiceBridge().getIntent(), - this._mentionMode === "on" - ); - } - - const nickKey = server.domain + " " + fromUser.nick; - let virtualMatrixUser; - if (this._registeredNicks[nickKey]) { - // save the database hit - const sendingUserId = server.getUserIdFromNick(fromUser.nick); - virtualMatrixUser = new MatrixUser(sendingUserId); - } - else { - virtualMatrixUser = yield this.ircBridge.getMatrixUser(fromUser); - this._registeredNicks[nickKey] = true; - } - - let matrixRooms = yield this.ircBridge.getStore().getMatrixRoomsForChannel(server, channel); - let promises = matrixRooms.map((room) => { - req.log.info( - "Relaying in room %s", room.getId() - ); - return this.ircBridge.sendMatrixAction(room, virtualMatrixUser, mxAction, req); - }); - if (matrixRooms.length === 0) { - req.log.info( - "No mapped matrix rooms for IRC channel %s", - channel - ); - } - yield Promise.all(promises); -}); - -/** - * Called when the AS receives an IRC join event. - * @param {IrcServer} server : The sending IRC server. - * @param {IrcUser} joiningUser : The user who joined. - * @param {string} chan : The channel that was joined. - * @param {string} kind : The kind of join (e.g. from a member list if - * the bot just connected, or an actual JOIN command) - * @return {Promise} which is resolved/rejected when the request finishes. - */ -IrcHandler.prototype.onJoin = Promise.coroutine(function*(req, server, joiningUser, chan, kind) { - if (kind === "names") { - this.incrementMetric("join.names"); - } - else { // Let's avoid any surprises - this.incrementMetric("join"); - } - - this._invalidateNickUserIdMap(server, chan); - - let nick = joiningUser.nick; - let syncType = kind === "names" ? "initial" : "incremental"; - if (!server.shouldSyncMembershipToMatrix(syncType, chan)) { - req.log.info("IRC onJoin(%s) %s to %s - not syncing.", kind, nick, chan); - return BridgeRequest.ERR_NOT_MAPPED; - } - - req.log.info("onJoin(%s) %s to %s", kind, nick, chan); - // if the person joining is a virtual IRC user, do nothing. - if (joiningUser.isVirtual) { - return BridgeRequest.ERR_VIRTUAL_USER; - } - - this.quitDebouncer.onJoin(nick, server); - - // get virtual matrix user - let matrixUser = yield this.ircBridge.getMatrixUser(joiningUser); - let matrixRooms = yield this.ircBridge.getStore().getMatrixRoomsForChannel(server, chan); - const intent = this.ircBridge.getAppServiceBridge().getIntent( - matrixUser.getId() - ); - const MAX_JOIN_ATTEMPTS = server.getJoinAttempts(); - let promises = matrixRooms.map((room) => { - /** If this is a "NAMES" query, we can make use of the joinedMembers call we made - * to check if the user already exists in the room. This should save oodles of time. - */ - if (kind === "names" && - this.ircBridge.memberListSyncers[server.domain].isRemoteJoinedToRoom( - room.getId(), - matrixUser.getId() - )) { - req.log.debug("Not joining to %s, already joined.", room.getId()); - return; - } - req.log.info("Joining room %s and setting presence to online", room.getId()); - const joinRetry = (attempts) => { - req.log.debug(`Joining room (attempts:${attempts})`); - return intent.join(room.getId()).catch((err) => { - // -1 to never retry, 0 to never give up - if (MAX_JOIN_ATTEMPTS !== 0 && - (attempts > MAX_JOIN_ATTEMPTS) ) { - req.log.error(`Not retrying join for ${room.getId()}.`); - return Promise.reject(err); - } - attempts++; - const delay = Math.min( - (JOIN_DELAY_MS * attempts) + (Math.random() * 500), - JOIN_DELAY_CAP_MS - ); - req.log.warn(`Failed to join ${room.getId()}, delaying for ${delay}ms`); - req.log.debug(`Failed with: ${err.errcode} ${err.message}`); - return Promise.delay(delay).then(() => { - return joinRetry(attempts); - }); - }); - }; - return Promise.all([ - joinRetry(0), - intent.setPresence("online") - ]); - }); - if (matrixRooms.length === 0) { - req.log.info("No mapped matrix rooms for IRC channel %s", chan); - } - else { - stats.membership(true, "join"); - } - yield Promise.all(promises); -}); - -IrcHandler.prototype.onKick = Promise.coroutine(function*(req, server, - kicker, kickee, chan, reason) { - this.incrementMetric("kick"); - req.log.info( - "onKick(%s) %s is kicking %s from %s", - server.domain, kicker.nick, kickee.nick, chan - ); - - /* - We know this is an IRC client kicking someone. - There are 2 scenarios to consider here: - - IRC on IRC kicking - - IRC on Matrix kicking - - IRC-IRC - ======= - __USER A____ ____USER B___ - | | | | - IRC vMatrix1 IRC vMatrix2 | Effect - ----------------------------------------------------------------------- - Kicker Kickee | vMatrix2 leaves room. - This avoid potential permission issues - in case vMatrix1 cannot kick vMatrix2 - on Matrix. - - IRC-Matrix - ========== - __USER A____ ____USER B___ - | | | | - Matrix vIRC IRC vMatrix | Effect - ----------------------------------------------------------------------- - Kickee Kicker | Bot tries to kick Matrix user via /kick. - */ - - if (kickee.isVirtual) { - // A real IRC user is kicking one of us - this is IRC on Matrix kicking. - let matrixRooms = yield this.ircBridge.getStore().getMatrixRoomsForChannel(server, chan); - if (matrixRooms.length === 0) { - req.log.info("No mapped matrix rooms for IRC channel %s", chan); - return; - } - let bridgedIrcClient = this.ircBridge.getClientPool().getBridgedClientByNick( - server, kickee.nick - ); - if (!bridgedIrcClient || bridgedIrcClient.isBot) { - return; // unexpected given isVirtual == true, but meh, bail. - } - yield this._leaveQueue.enqueue(chan + bridgedIrcClient.userId, { - rooms: matrixRooms, - userId: bridgedIrcClient.userId, - shouldKick: true, - kickReason: `${kicker.nick} has kicked this user from ${chan} (${reason})`, - retry: true, // We must retry a kick to avoid leaking history - req, - }); - } - else { - // the kickee is just some random IRC user, but we still need to bridge this as IRC - // will NOT send a PART command. We equally cannot make a fake PART command and - // reuse the same code path as we want to force this to go through, regardless of - // whether incremental join/leave syncing is turned on. - let matrixUser = yield this.ircBridge.getMatrixUser(kickee); - req.log.info("Mapped kickee nick %s to %s", kickee.nick, JSON.stringify(matrixUser)); - let matrixRooms = yield this.ircBridge.getStore().getMatrixRoomsForChannel(server, chan); - if (matrixRooms.length === 0) { - req.log.info("No mapped matrix rooms for IRC channel %s", chan); - return; - } - yield this._leaveQueue.enqueue(chan + matrixUser.getId(), { - rooms: matrixRooms, - userId: matrixUser.getId(), - shouldKick: false, - retry: true, - req, - deop: true, // deop real irc users, like real irc. - }); - } -}); - -/** - * Called when the AS receives an IRC part event. - * @param {IrcServer} server : The sending IRC server. - * @param {IrcUser} leavingUser : The user who parted. - * @param {string} chan : The channel that was left. - * @param {string} kind : The kind of part (e.g. PART, KICK, BAN, QUIT, netsplit, etc) - * @return {Promise} which is resolved/rejected when the request finishes. - */ -IrcHandler.prototype.onPart = Promise.coroutine(function*(req, server, leavingUser, chan, kind) { - this.incrementMetric("part"); - this._invalidateNickUserIdMap(server, chan); - // parts are always incremental (only NAMES are initial) - if (!server.shouldSyncMembershipToMatrix("incremental", chan)) { - req.log.info("Server doesn't mirror parts."); - return; - } - const nick = leavingUser.nick; - req.log.info("onPart(%s) %s to %s", kind, nick, chan); - - - const matrixRooms = yield this.ircBridge.getStore().getMatrixRoomsForChannel(server, chan); - if (matrixRooms.length === 0) { - req.log.info("No mapped matrix rooms for IRC channel %s", chan); - return; - } - - // if the person leaving is a virtual IRC user, do nothing. Unless it's a part. - if (leavingUser.isVirtual && kind !== "part") { - return BridgeRequest.ERR_VIRTUAL_USER; - } - - let matrixUser; - if (leavingUser.isVirtual) { - const bridgedClient = this.ircBridge.getClientPool().getBridgedClientByNick( - server, nick - ); - if (!bridgedClient.inChannel(chan)) { - req.log.info("Not kicking user from room, user is not in channel"); - // We don't need to send a leave to a channel we were never in. - return BridgeRequest.ERR_DROPPED; - } - matrixUser = bridgedClient.userId; - } - else { - matrixUser = yield this.ircBridge.getMatrixUser(leavingUser); - } - - // get virtual matrix user - req.log.info("Mapped nick %s to %s", nick, JSON.stringify(matrixUser)); - - // Presence syncing and Quit Debouncing - // When an IRC user quits, debounce before leaving them from matrix rooms. In the meantime, - // update presence to "offline". If the user rejoins a channel before timeout, do not part - // user from the room. Otherwise timeout and leave rooms. - if (kind === "quit" && server.shouldDebounceQuits()) { - const shouldBridgePart = yield this.quitDebouncer.debounceQuit( - req, server, matrixUser, nick - ); - if (!shouldBridgePart) { - return; - } - } - - const promise = this._leaveQueue.enqueue(chan+leavingUser.nick, { - id: chan+leavingUser.nick, - rooms: matrixRooms, - userId: typeof(matrixUser) === "string" ? matrixUser : matrixUser.getId(), - shouldKick: leavingUser.isVirtual, // kick if they are not ours - req, - kickReason: "Client PARTed from channel", // this will only be used if shouldKick is true - retry: true, // We must retry these so that membership isn't leaked. - deop: !leavingUser.isVirtual, // deop real irc users, like real irc. - }); - stats.membership(true, "part"); - yield promise; -}); - -/** - * Called when a user sets a mode in a channel. - * @param {Request} req The metadata request - * @param {IrcServer} server : The sending IRC server. - * @param {string} channel The channel that has the given mode. - * @param {string} mode The mode that the channel is in, e.g. +sabcdef - * @return {Promise} which is resolved/rejected when the request finishes. - */ -IrcHandler.prototype.onMode = Promise.coroutine(function*(req, server, channel, by, - mode, enabled, arg) { - this.incrementMetric("mode"); - req.log.info( - "onMode(%s) in %s by %s (arg=%s)", - (enabled ? ("+" + mode) : ("-" + mode)), - channel, by, arg - ); - yield this.roomAccessSyncer.onMode(req, server, channel, by, mode, enabled, arg); -}); - -/** - * Called when channel mode information is received - * @param {Request} req The metadata request - * @param {IrcServer} server : The sending IRC server. - * @param {string} channel The channel that has the given mode. - * @param {string} mode The mode that the channel is in, e.g. +sabcdef - * @return {Promise} which is resolved/rejected when the request finishes. - */ -IrcHandler.prototype.onModeIs = Promise.coroutine(function*(req, server, channel, mode) { - req.log.info(`onModeIs for ${channel} = ${mode}.`); - yield this.roomAccessSyncer.onModeIs(req, server, channel, mode); -}); - -/** - * Called when the AS connects/disconnects a Matrix user to IRC. - * @param {Request} req The metadata request - * @param {BridgedClient} client The client who is acting on behalf of the Matrix user. - * @param {string} msg The message to share with the Matrix user. - * @param {boolean} force True if ignoring startup suppresion. - * @return {Promise} which is resolved/rejected when the request finishes. - */ -IrcHandler.prototype.onMetadata = Promise.coroutine(function*(req, client, msg, force) { - req.log.info("%s : Sending metadata '%s'", client, msg); - if (!this.ircBridge.isStartedUp() && !force) { - req.log.info("Suppressing metadata: not started up."); - return BridgeRequest.ERR_NOT_MAPPED; - } - let botUser = new MatrixUser(this.ircBridge.getAppServiceUserId()); - - let adminRoom = yield this.ircBridge.getStore().getAdminRoomByUserId(client.userId); - if (!adminRoom) { - req.log.info("Creating an admin room with %s", client.userId); - let response = yield this.ircBridge.getAppServiceBridge().getIntent().createRoom({ - createAsClient: false, - options: { - name: `${client.server.getReadableName()} IRC Bridge status`, - topic: `This room shows any errors or status messages from ` + - `${client.server.domain}, as well as letting you control ` + - "the connection. ", - preset: "trusted_private_chat", - visibility: "private", - invite: [client.userId] - } - }); - adminRoom = new MatrixRoom(response.room_id); - yield this.ircBridge.getStore().storeAdminRoom(adminRoom, client.userId); - let newRoomMsg = `You've joined a Matrix room which is bridged to the IRC network ` + - `'${client.server.domain}', where you ` + - `are now connected as ${client.nick}. ` + - `This room shows any errors or status messages from IRC, as well as ` + - `letting you control the connection. Type !help for more information` - - let notice = new MatrixAction("notice", newRoomMsg); - yield this.ircBridge.sendMatrixAction(adminRoom, botUser, notice, req); - } - - let notice = new MatrixAction("notice", msg); - yield this.ircBridge.sendMatrixAction(adminRoom, botUser, notice, req); -}); - - -IrcHandler.prototype.invalidateCachingForUserId = function(userId) { - if (this._mentionMode === "force-off") { - return false; - } - this.nickUserIdMapCache.forEach((mapping, serverChannel) => { - if (Object.values(mapping).includes(userId)) { - this.nickUserIdMapCache.delete(serverChannel); - } - }); - return true; -} - -IrcHandler.prototype._invalidateNickUserIdMap = function(server, channel) { - this.nickUserIdMapCache.delete(`${server.domain}:${channel}`); -} - -IrcHandler.prototype._handleLeaveQueue = async function(item) { - const bridge = this.ircBridge.getAppServiceBridge(); - let retryRooms = []; - item.attempts = item.attempts || 0; - for (const room of item.rooms) { - const roomId = room.getId(); - item.req.log.info( - `Leaving room ${roomId} (${item.userId}) (attempt: ${item.attempts})`, - ); - try { - if (item.shouldKick) { - await bridge.getIntent().kick( - roomId, - item.userId, - item.kickReason, - ); - } - else { - await bridge.getIntent(item.userId).leave(roomId); - } - if (item.deop) { - try { - await this.roomAccessSyncer.removePowerLevels(roomId, [item.userId]); - } - catch (ex) { - // This is non-critical but annoying. - item.req.log.warn("Failed to remove power levels for leaving user."); - } - } - } - catch (ex) { - item.req.log.warn( - `Failed to ${item.shouldKick ? "kick" : "leave"} ${item.userId} ${roomId}: ${ex}`, - ); - const is400 = ex.httpStatus - 400 > 0 && ex.httpStatus - 400 < 100; - if (!item.retry || ex.errcode === "M_FORBIDDEN" || is400) { - item.req.log.warn("Not retrying"); - continue; - } - retryRooms.push(room); - } - if (retryRooms.length < 0) { - return; - } - await Promise.delay(LEAVE_DELAY_MS + (Math.random() * LEAVE_DELAY_JITTER)); - item.attempts++; - if (item.attempts >= LEAVE_MAX_ATTEMPTS) { - item.req.log.error("Couldn't leave: Hit attempt limit"); - return; - } - this._leaveQueue.enqueue(item.id + item.attempts, { - ...item, - rooms: retryRooms, - }); - } -} - -IrcHandler.prototype.incrementMetric = function(metric) { - if (this._callCountMetrics[metric] === undefined) { - this._callCountMetrics[metric] = 0; - } - this._callCountMetrics[metric]++; -} - -IrcHandler.prototype.getMetrics = function() { - const metrics = Object.assign({}, this._callCountMetrics); - this._callCountMetrics = { - "join.names": 0, - "join": 0, - "part": 0, - "pm": 0, - "invite": 0, - "topic": 0, - "message": 0, - "kick": 0, - "mode": 0, - }; - return metrics; -} - -module.exports = IrcHandler; diff --git a/lib/bridge/MatrixHandler.js b/lib/bridge/MatrixHandler.js deleted file mode 100644 index 6ca45022f..000000000 --- a/lib/bridge/MatrixHandler.js +++ /dev/null @@ -1,1751 +0,0 @@ -/*eslint no-invalid-this: 0 consistent-return: 0*/ -"use strict"; -const Promise = require("bluebird"); - -const stats = require("../config/stats"); -const MatrixRoom = require("matrix-appservice-bridge").MatrixRoom; -const IrcRoom = require("../models/IrcRoom"); -const MatrixAction = require("../models/MatrixAction"); -const IrcAction = require("../models/IrcAction"); -const IrcClientConfig = require("../models/IrcClientConfig"); -const MatrixUser = require("matrix-appservice-bridge").MatrixUser; -const BridgeRequest = require("../models/BridgeRequest"); -const toIrcLowerCase = require("../irc/formatting").toIrcLowerCase; -const StateLookup = require('matrix-appservice-bridge').StateLookup; - -const MSG_PMS_DISABLED = "[Bridge] Sorry, PMs are disabled on this bridge."; -const MSG_PMS_DISABLED_FEDERATION = "[Bridge] Sorry, PMs are disabled on " + -"this bridge over federation."; - -const KICK_RETRY_DELAY_MS = 15000; -const KICK_DELAY_JITTER = 30000; -/* Number of events to store in memory for use in replies. */ -const DEFAULT_EVENT_CACHE_SIZE = 4096; -/* Length of the source text in a formatted reply message */ -const REPLY_SOURCE_MAX_LENGTH = 32; - -const USER_FEATURES = ["mentions"]; - -function MatrixHandler(ircBridge, config) { - this.ircBridge = ircBridge; - // maintain a list of room IDs which are being processed invite-wise. This is - // required because invites are processed asyncly, so you could get invite->msg - // and the message is processed before the room is created. - this._processingInvitesForRooms = { - // roomId+userId: defer - }; - - this._memberTracker = null; - this._eventCache = new Map(); //eventId => {body, sender} - config = config || {} - this._eventCacheMaxSize = config.eventCacheSize === undefined ? - DEFAULT_EVENT_CACHE_SIZE : config.eventCacheSize; - this.metrics = { - //domain => {"metricname" => value} - }; -} - -// ===== Matrix Invite Handling ===== - -/** - * Process a Matrix invite event for an Admin room. - * @param {Object} event : The Matrix invite event. - * @param {Request} req : The request for this event. - * @param {MatrixUser} inviter : The user who invited the bot. - * @param {MatrixUser} botUser : The bot itself. - */ -MatrixHandler.prototype._handleAdminRoomInvite = Promise.coroutine(function*(req, event, - inviter, botUser) { - req.log.info("Handling invite from user directed to bot."); - // Real MX user inviting BOT to a private chat - let mxRoom = new MatrixRoom(event.room_id); - yield this.ircBridge.getAppServiceBridge().getIntent().join(event.room_id); - - // Do not create an admin room if the room is marked as 'plumbed' - let matrixClient = this.ircBridge.getAppServiceBridge().getClientFactory().getClientAs(); - - try { - let plumbedState = yield matrixClient.getStateEvent(event.room_id, 'm.room.plumbing'); - - if (plumbedState.status === "enabled") { - req.log.info( - 'This room is marked for plumbing (m.room.plumbing.status = "enabled"). ' + - 'Not treating room as admin room.' - ); - return Promise.resolve(); - } - } - catch (err) { - req.log.info(`Not a plumbed room: Error retrieving m.room.plumbing (${err.data.error})`); - } - - // clobber any previous admin room ID - yield this.ircBridge.getStore().storeAdminRoom(mxRoom, inviter.userId); -}); - -/** - * Process a Matrix invite event for an Admin room. - * @param {Object} event : The Matrix invite event. - * @param {Request} req : The request for this event. - * @param {IrcUser} invitedIrcUser : The IRC user the bot invited to a room. - */ -MatrixHandler.prototype._handleInviteFromBot = Promise.coroutine(function*(req, event, - invitedIrcUser) { - req.log.info("Handling invite from bot directed at %s on %s", - invitedIrcUser.server.domain, invitedIrcUser.nick); - // Bot inviting VMX to a matrix room which is mapped to IRC. Just make a - // matrix user and join the room (we trust the bot, so no additional checks) - let mxUser = yield this.ircBridge.getMatrixUser(invitedIrcUser); - yield this.ircBridge.getAppServiceBridge().getIntent(mxUser.getId()).join(event.room_id); -}); - -MatrixHandler.prototype._handleInviteFromUser = Promise.coroutine(function*(req, event, - invitedIrcUser) { - req.log.info("Handling invite from user directed at %s on %s", - invitedIrcUser.server.domain, invitedIrcUser.nick); - const invitedUser = yield this.ircBridge.getMatrixUser(invitedIrcUser); - const mxRoom = new MatrixRoom(event.room_id); - const intent = this.ircBridge.getAppServiceBridge().getIntent(invitedUser.getId()); - // Real MX user inviting VMX to a matrix room for PM chat - if (!invitedIrcUser.server.allowsPms()) { - req.log.error("Accepting invite, and then leaving: This server does not allow PMs."); - yield intent.join(event.room_id); - yield this.ircBridge.sendMatrixAction(mxRoom, invitedUser, new MatrixAction( - "notice", - MSG_PMS_DISABLED - ), req); - yield intent.leave(event.room_id); - return; - } - - // If no federated PMs are allowed, check the origin of the PM - // is same the domain as the bridge - if (!invitedIrcUser.server.shouldFederatePMs()) { - // Matches for the local part (the not-user part) - var localpart = event.user_id.match(/[^:]*:(.*)/)[1]; - if (localpart !== this.ircBridge.domain) { - req.log.error( - "Accepting invite, and then leaving: This server does not allow federated PMs." - ); - yield intent.join(event.room_id); - yield this.ircBridge.sendMatrixAction(mxRoom, invitedUser, new MatrixAction( - "notice", - MSG_PMS_DISABLED_FEDERATION - ), req); - yield intent.leave(event.room_id); - return; - } - req.log.info("(PM federation)Invite not rejected: user on local HS"); - } - else { - req.log.info("(PM federation)Invite not rejected: federated PMs allowed"); - } - // create a virtual Matrix user for the IRC user - yield intent.join(event.room_id); - req.log.info("Joined %s to room %s", invitedUser.getId(), event.room_id); - - // check if this room is a PM room or not. - let roomState = yield intent.roomState(event.room_id); - let joinedMembers = roomState.filter((ev) => { - return ev.type === "m.room.member" && ev.content.membership === "join"; - }).map((ev) => ev.state_key); - let isPmRoom = ( - joinedMembers.length === 2 && joinedMembers.indexOf(event.user_id) !== -1 - ); - - if (isPmRoom) { - // nick is the channel - let ircRoom = new IrcRoom( - invitedIrcUser.server, invitedIrcUser.nick - ); - yield this.ircBridge.getStore().setPmRoom( - ircRoom, mxRoom, event.user_id, event.state_key - ); - return; - } - req.log.error("This room isn't a 1:1 chat!"); - // whine that you don't do group chats and leave. - let notice = new MatrixAction("notice", - "Group chat not supported." - ); - try { - yield this.ircBridge.sendMatrixAction(mxRoom, invitedUser, notice, req); - } - catch (err) { - // ignore, we want to leave the room regardless. - } - yield intent.leave( - event.room_id - ); -}); - -// === Admin room handling === -MatrixHandler.prototype._onAdminMessage = Promise.coroutine(function*(req, event, adminRoom) { - req.log.info("Received admin message from %s", event.user_id); - - let botUser = new MatrixUser(this.ircBridge.getAppServiceUserId(), undefined, false); - - // If an admin room has more than 2 people in it, kick the bot out - let members = []; - if (this._memberTracker) { - // First call begins tracking, subsequent calls do nothing - yield this._memberTracker.trackRoom(adminRoom.getId()); - - members = this._memberTracker.getState( - adminRoom.getId(), - 'm.room.member' - ).filter( - function (m) { - return m.content.membership && m.content.membership === "join"; - } - ); - } - else { - req.log.warn('Member tracker not running'); - } - - if (members.length > 2) { - req.log.error( - `_onAdminMessage: admin room has ${members.length}` + - ` users instead of just 2; bot will leave` - ); - - // Notify users in admin room - let notice = new MatrixAction("notice", - "There are more than 2 users in this admin room" - ); - yield this.ircBridge.sendMatrixAction(adminRoom, botUser, notice, req); - - yield this.ircBridge.getAppServiceBridge().getIntent( - botUser.getId() - ).leave(adminRoom.getId()); - - return; - } - - // Assumes all commands have the form "!wxyz [irc.server] [args...]" - let segments = event.content.body.split(" "); - let cmd = segments.shift(); - let args = segments; - - if (cmd === "!help") { - let helpCommands = { - "!join": { - example: `!join [irc.example.net] #channel [key]`, - summary: `Join a channel (with optional channel key)`, - }, - "!nick": { - example: `!nick [irc.example.net] DesiredNick`, - summary: "Change your nick. If no arguments are supplied, " + - "your current nick is shown.", - }, - "!whois": { - example: `!whois [irc.example.net] NickName|@alice:matrix.org`, - summary: "Do a /whois lookup. If a Matrix User ID is supplied, " + - "return information about that user's IRC connection.", - }, - "!storepass": { - example: `!storepass [irc.example.net] passw0rd`, - summary: `Store a NickServ password (server password)`, - }, - "!removepass": { - example: `!removepass [irc.example.net]`, - summary: `Remove a previously stored NickServ password`, - }, - "!feature": { - example: `!feature feature-name [true/false/default]`, - summary: `Enable, disable or default a feature's status for your account.` + - `Will display the current feature status if true/false/default not given.`, - }, - "!quit": { - example: `!quit`, - summary: "Leave all bridged channels, on all networks, and remove your " + - "connections to all networks.", - }, - "!cmd": { - example: `!cmd [irc.example.net] COMMAND [arg0 [arg1 [...]]]`, - summary: "Issue a raw IRC command. These will not produce a reply." + - "(Note that the command must be all uppercase.)", - }, - "!bridgeversion": { - example: `!bridgeversion`, - summary: "Return the version from matrix-appservice-irc bridge.", - }, - }; - - - let notice = new MatrixAction("notice", null, - `This is an IRC admin room for controlling your IRC connection and sending ` + - `commands directly to IRC. ` + - `The following commands are available:
    \n\t` + - Object.keys(helpCommands).map((c) => { - return ( - `
  • ` + - `${helpCommands[c].example} : ${helpCommands[c].summary}` + - `
  • ` - ); - }).join(`\n\t`) + - `
` - ); - yield this.ircBridge.sendMatrixAction(adminRoom, botUser, notice, req); - return; - } - - // Work out which IRC server the command is directed at. - let clientList = this.ircBridge.getBridgedClientsForUserId(event.user_id); - let ircServer = this.ircBridge.getServer(args[0]); - - if (ircServer) { - args.shift(); // pop the server so commands don't need to know - } - else { - // default to the server the client is connected to if there is only one - if (clientList.length === 1) { - ircServer = clientList[0].server; - } - // default to the only server we know about if we only bridge 1 thing. - else if (this.ircBridge.getServers().length === 1) { - ircServer = this.ircBridge.getServers()[0]; - } - else { - let notice = new MatrixAction("notice", - "A server address must be specified." - ); - yield this.ircBridge.sendMatrixAction(adminRoom, botUser, notice, req); - return; - } - } - - if (cmd === "!nick") { - // Format is: "!nick irc.example.com NewNick" - if (!ircServer.allowsNickChanges()) { - let notice = new MatrixAction("notice", - "Server " + ircServer.domain + " does not allow nick changes." - ); - yield this.ircBridge.sendMatrixAction(adminRoom, botUser, notice, req); - return; - } - - let nick = args.length === 1 ? args[0] : null; // make sure they only gave 1 arg - if (!ircServer || !nick) { - let connectedNetworksStr = ""; - if (clientList.length === 0) { - connectedNetworksStr = ( - "You are not currently connected to any " + - "IRC networks which have nick changes enabled." - ); - } - else { - connectedNetworksStr = "Currently connected to IRC networks:\n"; - for (let i = 0; i < clientList.length; i++) { - connectedNetworksStr += clientList[i].server.domain + - " as " + clientList[i].nick + "\n"; - } - } - let notice = new MatrixAction("notice", - "Format: '!nick DesiredNick' or '!nick irc.server.name DesiredNick'\n" + - connectedNetworksStr - ); - yield this.ircBridge.sendMatrixAction(adminRoom, botUser, notice, req); - return; - } - req.log.info("%s wants to change their nick on %s to %s", - event.user_id, ircServer.domain, nick); - - if (ircServer.claimsUserId(event.user_id)) { - req.log.error("%s is a virtual user!", event.user_id); - return BridgeRequest.ERR_VIRTUAL_USER; - } - - // change the nick - let bridgedClient = yield this.ircBridge.getBridgedClient(ircServer, event.user_id); - try { - let response = yield bridgedClient.changeNick(nick, true); - let noticeRes = new MatrixAction("notice", response); - yield this.ircBridge.sendMatrixAction(adminRoom, botUser, noticeRes, req); - // persist this desired nick - let config = yield this.ircBridge.getStore().getIrcClientConfig( - event.user_id, ircServer.domain - ); - if (!config) { - config = IrcClientConfig.newConfig( - bridgedClient.matrixUser, ircServer.domain, nick - ); - } - config.setDesiredNick(nick); - yield this.ircBridge.getStore().storeIrcClientConfig(config); - return; - } - catch (err) { - if (err.stack) { - req.log.error(err); - } - let noticeErr = new MatrixAction("notice", err.message); - yield this.ircBridge.sendMatrixAction(adminRoom, botUser, noticeErr, req); - return; - } - } - else if (cmd === "!join") { - // TODO: Code dupe from !nick - // Format is: "!join irc.example.com #channel [key]" - - // check that the server exists and that the user_id is on the whitelist - let ircChannel = args[0]; - let key = args[1]; // keys can't have spaces in them, so we can just do this. - let errText = null; - if (!ircChannel || ircChannel.indexOf("#") !== 0) { - errText = "Format: '!join irc.example.com #channel [key]'"; - } - else if (ircServer.hasInviteRooms() && !ircServer.isInWhitelist(event.user_id)) { - errText = "You are not authorised to join channels on this server."; - } - - if (errText) { - yield this.ircBridge.sendMatrixAction( - adminRoom, botUser, new MatrixAction("notice", errText), req - ); - return; - } - req.log.info("%s wants to join the channel %s on %s", event.user_id, - ircChannel, ircServer.domain); - - // There are 2 main flows here: - // - The !join is instigated to make the BOT join a new channel. - // * Bot MUST join and invite user - // - The !join is instigated to make the USER join a new channel. - // * IRC User MAY have to join (if bridging incr joins or using a chan key) - // * Bot MAY invite user - // - // This means that in both cases: - // 1) Bot joins IRC side (NOP if bot is disabled) - // 2) Bot sends Matrix invite to bridged room. (ignore failures if already in room) - // And *sometimes* we will: - // 3) Force join the IRC user (if given key / bridging joins) - - // track the channel if we aren't already - let matrixRooms = yield this.ircBridge.getStore().getMatrixRoomsForChannel( - ircServer, ircChannel - ); - - if (matrixRooms.length === 0) { - // track the channel then invite them. - // TODO: Dupes onAliasQuery a lot - const initial_state = [ - { - type: "m.room.join_rules", - state_key: "", - content: { - join_rule: ircServer.getJoinRule() - } - }, - { - type: "m.room.history_visibility", - state_key: "", - content: { - history_visibility: "joined" - } - } - ]; - if (ircServer.areGroupsEnabled()) { - initial_state.push({ - type: "m.room.related_groups", - state_key: "", - content: { - groups: [ircServer.getGroupId()] - } - }); - } - let ircRoom = yield this.ircBridge.trackChannel(ircServer, ircChannel, key); - let response = yield this.ircBridge.getAppServiceBridge().getIntent( - event.user_id - ).createRoom({ - options: { - name: ircChannel, - visibility: "private", - preset: "public_chat", - creation_content: { - "m.federate": ircServer.shouldFederate() - }, - initial_state - } - }); - let mxRoom = new MatrixRoom(response.room_id); - yield this.ircBridge.getStore().storeRoom( - ircRoom, mxRoom, 'join' - ); - // /mode the channel AFTER we have created the mapping so we process - // +s and +i correctly. - this.ircBridge.publicitySyncer.initModeForChannel(ircServer, ircChannel).catch( - (err) => { - log.error( - `Could not init mode for channel ${ircChannel} on ${ircServer.domain}` - ); - }); - req.log.info( - "Created a room to track %s on %s and invited %s", - ircRoom.channel, ircServer.domain, event.user_id - ); - matrixRooms.push(mxRoom); - } - - // already tracking channel, so just invite them. - let invitePromises = matrixRooms.map((room) => { - req.log.info( - "Inviting %s to room %s", event.user_id, room.getId() - ); - return this.ircBridge.getAppServiceBridge().getIntent().invite( - room.getId(), event.user_id - ); - }); - - // check whether we should be force joining the IRC user - for (let i = 0; i < matrixRooms.length; i++) { - let m = matrixRooms[i]; - let userMustJoin = ( - key || ircServer.shouldSyncMembershipToIrc("incremental", m.getId()) - ); - if (userMustJoin) { - // force join then break out (we only ever join once no matter how many - // rooms the channel is bridged to) - let bc = yield this.ircBridge.getBridgedClient( - ircServer, event.user_id - ); - yield bc.joinChannel(ircChannel, key); - break; - } - } - - yield Promise.all(invitePromises); - } - else if (cmd === "!whois") { - // Format is: "!whois " - - let whoisNick = args.length === 1 ? args[0] : null; // ensure 1 arg - if (!whoisNick) { - yield this.ircBridge.sendMatrixAction( - adminRoom, botUser, - new MatrixAction("notice", "Format: '!whois nick|mxid'"), req - ); - return; - } - - if (whoisNick[0] === "@") { - // querying a Matrix user - whoisNick is the matrix user ID - req.log.info("%s wants whois info on %s", event.user_id, whoisNick); - let whoisClient = this.ircBridge.getIrcUserFromCache(ircServer, whoisNick); - try { - let noticeRes = new MatrixAction( - "notice", - whoisClient ? - `${whoisNick} is connected to ${ircServer.domain} as '${whoisClient.nick}'.` : - `${whoisNick} has no IRC connection via this bridge.`); - yield this.ircBridge.sendMatrixAction(adminRoom, botUser, noticeRes, req); - } - catch (err) { - if (err.stack) { - req.log.error(err); - } - let noticeErr = new MatrixAction("notice", "Failed to perform whois query."); - yield this.ircBridge.sendMatrixAction(adminRoom, botUser, noticeErr, req); - } - return; - } - - req.log.info("%s wants whois info on %s on %s", event.user_id, - whoisNick, ircServer.domain); - let bridgedClient = yield this.ircBridge.getBridgedClient(ircServer, event.user_id); - try { - let response = yield bridgedClient.whois(whoisNick); - let noticeRes = new MatrixAction("notice", response.msg); - yield this.ircBridge.sendMatrixAction(adminRoom, botUser, noticeRes, req); - } - catch (err) { - if (err.stack) { - req.log.error(err); - } - let noticeErr = new MatrixAction("notice", err.message); - yield this.ircBridge.sendMatrixAction(adminRoom, botUser, noticeErr, req); - } - return; - } - else if (cmd === "!quit") { - const msgText = yield this.quitUser( - req, event.user_id, clientList, ircServer, "issued !quit command" - ); - if (msgText) { - let notice = new MatrixAction("notice", msgText); - yield this.ircBridge.sendMatrixAction(adminRoom, botUser, notice, req); - } - return; - } - else if (cmd === "!storepass") { - let domain = ircServer.domain; - let userId = event.user_id; - let notice; - - try { - // Allow passwords with spaces - let pass = args.join(' '); - let explanation = `When you next reconnect to ${domain}, this password ` + - `will be automatically sent in a PASS command which most ` + - `IRC networks will use as your NickServ password. This ` + - `means you will not need to talk to NickServ. This does ` + - `NOT apply to your currently active connection: you still ` + - `need to talk to NickServ one last time to authenticate ` + - `your current connection if you haven't already.`; - - if (pass.length === 0) { - notice = new MatrixAction( - "notice", - "Format: '!storepass password' " + - "or '!storepass irc.server.name password'\n" + explanation - ); - } - else { - yield this.ircBridge.getStore().storePass(userId, domain, pass); - notice = new MatrixAction( - "notice", `Successfully stored password for ${domain}. ` + explanation - ); - } - } - catch (err) { - notice = new MatrixAction( - "notice", `Failed to store password: ${err.message}` - ); - req.log.error(err.stack); - } - - yield this.ircBridge.sendMatrixAction(adminRoom, botUser, notice, req); - return; - } - else if (cmd === "!removepass") { - let domain = ircServer.domain; - let userId = event.user_id; - let notice; - - try { - yield this.ircBridge.getStore().removePass(userId, domain); - notice = new MatrixAction( - "notice", `Successfully removed password.` - ); - } - catch (err) { - notice = new MatrixAction( - "notice", `Failed to remove password: ${err.message}` - ); - } - - yield this.ircBridge.sendMatrixAction(adminRoom, botUser, notice, req); - return; - } - else if (cmd === "!cmd" && args[0]) { - req.log.info(`No valid (old form) admin command, will try new format`); - - // Assumes commands have the form - // !cmd [irc.server] COMMAND [arg0 [arg1 [...]]] - - let currentServer = ircServer; - let blacklist = ['PROTOCTL']; - - try { - let keyword = args[0]; - - // keyword could be a failed server or a malformed command - if (!keyword.match(/^[A-Z]+$/)) { - // if not a domain OR is only word (which implies command) - if (!keyword.match(/^[a-z0-9:\.-]+$/) || args.length == 1) { - throw new Error(`Malformed command: ${keyword}`); - } - else { - throw new Error(`Domain not accepted: ${keyword}`); - } - } - - if (blacklist.indexOf(keyword) != -1) { - throw new Error(`Command blacklisted: ${keyword}`); - } - - // If no args after COMMAND, this will be [] - let sendArgs = args.splice(1); - sendArgs.unshift(keyword); - - let bridgedClient = yield this.ircBridge.getBridgedClient( - currentServer, event.user_id - ); - - if (!bridgedClient.unsafeClient) { - throw new Error('Possibly disconnected'); - } - - bridgedClient.unsafeClient.send.apply(bridgedClient.unsafeClient, sendArgs); - } - catch (err) { - let notice = new MatrixAction("notice", `${err}\n` ); - yield this.ircBridge.sendMatrixAction(adminRoom, botUser, notice, req); - return; - } - } - else if (cmd === "!bridgeversion") { - try { - const BridgeVersion = require("../../package.json").version; - yield this.ircBridge.sendMatrixAction(adminRoom, - botUser, - new MatrixAction("notice", `BridgeVersion: ${BridgeVersion}`), - req); - } - catch (err) { - let notice = new MatrixAction("notice", "Failed to get bridge version"); - yield this.ircBridge.sendMatrixAction(adminRoom, botUser, notice, req); - req.log.error(err.stack); - } - return; - } - else if (cmd === "!feature") { - if (args.length === 0 || !USER_FEATURES.includes(args[0].toLowerCase())) { - let notice = new MatrixAction("notice", - "Missing or unknown feature flag. Must be one of: " + USER_FEATURES.join(", ") - ); - yield this.ircBridge.sendMatrixAction(adminRoom, botUser, notice, req); - return; - } - const featureFlag = args[0]; - const features = yield this.ircBridge.getStore().getUserFeatures(event.user_id); - if (!args[1]) { - const val = features[featureFlag]; - let msg = `'${featureFlag}' is `; - if (val === true) { - msg += "enabled."; - } - else if (val === false) { - msg += "disabled."; - } - else { - msg += "set to the default value."; - } - let notice = new MatrixAction("notice", msg); - yield this.ircBridge.sendMatrixAction(adminRoom, botUser, notice, req); - return; - } - if (!["true", "false", "default"].includes(args[1].toLowerCase())) { - let notice = new MatrixAction("notice", - "Parameter must be either true, false or default." - ); - yield this.ircBridge.sendMatrixAction(adminRoom, botUser, notice, req); - return; - } - features[featureFlag] = args[1] === "default" ? undefined : - args[1].toLowerCase() === "true"; - - yield this.ircBridge.getStore().storeUserFeatures(event.user_id, features); - let note = ""; - if (featureFlag === "mentions") { - // We should invalidate caching for this user's channels. - if (!this.ircBridge.ircHandler.invalidateCachingForUserId(event.user_id)) { - note = " This bridge has disabled mentions, so this flag will do nothing."; - } - } - let notice = new MatrixAction("notice", - `Set ${featureFlag} to ${features[featureFlag]}.${note}` - ); - yield this.ircBridge.sendMatrixAction(adminRoom, botUser, notice, req); - } - else { - let notice = new MatrixAction("notice", - "The command was not recognised. Available commands are listed by !help"); - yield this.ircBridge.sendMatrixAction(adminRoom, botUser, notice, req); - } -}); - -MatrixHandler.prototype.quitUser = Promise.coroutine(function*(req, userId, clientList, - ircServer, reason) { - let clients = clientList; - if (ircServer) { - // Filter to get the clients for the [specified] server - clients = clientList.filter( - (bridgedClient) => bridgedClient.server.domain === ircServer.domain - ); - } - if (clients.length === 0) { - req.log.info(`No bridgedClients for ${userId}`); - return "You are not connected to any networks."; - } - - for (let i = 0; i < clients.length; i++) { - const bridgedClient = clients[i]; - if (bridgedClient.chanList.length === 0) { - req.log.info( - `Bridged client for ${userId} is not in any channels ` + - `on ${bridgedClient.server.domain}` - ); - } - else { - // Get all rooms that the bridgedClient is in - let rooms = yield Promise.all( - bridgedClient.chanList.map( - (channel) => { - return this.ircBridge.getStore().getMatrixRoomsForChannel( - bridgedClient.server, channel - ); - } - ) - ); - - // rooms is an array of arrays - rooms = rooms.reduce((a, b) => {return a.concat(b)}); - - let uniqueRoomIds = Array.from( - new Set(rooms.map((matrixRoom) => matrixRoom.roomId)) - ); - for (let j = 0; j < uniqueRoomIds.length; j++) { - let roomId = uniqueRoomIds[j]; - try { - yield this.ircBridge.getAppServiceBridge().getIntent().kick( - roomId, bridgedClient.userId, reason - ); - } - catch (err) { - req.log.error(err); - req.log.warn( - `Could not kick ${bridgedClient.userId} ` + - `from bridged room ${roomId}: ${err.message}` - ); - } - } - } - - req.log.info( - `Killing bridgedClient (nick = ${bridgedClient.nick}) for ${bridgedClient.userId}` - ); - // The success message will effectively be 'Your connection to ... has been lost.` - bridgedClient.kill(reason); - } - - return null; -}); - -/** - * Called when the AS receives a new Matrix invite/join/leave event. - * @param {Object} event : The Matrix member event. - */ -MatrixHandler.prototype._onMemberEvent = function(req, event) { - if (!this._memberTracker) { - let matrixClient = this.ircBridge.getAppServiceBridge().getClientFactory().getClientAs(); - - this._memberTracker = new StateLookup({ - client : matrixClient, - eventTypes: ['m.room.member'] - }); - } - else { - this._memberTracker.onEvent(event); - } -}; - -/** - * Called when the AS receives a new Matrix invite event. - * @param {Object} event : The Matrix invite event. - * @param {MatrixUser} inviter : The inviter (sender). - * @param {MatrixUser} invitee : The invitee (receiver). - * @return {Promise} which is resolved/rejected when the request finishes. - */ -MatrixHandler.prototype._onInvite = Promise.coroutine(function*(req, event, inviter, invitee) { - /* - * (MX=Matrix user, VMX=Virtual matrix user, BOT=AS bot) - * Valid invite flows: - * [1] MX --invite--> VMX (starting a PM chat) - * [2] bot --invite--> VMX (invite-only room that the bot is in who is inviting virtuals) - * [3] MX --invite--> BOT (admin room; auth) - * [4] bot --invite--> MX (bot telling real mx user IRC conn state) - Ignore. - * [5] irc --invite--> MX (real irc user PMing a Matrix user) - Ignore. - */ - req.log.info("onInvite: %s", JSON.stringify(event)); - this._onMemberEvent(req, event); - - // mark this room as being processed in case we simultaneously get - // messages for this room (which would fail if we haven't done the - // invite yet!) - this._processingInvitesForRooms[event.room_id + event.state_key] = req.getPromise(); - req.getPromise().finally(() => { - delete this._processingInvitesForRooms[event.room_id + event.state_key]; - }); - - - // work out which flow we're dealing with and fork off asap - // is the invitee the bot? - if (this.ircBridge.getAppServiceUserId() === event.state_key) { - // case [3] - yield this._handleAdminRoomInvite(req, event, inviter, invitee); - } - // else is the invitee a real matrix user? If they are, there will be no IRC server - else if (!this.ircBridge.getServerForUserId(event.state_key)) { - // cases [4] and [5] : We cannot accept on behalf of real matrix users, so nop - return BridgeRequest.ERR_NOT_MAPPED; - } - else { - // cases [1] and [2] : The invitee represents a real IRC user - let ircUser = yield this.ircBridge.matrixToIrcUser(invitee); - // is the invite from the bot? - if (this.ircBridge.getAppServiceUserId() === event.user_id) { - yield this._handleInviteFromBot(req, event, ircUser); // case [2] - } - else { - yield this._handleInviteFromUser(req, event, ircUser); // case [1] - } - } -}); - -MatrixHandler.prototype._onJoin = Promise.coroutine(function*(req, event, user) { - let self = this; - req.log.info("onJoin: %s", JSON.stringify(event)); - this._onMemberEvent(req, event); - // membershiplists injects leave events when syncing initial membership - // lists. We know if this event is injected because this flag is set. - let syncKind = event._injected ? "initial" : "incremental"; - let promises = []; // one for each join request - - if (this.ircBridge.getAppServiceUserId() === user.getId()) { - // ignore messages from the bot - return BridgeRequest.ERR_VIRTUAL_USER; - } - - // is this a tracked channel? - let ircRooms = yield this.ircBridge.getStore().getIrcChannelsForRoomId(event.room_id); - - // =========== Bridge Bot Joining =========== - // Make sure the bot is joining on all mapped IRC channels - ircRooms.forEach((ircRoom) => { - this.ircBridge.joinBot(ircRoom); - }); - - // =========== Client Joining =========== - // filter out rooms which don't mirror matrix join parts and are NOT frontier - // entries. Frontier entries must ALWAYS be joined else the IRC channel will - // not be bridged! - ircRooms = ircRooms.filter(function(room) { - return room.server.shouldSyncMembershipToIrc( - syncKind, event.room_id - ) || event._frontier; - }); - - if (ircRooms.length === 0) { - req.log.info( - "No tracked channels which mirror joins for this room." - ); - return BridgeRequest.ERR_NOT_MAPPED; - } - - // for each room (which may be on different servers) - ircRooms.forEach(function(room) { - if (room.server.claimsUserId(user.getId())) { - req.log.info("%s is a virtual user (claimed by %s)", - user.getId(), room.server.domain); - return; - } - // get the virtual IRC user for this user - promises.push(Promise.coroutine(function*() { - let bridgedClient; - let kickIntent; - try { - bridgedClient = yield self.ircBridge.getBridgedClient( - room.server, user.getId(), (event.content || {}).displayname - ); - } - catch (e) { - // We need to kick on failure to get a client. - req.log.info(`${user.getId()} failed to get a IRC connection. Kicking from room.`); - kickIntent = self.ircBridge.getAppServiceBridge().getIntent(); - } - - while (kickIntent) { - try { - yield kickIntent.kick( - event.room_id, user.getId(), - `IRC connection failure.` - ); - self._incrementMetric(room.server.domain, "connection_failure_kicks"); - break; - } - catch (err) { - const delay = KICK_RETRY_DELAY_MS + (Math.random() * KICK_DELAY_JITTER); - req.log.warn( - `User was not kicked. Retrying in ${delay}ms. ${err}` - ); - yield Promise.delay(delay); - } - } - - // Check for a displayname change and update nick accordingly. - if (event.content.displayname !== bridgedClient.displayName) { - bridgedClient.displayName = event.content.displayname; - // Changing the nick requires that: - // - the server allows nick changes - // - the nick is not custom - let config = yield self.ircBridge.getStore().getIrcClientConfig( - bridgedClient.userId, room.server.domain - ); - if (room.server.allowsNickChanges() && - config.getDesiredNick() === null - ) { - try { - const newNick = room.server.getNick( - bridgedClient.userId, event.content.displayname - ); - bridgedClient.changeNick(newNick, false); - } - catch (e) { - req.log.warn(`Didn't change nick on the IRC side: ${e}`); - } - } - } - - yield bridgedClient.joinChannel(room.channel); // join each channel - })()); - }); - - // We know ircRooms.length > 1. The only time when this isn't mapped into a Promise - // is when there is a virtual user: TODO: clean this up! Control flow is hard. - if (promises.length === 0) { - return BridgeRequest.ERR_VIRTUAL_USER; - } - - stats.membership(false, "join"); - yield Promise.all(promises); -}); - -MatrixHandler.prototype._onKick = Promise.coroutine(function*(req, event, kicker, kickee) { - req.log.info( - "onKick %s is kicking/banning %s from %s", - kicker.getId(), kickee.getId(), event.room_id - ); - this._onMemberEvent(req, event); - - /* - We know this is a Matrix client kicking someone. - There are 2 scenarios to consider here: - - Matrix on Matrix kicking - - Matrix on IRC kicking - - Matrix-Matrix - ============= - __USER A____ ____USER B___ - | | | | - Matrix vIRC1 Matrix vIRC2 | Effect - ----------------------------------------------------------------------- - Kicker Kickee | vIRC2 parts channel. - This avoids potential permission issues - in case vIRC1 cannot kick vIRC2 on IRC. - - Matrix-IRC - ========== - __USER A____ ____USER B___ - | | | | - Matrix vIRC IRC vMatrix | Effect - ----------------------------------------------------------------------- - Kicker Kickee | vIRC tries to kick IRC via KICK command. - */ - - let ircRooms = yield this.ircBridge.getStore().getIrcChannelsForRoomId(event.room_id); - // do we have an active connection for the kickee? This tells us if they are real - // or virtual. - let kickeeClients = this.ircBridge.getBridgedClientsForUserId(kickee.getId()); - - if (kickeeClients.length === 0) { - // Matrix on IRC kicking, work out which IRC user to kick. - let server = null; - for (let i = 0; i < ircRooms.length; i++) { - if (ircRooms[i].server.claimsUserId(kickee.getId())) { - server = ircRooms[i].server; - break; - } - } - if (!server) { - return; // kicking a bogus user - } - let kickeeNick = server.getNickFromUserId(kickee.getId()); - if (!kickeeNick) { - return; // bogus virtual user ID - } - // work out which client will do the kicking - let kickerClient = this.ircBridge.getIrcUserFromCache(server, kicker.getId()); - if (!kickerClient) { - // well this is awkward.. whine about it and bail. - req.log.error( - "%s has no client instance to send kick from. Cannot kick.", - kicker.getId() - ); - return; - } - // we may be bridging this matrix room into many different IRC channels, and we want - // to kick this user from all of them. - for (let i = 0; i < ircRooms.length; i++) { - if (ircRooms[i].server.domain !== server.domain) { - return; - } - kickerClient.kick( - kickeeNick, ircRooms[i].channel, - `Kicked by ${kicker.getId()}` + - (event.content.reason ? ` : ${event.content.reason}` : "") - ); - } - } - else { - // Matrix on Matrix kicking: part the channel. - let kickeeServerLookup = {}; - kickeeClients.forEach(function(ircClient) { - kickeeServerLookup[ircClient.server.domain] = ircClient; - }); - let promises = []; // one for each leave - ircRooms.forEach(function(ircRoom) { - // Make the connected IRC client leave the channel. - let client = kickeeServerLookup[ircRoom.server.domain]; - if (!client) { - return; // not connected to this server - } - // If we aren't joined this will no-op. - promises.push(client.leaveChannel( - ircRoom.channel, - `Kicked by ${kicker.getId()} ` + - (event.content.reason ? ` : ${event.content.reason}` : "") - )); - }); - yield Promise.all(promises); - } -}); - -MatrixHandler.prototype._onLeave = Promise.coroutine(function*(req, event, user, sender) { - req.log.info("onLeave: %s", JSON.stringify(event)); - // membershiplists injects leave events when syncing initial membership - // lists. We know if this event is injected because this flag is set. - let syncKind = event._injected ? "initial" : "incremental"; - - if (this.ircBridge.getAppServiceUserId() === user.getId()) { - // ignore messages from the bot - return BridgeRequest.ERR_VIRTUAL_USER; - } - - // do we have an active connection for this user? - let clientList = this.ircBridge.getBridgedClientsForUserId(user.getId()); - // filter out servers which don't mirror matrix join parts (unless it's a kick) - clientList = clientList.filter(function(client) { - return ( - client.server.shouldSyncMembershipToIrc(syncKind, event.room_id) && - !client.server.claimsUserId(user.getId()) - ); // not a virtual user - }); - - let serverLookup = {}; - clientList.forEach(function(ircClient) { - serverLookup[ircClient.server.domain] = ircClient; - }); - - - // which channels should the connected client leave? - let ircRooms = yield this.ircBridge.getStore().getIrcChannelsForRoomId(event.room_id); - - let promises = []; // one for each leave request - // ========== Client Parting ========== - // for each room, if we're connected to it, leave the channel. - ircRooms.forEach(function(ircRoom) { - // Make the connected IRC client leave the channel. - let client = serverLookup[ircRoom.server.domain]; - if (!client) { - return; // not connected to this server - } - // leave it; if we aren't joined this will no-op. - promises.push(client.leaveChannel(ircRoom.channel)); - }); - - if (promises.length === 0) { // no connected clients - return BridgeRequest.ERR_VIRTUAL_USER; - } - - // =========== Bridge Bot Parting =========== - // For membership list syncing only - ircRooms.forEach((ircRoom) => { - let client = serverLookup[ircRoom.server.domain]; - if (!client) { - return; // no client left the room, so no need to recheck part room. - } - if (!ircRoom.server.isBotEnabled()) { - return; // don't do expensive queries needlessly - } - if (!ircRoom.server.shouldJoinChannelsIfNoUsers()) { - if (ircRoom.server.domain) { - // this = IrcBridge - this.ircBridge.memberListSyncers[ircRoom.server.domain].checkBotPartRoom( - ircRoom, req - ); - } - } - }); - stats.membership(false, "part"); - yield Promise.all(promises); -}); - -/** - * Called when the AS receives a new Matrix Event. - * @param {Request} req - * @param {Object} event : A Matrix event - * @return {Promise} which is resolved/rejected when the request finishes. - */ -MatrixHandler.prototype._onMessage = Promise.coroutine(function*(req, event) { - let self = this; - /* - * Valid message flows: - * Matrix --> IRC (Bridged communication) - * Matrix --> Matrix (Admin room) - */ - - req.log.info("%s usr=%s rm=%s body=%s", - event.type, event.user_id, event.room_id, - (event.content.body ? event.content.body.substring(0, 20) : "") - ); - - // check if this message is from one of our virtual users - const servers = this.ircBridge.getServers(); - for (let i = 0; i < servers.length; i++) { - if (servers[i].claimsUserId(event.user_id)) { - req.log.info("%s is a virtual user (claimed by %s)", - event.user_id, servers[i].domain); - return BridgeRequest.ERR_VIRTUAL_USER; - } - } - - // wait a while if we just got an invite else we may not have the mapping stored - // yet... - if (this._processingInvitesForRooms[event.room_id + event.user_id]) { - req.log.info( - "Holding request for %s until invite for room %s is done.", - event.user_id, event.room_id - ); - yield this._processingInvitesForRooms[event.room_id + event.user_id]; - req.log.info( - "Finished holding event for %s in room %s", event.user_id, event.room_id - ); - } - - if (this.ircBridge.getAppServiceUserId() === event.user_id) { - // ignore messages from the bot - return BridgeRequest.ERR_VIRTUAL_USER; - } - - // The media URL to use to transform mxc:// URLs when handling m.room.[file|image]s - let mediaUrl = this.ircBridge.config.homeserver.media_url; - - let mxAction = MatrixAction.fromEvent( - this.ircBridge.getAppServiceBridge().getClientFactory().getClientAs(), event, mediaUrl - ); - let ircAction = IrcAction.fromMatrixAction(mxAction); - let ircRooms = yield this.ircBridge.getStore().getIrcChannelsForRoomId(event.room_id); - - // Sometimes bridge's message each other and get stuck in a silly loop. Ensure it's m.text - if (ircRooms.length === 0 && event.content && event.content.msgtype === "m.text") { - // could be an Admin room, so check. - let adminRoom = yield this.ircBridge.getStore().getAdminRoomById(event.room_id); - if (!adminRoom) { - req.log.info("No mapped channels."); - return; - } - // process admin request - yield this._onAdminMessage(req, event, adminRoom); - return; - } - - let promises = []; - - // Check for other matrix rooms which are bridged to this channel. - // If there are other rooms, send this message directly to that room as the virtual matrix user. - // E.g: send this message to MROOM2 and MROOM3: - // - // MROOM1 MROOM2 MROOM3 - // | | | - // +->>MSG>>----------------------------+ - // | | - // #chan #chan2 - // - let otherMatrixRoomIdsToServers = Object.create(null); - let otherPromises = []; - - ircRooms.forEach((ircRoom) => { - if (ircRoom.server.claimsUserId(event.user_id)) { - req.log.info("%s is a virtual user (claimed by %s)", - event.user_id, ircRoom.server.domain); - return; - } - req.log.info("Relaying message in %s on %s", - ircRoom.channel, ircRoom.server.domain); - - if (ircRoom.getType() === "channel") { - otherPromises.push( - this.ircBridge.getStore().getMatrixRoomsForChannel( - ircRoom.server, ircRoom.channel - ).then((otherMatrixRooms) => { - otherMatrixRooms.forEach((mxRoom) => { - otherMatrixRoomIdsToServers[mxRoom.getId()] = ircRoom.server; - }); - }) - ); - } - - // If we already have a cached client then yay, but if we - // don't then we need to hit out for their display name in - // this room. - if (!this.ircBridge.getIrcUserFromCache(ircRoom.server, event.user_id)) { - promises.push(Promise.coroutine(function*() { - let displayName = undefined; - try { - let res = yield self.ircBridge.getAppServiceBridge().getBot() - .getClient().getStateEvent( - event.room_id, "m.room.member", event.user_id - ); - displayName = res.displayname; - } - catch (err) { - req.log.error("Failed to get display name: %s", err); - // this is non-fatal, continue. - } - let ircUser = yield self.ircBridge.getBridgedClient( - ircRoom.server, event.user_id, displayName - ); - - yield self._sendIrcAction(req, ircRoom, ircUser, ircAction, event); - })()); - } - else { - // push each request so we don't block processing other rooms - promises.push(Promise.coroutine(function*() { - let ircUser = yield self.ircBridge.getBridgedClient( - ircRoom.server, event.user_id - ); - yield self._sendIrcAction(req, ircRoom, ircUser, ircAction, event); - })()); - } - }); - - yield Promise.all(otherPromises); - Object.keys(otherMatrixRoomIdsToServers).forEach((roomId) => { - if (roomId === event.room_id) { - return; // don't bounce back to the sender - } - let otherServer = otherMatrixRoomIdsToServers[roomId]; - // convert the sender's user ID to a nick and back to a virtual user for this server - // then send from that user ID (yuck!). - let n = otherServer.getNick(event.user_id); - let virtUserId = otherServer.getUserIdFromNick(n); - promises.push( - this.ircBridge.sendMatrixAction( - new MatrixRoom(roomId), new MatrixUser(virtUserId), mxAction, req - ) - ); - }); - - yield Promise.all(promises); -}); - -MatrixHandler.prototype._sendIrcAction = Promise.coroutine( - function*(req, ircRoom, ircClient, ircAction, event) { - // Send the action as is if it is not a text message - if (event.content.msgtype !== "m.text") { - yield this.ircBridge.sendIrcAction(ircRoom, ircClient, ircAction); - return; - } - - let text = event.content.body; - let cacheBody = text; - if (event.content["m.relates_to"] && event.content["m.relates_to"]["m.in_reply_to"]) { - const reply = yield this._textForReplyEvent(event, ircRoom); - if (reply !== undefined) { - ircAction.text = text = reply.formatted; - cacheBody = reply.reply; - } - } - this._eventCache.set(event.event_id, { - body: cacheBody.substr(0, REPLY_SOURCE_MAX_LENGTH), - sender: event.sender - }); - - // Cache events in here so we can refer to them for replies. - if (this._eventCache.size > this._eventCacheMaxSize) { - const delKey = this._eventCache.entries().next().value[0]; - this._eventCache.delete(delKey); - } - - // Check for the existance of the getSplitMessages method. - if (!(ircClient.unsafeClient && ircClient.unsafeClient.getSplitMessages)) { - yield this.ircBridge.sendIrcAction(ircRoom, ircClient, ircAction); - return; - } - - // Generate an array of individual messages that would be sent - let potentialMessages = ircClient.unsafeClient.getSplitMessages(ircRoom.channel, text); - let lineLimit = ircRoom.server.getLineLimit(); - - if (potentialMessages.length <= lineLimit) { - yield this.ircBridge.sendIrcAction(ircRoom, ircClient, ircAction); - return; - } - - // Message body too long, upload to HS instead - - // Use the current ISO datetime as the name of the file - // strip off milliseconds and replace 'T' with an underscore - // result e.g : 2016-08-03T10:40:48.620Z becomes 2016-08-03_10:40:48 - let fileName = new Date().toISOString() - .split(/[T|\.]/) - .splice(0, 2) - .join('_') + '.txt'; - - // somenick_2016-08-03_10:40:48.txt - fileName = ircClient.nick + '_' + fileName; - - let result = {}; - - try { - // Try to upload as a file and get URI - // (this could fail, see the catch statement) - let response = yield this.ircBridge.uploadTextFile(fileName, text); - result = JSON.parse(response); - } - catch (err) { - // Uploading the file to HS could fail - req.log.error("Failed to upload text file ", err); - } - - // The media URL to use to transform mxc:// URLs when handling m.room.[file|image]s - let mediaUrl = this.ircBridge.config.homeserver.media_url; - - // This is true if the upload was a success - if (result.content_uri) { - // Alter event object so that it is treated as if a file has been uploaded - event.content.url = result.content_uri; - event.content.msgtype = "m.file"; - event.content.body = "sent a long message: "; - - // Create a file event to reflect the recent upload - let cli = this.ircBridge.getAppServiceBridge().getClientFactory().getClientAs(); - let mAction = MatrixAction.fromEvent(cli, event, mediaUrl); - let bigFileIrcAction = IrcAction.fromMatrixAction(mAction); - - // Replace "Posted a File with..." - bigFileIrcAction.text = mAction.text; - - // Notify the IRC side of the uploaded text file - yield this.ircBridge.sendIrcAction(ircRoom, ircClient, bigFileIrcAction); - } - else { - req.log.warn("Sending truncated message"); - // Modify the event to become a truncated version of the original - // the truncation limits the number of lines sent to lineLimit. - - let msg = '\n...(truncated)'; - - event.content = { - msgtype : "m.text", - body : potentialMessages.splice(0, lineLimit - 1).join('\n') + msg - }; - - // Recreate action from modified event - let truncatedIrcAction = IrcAction.fromMatrixAction( - MatrixAction.fromEvent( - this.ircBridge.getAppServiceBridge().getClientFactory().getClientAs(), - event, - mediaUrl - ) - ); - - yield this.ircBridge.sendIrcAction(ircRoom, ircClient, truncatedIrcAction); - } -}); - -/** - * Called when the AS receives an alias query from the HS. - * @param {string} roomAlias : The room alias queried. - * @return {Promise} which is resolved/rejected when the request finishes. - */ -MatrixHandler.prototype._onAliasQuery = Promise.coroutine(function*(req, roomAlias) { - req.log.info("onAliasQuery %s", roomAlias); - - // check if alias maps to a valid IRC server and channel - let channelInfo = this.ircBridge.aliasToIrcChannel(roomAlias); - if (!channelInfo.channel) { - throw new Error("Unknown alias: " + roomAlias); // bad alias - } - if (!channelInfo.server.createsPublicAliases()) { - throw new Error("This server does not allow alias mappings."); - } - req.log.info("Mapped to %s on %s", - channelInfo.channel, channelInfo.server.domain - ); - - // See if we are already tracking this channel (case-insensitive - // channels but case-sensitive aliases) - let matrixRooms = yield this.ircBridge.getStore().getMatrixRoomsForChannel( - channelInfo.server, channelInfo.channel - ); - - if (matrixRooms.length === 0) { - // ====== Track the IRC channel - // lower case the name to join (there's a bug in the IRC lib - // where the join callback never fires if you try to join - // #WithCaps in channels :/) - channelInfo.channel = toIrcLowerCase(channelInfo.channel); - req.log.info("Going to track IRC channel %s", channelInfo.channel); - // join the irc server + channel - yield this.ircBridge.trackChannel(channelInfo.server, channelInfo.channel); - req.log.info("Bot is now tracking IRC channel."); - - // ======== Create the Matrix room - let newRoomId = null; - let botIntent = this.ircBridge.getAppServiceBridge().getIntent(); - try { // make the matrix room - const initial_state = [ - { - type: "m.room.join_rules", - state_key: "", - content: { - join_rule: channelInfo.server.getJoinRule() - } - }, - { - type: "m.room.history_visibility", - state_key: "", - content: { - history_visibility: "joined" - } - } - ]; - if (channelInfo.server.areGroupsEnabled()) { - initial_state.push({ - type: "m.room.related_groups", - state_key: "", - content: { - groups: [channelInfo.server.getGroupId()] - } - }); - } - const options = { - room_alias_name: roomAlias.split(":")[0].substring(1), // localpart - name: channelInfo.channel, - visibility: "private", - preset: "public_chat", - creation_content: { - "m.federate": channelInfo.server.shouldFederate() - }, - initial_state - }; - if (channelInfo.server.forceRoomVersion()) { - options.room_version = channelInfo.server.forceRoomVersion(); - } - const res = yield botIntent.createRoom({ - options, - }); - newRoomId = res.room_id; - } - catch (e) { - if (e && e.errorcode === "M_UNKNOWN") { - // alias already taken, must be us. Join the room alias. - let room = yield botIntent.join(alias); - newRoomId = room.getId(); - } - else { - req.log.error("Failed to create room: %s", e.stack); - throw e; - } - } - - let matrixRoom = new MatrixRoom(newRoomId); - req.log.info("Matrix room %s created.", matrixRoom.getId()); - - // TODO set topic, add matrix members f.e. irc user(?) given - // they are cheap to do. - - // ========= store the mapping and return OK - let ircRoom = new IrcRoom(channelInfo.server, channelInfo.channel); - yield this.ircBridge.getStore().storeRoom(ircRoom, matrixRoom, 'alias'); - - // /mode the channel AFTER we have created the mapping so we process +s and +i correctly. - this.ircBridge.publicitySyncer.initModeForChannel( - channelInfo.server, channelInfo.channel - ).catch((err) => { - log.error( - `Could not init mode for channel ${channelInfo.channel} on ` + - `${channelInfo.server.domain}` - ); - }); - } - else { - // create an alias pointing to this room (take first) - // TODO: Take first with public join_rules - let roomId = matrixRooms[0].getId(); - req.log.info("Pointing alias %s to %s", roomAlias, roomId); - yield this.ircBridge.getAppServiceBridge().getBot().getClient().createAlias( - roomAlias, roomId - ); - } -}); - -/** - * Called when the AS receives a user query from the HS. - * @param {string} userId : The user ID queried. - * @return {Promise} which is resolved/rejected when the request finishes. - */ -MatrixHandler.prototype._onUserQuery = Promise.coroutine(function*(req, userId) { - if (this.ircBridge.getAppServiceUserId() === userId) { - return; - } - req.log.info("onUserQuery: %s", userId); - let matrixUser = new MatrixUser(userId); - let ircUser = yield this.ircBridge.matrixToIrcUser(matrixUser); - yield this.ircBridge.getMatrixUser(ircUser); -}); - -MatrixHandler.prototype._textForReplyEvent = Promise.coroutine(function*(event, ircRoom) { - const REPLY_REGEX = /> <(@.*:.*)>(.*)\n\n(.*)/; - const REPLY_NAME_MAX_LENGTH = 12; - const eventId = event.content["m.relates_to"]["m.in_reply_to"].event_id; - const match = REPLY_REGEX.exec(event.content.body); - if (match.length !== 4) { - return; - } - - let rplName; - let rplSource; - const rplText = match[3]; - if (!this._eventCache.has(eventId)) { - // Fallback to fetching from the homeserver. - try { - const eventContent = yield this.ircBridge.getAppServiceBridge().getIntent().getEvent( - event.room_id, eventId - ); - rplName = eventContent.sender; - if (typeof(eventContent.content.body) !== "string") { - throw Error("'body' was not a string."); - } - const isReply = eventContent.content["m.relates_to"] && - eventContent.content["m.relates_to"]["m.in_reply_to"]; - if (isReply) { - const sourceMatch = REPLY_REGEX.exec(eventContent.content.body); - rplSource = sourceMatch.length === 4 ? sourceMatch[3] : event.content.body; - } - else { - rplSource = eventContent.content.body; - } - rplSource = rplSource.substr(0, REPLY_SOURCE_MAX_LENGTH); - this._eventCache.set(eventId, {sender: rplName, body: rplSource}); - } - catch (err) { - // If we couldn't find the event, then frankly we can't - // trust it and we won't treat it as a reply. - return { - formatted: rplText, - reply: rplText, - }; - } - } - else { - rplName = this._eventCache.get(eventId).sender; - rplSource = this._eventCache.get(eventId).body; - } - - // Get the first non-blank line from the source. - const lines = rplSource.split('\n').filter((line) => !/^\s*$/.test(line)) - if (lines.length > 0) { - // Cut to a maximum length. - rplSource = lines[0].substr(0, REPLY_SOURCE_MAX_LENGTH); - // Ellipsis if needed. - if (lines[0].length > REPLY_SOURCE_MAX_LENGTH) { - rplSource = rplSource + "..."; - } - // Wrap in formatting - rplSource = ` "${rplSource}"`; - } - else { - // Don't show a source because we couldn't format one. - rplSource = ""; - } - - // Fetch the sender's IRC nick. - const sourceClient = this.ircBridge.getIrcUserFromCache(ircRoom.server, rplName); - if (sourceClient) { - rplName = sourceClient.nick; - } - else { - // If we couldn't find a client for them, they might be a ghost. - const ghostName = ircRoom.getServer().getNickFromUserId(rplName); - // If we failed to get a name, just make a guess of it. - rplName = ghostName !== null ? ghostName : rplName.substr(1, - Math.min(REPLY_NAME_MAX_LENGTH, rplName.indexOf(":") - 1) - ); - } - - return { - formatted: `<${rplName}${rplSource}> ${rplText}`, - reply: rplText, - }; -}); - -MatrixHandler.prototype._incrementMetric = function(serverDomain, metricName) { - let metricSet = this.metrics[serverDomain]; - if (!metricSet) { - metricSet = this.metrics[serverDomain] = {}; - } - if (metricSet[metricName] === undefined) { - metricSet[metricName] = 1; - } - else { - metricSet[metricName]++; - } - this.metrics[serverDomain] = metricSet; -} - -// EXPORTS - -MatrixHandler.prototype.onMemberEvent = function(req, event, inviter, invitee) { - return reqHandler(req, this._onMemberEvent(req, event, inviter, invitee)); -}; - -MatrixHandler.prototype.onInvite = function(req, event, inviter, invitee) { - return reqHandler(req, this._onInvite(req, event, inviter, invitee)); -}; - -MatrixHandler.prototype.onJoin = function(req, event, user) { - return reqHandler(req, this._onJoin(req, event, user)); -}; - -MatrixHandler.prototype.onLeave = function(req, event, user, sender) { - return reqHandler(req, this._onLeave(req, event, user, sender)); -}; - -MatrixHandler.prototype.onKick = function(req, event, kicker, kickee) { - return reqHandler(req, this._onKick(req, event, kicker, kickee)); -}; - -MatrixHandler.prototype.onMessage = function(req, event) { - return reqHandler(req, this._onMessage(req, event)); -}; - -MatrixHandler.prototype.onAliasQuery = function(req, alias) { - return reqHandler(req, this._onAliasQuery(req, alias)); -}; - -MatrixHandler.prototype.onUserQuery = function(req, userId) { - return reqHandler(req, this._onUserQuery(req, userId)) -}; - -MatrixHandler.prototype.getMetrics = function(serverDomain) { - const metrics = this.metrics[serverDomain] || {}; - this.metrics[serverDomain] = {} - return metrics || {}; -} - -function reqHandler(req, promise) { - return promise.then(function(res) { - req.resolve(res); - return res; - }, function(err) { - req.reject(err); - throw err; - }); -} - -module.exports = MatrixHandler; diff --git a/lib/bridge/MemberListSyncer.js b/lib/bridge/MemberListSyncer.js deleted file mode 100644 index c0910ce8b..000000000 --- a/lib/bridge/MemberListSyncer.js +++ /dev/null @@ -1,473 +0,0 @@ -/*eslint no-invalid-this: 0*/ // eslint doesn't understand Promise.coroutine wrapping - -// Controls the logic for determining which membership lists should be synced and -// handles the sequence of events until the lists are in sync. -"use strict"; - -const Promise = require("bluebird"); -const promiseutil = require("../promiseutil"); -const log = require("../logging").get("MemberListSyncer"); -const stats = require("../config/stats"); -const QueuePool = require("../util/QueuePool"); -const Queue = require("../util/Queue"); - -function MemberListSyncer(ircBridge, appServiceBot, server, appServiceUserId, injectJoinFn) { - this.ircBridge = ircBridge; - this.appServiceBot = appServiceBot; - this.server = server; - this.appServiceUserId = appServiceUserId; - this.injectJoinFn = injectJoinFn; - this._syncableRoomsPromise = null; - this._memberLists = { - matrix: { - //$roomId : { - // id: roomId, - // state: stateEvents, - // realJoinedUsers: [], - // remoteJoinedUsers: [] - // } - }, - irc: { - //$channel : nick[] - } - }; - // This is used for metrics - this._usersToJoin = 0; - this._usersToLeave = 0; - - // A queue which controls the rate at which leaves are sent to Matrix. We need this queue - // because Synapse is slow. Synapse locks based on the room ID, so there is no benefit to - // having 2 in-flight requests for the same room ID. As a result, we want to queue based - // on the room ID, and let N "room queues" be processed concurrently. This can be - // represented as a QueuePool of size N, which enqueues all the requests for a single - // room in one go, which we can do because IRC sends all the nicks down as NAMES. For each - // block of users in a room queue, we need another Queue to ensure that there is only ever - // 1 in-flight leave request at a time per room queue. - this._leaveQueuePool = new QueuePool(3, this._leaveUsersInRoom.bind(this)); -} - -MemberListSyncer.prototype.isRemoteJoinedToRoom = function (roomId, userId) { - const room = this._memberLists.matrix[roomId]; - if (room !== undefined) { - return room.remoteJoinedUsers.includes(userId); - } - return false; -}; - -MemberListSyncer.prototype.sync = Promise.coroutine(function*() { - let server = this.server; - if (!server.isMembershipListsEnabled()) { - log.info("%s does not have membership list syncing enabled.", server.domain); - return; - } - if (!server.shouldSyncMembershipToIrc("initial")) { - log.info("%s shouldn't sync initial memberships to irc.", server.domain); - return; - } - log.info("Checking membership lists for syncing on %s", server.domain); - let start = Date.now(); - let rooms = yield this.getSyncableRooms(); - log.info("Found %s syncable rooms (%sms)", rooms.length, Date.now() - start); - this.leaveIrcUsersFromRooms(rooms, server); - start = Date.now(); - log.info("Joining Matrix users to IRC channels..."); - yield this._joinMatrixUsersToChannels(rooms, server, this.injectJoinFn); - log.info("Joined Matrix users to IRC channels. (%sms)", Date.now() - start); - // NB: We do not need to explicitly join IRC users to Matrix rooms - // because we get all of the NAMEs/JOINs as events when we connect to - // the IRC server. This effectively "injects" the list for us. -}); - -MemberListSyncer.prototype.getChannelsToJoin = Promise.coroutine(function*() { - let server = this.server; - log.debug("getChannelsToJoin => %s", server.domain); - let rooms = yield this.getSyncableRooms(); - - // map room IDs to channels on this server. - let channels = new Set(); - let roomInfoMap = {}; - let roomIds = rooms.map((roomInfo) => { - roomInfoMap[roomInfo.id] = roomInfo; - return roomInfo.id; - }); - yield this.ircBridge.getStore().getIrcChannelsForRoomIds(roomIds).then((roomIdToIrcRoom) => { - Object.keys(roomIdToIrcRoom).forEach((roomId) => { - // only interested in rooms for this server - let ircRooms = roomIdToIrcRoom[roomId].filter((ircRoom) => { - return ircRoom.server.domain === server.domain; - }); - ircRooms.forEach((ircRoom) => { - channels.add(ircRoom.channel); - log.debug( - "%s should be joined because %s real Matrix users are in room %s", - ircRoom.channel, roomInfoMap[roomId].realJoinedUsers.length, roomId - ); - if (roomInfoMap[roomId].realJoinedUsers.length < 5) { - log.debug("These are: %s", JSON.stringify(roomInfoMap[roomId].realJoinedUsers)); - } - }); - }) - }); - - let channelsArray = Array.from(channels); - log.debug( - "getChannelsToJoin => %s should be synced: %s", - channelsArray.length, JSON.stringify(channelsArray) - ); - return channelsArray; -}); - -// map irc channel to a list of room IDs. If all of those -// room IDs have no real users in them, then part the bridge bot too. -MemberListSyncer.prototype.checkBotPartRoom = Promise.coroutine(function*(ircRoom, req) { - if (ircRoom.channel.indexOf("#") !== 0) { - return; // don't leave PM rooms - } - let matrixRooms = yield this.ircBridge.getStore().getMatrixRoomsForChannel( - ircRoom.server, ircRoom.channel - ); - - if (matrixRooms.length === 0) { - // no mapped rooms, leave the channel. - yield this.ircBridge.partBot(ircRoom); - return; - } - - // At least 1 mapped room - query for the membership list in each room. If there are - // any real users still left in the room, then do not part the bot from the channel. - // Query via /$room_id/state rather than /initialSync as the latter can cause - // the bridge to spin for minutes if the response is large. - - let shouldPart = true; - for (let i = 0; i < matrixRooms.length; i++) { - let roomId = matrixRooms[i].getId(); - req.log.debug("checkBotPartRoom: Querying room state in room %s", roomId); - let res = yield this.appServiceBot.getClient().roomState(roomId); - let data = getRoomMemberData(ircRoom.server, roomId, res, this.appServiceUserId); - req.log.debug( - "checkBotPartRoom: %s Matrix users are in room %s", data.reals.length, roomId - ); - if (data.reals.length > 0) { - shouldPart = false; - break; - } - } - - if (shouldPart) { - yield this.ircBridge.partBot(ircRoom); - } -}); - -// grab all rooms the bot knows about which have at least 1 real user in them. -// On startup, this can be called multiple times, so we cache the first request's promise -// and return that instead of making double hits. -// -// returns [ -// { -// id: roomId, -// realJoinedUsers: [], -// remoteJoinedUsers: [] -// }, -// ... -// ] -MemberListSyncer.prototype.getSyncableRooms = function(resetCache = false) { - if (resetCache) { - this._syncableRoomsPromise = null; - } - if (this._syncableRoomsPromise) { - log.debug("Returning existing getSyncableRooms Promise"); - return this._syncableRoomsPromise; - } - - let self = this; - let fetchRooms = Promise.coroutine(function*() { - let roomInfoList = []; - - let roomIdToChannel = yield self.ircBridge.getStore().getAllChannelMappings(); - let joinedRoomIds = Object.keys(roomIdToChannel); - - // fetch joined members allowing 50 in-flight reqs at a time - let pool = new QueuePool(50, Promise.coroutine(function*(roomId) { - let userMap = null; - while (!userMap) { - try { - userMap = yield self.appServiceBot.getJoinedMembers(roomId); - } - catch (err) { - log.error(`Failed to getJoinedMembers in room ${roomId}: ${err}`); - yield Promise.delay(3000); // wait a bit before retrying - } - } - let roomInfo = { - id: roomId, - displayNames: {}, // user ID => Display Name - realJoinedUsers: [], // user IDs - remoteJoinedUsers: [], // user IDs - }; - let userIds = Object.keys(userMap); - for (let j = 0; j < userIds.length; j++) { - let userId = userIds[j]; - if (self.appServiceBot.getUserId() === userId) { - continue; - } - // TODO: Make this function public, it's useful! - if (self.appServiceBot._isRemoteUser(userId)) { - roomInfo.remoteJoinedUsers.push(userId); - } - else { - roomInfo.realJoinedUsers.push(userId); - } - - if (userMap[userId].display_name) { - roomInfo.displayNames[userId] = userMap[userId].display_name; - } - } - roomInfoList.push(roomInfo); - log.info( - "%s has %s real Matrix users and %s remote users (%s/%s)", - roomId, roomInfo.realJoinedUsers.length, roomInfo.remoteJoinedUsers.length, - roomInfoList.length, joinedRoomIds.length - ); - })); - // wait for all the requests to go through - yield Promise.all(joinedRoomIds.map((roomId) => { - return pool.enqueue(roomId, roomId); - })); - - return roomInfoList.filter(function(roomInfo) { - // filter out rooms with no real matrix users in them. - return roomInfo.realJoinedUsers.length > 0; - }); - }); - - this._syncableRoomsPromise = fetchRooms(); - return this._syncableRoomsPromise; -}; - -MemberListSyncer.prototype._joinMatrixUsersToChannels = function(rooms, server, injectJoinFn) { - var d = promiseutil.defer(); - - // filter out rooms listed in the rules - var filteredRooms = []; - rooms.forEach(function(roomInfo) { - if (!server.shouldSyncMembershipToIrc("initial", roomInfo.id)) { - log.debug( - "Trimming room %s according to config rules (matrixToIrc=false)", - roomInfo.id - ); - if (!roomInfo.realJoinedUsers[0]) { - return; // no joined users at all - } - // trim the list to a single user. We do this rather than filter the - // room out entirely because otherwise there will be NO matrix users - // on the IRC-side resulting in no traffic whatsoever. - roomInfo.realJoinedUsers = [roomInfo.realJoinedUsers[0]]; - log.debug("Trimmed to " + roomInfo.realJoinedUsers); - } - filteredRooms.push(roomInfo); - }); - - log.debug("%s rooms passed the config rules", filteredRooms.length); - - // map the filtered rooms to a list of users to join - // [Room:{reals:[uid,uid]}, ...] => [{uid,roomid}, ...] - var entries = []; - filteredRooms.forEach(function(roomInfo) { - roomInfo.realJoinedUsers.forEach(function(uid, index) { - entries.push({ - roomId: roomInfo.id, - displayName: roomInfo.displayNames[uid], - userId: uid, - // Mark the first real matrix user f.e room so we can inject - // them first to get back up and running more quickly when there - // is no bot. - frontier: (index === 0) - }); - }); - }); - // sort frontier markers to the front of the array - entries.sort(function(a, b) { - if (a.frontier && !b.frontier) { - return -1; // a comes first - } - else if (b.frontier && !a.frontier) { - return 1; // b comes first - } - return 0; // don't care - }); - - log.debug("Got %s matrix join events to inject.", entries.length); - this._usersToJoin = entries.length; - // take the first entry and inject a join event - const joinNextUser = () => { - var entry = entries.shift(); - if (!entry) { - d.resolve(); - return; - } - if (entry.userId.indexOf("@-") === 0) { - joinNextUser(); - return; - } - log.debug( - "Injecting join event for %s in %s (%s left) is_frontier=%s", - entry.userId, entry.roomId, entries.length, entry.frontier - ); - this._usersToJoin--; - injectJoinFn(entry.roomId, entry.userId, entry.displayName, entry.frontier).timeout( - server.getMemberListFloodDelayMs() - ).then(() => { - joinNextUser(); - }, (err) => { // discard error, this will be due to timeouts which we don't want to log - joinNextUser(); - }); - } - - joinNextUser(); - - return d.promise; -} - -MemberListSyncer.prototype.leaveIrcUsersFromRooms = function(rooms, server) { - log.info( - `leaveIrcUsersFromRooms: storing member list info for ${rooms.length} ` + - `rooms for server ${server.domain}` - ); - - // Store the matrix room info in memory for later retrieval when NAMES is received - // and updateIrcMemberList is called. At that point, we have enough information to - // leave users from the channel that the NAMES is for. - rooms.forEach((roomInfo) => { - this._memberLists.matrix[roomInfo.id] = roomInfo; - }); -} - -// Critical section of the leave queue pool. -// item looks like: -// { -// roomId: "!foo:bar", userIds: [ "@alice:bar", "@bob:bar", ... ] -// } -MemberListSyncer.prototype._leaveUsersInRoom = Promise.coroutine(function*(item) { - // We need to queue these up in ANOTHER queue so as not to have - // 2 in-flight requests at the same time. We return a promise which resolves - // when this room is completely done. - const q = new Queue(async (userId) => { - // Do this here, we might not manage to leave but we won't retry. - this._usersToLeave--; - await this.ircBridge.getAppServiceBridge().getIntent(userId).leave(item.roomId); - stats.membership(true, "part"); - }); - - yield Promise.all(item.userIds.map((userId) => - q.enqueue(userId, userId) - )); - - // Make sure to deop any users - yield this.ircBridge.ircHandler.roomAccessSyncer.removePowerLevels(item.roomId, item.userIds); -}); - -// Update the MemberListSyncer with the IRC NAMES_RPL that has been received for channel. -// This will leave any matrix users that do not have their associated IRC nick in the list -// of names for this channel. -MemberListSyncer.prototype.updateIrcMemberList = Promise.coroutine(function*(channel, names) { - if (this._memberLists.irc[channel] !== undefined || - !this.server.shouldSyncMembershipToMatrix("initial", channel)) { - return; - } - this._memberLists.irc[channel] = Object.keys(names); - - log.info( - `updateIrcMemberList: Updating IRC member list for ${channel} with ` + - `${this._memberLists.irc[channel].length} IRC nicks` - ); - - // Convert the IRC channels nicks to userIds - let ircUserIds = this._memberLists.irc[channel].map( - (nick) => this.server.getUserIdFromNick(nick) - ); - - // For all bridged rooms, leave users from matrix that are not in the channel - let roomsForChannel = yield this.ircBridge.getStore().getMatrixRoomsForChannel( - this.server, channel - ); - - if (roomsForChannel.length === 0) { - log.info(`updateIrcMemberList: No bridged rooms for channel ${channel}`); - return; - } - - // If a userId is in remoteJoinedUsers, but not ircUserIds, intend on leaving roomId - let promises = []; - roomsForChannel.forEach((matrixRoom) => { - let roomId = matrixRoom.getId(); - if (!( - this._memberLists.matrix[roomId] && - this._memberLists.matrix[roomId].remoteJoinedUsers - )) { - return; - } - - let usersToLeave = this._memberLists.matrix[roomId].remoteJoinedUsers.filter( - (userId) => { - return !ircUserIds.includes(userId); - } - ); - - this._usersToLeave += usersToLeave.length; - // ID is the complete mapping of roomID/channel which will be unique - promises.push(this._leaveQueuePool.enqueue(roomId + " " + channel, { - roomId: roomId, - userIds: usersToLeave, - })); - }); - log.info( - `updateIrcMemberList: Leaving ${promises.length} users as they are not in ${channel}.` - ); - yield Promise.all(promises); -}); - -MemberListSyncer.prototype.getUsersWaitingToJoin = function() { - return this._usersToJoin; -} - -MemberListSyncer.prototype.getUsersWaitingToLeave = function() { - return this._usersToLeave; -} - -MemberListSyncer.prototype.addToLeavePool = function(userIds, roomId, channel) { - this._usersToLeave += userIds.length; - this._leaveQueuePool.enqueue(roomId + " " + channel, { - roomId, - userIds - }); -} - -function getRoomMemberData(server, roomId, stateEvents, appServiceUserId) { - stateEvents = stateEvents || []; - var data = { - roomId: roomId, - virtuals: [], - reals: [] - }; - stateEvents.forEach(function(event) { - if (event.type !== "m.room.member" || event.content.membership !== "join") { - return; - } - var userId = event.state_key; - if (userId === appServiceUserId) { - return; - } - if (server.claimsUserId(userId)) { - data.virtuals.push(userId); - } - else if (userId.indexOf("@-") === 0) { - // Ignore guest user IDs -- TODO: Do this properly by passing them through - } - else { - data.reals.push(userId); - } - }); - return data; -} - -module.exports = MemberListSyncer; diff --git a/lib/bridge/PublicitySyncer.js b/lib/bridge/PublicitySyncer.js deleted file mode 100644 index b12ee0d75..000000000 --- a/lib/bridge/PublicitySyncer.js +++ /dev/null @@ -1,227 +0,0 @@ -/*eslint no-invalid-this: 0*/ // eslint doesn't understand Promise.coroutine wrapping -"use strict"; -const Promise = require("bluebird"); -const log = require("../logging").get("PublicitySyncer"); - -// This class keeps the +s state of every channel bridged synced with the RoomVisibility -// of any rooms that are connected to the channels, regardless of the number of hops -// required to traverse the mapping graph (rooms to channels). -// -// NB: This is only in the direction I->M -// -// +s = 'private' -// -s = 'public' -// Modes received, but +s missing = 'public' -function PublicitySyncer(ircBridge) { - this.ircBridge = ircBridge; - - // Cache the mode of each channel, the visibility of each room and the - // known mappings between them. When any of these change, any inconsistencies - // should be resolved by keeping the matrix side as private as necessary - this._visibilityMap = { - mappings: { - //room_id: ['funNetwork #channel1', 'funNetwork channel2',...] - }, - networkToRooms: { - //'funNetwork #channel1': [room_id, room_id, ...] - }, - channelIsSecret: { - // '$networkId $channel': true | false - }, - roomVisibilities: { - // room_id: "private" | "public" - } - } -} - -PublicitySyncer.prototype.initModeForChannel = function(server, chan) { - return this.ircBridge.getBotClient(server).then( - (client) => { - if (!client.unsafeClient) { - log.error(`Can't request modes, bot client not connected`); - } - log.info(`Bot requesting mode for ${chan} on ${server.domain}`); - client.unsafeClient.mode(chan); - }, - (err) => { - log.error(`Could not request mode of ${chan} (${err.message})`); - } - ); -} - -PublicitySyncer.prototype.initModes = Promise.coroutine(function*(server) { - //Get all channels and call modes for each one - - let channels = yield this.ircBridge.getStore().getTrackedChannelsForServer(server.domain); - - channels = new Set(channels); - - channels.forEach((chan) => { - // Request mode for channel - this.initModeForChannel(server, chan).catch((err) => { - log.error(err.stack); - }); - }); -}); - -/** - * Returns the key used when calling `updateVisibilityMap` for updating an IRC channel - * visibility mode (+s or -s). - * ``` - * // Set channel on server to be +s - * const key = publicitySyncer.getIRCVisMapKey(server.getNetworkId(), channel); - * publicitySyncer.updateVisibilityMap(true, key, true); - * ``` - * @param {string} networkId - * @param {string} channel - * @returns {string} - */ -PublicitySyncer.prototype.getIRCVisMapKey = function(networkId, channel) { - return networkId + ' ' + channel; -} - -// This is used so that any updates to the visibility map will cause the syncer to -// reset a timer and begin counting down again to the eventual call to solve any -// inconsistencies in the visibility map. -var solveVisibilityTimeoutId = null; - -PublicitySyncer.prototype.updateVisibilityMap = function(isMode, key, value) { - let hasChanged = false; - if (isMode) { - if (typeof value !== 'boolean') { - throw new Error('+s state must be indicated with a boolean'); - } - if (this._visibilityMap.channelIsSecret[key] !== value) { - this._visibilityMap.channelIsSecret[key] = value; - hasChanged = true; - } - } - else { - if (typeof value !== 'string' || (value !== "private" && value !== "public")) { - throw new Error('Room visibility must = "private" | "public"'); - } - - if (this._visibilityMap.roomVisibilities[key] !== value) { - this._visibilityMap.roomVisibilities[key] = value; - hasChanged = true; - } - } - - if (hasChanged) { - clearTimeout(solveVisibilityTimeoutId); - - solveVisibilityTimeoutId = setTimeout(() => { - this._solveVisibility().catch((err) => { - log.error("Failed to sync publicity: " + err.message); - }); - }, 10000); - return Promise.resolve(); - } - - return Promise.resolve(); -} - -/* Solve any inconsistencies between the currently known state of channels '+s' modes - and rooms 'visibility' states. This does full graph traversal to prevent any +s - channels ever escaping into a 'public' room. This function errs on the side of - caution by assuming an unknown channel state is '+s'. This just means that if the - modes of a channel are not received yet (e.g when no virtual user is in said channel) - then the room is assumed secret (+s). - - The bare minimum is done to make sure no private channels are leaked into public - matrix channels. If ANY +s channel is somehow being bridged into a room, that room - is updated to private. If ALL channels somehow being bridged into a room are NOT +s, - that room is allowed to be public. -*/ -PublicitySyncer.prototype._solveVisibility = Promise.coroutine(function*() { - // For each room, do a big OR on all of the channels that are linked in any way - - let mappings = yield this.ircBridge.getStore().getAllChannelMappings(); - - let roomIds = Object.keys(mappings); - - this._visibilityMap.mappings = {}; - - roomIds.forEach((roomId) => { - this._visibilityMap.mappings[roomId] = mappings[roomId].map((mapping) => { - const key = this.getIRCVisMapKey(mapping.networkId, mapping.channel); - // also assign reverse mapping for lookup speed later - if (!this._visibilityMap.networkToRooms[key]) { - this._visibilityMap.networkToRooms[key] = []; - } - this._visibilityMap.networkToRooms[key].push(roomId); - return key; - }); - }); - - let shouldBePrivate = (roomId, checkedChannels) => { - // If any channel connected to this room is +s, stop early and call it private - - // List first connected - let channels = this._visibilityMap.mappings[roomId]; - // = ['localhost #channel1', 'localhost #channel2', ... ] - - // No channels mapped to this roomId - if (!channels) { - return false; - } - - // Filter out already checked channels - channels = channels.filter((c) => checkedChannels.indexOf(c) === -1); - - let anyAreSecret = channels.some((channel) => { - let channelIsSecret = this._visibilityMap.channelIsSecret[channel]; - - // If a channel mode is unknown, assume it is secret - if (typeof channelIsSecret === 'undefined') { - log.info('Assuming channel ' + channel + ' is secret'); - channelIsSecret = true; - } - - return channelIsSecret; - }); - if (anyAreSecret) { - return true; - } - - // Otherwise, recurse with the rooms connected to each channel - - // So get all the roomIds that this channel is mapped to and return whether any - // are mapped to channels that are secret - return channels.map((channel) => { - return this._visibilityMap.networkToRooms[channel] || []; - }).some((roomIds2) => { - return roomIds2.some((roomId2) => { - return shouldBePrivate(roomId2, checkedChannels.concat(channels)); - }); - }); - } - - let cli = this.ircBridge._bridge.getBot().client; - - // Update rooms to correct visibilities - let promises = roomIds.map((roomId, index) => { - let currentState = this._visibilityMap.roomVisibilities[roomId]; - let correctState = shouldBePrivate(roomId, []) ? 'private' : 'public'; - - // Use the server network ID of the first mapping - // 'funNetwork #channel1' => 'funNetwork' - const networkId = this._visibilityMap.mappings[roomId][0].split(' ')[0]; - - if (currentState !== correctState) { - return cli.setRoomDirectoryVisibilityAppService(networkId, roomId, correctState).then( - () => { - // Update cache - this._visibilityMap.roomVisibilities[roomId] = correctState; - } - ).catch((e) => { - log.error(`Failed to setRoomDirectoryVisibility (${e.message})`); - }); - } - return undefined; - }); - - return Promise.all(promises); -}); - -module.exports = PublicitySyncer; diff --git a/lib/bridge/QuitDebouncer.js b/lib/bridge/QuitDebouncer.js deleted file mode 100644 index bb181edcd..000000000 --- a/lib/bridge/QuitDebouncer.js +++ /dev/null @@ -1,142 +0,0 @@ -/*eslint no-invalid-this: 0*/ -"use strict"; -const Promise = require("bluebird"); - -const QUIT_WAIT_DELAY_MS = 100; -const QUIT_WINDOW_MS = 1000; -const QUIT_PRESENCE = "offline"; - -function QuitDebouncer(ircBridge) { - this.ircBridge = ircBridge; - - // Measure the probability of a net-split having just happened using QUIT frequency. - // This is to smooth incoming PART spam from IRC clients that suffer from a - // net-split (or other issues that lead to mass PART-ings) - this._debouncerForServer = { - // $server.domain: { - // rejoinPromises: { - // $nick: { - // // Promise that resolves if the user joins a channel having quit - // promise: Promise, - // // Resolving function of the promise to call when a user joins - // resolve: Function - // } - // }, - // // Timestamps recorded per-server when debounceQuit is called. Old timestamps - // // are removed when a new timestamp is added. - // quitTimestampsMs:{ - // $server : [1477386173850, 1477386173825, ...] - // } - // } - }; - - // Keep a track of the times at which debounceQuit was called, and use this to - // determine the rate at which quits are being received. This can then be used - // to detect net splits. - Object.keys(this.ircBridge.config.ircService.servers).forEach((domain) => { - this._debouncerForServer[domain] = { - rejoinPromises: {}, - quitTimestampsMs: [] - }; - }); -} - -/** - * Called when the IrcHandler receives a JOIN. This resolves any promises to join that were made - * when a quit was debounced during a split. - * @param {string} nick The nick of the IRC user joining. - * @param {IrcServer} server The sending IRC server. - */ -QuitDebouncer.prototype.onJoin = function (nick, server) { - if (!this._debouncerForServer[server.domain]) { - return; - } - let rejoin = this._debouncerForServer[server.domain].rejoinPromises[nick]; - if (rejoin) { - rejoin.resolve(); - } -} - -/** - * Debounce a QUIT received by the IrcHandler to prevent net-splits from spamming leave events - * into a room when incremental membership syncing is enabled. - * @param {Request} req The metadata request. - * @param {IrcServer} server The sending IRC server. - * @param {string} matrixUser The virtual user of the user that sent QUIT. - * @param {string} nick The nick of the IRC user quiting. - * @return {Promise} which resolves to true if a leave should be sent, false otherwise. - */ -QuitDebouncer.prototype.debounceQuit = Promise.coroutine(function*(req, server, matrixUser, nick) { - // Maintain the last windowMs worth of timestamps corresponding with calls to this function. - const debouncer = this._debouncerForServer[server.domain]; - - const now = Date.now(); - debouncer.quitTimestampsMs.push(now); - - const threshold = server.getDebounceQuitsPerSecond();// Rate of quits to call net-split - - // Filter out timestamps from more than QUIT_WINDOW_MS ago - debouncer.quitTimestampsMs = debouncer.quitTimestampsMs.filter( - (t) => t > (now - QUIT_WINDOW_MS) - ); - - // Wait for a short time to allow other potential splitters to send QUITs - yield Promise.delay(QUIT_WAIT_DELAY_MS); - const isSplitOccuring = debouncer.quitTimestampsMs.length > threshold; - - // TODO: This should be replaced with "disconnected" as per matrix-appservice-irc#222 - try { - yield this.ircBridge.getAppServiceBridge().getIntent( - matrixUser.getId() - ).setPresence(QUIT_PRESENCE); - } - catch (err) { - req.log.error( - `QuitDebouncer Failed to set presence to ${QUIT_PRESENCE} for user %s: %s`, - matrixUser.getId(), - err.message - ); - } - - // Bridge QUITs if a net split is not occurring. This is in the case where a QUIT is - // received for reasons such as ping timeout or IRC client (G)UI being killed. - // We don't want to debounce users that are quiting legitimately so return early, and - // we do want to make their virtual matrix user leave the room, so return true. - if (!isSplitOccuring) { - return true; - } - - const debounceDelayMinMs = server.getQuitDebounceDelayMinMs(); - const debounceDelayMaxMs = server.getQuitDebounceDelayMaxMs(); - - const debounceMs = debounceDelayMinMs + Math.random() * ( - debounceDelayMaxMs - debounceDelayMinMs - ); - - // We do want to immediately bridge a leave if <= 0 - if (debounceMs <= 0) { - return true; - } - - req.log.info('Debouncing for ' + debounceMs + 'ms'); - - debouncer.rejoinPromises[nick] = {}; - - let p = new Promise((resolve) => { - debouncer.rejoinPromises[nick].resolve = resolve; - }).timeout(debounceMs); - debouncer.rejoinPromises[nick].promise = p; - - // Return whether the part should be bridged as a leave - try { - yield debouncer.rejoinPromises[nick].promise; - // User has joined a channel, presence has been set to online, do not leave rooms - return false; - } - catch (err) { - req.log.info("User did not rejoin (%s)", err.message); - return true; - } -}); - -module.exports = QuitDebouncer; diff --git a/lib/irc/BridgedClient.js b/lib/irc/BridgedClient.js deleted file mode 100644 index 5150717e6..000000000 --- a/lib/irc/BridgedClient.js +++ /dev/null @@ -1,751 +0,0 @@ -/*eslint no-invalid-this: 0 */ -"use strict"; - -const Promise = require("bluebird"); -const promiseutil = require("../promiseutil"); -const util = require("util"); -const EventEmitter = require("events").EventEmitter; -const ident = require("./ident"); -const ConnectionInstance = require("./ConnectionInstance"); -const IrcRoom = require("../models/IrcRoom"); -const log = require("../logging").get("BridgedClient"); - -// The length of time to wait before trying to join the channel again -const JOIN_TIMEOUT_MS = 15 * 1000; // 15s -const NICK_DELAY_TIMER_MS = 10 * 1000; // 10s - -/** - * Create a new bridged IRC client. - * @constructor - * @param {IrcServer} server - * @param {IrcClientConfig} ircClientConfig : The IRC user to create a connection for. - * @param {MatrixUser} matrixUser : Optional. The matrix user representing this virtual IRC user. - * @param {boolean} isBot : True if this is the bot - * @param {IrcEventBroker} eventBroker - * @param {IdentGenerator} identGenerator - * @param {Ipv6Generator} ipv6Generator - */ -function BridgedClient(server, ircClientConfig, matrixUser, isBot, eventBroker, identGenerator, - ipv6Generator) { - this._eventBroker = eventBroker; - this._identGenerator = identGenerator; - this._ipv6Generator = ipv6Generator; - this._clientConfig = ircClientConfig; - this.matrixUser = matrixUser; - this.server = server; - this.userId = matrixUser ? this.matrixUser.getId() : null; - this.displayName = matrixUser ? this.matrixUser.getDisplayName() : null; - this.nick = this._getValidNick( - ircClientConfig.getDesiredNick() || server.getNick(this.userId, this.displayName), - false); - this.password = ( - ircClientConfig.getPassword() ? ircClientConfig.getPassword() : server.config.password - ); - - this.isBot = Boolean(isBot); - this.lastActionTs = Date.now(); - this.inst = null; - this.instCreationFailed = false; - this.explicitDisconnect = false; - this.disconnectReason = null; - this.chanList = []; - this._connectDefer = promiseutil.defer(); - this._id = (Math.random() * 1e20).toString(36); - // decorate log lines with the nick and domain, along with an instance id - var prefix = "<" + this.nick + "@" + this.server.domain + "#" + this._id + "> "; - if (this.userId) { - prefix += "(" + this.userId + ") "; - } - this.log = { - debug: function() { - arguments[0] = prefix + arguments[0]; - log.debug.apply(log, arguments); - }, - info: function() { - arguments[0] = prefix + arguments[0]; - log.info.apply(log, arguments); - }, - error: function() { - arguments[0] = prefix + arguments[0]; - log.error.apply(log, arguments); - } - }; - - this._cachedOperatorNicksInfo = { - // $channel : info - }; -} -util.inherits(BridgedClient, EventEmitter); - -BridgedClient.prototype.getClientConfig = function() { - return this._clientConfig; -}; - -BridgedClient.prototype.kill = function(reason) { - // Nullify so that no further commands can be issued - // via unsafeClient, which should be null checked - // anyway as it is not instantiated until a connection - // has occurred. - this.unsafeClient = null; - // kill connection instance - log.info('Killing client ', this.nick); - return this.disconnect(reason || "Bridged client killed"); -} - -BridgedClient.prototype.isDead = function() { - if (this.instCreationFailed || (this.inst && this.inst.dead)) { - return true; - } - return false; -}; - -BridgedClient.prototype.toString = function() { - let domain = this.server ? this.server.domain : "NO_DOMAIN"; - return `${this.nick}@${domain}#${this._id}~${this.userId}`; -}; - -/** - * @return {ConnectionInstance} A new connected connection instance. - */ -BridgedClient.prototype.connect = Promise.coroutine(function*() { - var server = this.server; - try { - let nameInfo = yield this._identGenerator.getIrcNames( - this._clientConfig, this.matrixUser - ); - if (this.server.getIpv6Prefix()) { - // side-effects setting the IPv6 address on the client config - yield this._ipv6Generator.generate( - this.server.getIpv6Prefix(), this._clientConfig - ); - } - this.log.info( - "Connecting to IRC server %s as %s (user=%s)", - server.domain, this.nick, nameInfo.username - ); - this._eventBroker.sendMetadata(this, - `Connecting to the IRC network '${this.server.domain}' as ${this.nick}...` - ); - - let connInst = yield ConnectionInstance.create(server, { - nick: this.nick, - username: nameInfo.username, - realname: nameInfo.realname, - password: this.password, - // Don't use stored IPv6 addresses unless they have a prefix else they - // won't be able to turn off IPv6! - localAddress: ( - this.server.getIpv6Prefix() ? this._clientConfig.getIpv6Address() : undefined - ) - }, (inst) => { - this._onConnectionCreated(inst, nameInfo); - }); - - this.inst = connInst; - this.unsafeClient = connInst.client; - this.emit("client-connected", this); - // we may have been assigned a different nick, so update it from source - this.nick = connInst.client.nick; - this._connectDefer.resolve(); - this._keepAlive(); - - let connectText = ( - `You've been connected to the IRC network '${this.server.domain}' as ${this.nick}.` - ); - - let userModes = this.server.getUserModes(); - if (userModes.length > 0 && !this.isBot) { - // These can fail, but the generic error listener will catch them and send them - // into the same room as the connect text, so it's probably good enough to not - // explicitly handle them. - this.unsafeClient.setUserMode("+" + userModes); - connectText += ( - ` User modes +${userModes} have been set.` - ); - } - - this._eventBroker.sendMetadata(this, connectText); - - connInst.client.addListener("nick", (old, newNick) => { - if (old === this.nick) { - this.log.info( - "NICK: Nick changed from '" + old + "' to '" + newNick + "'." - ); - this.nick = newNick; - this.emit("nick-change", this, old, newNick); - } - }); - connInst.client.addListener("error", (err) => { - // Errors we MUST notify the user about, regardless of the bridge's admin room config. - const ERRORS_TO_FORCE = ["err_nononreg"] - if (!err || !err.command || connInst.dead) { - return; - } - var msg = "Received an error on " + this.server.domain + ": " + err.command + "\n"; - msg += JSON.stringify(err.args); - this._eventBroker.sendMetadata(this, msg, ERRORS_TO_FORCE.includes(err.command)); - }); - return connInst; - } - catch (err) { - this.log.debug("Failed to connect."); - this.instCreationFailed = true; - throw err; - } -}); - -BridgedClient.prototype.disconnect = function(reason) { - this.explicitDisconnect = true; - if (!this.inst || this.inst.dead) { - return Promise.resolve(); - } - return this.inst.disconnect(reason); -}; - -/** - * Change this user's nick. - * @param {string} newNick The new nick for the user. - * @param {boolean} throwOnInvalid True to throw an error on invalid nicks - * instead of coercing them. - * @return {Promise} Which resolves to a message to be sent to the user. - */ -BridgedClient.prototype.changeNick = function(newNick, throwOnInvalid) { - let validNick = newNick; - try { - validNick = this._getValidNick(newNick, throwOnInvalid); - if (validNick === this.nick) { - return Promise.resolve(`Your nick is already '${validNick}'.`); - } - } - catch (err) { - return Promise.reject(err); - } - if (!this.unsafeClient) { - return Promise.reject(new Error("You are not connected to the network.")); - } - - return new Promise((resolve, reject) => { - var nickListener, nickErrListener; - var timeoutId = setTimeout(() => { - this.log.error("Timed out trying to change nick to %s", validNick); - // may have d/ced between sending nick change and now so recheck - if (this.unsafeClient) { - this.unsafeClient.removeListener("nick", nickListener); - this.unsafeClient.removeListener("error", nickErrListener); - } - reject(new Error("Timed out waiting for a response to change nick.")); - }, NICK_DELAY_TIMER_MS); - nickListener = (old, n) => { - clearTimeout(timeoutId); - this.unsafeClient.removeListener("error", nickErrListener); - resolve("Nick changed from '" + old + "' to '" + n + "'."); - }; - nickErrListener = (err) => { - if (!err || !err.command) { return; } - var failCodes = [ - "err_banonchan", "err_nickcollision", "err_nicknameinuse", - "err_erroneusnickname", "err_nonicknamegiven", "err_eventnickchange", - "err_nicktoofast", "err_unavailresource" - ]; - if (failCodes.indexOf(err.command) !== -1) { - this.log.error("Nick change error : %s", err.command); - clearTimeout(timeoutId); - this.unsafeClient.removeListener("nick", nickListener); - reject(new Error("Failed to change nick: " + err.command)); - } - }; - this.unsafeClient.once("nick", nickListener); - this.unsafeClient.once("error", nickErrListener); - this.unsafeClient.send("NICK", validNick); - }); -}; - -BridgedClient.prototype.joinChannel = function(channel, key) { - return this._joinChannel(channel, key); -}; - -BridgedClient.prototype.leaveChannel = function(channel, reason) { - return this._leaveChannel(channel, reason); -}; - -BridgedClient.prototype._leaveChannel = function(channel, reason) { - reason = reason || "User left"; - if (!this.inst || this.inst.dead) { - return Promise.resolve(); // we were never connected to the network. - } - if (channel.indexOf("#") !== 0) { - return Promise.resolve(); // PM room - } - if (!this.inChannel(channel)) { - return Promise.resolve(); // we were never joined to it. - } - var self = this; - var defer = promiseutil.defer(); - this._removeChannel(channel); - self.log.debug("Leaving channel %s", channel); - this.unsafeClient.part(channel, reason, function() { - self.log.debug("Left channel %s", channel); - defer.resolve(); - }); - - return defer.promise; -}; - -BridgedClient.prototype.inChannel = function(channel) { - return this.chanList.includes(channel); -} - -BridgedClient.prototype.kick = function(nick, channel, reason) { - reason = reason || "User kicked"; - if (!this.inst || this.inst.dead) { - return Promise.resolve(); // we were never connected to the network. - } - if (Object.keys(this.unsafeClient.chans).indexOf(channel) === -1) { - // we were never joined to it. We need to be joined to it to kick people. - return Promise.resolve(); - } - if (channel.indexOf("#") !== 0) { - return Promise.resolve(); // PM room - } - - return new Promise((resolve, reject) => { - this.log.debug("Kicking %s from channel %s", nick, channel); - this.unsafeClient.send("KICK", channel, nick, reason); - resolve(); // wait for some response? Is there even one? - }); -}; - -BridgedClient.prototype.sendAction = function(room, action) { - this._keepAlive(); - let expiryTs = 0; - if (action.ts && this.server.getExpiryTimeSeconds()) { - expiryTs = action.ts + (this.server.getExpiryTimeSeconds() * 1000); - } - switch (action.type) { - case "message": - return this._sendMessage(room, "message", action.text, expiryTs); - case "notice": - return this._sendMessage(room, "notice", action.text, expiryTs); - case "emote": - return this._sendMessage(room, "action", action.text, expiryTs); - case "topic": - return this._setTopic(room, action.text); - default: - this.log.error("Unknown action type: %s", action.type); - } - return Promise.reject(new Error("Unknown action type: " + action.type)); -}; - -/** - * Get the whois info for an IRC user - * @param {string} nick : The nick to call /whois on - */ -BridgedClient.prototype.whois = function(nick) { - var self = this; - return new Promise(function(resolve, reject) { - self.unsafeClient.whois(nick, function(whois) { - if (!whois.user) { - reject(new Error("Cannot find nick on whois.")); - return; - } - let idle = whois.idle ? `${whois.idle} seconds idle` : ""; - let chans = ( - (whois.channels && whois.channels.length) > 0 ? - `On channels: ${JSON.stringify(whois.channels)}` : - "" - ); - - let info = `${whois.user}@${whois.host} - Real name: ${whois.realname} - ${chans} - ${idle} - `; - resolve({ - server: self.server, - nick: nick, - msg: `Whois info for '${nick}': ${info}` - }); - }); - }); -}; - - -/** - * Get the operators of a channel (including users more powerful than operators) - * @param {string} channel : The channel to call /names on - * @param {object} opts: Optional. An object containing the following key-value pairs: - * @param {string} key : Optional. The key to use to join the channel. - * @param {integer} cacheDurationMs : Optional. The duration of time to keep a - * list of operator nicks cached. If > 0, the operator nicks will be returned - * whilst the cache is still valid and it will become invalid after cacheDurationMs - * milliseconds. Cache will not be used if left undefined. - */ -BridgedClient.prototype.getOperators = function(channel, opts) { - let key = opts.key; - let cacheDurationMs = opts.cacheDurationMs; - - if (typeof key !== 'undefined' && typeof key !== 'string') { - throw new Error('key must be a string'); - } - - if (typeof cacheDurationMs !== 'undefined') { - if (!(Number.isInteger(cacheDurationMs) && cacheDurationMs > 0)) { - throw new Error('cacheDurationMs must be a positive integer'); - } - // If cached previously, use cache - if (typeof this._cachedOperatorNicksInfo[channel] !== 'undefined') { - return Promise.resolve(this._cachedOperatorNicksInfo[channel]); - } - } - - return this._joinChannel(channel, key).then(() => { - return this.getNicks(channel); - }).then((nicksInfo) => { - return this._leaveChannel(channel).then(() => nicksInfo); - }).then((nicksInfo) => { - let nicks = nicksInfo.nicks; - // RFC 1459 1.3.1: - // A channel operator is identified by the '@' symbol next to their - // nickname whenever it is associated with a channel (ie replies to the - // NAMES, WHO and WHOIS commands). - - // http://www.irc.org/tech_docs/005.html - // ISUPPORT PREFIX: - // A list of channel modes a person can get and the respective prefix a channel - // or nickname will get in case the person has it. The order of the modes goes - // from most powerful to least powerful. Those prefixes are shown in the output - // of the WHOIS, WHO and NAMES command. - // Note: Some servers only show the most powerful, others may show all of them. - - // Ergo: They are a chan op if they are "@" or "more powerful than @". - nicksInfo.operatorNicks = nicks.filter((nick) => { - for (let i = 0; i < nicksInfo.names[nick].length; i++) { - let prefix = nicksInfo.names[nick][i]; - if (prefix === "@") { - return true; - } - let cli = this.unsafeClient; - if (!cli) { - throw new Error("Missing client"); - } - if (cli.isUserPrefixMorePowerfulThan(prefix, "@")) { - return true; - } - } - return false; - }); - - if (typeof cacheDurationMs !== 'undefined') { - this._cachedOperatorNicksInfo[channel] = nicksInfo; - setTimeout(()=>{ - //Invalidate the cache - delete this._cachedOperatorNicksInfo[channel]; - }, cacheDurationMs); - } - - return nicksInfo; - }); -}; - -/** - * Get the nicks of the users in a channel - * @param {string} channel : The channel to call /names on - */ -BridgedClient.prototype.getNicks = function(channel) { - var self = this; - return new Promise(function(resolve, reject) { - self.unsafeClient.names(channel, function(channelName, names) { - // names maps nicks to chan op status, where '@' indicates chan op - // names = {'nick1' : '', 'nick2' : '@', ...} - resolve({ - server: self.server, - channel: channelName, - nicks: Object.keys(names), - names: names, - }); - }); - }).timeout(5000); -}; - - -/** - * Convert the given nick into a valid nick. This involves length and character - * checks on the provided nick. If the client is connected to an IRCd then the - * cmds received (e.g. NICKLEN) will be used in the calculations. If the client - * is NOT connected to an IRCd then this function will NOT take length checks - * into account. This means this function will optimistically allow long nicks - * in the hopes that it will succeed, rather than use the RFC stated maximum of - * 9 characters which is far too small. In testing, IRCds coerce long - * nicks up to the limit rather than preventing the connection entirely. - * - * This function may modify the nick in interesting ways in order to coerce the - * given nick into a valid nick. If throwOnInvalid is true, this function will - * throw a human-readable error instead of coercing the nick on invalid nicks. - * - * @param {string} nick The nick to convert into a valid nick. - * @param {boolean} throwOnInvalid True to throw an error on invalid nicks - * instead of coercing them. - * @return {string} A valid nick. - * @throws Only if throwOnInvalid is true and the nick is not a valid nick. - * The error message will contain a human-readable message which can be sent - * back to a user. - */ -BridgedClient.prototype._getValidNick = function(nick, throwOnInvalid) { - // Apply a series of transformations to the nick, and check after each - // stage for mismatches to the input (and throw if appropriate). - - - // strip illegal chars according to RFC 2812 Sect 2.3.1 - let n = nick.replace(BridgedClient.illegalCharactersRegex, ""); - if (throwOnInvalid && n !== nick) { - throw new Error(`Nick '${nick}' contains illegal characters.`); - } - - // nicks must start with a letter - if (!/^[A-Za-z]/.test(n)) { - if (throwOnInvalid) { - throw new Error(`Nick '${nick}' must start with a letter.`); - } - // Add arbitrary letter prefix. This is important for guest user - // IDs which are all numbers. - n = "M" + n; - } - - if (this.unsafeClient) { - // nicks can't be too long - let maxNickLen = 9; // RFC 1459 default - if (this.unsafeClient.supported && - typeof this.unsafeClient.supported.nicklength == "number") { - maxNickLen = this.unsafeClient.supported.nicklength; - } - if (n.length > maxNickLen) { - if (throwOnInvalid) { - throw new Error(`Nick '${nick}' is too long. (Max: ${maxNickLen})`); - } - n = n.substr(0, maxNickLen); - } - } - - return n; -} - -BridgedClient.prototype._keepAlive = function() { - this.lastActionTs = Date.now(); - var idleTimeout = this.server.getIdleTimeout(); - if (idleTimeout > 0) { - if (this._idleTimeout) { - // stop the timeout - clearTimeout(this._idleTimeout); - } - this.log.debug( - "_keepAlive; Restarting %ss idle timeout", idleTimeout - ); - // restart the timeout - var self = this; - this._idleTimeout = setTimeout(function() { - self.log.info("Idle timeout has expired"); - if (self.server.shouldSyncMembershipToIrc("initial")) { - self.log.info( - "Not disconnecting because %s is mirroring matrix membership lists", - self.server.domain - ); - return; - } - if (self.isBot) { - self.log.info("Not disconnecting because this is the bot"); - return; - } - self.disconnect( - "Idle timeout reached: " + idleTimeout + "s" - ).done(function() { - self.log.info("Idle timeout reached: Disconnected"); - }, function(e) { - self.log.error("Error when disconnecting: %s", JSON.stringify(e)); - }); - }, (1000 * idleTimeout)); - } -}; -BridgedClient.prototype._removeChannel = function(channel) { - var i = this.chanList.indexOf(channel); - if (i === -1) { - return; - } - this.chanList.splice(i, 1); -}; -BridgedClient.prototype._addChannel = function(channel) { - var i = this.chanList.indexOf(channel); - if (i !== -1) { - return; // already added - } - this.chanList.push(channel); -}; -BridgedClient.prototype.getLastActionTs = function() { - return this.lastActionTs; -}; -BridgedClient.prototype._onConnectionCreated = function(connInst, nameInfo) { - // listen for a connect event which is done when the TCP connection is - // established and set ident info (this is different to the connect() callback - // in node-irc which actually fires on a registered event..) - connInst.client.once("connect", function() { - var localPort = -1; - if (connInst.client.conn && connInst.client.conn.localPort) { - localPort = connInst.client.conn.localPort; - } - if (localPort > 0) { - ident.setMapping(nameInfo.username, localPort); - } - }); - - connInst.onDisconnect = (reason) => { - this.disconnectReason = reason; - if (reason === "banned") { - // If we've been banned, this is intentional. - this.explicitDisconnect = true; - } - this.emit("client-disconnected", this); - this._eventBroker.sendMetadata(this, - "Your connection to the IRC network '" + this.server.domain + - "' has been lost. " - ); - clearTimeout(this._idleTimeout); - }; - - this._eventBroker.addHooks(this, connInst); -}; - -BridgedClient.prototype._setTopic = function(room, topic) { - // join the room if we haven't already - return this._joinChannel(room.channel).then(() => { - this.log.info("Setting topic to %s in channel %s", topic, room.channel); - this.unsafeClient.send("TOPIC", room.channel, topic); - }); -} - -BridgedClient.prototype._sendMessage = function(room, msgType, text, expiryTs) { - // join the room if we haven't already - var defer = promiseutil.defer(); - msgType = msgType || "message"; - this._connectDefer.promise.then(() => { - return this._joinChannel(room.channel); - }).done(() => { - // re-check timestamp to see if we should send it now - if (expiryTs && Date.now() > expiryTs) { - this.log.error(`Dropping event: too old (expired at ${expiryTs})`); - defer.resolve(); - return; - } - - if (msgType == "action") { - this.unsafeClient.action(room.channel, text); - } - else if (msgType == "notice") { - this.unsafeClient.notice(room.channel, text); - } - else if (msgType == "message") { - this.unsafeClient.say(room.channel, text); - } - defer.resolve(); - }, (e) => { - this.log.error("sendMessage: Failed to join channel " + room.channel); - defer.reject(e); - }); - return defer.promise; -} - -BridgedClient.prototype._joinChannel = function(channel, key, attemptCount) { - attemptCount = attemptCount || 1; - if (!this.unsafeClient) { - // we may be trying to join before we've connected, so check and wait - if (this._connectDefer && this._connectDefer.promise.isPending()) { - return this._connectDefer.promise.then(() => { - return this._joinChannel(channel, key, attemptCount); - }); - } - return Promise.reject(new Error("No client")); - } - if (Object.keys(this.unsafeClient.chans).indexOf(channel) !== -1) { - return Promise.resolve(new IrcRoom(this.server, channel)); - } - if (channel.indexOf("#") !== 0) { - // PM room - return Promise.resolve(new IrcRoom(this.server, channel)); - } - if (this.server.isExcludedChannel(channel)) { - return Promise.reject(new Error(channel + " is a do-not-track channel.")); - } - var defer = promiseutil.defer(); - this.log.debug("Joining channel %s", channel); - this._addChannel(channel); - var client = this.unsafeClient; - // listen for failures to join a channel (e.g. +i, +k) - var failFn = (err) => { - if (!err || !err.args) { return; } - var failCodes = [ - "err_nosuchchannel", "err_toomanychannels", "err_channelisfull", - "err_inviteonlychan", "err_bannedfromchan", "err_badchannelkey", - "err_needreggednick" - ]; - this.log.error("Join channel %s : %s", channel, JSON.stringify(err)); - if (failCodes.indexOf(err.command) !== -1 && - err.args.indexOf(channel) !== -1) { - this.log.error("Cannot track channel %s: %s", channel, err.command); - client.removeListener("error", failFn); - defer.reject(new Error(err.command)); - this.emit("join-error", this, channel, err.command); - this._eventBroker.sendMetadata( - this, `Could not join ${channel} on '${this.server.domain}': ${err.command}`, true - ); - } - }; - client.once("error", failFn); - - // add a timeout to try joining again - setTimeout(() => { - if (!this.unsafeClient) { - log.error( - `Could not try to join: no client for ${this.nick}, channel = ${channel}` - ); - return; - } - // promise isn't resolved yet and we still want to join this channel - if (defer.promise.isPending() && this.chanList.indexOf(channel) !== -1) { - // we may have joined but didn't get the callback so check the client - if (Object.keys(this.unsafeClient.chans).indexOf(channel) !== -1) { - // we're joined - this.log.debug("Timed out joining %s - didn't get callback but " + - "are now joined. Resolving.", channel); - defer.resolve(new IrcRoom(this.server, channel)); - return; - } - if (attemptCount >= 5) { - defer.reject( - new Error("Failed to join " + channel + " after multiple tries") - ); - return; - } - - this.log.error("Timed out trying to join %s - trying again.", channel); - // try joining again. - attemptCount += 1; - this._joinChannel(channel, key, attemptCount).done(function(s) { - defer.resolve(s); - }, function(e) { - defer.reject(e); - }); - } - }, JOIN_TIMEOUT_MS); - - // send the JOIN with a key if it was specified. - this.unsafeClient.join(channel + (key ? " " + key : ""), () => { - this.log.debug("Joined channel %s", channel); - client.removeListener("error", failFn); - var room = new IrcRoom(this.server, channel); - defer.resolve(room); - }); - - return defer.promise; -} - -BridgedClient.illegalCharactersRegex = /[^A-Za-z0-9\]\[\^\\\{\}\-`_\|]/g; - -module.exports = BridgedClient; diff --git a/lib/irc/ClientPool.js b/lib/irc/ClientPool.js deleted file mode 100644 index 4858dc816..000000000 --- a/lib/irc/ClientPool.js +++ /dev/null @@ -1,480 +0,0 @@ -/*eslint no-invalid-this: 0*/ -/* - * Maintains a lookup of connected IRC clients. These connections are transient - * and may be closed for a variety of reasons. - */ -"use strict"; -const stats = require("../config/stats"); -const log = require("../logging").get("ClientPool"); -const Promise = require("bluebird"); -const QueuePool = require("../util/QueuePool"); -const BridgeRequest = require("../models/BridgeRequest"); - -class ClientPool { - constructor(ircBridge) { - this._ircBridge = ircBridge; - // The list of bot clients on servers (not specific users) - this._botClients = { - // server_domain: BridgedClient - }; - - // list of virtual users on servers - this._virtualClients = { - // server_domain: { - // nicks: { - // : BridgedClient - // }, - // userIds: { - // : BridgedClient - // } - // These users are in the process of being - // connected with an *assumed* nick. - // pending: { - // - // } - // } - } - - // map of numbers of connected clients on each server - // Counting these is quite expensive because we have to - // ignore entries where the value is undefined. Instead, - // just keep track of how many we have. - this._virtualClientCounts = { - // server_domain: number - }; - - this._reconnectQueues = { - // server_domain: QueuePool - }; - } - - nickIsVirtual(server, nick) { - if (!this._virtualClients[server.domain]) { - return false; - } - - if (this.getBridgedClientByNick(server, nick)) { - return true; - } - - // The client may not have signalled to us that it's connected, but it is connect*ing*. - const pending = Object.keys(this._virtualClients[server.domain].pending || {}); - return pending.includes(nick); - } -} - -ClientPool.prototype.killAllClients = function() { - let domainList = Object.keys(this._virtualClients); - let clients = []; - domainList.forEach((domain) => { - clients = clients.concat( - Object.keys(this._virtualClients[domain].nicks).map( - (nick) => this._virtualClients[domain].nicks[nick] - ) - ); - - clients = clients.concat( - Object.keys(this._virtualClients[domain].userIds).map( - (userId) => this._virtualClients[domain].userIds[userId] - ) - ); - - clients.push(this._botClients[domain]); - }); - - clients = clients.filter((c) => Boolean(c)); - - return Promise.all( - clients.map( - (client) => client.kill() - ) - ); -} - -ClientPool.prototype.getOrCreateReconnectQueue = function(server) { - if (server.getConcurrentReconnectLimit() === 0) { - return null; - } - let q = this._reconnectQueues[server.domain]; - if (q === undefined) { - q = this._reconnectQueues[server.domain] = new QueuePool( - server.getConcurrentReconnectLimit(), - (item) => { - log.info(`Reconnecting client. ${q.waitingItems} left.`); - return this._reconnectClient(item); - } - ); - } - return q; -}; - -ClientPool.prototype.setBot = function(server, client) { - this._botClients[server.domain] = client; -}; - -ClientPool.prototype.getBot = function(server) { - return this._botClients[server.domain]; -}; - -ClientPool.prototype.createIrcClient = function(ircClientConfig, matrixUser, isBot) { - var bridgedClient = this._ircBridge.createBridgedClient( - ircClientConfig, matrixUser, isBot - ); - var server = bridgedClient.server; - - if (this._virtualClients[server.domain] === undefined) { - this._virtualClients[server.domain] = { - nicks: Object.create(null), - userIds: Object.create(null), - pending: {}, - }; - this._virtualClientCounts[server.domain] = 0; - } - if (isBot) { - this._botClients[server.domain] = bridgedClient; - } - - // `pending` is used to ensure that we know if a nick belongs to a userId - // before they have been connected. It's impossible to know for sure - // what nick they will be assigned before being connected, but this - // should catch most cases. Knowing the nick is important, because - // slow clients may not send a 'client-connected' signal before a join is - // emitted, which means ghost users may join with their nickname into matrix. - this._virtualClients[server.domain].pending[bridgedClient.nick] = bridgedClient.userId; - - // add event listeners - bridgedClient.on("client-connected", this._onClientConnected.bind(this)); - bridgedClient.on("client-disconnected", this._onClientDisconnected.bind(this)); - bridgedClient.on("nick-change", this._onNickChange.bind(this)); - bridgedClient.on("join-error", this._onJoinError.bind(this)); - bridgedClient.on("irc-names", this._onNames.bind(this)); - - // store the bridged client immediately in the pool even though it isn't - // connected yet, else we could spawn 2 clients for a single user if this - // function is called quickly. - this._virtualClients[server.domain].userIds[bridgedClient.userId] = bridgedClient; - this._virtualClientCounts[server.domain] = this._virtualClientCounts[server.domain] + 1; - - // Does this server have a max clients limit? If so, check if the limit is - // reached and start cycling based on oldest time. - this._checkClientLimit(server); - return bridgedClient; -}; - -ClientPool.prototype.getBridgedClientByUserId = function(server, userId) { - if (!this._virtualClients[server.domain]) { - return undefined; - } - var cli = this._virtualClients[server.domain].userIds[userId]; - if (!cli || cli.isDead()) { - return undefined; - } - return cli; -}; - -ClientPool.prototype.getBridgedClientByNick = function(server, nick) { - var bot = this.getBot(server); - if (bot && bot.nick === nick) { - return bot; - } - - if (!this._virtualClients[server.domain]) { - return undefined; - } - var cli = this._virtualClients[server.domain].nicks[nick]; - if (!cli || cli.isDead()) { - return undefined; - } - return cli; -}; - -ClientPool.prototype.getBridgedClientsForUserId = function(userId) { - var domainList = Object.keys(this._virtualClients); - var clientList = []; - domainList.forEach((domain) => { - var cli = this._virtualClients[domain].userIds[userId]; - if (cli && !cli.isDead()) { - clientList.push(cli); - } - }); - return clientList; -}; - -ClientPool.prototype.getBridgedClientsForRegex = function(userIdRegex) { - userIdRegex = new RegExp(userIdRegex); - const domainList = Object.keys(this._virtualClients); - const clientList = {}; - domainList.forEach((domain) => { - Object.keys( - this._virtualClients[domain].userIds - ).filter( - (u) => userIdRegex.exec(u) !== null - ).forEach((userId) => { - if (!clientList[userId]) { - clientList[userId] = []; - } - clientList[userId].push(this._virtualClients[domain].userIds[userId]); - }); - }); - return clientList; -}; - - -ClientPool.prototype._checkClientLimit = function(server) { - if (server.getMaxClients() === 0) { - return; - } - - var numConnections = this._getNumberOfConnections(server); - this._sendConnectionMetric(server); - - if (numConnections < server.getMaxClients()) { - // under the limit, we're good for now. - log.debug( - "%s active connections on %s", - numConnections, server.domain - ); - return; - } - - log.debug( - "%s active connections on %s (limit %s)", - numConnections, server.domain, server.getMaxClients() - ); - - // find the oldest client to kill. - var oldest = null; - Object.keys(this._virtualClients[server.domain].nicks).forEach((nick) => { - var client = this._virtualClients[server.domain].nicks[nick]; - if (!client) { - // possible since undefined/null values can be present from culled entries - return; - } - if (client.isBot) { - return; // don't ever kick the bot off. - } - if (oldest === null) { - oldest = client; - return; - } - if (client.getLastActionTs() < oldest.getLastActionTs()) { - oldest = client; - } - }); - if (!oldest) { - return; - } - // disconnect and remove mappings. - this._removeBridgedClient(oldest); - oldest.disconnect("Client limit exceeded: " + server.getMaxClients()).then( - function() { - log.info("Client limit exceeded: Disconnected %s on %s.", - oldest.nick, oldest.server.domain); - }, - function(e) { - log.error("Error when disconnecting %s on server %s: %s", - oldest.nick, oldest.server.domain, JSON.stringify(e)); - }); -}; - -ClientPool.prototype._getNumberOfConnections = function(server) { - if (!server || !this._virtualClients[server.domain]) { return 0; } - return this._virtualClientCounts[server.domain]; -}; - -ClientPool.prototype.countTotalConnections = function() { - var count = 0; - - Object.keys(this._virtualClients).forEach((domain) => { - let server = this._ircBridge.getServer(domain); - count += this._getNumberOfConnections(server); - }); - - return count; -}; - -ClientPool.prototype.totalReconnectsWaiting = function (serverDomain) { - if (this._reconnectQueues[serverDomain] !== undefined) { - return this._reconnectQueues[serverDomain].waitingItems; - } - return 0; -}; - -ClientPool.prototype.updateActiveConnectionMetrics = function(server, ageCounter) { - if (this._virtualClients[server] === undefined) { - return; - } - const clients = Object.values(this._virtualClients[server].userIds); - clients.forEach((bridgedClient) => { - if (!bridgedClient || bridgedClient.isDead()) { - // We don't want to include dead ones, or ones that don't exist. - return; - } - ageCounter.bump((Date.now() - bridgedClient.getLastActionTs()) / 1000); - }); -}; - -ClientPool.prototype.getNickUserIdMappingForChannel = function(server, channel) { - const nickUserIdMap = {}; - const cliSet = this._virtualClients[server.domain].userIds; - Object.keys(cliSet).filter((userId) => - cliSet[userId] && cliSet[userId].chanList - && cliSet[userId].chanList.includes(channel) - ).forEach((userId) => { - nickUserIdMap[cliSet[userId].nick] = userId; - }); - // Correctly map the bot too. - nickUserIdMap[server.getBotNickname()] = this._ircBridge.getAppServiceUserId(); - return nickUserIdMap; -}; - -ClientPool.prototype._sendConnectionMetric = function(server) { - stats.ircClients(server.domain, this._getNumberOfConnections(server)); -}; - -ClientPool.prototype._removeBridgedClient = function(bridgedClient) { - var server = bridgedClient.server; - this._virtualClients[server.domain].userIds[bridgedClient.userId] = undefined; - this._virtualClients[server.domain].nicks[bridgedClient.nick] = undefined; - this._virtualClientCounts[server.domain] = this._virtualClientCounts[server.domain] - 1; - - if (bridgedClient.isBot) { - this._botClients[server.domain] = undefined; - } -}; - -ClientPool.prototype._onClientConnected = function(bridgedClient) { - var server = bridgedClient.server; - var oldNick = bridgedClient.nick; - var actualNick = bridgedClient.unsafeClient.nick; - - // remove the pending nick we had set for this user - delete this._virtualClients[server.domain].pending[oldNick]; - - // assign a nick to this client - this._virtualClients[server.domain].nicks[actualNick] = bridgedClient; - - // informative logging - if (oldNick !== actualNick) { - log.debug("Connected with nick '%s' instead of desired nick '%s'", - actualNick, oldNick); - } -}; - -ClientPool.prototype._onClientDisconnected = function(bridgedClient) { - this._removeBridgedClient(bridgedClient); - this._sendConnectionMetric(bridgedClient.server); - - // remove the pending nick we had set for this user - if (this._virtualClients[bridgedClient.server]) { - delete this._virtualClients[bridgedClient.server].pending[bridgedClient.nick]; - } - - if (bridgedClient.disconnectReason === "banned") { - const req = new BridgeRequest(this._ircBridge._bridge.getRequestFactory().newRequest()); - this._ircBridge.matrixHandler.quitUser( - req, bridgedClient.userId, [bridgedClient], - null, "User was banned from the network" - ); - } - - if (bridgedClient.explicitDisconnect) { - // don't reconnect users which explicitly disconnected e.g. client - // cycling, idle timeouts, leaving rooms, etc. - return; - } - // Reconnect this user - // change the client config to use the current nick rather than the desired nick. This - // makes sure that the client attempts to reconnect with the *SAME* nick, and also draws - // from the latest !nick change, as the client config here may be very very old. - var cliConfig = bridgedClient.getClientConfig(); - cliConfig.setDesiredNick(bridgedClient.nick); - - - var cli = this.createIrcClient( - cliConfig, bridgedClient.matrixUser, bridgedClient.isBot - ); - var chanList = bridgedClient.chanList; - // remove ref to the disconnected client so it can be GC'd. If we don't do this, - // the timeout below holds it in a closure, preventing it from being GC'd. - bridgedClient = undefined; - - if (chanList.length === 0) { - log.info(`Dropping ${cli._id} ${cli.nick} because they are not joined to any channels`); - return; - } - let queue = this.getOrCreateReconnectQueue(cli.server); - if (queue === null) { - this._reconnectClient({ - cli: cli, - chanList: chanList, - }); - return; - } - queue.enqueue(cli._id, { - cli: cli, - chanList: chanList, - }); -}; - -ClientPool.prototype._reconnectClient = function(cliChan) { - const cli = cliChan.cli; - const chanList = cliChan.chanList; - return cli.connect().then(() => { - log.info( - "<%s> Reconnected %s@%s", cli._id, cli.nick, cli.server.domain - ); - log.info("<%s> Rejoining %s channels", cli._id, chanList.length); - chanList.forEach(function(c) { - cli.joinChannel(c); - }); - this._sendConnectionMetric(cli.server); - }, (e) => { - log.error( - "<%s> Failed to reconnect %s@%s", cli._id, cli.nick, cli.server.domain - ); - }); -} - -ClientPool.prototype._onNickChange = function(bridgedClient, oldNick, newNick) { - this._virtualClients[bridgedClient.server.domain].nicks[oldNick] = undefined; - this._virtualClients[bridgedClient.server.domain].nicks[newNick] = bridgedClient; -}; - -ClientPool.prototype._onJoinError = Promise.coroutine(function*(bridgedClient, chan, err) { - var errorsThatShouldKick = [ - "err_bannedfromchan", // they aren't allowed in channels they are banned on. - "err_inviteonlychan", // they aren't allowed in invite only channels - "err_channelisfull", // they aren't allowed in if the channel is full - "err_badchannelkey", // they aren't allowed in channels with a bad key - "err_needreggednick", // they aren't allowed in +r channels if they haven't authed - ]; - if (errorsThatShouldKick.indexOf(err) === -1) { - return; - } - if (!bridgedClient.userId || bridgedClient.isBot) { - return; // the bot itself can get these join errors - } - // TODO: this is a bit evil, no one in their right mind would expect - // the client pool to be kicking matrix users from a room :( - log.info(`Kicking ${bridgedClient.userId} from room due to ${err}`); - let matrixRooms = yield this._ircBridge.getStore().getMatrixRoomsForChannel( - bridgedClient.server, chan - ); - let promises = matrixRooms.map((room) => { - return this._ircBridge.getAppServiceBridge().getIntent().kick( - room.getId(), bridgedClient.userId, `IRC error on ${chan}: ${err}` - ); - }); - yield Promise.all(promises); -}); - -ClientPool.prototype._onNames = Promise.coroutine(function*(bridgedClient, chan, names) { - let mls = this._ircBridge.memberListSyncers[bridgedClient.server.domain]; - if (!mls) { - return; - } - yield mls.updateIrcMemberList(chan, names); -}); - -module.exports = ClientPool; diff --git a/lib/irc/ConnectionInstance.js b/lib/irc/ConnectionInstance.js deleted file mode 100644 index e28f81a3d..000000000 --- a/lib/irc/ConnectionInstance.js +++ /dev/null @@ -1,404 +0,0 @@ -"use strict"; -const irc = require("irc"); -const promiseutil = require("../promiseutil"); -const logging = require("../logging"); -var log = logging.get("client-connection"); -const Scheduler = require("./Scheduler.js"); -const Promise = require("bluebird"); - -// The time we're willing to wait for a connect callback when connecting to IRC. -const CONNECT_TIMEOUT_MS = 30 * 1000; // 30s -// The delay between messages when there are >1 messages to send. -const FLOOD_PROTECTION_DELAY_MS = 700; -// The max amount of time we should wait for the server to ping us before reconnecting. -// Servers ping infrequently (2-3mins) so this should be high enough to allow up -// to 2 pings to lapse before reconnecting (5-6mins). -const PING_TIMEOUT_MS = 1000 * 60 * 10; -// The minimum time to wait between connection attempts if we were disconnected -// due to throttling. -const THROTTLE_WAIT_MS = 20 * 1000; - -// The rate at which to send pings to the IRCd if the client is being quiet for a while. -// Whilst the IRCd *should* be sending pings to us to keep the connection alive, it appears -// that sometimes they don't get around to it and end up ping timing us out. -const PING_RATE_MS = 1000 * 60; - -// String reply of any CTCP Version requests -const CTCP_VERSION = 'matrix-appservice-irc, part of the Matrix.org Network'; - -const CONN_LIMIT_MESSAGES = [ - "too many host connections", // ircd-seven - "no more connections allowed in your connection class", - "this server is full", // unrealircd -] - -// Log an Error object to stderr -function logError(err) { - if (!err || !err.message) { - return; - } - log.error(err.message); -} - -/** - * Create an IRC connection instance. Wraps the node-irc library to handle - * connections correctly. - * @constructor - * @param {IrcClient} ircClient The new IRC client. - * @param {string} domain The domain (for logging purposes) - * @param {string} nick The nick (for logging purposes) - */ -function ConnectionInstance(ircClient, domain, nick) { - this.client = ircClient; - this.domain = domain; - this.nick = nick; - this._listenForErrors(); - this._listenForPings(); - this._listenForCTCPVersions(); - this.dead = false; - this.state = "created"; // created|connecting|connected - this._connectDefer = promiseutil.defer(); - this._pingRateTimerId = null; - this._clientSidePingTimeoutTimerId = null; -} - -/** - * Connect this client to the server. There are zero guarantees this will ever - * connect. - * @return {Promise} Resolves if connected; rejects if failed to connect. - */ -ConnectionInstance.prototype.connect = function() { - if (this.dead) { - throw new Error("connect() called on dead client: " + this.nick); - } - this.state = "connecting"; - var self = this; - var domain = self.domain; - var gotConnectedCallback = false; - setTimeout(function() { - if (!gotConnectedCallback && !self.dead) { - log.error( - "%s@%s still not connected after %sms. Killing connection.", - self.nick, domain, CONNECT_TIMEOUT_MS - ); - self.disconnect("timeout").catch(logError); - } - }, CONNECT_TIMEOUT_MS); - - self.client.connect(1, function() { - gotConnectedCallback = true; - self.state = "connected"; - self._resetPingSendTimer(); - self._connectDefer.resolve(self); - }); - return this._connectDefer.promise; -}; - -/** - * Blow away the connection. You MUST destroy this object afterwards. - * @param {string} reason - Reason to reject with. One of: - * throttled|irc_error|net_error|timeout|raw_error|toomanyconns - */ -ConnectionInstance.prototype.disconnect = function(reason) { - if (this.dead) { - return Promise.resolve(); - } - log.info( - "disconnect()ing %s@%s - %s", this.nick, this.domain, reason - ); - this.dead = true; - - return new Promise((resolve, reject) => { - // close the connection - this.client.disconnect(reason, function() {}); - // remove timers - if (this._pingRateTimerId) { - clearTimeout(this._pingRateTimerId); - this._pingRateTimerId = null; - } - if (this._clientSidePingTimeoutTimerId) { - clearTimeout(this._clientSidePingTimeoutTimerId); - this._clientSidePingTimeoutTimerId = null; - } - if (this.state !== "connected") { - // we never resolved this defer, so reject it. - this._connectDefer.reject(new Error(reason)); - } - if (this.state === "connected" && this.onDisconnect) { - // we only invoke onDisconnect once we've had a successful connect. - // Connection *attempts* are managed by the create() function so if we - // call this now it would potentially invoke this 3 times (once per - // connection instance!). Each time would have dead=false as they are - // separate objects. - this.onDisconnect(reason); - } - resolve(); - }); -}; - -ConnectionInstance.prototype.addListener = function(eventName, fn) { - var self = this; - this.client.addListener(eventName, function() { - if (self.dead) { - log.error( - "%s@%s RECV a %s event for a dead connection", - self.nick, self.domain, eventName - ); - return; - } - // do the callback - fn.apply(fn, arguments); - }); -}; - -ConnectionInstance.prototype._listenForErrors = function() { - var self = this; - var domain = self.domain; - var nick = self.nick; - self.client.addListener("error", function(err) { - log.error("Server: %s (%s) Error: %s", domain, nick, JSON.stringify(err)); - // We should disconnect the client for some but not all error codes. This - // list is a list of codes which we will NOT disconnect the client for. - var failCodes = [ - "err_nosuchchannel", "err_toomanychannels", "err_channelisfull", - "err_inviteonlychan", "err_bannedfromchan", "err_badchannelkey", - "err_needreggednick", "err_nosuchnick", "err_cannotsendtochan", - "err_toomanychannels", "err_erroneusnickname", "err_usernotinchannel", - "err_notonchannel", "err_useronchannel", "err_notregistered", - "err_alreadyregistred", "err_noprivileges", "err_chanoprivsneeded", - "err_banonchan", "err_nickcollision", "err_nicknameinuse", - "err_erroneusnickname", "err_nonicknamegiven", "err_eventnickchange", - "err_nicktoofast", "err_unknowncommand", "err_unavailresource", - "err_umodeunknownflag", "err_nononreg" - ]; - if (err && err.command) { - if (failCodes.indexOf(err.command) !== -1) { - return; // don't disconnect for these error codes. - } - } - if (err && err.command === "err_yourebannedcreep") { - self.disconnect("banned").catch(logError); - return; - } - self.disconnect("irc_error").catch(logError); - }); - self.client.addListener("netError", function(err) { - log.error( - "Server: %s (%s) Network Error: %s", domain, nick, - JSON.stringify(err, undefined, 2) - ); - self.disconnect("net_error").catch(logError); - }); - self.client.addListener("abort", function() { - log.error( - "Server: %s (%s) Connection Aborted", domain, nick - ); - self.disconnect("net_error").catch(logError); - }); - self.client.addListener("raw", function(msg) { - if (logging.isVerbose()) { - log.debug( - "%s@%s: %s", nick, domain, JSON.stringify(msg) - ); - } - if (msg && (msg.command === "ERROR" || msg.rawCommand === "ERROR")) { - log.error( - "%s@%s: %s", nick, domain, JSON.stringify(msg) - ); - var wasThrottled = false; - if (msg.args) { - var errText = ("" + msg.args[0]) || ""; - errText = errText.toLowerCase(); - wasThrottled = errText.indexOf("throttl") !== -1; - if (wasThrottled) { - self.disconnect("throttled").catch(logError); - return; - } - const wasBanned = errText.includes("banned") || errText.includes("k-lined"); - if (wasBanned) { - self.disconnect("banned").catch(logError); - return; - } - const tooManyHosts = CONN_LIMIT_MESSAGES.find((connLimitMsg) => { - return errText.includes(connLimitMsg); - }) !== undefined; - if (tooManyHosts) { - self.disconnect("toomanyconns").catch(logError); - return; - } - } - if (!wasThrottled) { - self.disconnect("raw_error").catch(logError); - } - } - }); -}; - -ConnectionInstance.prototype._listenForPings = function() { - // BOTS-65 : A client can get ping timed out and not reconnect. - // ------------------------------------------------------------ - // The client is doing IRC ping/pongs, but there is no check to say - // "hey, the server hasn't pinged me in a while, it's probably dead". The - // RFC for pings states that pings are sent "if no other activity detected - // from a connection." so we need to count anything we shove down the wire - // as a ping refresh. - var self = this; - var domain = self.domain; - var nick = self.nick; - function _keepAlivePing() { // refresh the ping timer - if (self._clientSidePingTimeoutTimerId) { - clearTimeout(self._clientSidePingTimeoutTimerId); - } - self._clientSidePingTimeoutTimerId = setTimeout(function() { - log.info( - "Ping timeout: knifing connection for %s on %s", - domain, nick - ); - // Just emit an netError which clients need to handle anyway. - self.client.emit("netError", { - msg: "Client-side ping timeout" - }); - }, PING_TIMEOUT_MS); - } - self.client.on("ping", function(svr) { - log.debug("Received ping from %s directed at %s", svr, nick); - _keepAlivePing(); - }); - // decorate client.send to refresh the timer - var realSend = self.client.send; - self.client.send = function(command) { - _keepAlivePing(); - self._resetPingSendTimer(); // sending a message counts as a ping - realSend.apply(self.client, arguments); - }; -}; - -ConnectionInstance.prototype._listenForCTCPVersions = function() { - const self = this; - self.client.addListener("ctcp-version", function (from) { - self.client.ctcp(from, 'reply', `VERSION ${CTCP_VERSION}`); - }); -}; - -ConnectionInstance.prototype._resetPingSendTimer = function() { - // reset the ping rate timer - if (this._pingRateTimerId) { - clearTimeout(this._pingRateTimerId); - } - this._pingRateTimerId = setTimeout(() => { - if (this.dead) { - return; - } - // Do what XChat does - this.client.send("PING", "LAG" + Date.now()); - // keep doing it. - this._resetPingSendTimer(); - }, PING_RATE_MS); -}; - -/** - * Create an IRC client connection and connect to it. - * @param {IrcServer} server The server to connect to. - * @param {Object} opts Options for this connection. - * @param {string} opts.nick The nick to use. - * @param {string} opts.username The username to use. - * @param {string} opts.realname The real name of the user. - * @param {string} opts.password The password to give NickServ. - * @param {string} opts.localAddress The local address to bind to when connecting. - * @param {Function} onCreatedCallback Called with the client when created. - * @return {Promise} Resolves to an ConnectionInstance or rejects. - */ -ConnectionInstance.create = Promise.coroutine(function*(server, opts, onCreatedCallback) { - if (!opts.nick || !server) { - throw new Error("Bad inputs. Nick: " + opts.nick); - } - onCreatedCallback = onCreatedCallback || function() {}; - let connectionOpts = { - userName: opts.username, - realName: opts.realname, - password: opts.password, - localAddress: opts.localAddress, - autoConnect: false, - autoRejoin: false, - floodProtection: true, - floodProtectionDelay: FLOOD_PROTECTION_DELAY_MS, - port: server.getPort(), - selfSigned: server.useSslSelfSigned(), - certExpired: server.allowExpiredCerts(), - retryCount: 0, - family: server.getIpv6Prefix() || server.getIpv6Only() ? 6 : null, - bustRfc3484: true, - sasl: opts.password ? server.useSasl() : false, - }; - - if (server.useSsl()) { - connectionOpts.secure = { ca: server.getCA() }; - } - - // Returns: A promise which resolves to a ConnectionInstance - let retryConnection = () => { - let nodeClient = new irc.Client( - server.randomDomain(), opts.nick, connectionOpts - ); - let inst = new ConnectionInstance( - nodeClient, server.domain, opts.nick - ); - onCreatedCallback(inst); - return inst.connect(); - }; - - let connAttempts = 0; - let retryTimeMs = 0; - const BASE_RETRY_TIME_MS = 1000; - while (true) { - try { - if (server.getReconnectIntervalMs() > 0) { - // wait until scheduled - let cli = yield Scheduler.reschedule( - server, retryTimeMs, retryConnection, opts.nick - ); - return cli; - } - // Try to connect immediately: we'll wait if we fail. - let cli = yield retryConnection(); - return cli; - } - catch (err) { - connAttempts += 1; - log.error( - `ConnectionInstance.connect failed after ${connAttempts} attempts (${err.message})` - ); - - if (err.message === "throttled") { - retryTimeMs += THROTTLE_WAIT_MS; - } - - if (err.message === "banned") { - log.error( - `${opts.nick} is banned from ${server.domain}, ` + - `throwing` - ); - throw new Error("User is banned from the network."); - // If the user is banned, we should part them from any rooms. - } - - if (err.message === "toomanyconns") { - log.error( - `User ${opts.nick} was ILINED. This may be the network limiting us!` - ); - throw new Error("Connection was ILINED. We cannot retry this."); - } - - // always set a staggered delay here to avoid thundering herd - // problems on mass-disconnects - let delay = (BASE_RETRY_TIME_MS * Math.random())+ retryTimeMs + - Math.round((connAttempts * 1000) * Math.random()); - log.info(`Retrying connection for ${opts.nick} on ${server.domain} `+ - `in ${delay}ms (attempts ${connAttempts})`); - yield Promise.delay(delay); - } - } -}); - - -module.exports = ConnectionInstance; diff --git a/lib/irc/IdentGenerator.js b/lib/irc/IdentGenerator.js deleted file mode 100644 index 6fecd7ab8..000000000 --- a/lib/irc/IdentGenerator.js +++ /dev/null @@ -1,224 +0,0 @@ -/*eslint no-invalid-this: 0 no-constant-condition: 0 */ -"use strict"; -const Promise = require("bluebird"); -const Queue = require("../util/Queue"); -const log = require("../logging").get("IdentGenerator"); - -function IdentGenerator(store) { - // Queue of ident generation requests. - // We need to queue them because otherwise 2 clashing user_ids could be assigned - // the same ident value (won't be in the database yet) - this.queue = new Queue(this._process.bind(this)); - this.dataStore = store; -} - -// debugging: util.inspect() -IdentGenerator.prototype.inspect = function(depth) { - return "IdentGenerator queue length=" + - (this.queue._queue ? - this.queue._queue.length : -1); -} - - -/** - * Get the IRC name info for this user. - * @param {IrcClientConfig} clientConfig IRC client configuration info. - * @param {MatrixUser} matrixUser Optional. The matrix user. - * @return {Promise} Resolves to { - * username: 'username_to_use', - * realname: 'realname_to_use' - * } - */ -IdentGenerator.prototype.getIrcNames = Promise.coroutine(function*(ircClientConfig, matrixUser) { - var info = { - username: null, - realname: (matrixUser ? - sanitiseRealname(matrixUser.getId()) : - sanitiseRealname(ircClientConfig.getUsername()) - ).substring( - 0, IdentGenerator.MAX_REAL_NAME_LENGTH - ) - }; - if (matrixUser) { - if (ircClientConfig.getUsername()) { - log.debug( - "Using cached ident username %s for %s on %s", - ircClientConfig.getUsername(), matrixUser.getId(), ircClientConfig.getDomain() - ); - info.username = sanitiseUsername(ircClientConfig.getUsername()); - info.username = info.username.substring( - 0, IdentGenerator.MAX_USER_NAME_LENGTH - ); - } - else { - try { - log.debug( - "Pushing username generation request for %s on %s to the queue...", - matrixUser.getId(), ircClientConfig.getDomain() - ); - let uname = yield this.queue.enqueue(matrixUser.getId(), { - matrixUser: matrixUser, - ircClientConfig: ircClientConfig - }); - info.username = uname; - } - catch (err) { - log.error( - "Failed to generate ident username for %s on %s", - matrixUser.getId(), ircClientConfig.getDomain() - ); - log.error(err.stack); - throw err; - } - } - } - else { - info.username = sanitiseUsername( - ircClientConfig.getUsername() // the bridge won't have a matrix user - ); - } - return info; -}); - -IdentGenerator.prototype._process = Promise.coroutine(function*(item) { - var matrixUser = item.matrixUser; - var ircClientConfig = item.ircClientConfig; - var configDomain = ircClientConfig.getDomain(); - - log.debug( - "Generating username for %s on %s", matrixUser.getId(), configDomain - ); - let uname = yield this._generateIdentUsername( - configDomain, matrixUser.getId() - ); - let existingConfig = yield this.dataStore.getIrcClientConfig( - matrixUser.getId(), configDomain - ); - let config = existingConfig ? existingConfig : ircClientConfig; - config.setUsername(uname); - - // persist to db here before releasing the lock on this request. - yield this.dataStore.storeIrcClientConfig(config); - return config.getUsername(); -}); - -/** - * Generate a new IRC username for the given Matrix user on the given server. - * @param {string} domain The IRC server domain - * @param {string} userId The matrix user being bridged - * @return {Promise} resolves to the username {string}. - */ -IdentGenerator.prototype._generateIdentUsername = Promise.coroutine(function*(domain, userId) { - // @foobar££stuff:domain.com => foobar__stuff_domain_com - var uname = sanitiseUsername(userId.substring(1)); - if (uname < IdentGenerator.MAX_USER_NAME_LENGTH) { // bwahaha not likely. - return uname; - } - uname = uname.substring(0, IdentGenerator.MAX_USER_NAME_LENGTH); - /* LONGNAM~1 ing algorithm: - * foobar => foob~1 => foob~2 => ... => foob~9 => foo~10 => foo~11 => ... - * f~9999 => FAIL. - * - * Ideal data structure (Tries): TODO - * (each level from the root node increases digit count by 1) - * .---[f]---. Translation: - * 123[o] [a]743 Up to fo~123 is taken - * | Up to fa~743 is taken - * 34[o] Up to foo~34 is taken - * | Up to foot~9 is taken (re-search as can't increment) - * 9[t] - * - * while not_free(uname): - * if ~ not in uname: - * uname = uname[0:-2] + "~1" // foobar => foob~1 - * continue - * [name, num] = uname.split(~) // foob~9 => ['foob', '9'] - * old_digits_len = len(str(num)) // '9' => 1 - * num += 1 - * new_digits_len = len(str(num)) // '10' => 2 - * if new_digits_len > old_digits_len: - * uname = name[:-1] + "~" + num // foob,10 => foo~10 - * else: - * uname = name + "~" + num // foob,8 => foob~8 - * - * return uname - */ - var delim = "_"; - function modifyUsername() { - if (uname.indexOf(delim) === -1) { - uname = uname.substring(0, uname.length - 2) + delim + "1"; - return true; - } - var segments = uname.split(delim); - var oldLen = segments[1].length; - var num = parseInt(segments[1]) + 1; - if (("" + num).length > oldLen) { - uname = segments[0].substring(0, segments[0].length - 1) + delim + num; - } - else { - uname = segments[0] + delim + num; - } - return uname.indexOf(delim) !== 0; // break out if '~10000' - } - - // TODO: This isn't efficient currently; since this will be called worst - // case 10^[num chars in string] => 10^10 - // We should instead be querying to extract the max occupied number for - // that char string (which is worst case [num chars in string]), e.g. - // fooba => 9, foob => 99, foo => 999, fo => 4523 = fo~4524 - while (true) { - let usr = yield this.dataStore.getMatrixUserByUsername(domain, uname); - if (usr && usr.getId() !== userId) { // occupied username! - if (!modifyUsername()) { - throw new Error("Ran out of entries: " + uname); - } - } - else { - if (!usr) { - log.info( - "Generated ident username %s for %s on %s", - uname, userId, domain - ); - } - else { - log.info( - "Returning cached ident username %s for %s on %s", - uname, userId, domain - ); - } - break; - } - } - return uname; -}); - -function sanitiseUsername(username, replacementChar) { - replacementChar = replacementChar || ""; // default remove chars - username = username.toLowerCase(); - // strip illegal chars according to RFC 1459 Sect 2.3.1 - // (technically it's any ascii for but meh) - // also strip '_' since we use that as the delimiter - username = username.replace(/[^A-Za-z0-9\]\[\^\\\{\}\-`]/g, replacementChar); - // Whilst the RFC doesn't say you can't have special characters eg ("-") as the - // first character of a USERNAME, empirically Freenode rejects connections - // stating "Invalid username". Having "-" is valid, so long as it isn't the first. - // Prefix usernames with "M" if they start with a special character. - if (/^[^A-Za-z]/.test(username)) { - return "M" + username; - } - return username; -} - -function sanitiseRealname(realname) { - // real name can be any old ASCII - return realname.replace(/[^\x00-\x7F]/g, ""); -} - -// The max length of in USER commands -IdentGenerator.MAX_REAL_NAME_LENGTH = 48; - -// The max length of in USER commands -IdentGenerator.MAX_USER_NAME_LENGTH = 10; - - -module.exports = IdentGenerator; diff --git a/lib/irc/Ipv6Generator.js b/lib/irc/Ipv6Generator.js deleted file mode 100644 index e39292091..000000000 --- a/lib/irc/Ipv6Generator.js +++ /dev/null @@ -1,85 +0,0 @@ -/*eslint no-invalid-this: 0 */ -"use strict"; -const Promise = require("bluebird"); -const Queue = require("../util/Queue"); -const log = require("../logging").get("Ipv6Generator"); - -function Ipv6Generator(store) { - // Queue of ipv6 generation requests. - // We need to queue them because otherwise 2 clashing user_ids could be assigned - // the same ipv6 value (won't be in the database yet) - this._queue = new Queue(this._process.bind(this)); - this._dataStore = store; - this._counter = -1; -} - -// debugging: util.inspect() -Ipv6Generator.prototype.inspect = function(depth) { - return "IPv6Counter=" + this._counter + - ",Queue.length=" + (this._queue._queue ? - this._queue._queue.length : -1); -} - -/** - * Generate a new IPv6 address for the given IRC client config. - * @param {string} prefix The IPv6 prefix to use. - * @param {IrcClientConfig} ircClientConfig The config to set the address on. - * @return {Promise} Resolves to the IPv6 address generated; the IPv6 address will - * already be set on the given config. - */ -Ipv6Generator.prototype.generate = Promise.coroutine(function*(prefix, ircClientConfig) { - if (ircClientConfig.getIpv6Address()) { - log.info( - "Using existing IPv6 address %s for %s", - ircClientConfig.getIpv6Address(), - ircClientConfig.getUserId() - ); - return ircClientConfig.getIpv6Address(); - } - if (this._counter === -1) { - log.info("Retrieving counter..."); - this._counter = yield this._dataStore.getIpv6Counter(); - } - - // the bot user will not have a user ID - let id = ircClientConfig.getUserId() || ircClientConfig.getUsername(); - log.info("Enqueueing IPv6 generation request for %s", id); - yield this._queue.enqueue(id, { - prefix: prefix, - ircClientConfig: ircClientConfig - }); - return undefined; -}); - -Ipv6Generator.prototype._process = Promise.coroutine(function*(item) { - this._counter += 1; - - // insert : every 4 characters from the end of the string going to the start - // e.g. 1a2b3c4d5e6 => 1a2:b3c4:d5e6 - let suffix = this._counter.toString(16); - suffix = suffix.replace(/\B(?=(.{4})+(?!.))/g, ':'); - let address = item.prefix + suffix; - - let config = item.ircClientConfig; - config.setIpv6Address(address); - - // we only want to persist the IPv6 address for real matrix users - if (item.ircClientConfig.getUserId()) { - let existingConfig = yield this._dataStore.getIrcClientConfig( - item.ircClientConfig.getUserId(), item.ircClientConfig.getDomain() - ); - if (existingConfig) { - config = existingConfig; - config.setIpv6Address(address); - } - log.info("Generated new IPv6 address %s for %s", address, config.getUserId()); - // persist to db here before releasing the lock on this request. - yield this._dataStore.storeIrcClientConfig(config); - } - - yield this._dataStore.setIpv6Counter(this._counter); - - return config.getIpv6Address(); -}); - -module.exports = Ipv6Generator; diff --git a/lib/irc/IrcEventBroker.js b/lib/irc/IrcEventBroker.js deleted file mode 100644 index 4fe08c8c4..000000000 --- a/lib/irc/IrcEventBroker.js +++ /dev/null @@ -1,509 +0,0 @@ -/* - * This module contains all the logic to determine how incoming events from - * IRC clients are mapped to events which are passed to the bridge. - * - * For example, every connected IRC client will get messages down their TCP - * stream, but only 1 client should pass this through to the bridge to - * avoid duplicates. This is typically handled by the MatrixBridge which is a - * bot whose job it is to be the unique entity to have responsibility for passing - * these events through to the bridge. - * - * However, we support disabling the bridge entirely which means one of the many - * TCP streams needs to be responsible for passing the message to the bridge. - * This is done using the following algorithm: - * - Create a hash "H" of (prefix, command, command-parameters) (aka the line) - * - Does H exist in the "processed" list? - * * YES: Was it you who processed H before? - * * YES: Process it again (someone sent the same message twice). - * * NO: Ignore this message. (someone else has processed this) - * * NO: Add H to the "processed" list with your client associated with it - * (this works without racing because javascript is single-threaded) - * and pass the message to the bridge for processing. - * There are problems with this approach: - * - Unbounded memory consumption on the "processed" list. - * - Clients who previously "owned" messages disconnecting and not handling - * a duplicate messsage. - * These are fixed by: - * - Periodically culling the "processed" list after a time T. - * - Checking if the client who claimed a message still has an active TCP - * connection to the server. If they do not have an active connection, the - * message hash can be "stolen" by another client. - * - * Rationale - * --------- - * In an ideal world, we'd have unique IDs on each message and it'd be first come, - * first serve to claim an incoming message, but IRC doesn't "do" unique IDs. - * - * As a result, we need to handle the case where we get a message down that looks - * exactly like one that was previously handled. Handling this across clients is - * impossible (every message comes down like this, appearing as dupes). Handling - * this *within* a client is possible; the *SAME* client which handled the prev - * message knows that this isn't a dupe because dupes aren't sent down the same - * TCP connection. - * - * Handling messages like this is risky though. We don't know for sure if the - * client that handled the prev message will handle this new message. Therefore, - * we check if the client who did the prev message is "dead" (inactive TCP conn), - * and then "steal" ownership of that message if it is dead (again, this is - * thread-safe provided the check and steal is done on a single turn of the event - * loop). Even this isn't perfect though, as the connection may die without us - * being aware of it (before TCP/app timeouts kick in), so we want to avoid having - * to rely on stealing messages. - * - * We use a hashing algorithm mainly to reduce the key length per message - * (which would otherwise be max 510 bytes). The strength of the hash (randomness) - * determines the reliability of the bridge because it determines the rate of - * "stealing" that is performed. At the moment, a max key size of 510 bytes is - * acceptable with our expected rate of messages, so we're using the identity - * function as our hash algorithm. - * - * Determining when to remove these keys from the processed dict is Hard. We can't - * just mark it off when "all clients" get the message because all clients MAY NOT - * always get the message e.g. due to a disconnect (leading to dead keys which - * are never collected). Timeouts are reasonable but they need to be > TCP level - * MSL (worse case) assuming the IRCd in question doesn't store-and-forward. The - * MSL is typically 2 minutes, so a collection interval of 10 minutes is long - * enough. - */ - -"use strict"; -const IrcAction = require("../models/IrcAction"); -const IrcUser = require("../models/IrcUser"); -const BridgeRequest = require("../models/BridgeRequest"); -const log = require("../logging").get("IrcEventBroker"); - -const CLEANUP_TIME_MS = 1000 * 60 * 10; // 10min - -function ProcessedDict() { - this.processed = { - // server.domain: { - // hash: { - // nick: , - // ts: