diff --git a/.check-gofmt.sh b/.check-gofmt.sh deleted file mode 100755 index b8ab7c78..00000000 --- a/.check-gofmt.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash - -# exclude vendor/ -SOURCES="./ergo.go ./irc" - -if [ "$1" = "--fix" ]; then - exec gofmt -s -w $SOURCES -fi - -if [ -n "$(gofmt -s -l $SOURCES)" ]; then - echo "Go code is not formatted correctly with \`gofmt -s\`:" - gofmt -s -d $SOURCES - exit 1 -fi diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index e53671d9..00000000 --- a/.gitattributes +++ /dev/null @@ -1,2 +0,0 @@ -vendor/* linguist-vendored -languages/* linguist-vendored diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 88765afc..00000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: "build" - -on: - pull_request: - branches: - - "master" - - "stable" - push: - branches: - - "master" - - "stable" - -jobs: - build: - runs-on: "ubuntu-22.04" - steps: - - name: "checkout repository" - uses: "actions/checkout@v3" - - name: "setup go" - uses: "actions/setup-go@v3" - with: - go-version: "1.23" - - name: "install python3-pytest" - run: "sudo apt install -y python3-pytest" - - name: "make install" - run: "make install" - - name: "make test" - run: "make test" - - name: "make smoke" - run: "make smoke" - - name: "make irctest" - run: "make irctest" diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml deleted file mode 100644 index 97ebb025..00000000 --- a/.github/workflows/docker-image.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: 'ghcr' - -on: - push: - branches: - - "master" - - "stable" - tags: - - 'v*' - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -jobs: - build: - name: Build - runs-on: ubuntu-latest - steps: - - name: Checkout Git repository - uses: actions/checkout@v3 - - - name: Authenticate to container registry - uses: docker/login-action@v2 - if: github.event_name != 'pull_request' - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v4 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - - name: Setup Docker buildx driver - id: buildx - uses: docker/setup-buildx-action@v2 - - - name: Build and publish image - uses: docker/build-push-action@v3 - with: - context: . - push: ${{ github.event_name != 'pull_request' }} - platforms: linux/amd64,linux/arm64 - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore index 385b220d..896b8dd0 100644 --- a/.gitignore +++ b/.gitignore @@ -95,7 +95,7 @@ _testmain.go *.out -### custom ### +### Oragono ### /_site/ /.vscode/* /ircd* @@ -103,11 +103,9 @@ _testmain.go /web.* /ssl.* /tls.* -/ergo +/oragono /build/* _test -ergo.prof -ergo.mprof +oragono.prof +oragono.mprof /dist -*.pem -.dccache \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index 9cbfa433..3f6c07b5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "irctest"] - path = irctest - url = https://github.com/ergochat/irctest +[submodule "vendor"] + path = vendor + url = https://github.com/oragono/oragono-vendor.git diff --git a/.goreleaser.yml b/.goreleaser.yml index 71dfe094..1a256765 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,81 +1,47 @@ # .goreleaser.yml # Build customization -version: 2 -project_name: ergo +project_name: oragono builds: - - main: ergo.go - env: - - CGO_ENABLED=0 - binary: ergo + - main: oragono.go + binary: oragono goos: - - linux + - freebsd - windows - darwin - - freebsd - - openbsd - - plan9 + - linux goarch: - amd64 - arm - arm64 - - riscv64 goarm: - 6 + - 7 ignore: - goos: windows goarch: arm - - goos: windows - goarch: arm64 - - goos: windows - goarch: riscv64 - goos: darwin goarch: arm - - goos: darwin - goarch: riscv64 - goos: freebsd goarch: arm - goos: freebsd goarch: arm64 - - goos: freebsd - goarch: riscv64 - - goos: openbsd - goarch: arm - - goos: openbsd - goarch: arm64 - - goos: openbsd - goarch: riscv64 - - goos: plan9 - goarch: arm - - goos: plan9 - goarch: arm64 - - goos: plan9 - goarch: riscv64 - flags: - - -trimpath - -archives: - - - name_template: >- - {{ .ProjectName }}-{{ .Version }}- - {{- if eq .Os "darwin" }}macos{{- else }}{{ .Os }}{{ end -}}- - {{- if eq .Arch "amd64" }}x86_64{{- else }}{{ .Arch }}{{ end -}} - {{ if .Arm }}v{{ .Arm }}{{ end -}} - format: tar.gz - format_overrides: - - goos: windows - format: zip - files: - - README - - CHANGELOG.md - - LICENSE - - ergo.motd - - default.yaml - - traditional.yaml - - docs/MANUAL.md - - docs/USERGUIDE.md - - languages/*.yaml - - languages/*.json - - languages/*.md - wrap_in_directory: true +archive: + name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" + format: tar.gz + replacements: + amd64: x64 + darwin: osx + format_overrides: + - goos: windows + format: zip + files: + - README + - CHANGELOG.md + - oragono.motd + - oragono.yaml + - docs/* + - languages/*.yaml + - languages/*.json + - languages/*.md checksum: name_template: "{{ .ProjectName }}-{{ .Version }}-checksums.txt" diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..4c7b9780 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,9 @@ +language: go + +install: make deps + +script: +- wget https://github.com/goreleaser/goreleaser/releases/download/v0.62.2/goreleaser_Linux_x86_64.tar.gz +- tar -xzf goreleaser_Linux_x86_64.tar.gz -C $GOPATH/bin +- make +- make test diff --git a/CHANGELOG.md b/CHANGELOG.md index cdac5518..4fc4e487 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,1299 +1,32 @@ # Changelog -All notable changes to Ergo will be documented in this file. +All notable changes to Oragono will be documented in this file. -## [2.14.0] - 2024-06-30 +This project adheres to [Semantic Versioning](http://semver.org/). For the purposes of versioning, we consider the "public API" to refer to the configuration files, CLI interface and database format. -We're pleased to be publishing v2.14.0, a new stable release. This release contains primarily bug fixes, with the addition of some new authentication mechanisms for integrating with web clients. -This release includes changes to the config file format, all of which are fully backwards-compatible and do not require updating the file before upgrading. It includes no changes to the database file format. - -Many thanks to [@al3xandros](https://github.com/al3xandros), donio, [@eeeeeta](https://github.com/eeeeeta), [@emersion](https://github.com/emersion), [@Eriner](https://github.com/Eriner), [@eskimo](https://github.com/eskimo), [@Herringway](https://github.com/Herringway), [@jwheare](https://github.com/jwheare), [@knolley](https://github.com/knolley), [@mengzhuo](https://github.com/mengzhuo), pathof, [@poVoq](https://github.com/poVoq), [@progval](https://github.com/progval), [@RNDpacman](https://github.com/RNDpacman), and [@xnaas](https://github.com/xnaas) for contributing patches, reporting issues, and helping test. - -### Config changes -* Added `accounts.oauth2` and `accounts.jwt-auth` blocks for configuring OAuth2 and JWT authentication (#2004) -* Added `protocol` and `local-address` options to `accounts.registration.email-verification`, to force emails to be sent over IPv4 (or IPv6) or to force the use of a particular source address (#2142) -* Added `limits.realnamelen`, a configurable limit on the length of realnames. If unset, no limit is enforced beyond the IRC protocol line length limits (the previous behavior). (#2123, thanks [@eskimo](https://github.com/eskimo)!) -* Added the `accept-hostname` option to the webirc config block, allowing Ergo to accept hostnames passed from reverse proxies on the `WEBIRC` line. Note that this will have no effect under the default/recommended configuration, in which cloaks are used instead (#1686, #2146, thanks [@RNDpacman](https://github.com/RNDpacman)!) -* The default/recommended value of `limits.chan-list-modes` (the size limit for ban/except/invite lists) was raised to 100 (#2081, #2165, #2167) - -### Added -* Added support for the `OAUTHBEARER` SASL mechanism, allowing Ergo to interoperate with Gamja and an OAuth2 provider (#2004, #2122, thanks [@emersion](https://github.com/emersion)!) -* Added support for the [`IRCV3BEARER` SASL mechanism](https://github.com/ircv3/ircv3-specifications/pull/545), allowing Ergo to accept OAuth2 or JWT bearer tokens (#2158) -* Added support for the legacy `rfc1459` and `rfc1459-strict` casemappings (#2099, #2159, thanks [@xnaas](https://github.com/xnaas)!) -* The new `ergo defaultconfig` subcommand prints a copy of the default config file to standard output (#2157, #2160, thanks [@al3xandros](https://github.com/al3xandros)!) - -### Fixed -* Even with `allow-truncation: false` (the recommended default), some oversized messages were being accepted and relayed with truncation. These messages will now be rejected with `417 ERR_INPUTTOOLONG` as expected (#2170) -* NICK and QUIT from invisible members of auditorium channels are no longer recorded in history (#2133, #2137, thanks [@knolley](https://github.com/knolley) and [@poVoq](https://github.com/poVoq)!) -* If channel registration was disabled, registered channels could become inaccessible after rehash; this has been fixed (#2130, thanks [@eeeeeta](https://github.com/eeeeeta)!) -* Attempts to use unrecognized SASL mechanisms no longer count against the login throttle, improving compatibility with Pidgin (#2156, thanks donio and pathof!) -* Fixed database autoupgrade on Windows, which was previously broken due to the use of a colon in the backup filename (#2139, #2140, thanks [@Herringway](https://github.com/Herringway)!) -* Fixed handling of `NS CERT ADD ` when an unprivileged user invokes it on themself (#2128, #2098, thanks [@Eriner](https://github.com/Eriner)!) -* Fixed missing human-readable trailing parameters for two multiline `FAIL` messages (#2043, #2162, thanks [@jwheare](https://github.com/jwheare) and [@progval](https://github.com/progval)!) -* Fixed symbol sent by `353 RPL_NAMREPLY` for secret channels (#2144, #2145, thanks savoyard!) - -### Changed -* Trying to claim a registered nickname that is also actually in use by another client now produces `433 ERR_NICKNAMEINUSE` as expected (#2135, #2136, thanks savoyard!) -* `SAMODE` now overrides the enforcement of `limits.chan-list-modes` (the size limit for ban/except/invite lists) (#2081, #2165) -* Certain unsuccessful `MODE` changes no longer send `324 RPL_CHANNELMODEIS` and `329 RPL_CREATIONTIME` (#2163) -* Debug logging for environment variable configuration overrides no longer prints the value, only the key (#2129, #2132, thanks [@eeeeeta](https://github.com/eeeeeta)!) - -### Internal - -* Official release builds use Go 1.22.4 -* Added a linux/riscv64 release (#2172, #2173, thanks [@mengzhuo](https://github.com/mengzhuo)!) - -## [2.13.1] - 2024-05-06 - -Ergo 2.13.1 is a bugfix release, fixing an exploitable deadlock that could lead to a denial of service. We regret the oversight. - -This release includes no changes to the config file format or database format. - -### Security - -* Fixed an exploitable deadlock that could lead to a denial of service (#2149) - -### Internal - -* Official release builds use Go 1.22.2 - - -## [2.13.0] - 2024-01-14 - -We're pleased to be publishing v2.13.0, a new stable release. This is a bugfix release that fixes some issues, including a crash. - -This release includes no changes to the config file format or database format. - -Many thanks to [@dallemon](https://github.com/dallemon), [@jwheare](https://github.com/jwheare), [@Mikaela](https://github.com/Mikaela), [@nealey](https://github.com/nealey), and [@Sheikah45](https://github.com/Sheikah45) for contributing patches, reporting issues, and helping test. - -### Fixed - -* Fixed a (hopefully rare) crash when persisting always-on client statuses (#2113, #2117, thanks [@Sheikah45](https://github.com/Sheikah45)!) -* Fixed not being able to message channels with `/` (or the configured `RELAYMSG` separator) in their names (#2114, thanks [@Mikaela](https://github.com/Mikaela)!) -* Verification emails now always include a `Message-ID` header, improving compatibility with Gmail (#2108, #2110) -* Improved human-readable description of `REDACT_FORBIDDEN` (#2101, thanks [@jwheare](https://github.com/jwheare)!) - -### Removed - -* Removed numerics associated with the retired ACC spec (#2109, #2111, thanks [@jwheare](https://github.com/jwheare)!) - -### Internal - -* Upgraded the Docker base image from Alpine 3.13 to 3.19. The resulting images are incompatible with Docker 19.x and lower (all currently non-EOL Docker versions should be supported). (#2103) -* Official release builds use Go 1.21.6 - - -## [2.12.0] - 2023-10-10 - -We're pleased to be publishing v2.12.0, a new stable release. This is another bugfix release aimed at improving client compatibility and keeping up with the IRCv3 specification process. - -This release includes changes to the config file format, one of which is a compatibility break: if you were using `accounts.email-verification.blacklist-regexes`, you can restore the previous functionality by renaming `blacklist-regexes` to `address-blacklist` and setting the additional key `address-blacklist-syntax: regex`. See [default.yaml](https://github.com/ergochat/ergo/blob/e7597876d987a6fc061b768fcf878d0035d1c85a/default.yaml#L422-L424) for an example; for more details, see the "Changed" section below. - -This release includes a database change. If you have `datastore.autoupgrade` set to `true` in your configuration, it will be automatically applied when you restart Ergo. Otherwise, you can update the database manually by running `ergo upgradedb` (see the manual for complete instructions). - -Many thanks to [@adsr](https://github.com/adsr), [@avollmerhaus](https://github.com/avollmerhaus), [@csmith](https://github.com/csmith), [@EchedeyLR](https://github.com/EchedeyLR), [@emersion](https://github.com/emersion), [@eskimo](https://github.com/eskimo), [@julio-b](https://github.com/julio-b), knolle, [@KoxSosen](https://github.com/KoxSosen), [@Mikaela](https://github.com/Mikaela), [@mogad0n](https://github.com/mogad0n), and [@progval](https://github.com/progval) for contributing patches, reporting issues, and helping test. - -### Config changes -* Removed `accounts.email-verification.blacklist-regexes` in favor of `address-blacklist`, `address-blacklist-syntax`, and `address-blacklist-file`. See the "Changed" section below for the semantics of these new keys. (#1997, #2088) -* Added `implicit-tls` (TLS from the first byte) support for MTAs (#2048, #2049, thanks [@EchedeyLR](https://github.com/EchedeyLR)!) - -### Fixed -* Fixed an edge case under `allow-truncation: true` (the recommended default is `false`) where Ergo could truncate a message in the middle of a UTF-8 codepoint (#2074) -* Fixed `CHATHISTORY TARGETS` being sent in a batch even without negotiation of the `batch` capability (#2066, thanks [@julio-b](https://github.com/julio-b)!) -* Errors from `/REHASH` are now properly sanitized before being sent to the user, fixing an edge case where they would be dropped (#2031, thanks [@eskimo](https://github.com/eskimo)! -* Fixed some edge cases in auto-away aggregation (#2044) -* Fixed a FAIL code sent by draft/account-registration (#2092, thanks [@progval](https://github.com/progval)!) -* Fixed a socket leak in the ident client (default/recommended configurations of Ergo disable ident and are not affected by this issue) (#2089) - -### Changed -* Bouncer reattach from an "insecure" session is no longer disallowed. We continue to recommend that operators preemptively disable all insecure transports, such as plaintext listeners (#2013) -* Email addresses are now converted to lowercase before checking them against the blacklist (#1997, #2088) -* The default syntax for the email address blacklist is now "glob" (expressions with `*` and `?` as wildcard characters), as opposed to the full [Go regular expression syntax](https://github.com/google/re2/wiki/Syntax). To enable full regular expression syntax, set `address-blacklist-syntax: regex`. -* Due to line length limitations, some capabilities are now hidden from clients that only support version 301 CAP negotiation. To the best of our knowledge, all clients that support these capabilities also support version 302 CAP negotiation, rendering this moot (#2068) -* The default/recommended configuration now advertises the SCRAM-SHA-256 SASL method. We still do not recommend using this method in production. (#2032) -* Improved KILL messages (#2053, #2041, thanks [@mogad0n](https://github.com/mogad0n)!) - -### Added -* Added support for automatically joining new clients to a channel or channels (#2077, #2079, thanks [@adsr](https://github.com/adsr)!) -* Added implicit TLS (TLS from the first byte) support for MTAs (#2048, #2049, thanks [@EchedeyLR](https://github.com/EchedeyLR)!) -* Added support for [draft/message-redaction](https://github.com/ircv3/ircv3-specifications/pull/524) (#2065, thanks [@progval](https://github.com/progval)!) -* Added support for [draft/pre-away](https://github.com/ircv3/ircv3-specifications/pull/514) (#2044) -* Added support for [draft/no-implicit-names](https://github.com/ircv3/ircv3-specifications/pull/527) (#2083) -* Added support for the [MSGREFTYPES](https://ircv3.net/specs/extensions/chathistory#isupport-tokens) 005 token (#2042) -* Ergo now advertises the [standard-replies](https://ircv3.net/specs/extensions/standard-replies) capability. Requesting this capability does not change Ergo's behavior. - -### Internal -* Release builds are now statically linked by default. This should not affect normal chat operations, but may disrupt attempts to connect to external services (e.g. MTAs) that are configured using a hostname that relies on libc's name resolution behavior. To restore the old behavior, build from source with `CGO_ENABLED=1`. (#2023) -* Upgraded to Go 1.21 (#2045, #2084); official release builds use Go 1.21.3, which includes a fix for CVE-2023-44487 -* The default `make` target is now `build` (which builds an `ergo` binary in the working directory) instead of `install` (which builds and installs an `ergo` binary to `${GOPATH}/bin/ergo`). Take note if building from source, or testing Ergo in development! (#2047) -* `make irctest` now depends on `make install`, in an attempt to ensure that irctest runs against the intended development version of Ergo (#2047) - -## [2.11.1] - 2022-01-22 - -Ergo 2.11.1 is a bugfix release, fixing a denial-of-service issue in our websocket implementation. We regret the oversight. - -This release includes no changes to the config file format or database file format. - -### Security -* Fixed a denial-of-service issue affecting websocket clients (#2039) - -## [2.11.0] - 2022-12-25 - -We're pleased to be publishing v2.11.0, a new stable release. This is another bugfix release aimed at improving client compatibility and keeping up with the IRCv3 specification process. - -This release includes changes to the config file format, all of which are fully backwards-compatible and do not require updating the file before upgrading. It includes no changes to the database file format. - -Many thanks to dedekro, [@emersion](https://github.com/emersion), [@eskimo](https://github.com/eskimo), [@FiskFan1999](https://github.com/FiskFan1999), hauser, [@jwheare](https://github.com/jwheare), [@kingter-sutjiadi](https://github.com/kingter-sutjiadi), knolle, [@Mikaela](https://github.com/Mikaela), [@mogad0n](https://github.com/mogad0n), [@PeGaSuS-Coder](https://github.com/PeGaSuS-Coder), and [@progval](https://github.com/progval) for contributing patches, reporting issues, and helping test. - -### Config changes - -* Added `fakelag.command-budgets`, which allows each client session a limited number of specific commands that are exempt from fakelag. This improves compatibility with Goguma in particular. For the current recommended default, see `default.yaml` (#1978, thanks [@emersion](https://github.com/emersion)!) -* The recommended value of `server.casemapping` is now `ascii` instead of `precis`. PRECIS remains fully supported; if you are already running an Ergo instance, we do not recommend changing the value unless you are confident that your existing users are not relying on non-ASCII nicknames and channel names. (#1718) - -### Changed - -* Network services like `NickServ` now appear in `WHO` responses where applicable (#1850, thanks [@emersion](https://github.com/emersion)!) -* The `extended-monitor` capability now appears under its ratified name (#2006, thanks [@progval](https://github.com/progval)!) -* `TAGMSG` no longer receives automatic `RPL_AWAY` responses (#1983, thanks [@eskimo](https://github.com/eskimo)!) -* `UBAN` now states explicitly that bans without a time limit have "indefinite" duration (#1988, thanks [@mogad0n](https://github.com/mogad0n)!) - -### Fixed - -* `WHO` with a bare nickname as an argument now shows invisible users, comparable to `WHOIS` (#1991, thanks [@emersion](https://github.com/emersion)!) -* MySQL did not work on 32-bit architectures; this has been fixed (#1969, thanks hauser!) -* Fixed the name of the `CHATHISTORY` 005 token (#2008, #2009, thanks [@emersion](https://github.com/emersion)!) -* Fixed handling of the address `::1` in WHOX output (#1980, thanks knolle!) -* Fixed handling of `AWAY` with an empty parameter (the de facto standard is to treat as a synonym for no parameter, which means "back") (#1996, thanks [@emersion](https://github.com/emersion), [@jwheare](https://github.com/jwheare)!) -* Fixed incorrect handling of some invalid modes in `CS AMODE` (#2002, thanks [@eskimo](https://github.com/eskimo)!) -* Fixed incorrect help text for `NS SAVERIFY` (#2021, thanks [@FiskFan1999](https://github.com/FiskFan1999)!) - -### Added - -* Added the `draft/persistence` capability and associated `PERSISTENCE` command. This is a first attempt to standardize Ergo's "always-on" functionality so that clients can interact with it programmatically. (#1982) -* Sending `SIGUSR1` to the Ergo process now prints a full goroutine stack dump to stderr, allowing debugging even when the HTTP pprof listener is disabled (#1975) - -### Internal - -* Upgraded to Go 1.19; this makes further architecture-specific bugs like #1969 much less likely (#1987, #1989) -* The test suite is now parallelized (#1976, thanks [@progval](https://github.com/progval)!) - - -## [2.10.0] - 2022-05-29 - -We're pleased to be publishing v2.10.0, a new stable release. - -This release contains no changes to the config file format or database file format. - -Many thanks to [@csmith](https://github.com/csmith), [@FiskFan1999](https://github.com/FiskFan1999), [@Mikaela](https://github.com/Mikaela), [@progval](https://github.com/progval), and [@thesamesam](https://github.com/thesamesam) for contributing patches, and to [@emersion](https://github.com/emersion), [@eskimo](https://github.com/eskimo), [@FiskFan1999](https://github.com/FiskFan1999), [@jigsy1](https://github.com/jigsy1), [@Mikaela](https://github.com/Mikaela), [@mogad0n](https://github.com/mogad0n), [@progval](https://github.com/progval), and [@xnaas](https://github.com/xnaas) for reporting issues and helping test. - -### Config changes - -* For better interoperability with [Goguma](https://sr.ht/~emersion/goguma/), the recommended value of `history.chathistory-maxmessages` has been increased to `1000` (previously `100`) (#1919) - -### Changed -* Persistent voice (`AMODE +v`) in a channel is now treated as a permanent invite (i.e. overriding `+i` on the channel) (#1901, thanks [@eskimo](https://github.com/eskimo)!) -* If you are `+R`, sending a direct message to an anonymous user allows them to send you replies (#1687, #1688, thanks [@Mikaela](https://github.com/Mikaela) and [@progval](https://github.com/progval)!) -* `0` is no longer valid as a nickname or account name, with a grandfather exception if it was registered on a previous version of Ergo (#1896) -* Implemented the [ratified version of the bot mode spec](https://ircv3.net/specs/extensions/bot-mode); the tag name is now `bot` instead of `draft/bot` (#1938) -* Privileged WHOX on a user with multiclient shows an arbitrarily chosen client IP address, comparable to WHO (#1897) -* `SAREGISTER` is allowed even under `DEFCON` levels 4 and lower (#1922) -* Operators with the `history` capability are now exempted from time cutoff restrictions on history retrieval (#1593, #1955) - -### Added -* Added `draft/read-marker` capability, allowing server-side tracking of read messages for synchronization across multiple clients. (#1926, thanks [@emersion](https://github.com/emersion)!) -* `INFO` now includes the server start time (#1895, thanks [@xnaas](https://github.com/xnaas)!) -* Added `ACCEPT` command modeled on Charybdis/Solanum, allowing `+R` users to whitelist users who can DM them (#1688, thanks [@Mikaela](https://github.com/Mikaela)!) -* Added `NS SAVERIFY` for operators to manually complete an account verification (#1924, #1952, thanks [@tacerus](https://github.com/tacerus)!) - -### Fixed -* Having the `samode` operator capability made all uses of the `KICK` command privileged (i.e. overriding normal channel privilege checks); this has been fixed (#1906, thanks [@pcho](https://github.com/pcho)!) -* Fixed `LIST * *` as expected; this has been fixed (#1883, #1884, thanks [@FiskFan1999](https://github.com/FiskFan1999)!) -* RELAYMSG identifiers that were not already in their case-normalized form could not be muted with `+b m:`; this has been fixed (#1838, thanks [@mogad0n](https://github.com/mogad0n)!) -* CS AMODE changes did not take immediate effect if `force-nick-equals-account` was disabled and the nick did not coincide with the account; this has been fixed (#1860, thanks [@eskimo](https://github.com/eskimo)!) -* `315 RPL_ENDOFWHO` now sends the exact, un-normalized mask argument provided by the client (#1831, thanks [@progval](https://github.com/progval)!) -* A leading `$` character is now disallowed in new nicknames and account names, to avoid collision with the massmessage syntax (#1857, thanks [@emersion](https://github.com/emersion)!) -* The [deprecated](https://github.com/ircdocs/modern-irc/pull/138) `o` parameter of `WHO` now returns an empty list of results, instead of being ignored (#1730, thanks [@kylef](https://github.com/kylef), [@emersion](https://github.com/emersion), [@progval](https://github.com/progval)!) -* WHOX queries for channel oplevel now receive `*` instead of `0` (#1866, thanks [@Jobe1986](https://github.com/Jobe1986)!) - -### Internal -* Updated list of official release binaries: added Apple M1, OpenBSD x86-64, and Plan 9 x86-64, removed Linux armv7, FreeBSD x86-32, and Windows x86-32. (The removed platforms are still fully supported by Ergo; you can build them from source or ask us for help.) (#1833) -* Added an official Linux arm64 Docker image (#1855, thanks [@erincerys](https://github.com/erincerys)!) -* Added service management files for OpenSolaris/Illumos (#1846, thanks [@tacerus](https://github.com/tacerus)!) - - -## [2.8.0] - 2021-11-14 - -We're pleased to be publishing Ergo 2.8.0. This release contains many fixes and enhancements, plus one major user-facing feature: user-initiated password resets via e-mail (#734). - -This release includes changes to the config file format, all of which are fully backwards-compatible and do not require updating the file before upgrading. - -This release includes a database change. If you have `datastore.autoupgrade` set to `true` in your configuration, it will be automatically applied when you restart Ergo. Otherwise, you can update the database manually by running `ergo upgradedb` (see the manual for complete instructions). - -As part of this release, our official Docker images have moved from Docker Hub to the GitHub Container Registry, at `ghcr.io/ergochat/ergo`. The `stable` and `master` tags correspond to the respective branches. Tagged releases (e.g. `v2.8.0`) are available under the corresponding named tags. - -Many thanks to [@ajaspers](https://github.com/ajaspers), [@delthas](https://github.com/delthas), [@mogad0n](https://github.com/mogad0n), [@majiru](https://github.com/majiru), [@ProgVal](https://github.com/ProgVal), and [@tacerus](https://github.com/tacerus) for contributing patches, to [@ajaspers](https://github.com/ajaspers) for contributing code review, to [@ajaspers](https://github.com/ajaspers), [@cxxboy](https://github.com/cxxboy), [@dallemon](https://github.com/dallemon), [@emersion](https://github.com/emersion), [@erikh](https://github.com/erikh), [@eskimo](https://github.com/eskimo), [@jwheare](https://github.com/jwheare), [@kylef](https://github.com/kylef), [@Mikaela](https://github.com/Mikaela), [@mogad0n](https://github.com/mogad0n), [@MystaraTheGreat](https://github.com/MystaraTheGreat), [@ProgVal](https://github.com/ProgVal), [@tacerus](https://github.com/tacerus), [@tamiko](https://github.com/tamiko), and [@xnaas](https://github.com/xnaas) for reporting issues and helping test, and to our translators for contributing translations. - -### Config changes -* Added `accounts.registration.email-verification.password-reset` block to configure e-mail-based password reset (#734, #1779) -* Added `accounts.registration.email-verification.timeout` to impose a timeout on e-mail sending; the recommended default value is `60s` (60 seconds) (#1741) -* Added `server.suppress-lusers` to allow hiding the LUSERS counts (#1802, thanks [@eskimo](https://github.com/eskimo)!) - -### Security -* Added `accounts.registration.email-verification.timeout` to impose a timeout on e-mail sending; the recommended default value is `60s` (60 seconds) (#1741) - -### Added -* Added user-initiated password resets via email (#734). This requires e-mail verification of accounts, and must additionally be enabled explicitly: see the `email-verification` block in `default.yaml` for more information. -* Added the `draft/extended-monitor` capability (#1761, thanks [@delthas](https://github.com/delthas)!) -* When doing direct sending of verification emails, make email delivery failures directly visible to the end user (#1659, #1741, thanks [@tacerus](https://github.com/tacerus)!) -* For operators, `NS INFO` now shows the user's email address (you can also view your own address) (#1677, thanks [@ajaspers](https://github.com/ajaspers)!) -* Operators with the appropriate permissions will now see IPs in `/WHOWAS` output (#1702, thanks [@ajaspers](https://github.com/ajaspers)!) -* Added the `+s d` snomask, for operators to receive information about session disconnections that do not result in a full QUIT (#1709, #1728, thanks [@mogad0n](https://github.com/mogad0n)!) -* Added support for the `SCRAM-SHA-256` SASL authentication mechanism (#175). This mechanism is not currently advertised in `CAP LS` output because IRCCloud handles it incorrectly. We also [recommend against using SCRAM because of its lack of genuine security benefits](https://gist.github.com/slingamn/3f2fed196df5ef14d1316a1ffa9d59f8). -* `/UBAN LIST` output now includes the time the ban was created (#1725, #1755, thanks [@Mikaela](https://github.com/Mikaela) and [@mogad0n](https://github.com/mogad0n)!) -* Added support for running as a `Type=notify` systemd service (#1733) -* Added a warning to help users detect incorrect uses of `/QUOTE` (#1530) - -### Fixed -* The `+M` (only registered users can speak) channel mode did not work; this has been fixed (#1696, thanks [@Mikaela](https://github.com/Mikaela)!) -* A channel `/RENAME` that only changed the case of the channel would delete the channel registration; this has been fixed (#1751, thanks [@Mikaela](https://github.com/Mikaela)!) -* Fixed `allow-truncation: true` not actually allowing truncation of overlong lines (#1766, thanks [@tacerus](https://github.com/tacerus)!) -* Fixed several pagination bugs in `CHATHISTORY` (#1676, thanks [@emersion](https://github.com/emersion)!) -* Fixed support for kicking multiple users from a channel on the same line, the `TARGMAX` 005 parameter that advertises this, and the default kick message (#1748, #1777, #1776), thanks [@ProgVal](https://github.com/ProgVal)!) -* Fixed `/SAMODE` on a channel not producing a snomask (#1787, thanks [@mogad0n](https://github.com/mogad0n), [@ajaspers](https://github.com/ajaspers)!) -* Adding `+f` to a channel with `SAMODE` used to require channel operator privileges on the receiving channel; this has been fixed (#1825, thanks [@Mikaela](https://github.com/Mikaela)!) -* Fixed parameters sent with `697 ERR_LISTMODEALREADYSET` and `698 ERR_LISTMODENOTSET` (#1727, thanks [@kylef](https://github.com/kylef)!) -* Fixed parameter sent with `696 ERR_INVALIDMODEPARAM` (#1773, thanks [@kylef](https://github.com/kylef)!) -* Fixed handling of channel mode `+k` with an empty parameter (#1774, #1775, thanks [@ProgVal](https://github.com/ProgVal)!) -* `WHOWAS` with an empty string as the parameter now produces an appropriate error response (#1703, thanks [@kylef](https://github.com/kylef)!) -* Fixed error response to an empty realname on the `USER` line (#1778, thanks [@ProgVal](https://github.com/ProgVal)!) -* Fixed `/UBAN ADD` of a NUH mask (i.e. a k-line) not killing affected clients (#1736, thanks [@mogad0n](https://github.com/mogad0n)!) -* Fixed buggy behavior when `+i` is configured as a default mode for channels (#1756, thanks [@Mikaela](https://github.com/Mikaela)!) -* Fixed issues with `channels.operator-only-creation` not respecting `/SAJOIN` or always-on clients (#1757) -* Protocol-breaking operator vhosts are now disallowed during config validation (#1722) -* Fixed error message associated with `/NS PASSWD` on a nonexistent account (#1738, thanks [@Mikaela](https://github.com/Mikaela)!) -* Fixed an incorrect `CHATHISTORY` fail message (#1731, thanks [@ProgVal](https://github.com/ProgVal)!) -* Fixed a panic on an invalid configuration case (#1714, thanks [@erikh](https://github.com/erikh)!) - -### Changed -* Upgraded the `draft/register` capability to the latest [`draft/account-registration`](https://github.com/ircv3/ircv3-specifications/pull/435) iteration (#1740) -* Unregistered users with `+v` or higher can now speak in `+R` (registered-only) channels (#1715, thanks [@Mikaela](https://github.com/Mikaela) and [@ajaspers](https://github.com/ajaspers)!) -* For always-on clients with at least one active connection, `338 RPL_WHOISACTUALLY` now displays an arbitrarily chosen client IP address (#1650, thanks [@MystaraTheGreat](https://github.com/MystaraTheGreat)!) -* `#` can no longer be used in new account names and nicknames, or as the RELAYMSG separator (#1679) -* The `oragono.io/nope` capability was renamed to `ergo.chat/nope` (#1793) - -### Removed -* `never` is no longer accepted as a value of the `replay-joins` NickServ setting (`/NS SET replay-joins`); user accounts which enabled this setting have been reverted to the default value of `commands-only` (#1676) - -### Internal -* We have a cool new logo! -* Official builds now use Go 1.17 (#1781) -* Official Docker containers are now at [ghcr.io/ergochat/ergo](https://ghcr.io/ergochat/ergo) (#1808) -* Added a traditional SysV init script (#1691, thanks [@tacerus](https://github.com/tacerus)!) -* Added an s6 init script (#1786, thanks [@majiru](https://github.com/majiru)!) - -## [2.7.0] - 2021-06-07 - -We're pleased to be publishing Ergo 2.7.0, our first official release under our new name of Ergo. This release contains bug fixes and minor enhancements. - -This release includes changes to the config file format, all of which are fully backwards-compatible and do not require updating the file before upgrading. This release includes no changes to the database format. - -Because the name of the executable has changed from `oragono` to `ergo` (`ergo.exe` on Windows), you may need to update your system configuration (e.g., scripts or systemd unit files that reference the executable). - -Many thanks to [@ajaspers](https://github.com/ajaspers) and [@jesopo](https://github.com/jesopo) for contributing patches, to [@ajaspers](https://github.com/ajaspers), [@ChrisTX](https://github.com/ChrisTX), [@emersion](https://github.com/emersion), [@jwheare](https://github.com/jwheare), [@kylef](https://github.com/kylef), [@Mikaela](https://github.com/Mikaela), [@mogad0n](https://github.com/mogad0n), and [@ProgVal](https://github.com/ProgVal) for reporting issues and helping test, and to our translators for contributing translations. - -### Changed -* The project was renamed from "Oragono" to "Ergo" (#897, thanks to everyone who contributed feedback or voted in the poll) - -### Config changes -* Entries in `server.listeners` now take a new key, `min-tls-version`, that can be used to set the minimum required TLS version; the recommended default value is `1.2` (#1611, thanks [@ChrisTX](https://github.com/ChrisTX)!) -* Added `max-conns` (maximum connection count) and `max-conn-lifetime` (maximum lifetime of a connection before it is cycled) to `datastore.mysql` (#1622) -* Added `massmessage` operator capability to allow sending NOTICEs to all connected users (#1153, #1629, thanks [@jesopo](https://github.com/jesopo)!) - -### Security -* If `require-sasl.enabled` is set to `true`, `tor-listeners.require-sasl` will be automatically set to `true` as well (#1636) -* It is now possible to set the minimum required TLS version, using the `min-tls-version` key in listener configuration -* Configurations that require SASL but allow user registration now produce a warning (#1637) - -### Added: -* Operators with the correct permissions can now send "mass messages", e.g. `/NOTICE $$*` will send a `NOTICE` to all users (#1153, #1629, thanks [@jesopo](https://github.com/jesopo)!) -* Operators can now extend the maximum (non-tags) length of the IRC line using the `server.max-line-len` configuration key. This is not recommended for use outside of "closed-circuit" deployments where IRC operators have full control of all client software. (#1651) - -### Fixed -* `RELAYMSG` now sends a full NUH ("nick-user-host"), instead of only the relay nickname, as the message source (#1647, thanks [@ProgVal](https://github.com/ProgVal), [@jwheare](https://github.com/jwheare), and [@Mikaela](https://github.com/Mikaela)!) -* Fixed a case where channels would remain visible in `/LIST` after unregistration (#1619, thanks [@ajaspers](https://github.com/ajaspers)!) -* Fixed incorrect tags on `JOIN` lines in `+u` ("auditorium") channels (#1642) -* Fixed an issue where LUSERS counts could get out of sync (#1617) -* It was impossible to add a restricted set of snomasks to an operator's permissions; this has been fixed (#1618) -* Fixed incorrect language in `NS INFO` responses (#1627, thanks [@ajaspers](https://github.com/ajaspers)!) -* Fixed a case where the `REGISTER` command would emit an invalid error message (#1633, thanks [@ajaspers](https://github.com/ajaspers)!) -* Fixed snomasks displaying in a nondeterministic order (#1669, thanks [@Mikaela](https://github.com/Mikaela)!) - -### Removed -* Removed the `draft/resume-0.5` capability, and the associated `RESUME` and `BRB` commands (#1624) - -### Internal -* Optimized MySQL storage of direct messages (#1615) - -## [2.6.1] - 2021-04-26 - -Oragono 2.6.1 is a bugfix release, fixing a security issue that is critical for some private server configurations. We regret the oversight. - -The issue affects two classes of server configuration: - -1. Private servers that use `server.password` (i.e., the `PASS` command) for protection. If `accounts.registration.allow-before-connect` is enabled, the `REGISTER` command can be used to bypass authentication. Affected operators should set this field to `false`, or upgrade to 2.6.1, which disallows the insecure configuration. (If the field does not appear in the configuration file, the configuration is secure since the value defaults to false when unset.) -2. Private servers that use `accounts.require-sasl` for protection. If these servers do not additionally set `accounts.registration.enabled` to `false`, the `REGISTER` command can potentially be used to bypass authentication. Affected operators should set `accounts.registration.enabled` to false; this recommendation appeared in the operator manual but was not emphasized sufficiently. (Configurations that require SASL but allow open registration are potentially valid, e.g., in the case of public servers that require everyone to use a registered account; accordingly, Oragono 2.6.1 continues to permit such configurations.) - -This release includes no changes to the config file format or the database. - -Many thanks to [@ajaspers](https://github.com/ajaspers) for reporting the issue. - -### Security -* Fixed and documented potential authentication bypasses via the `REGISTER` command (#1634, thanks [@ajaspers](https://github.com/ajaspers)!) - -## [2.6.0] - 2021-04-18 - -We're pleased to announce Oragono 2.6.0, a new stable release. - -This release has some user-facing enhancements, but is primarily focused on fixing bugs and advancing the state of IRCv3 standardization (by publishing a release that implements the latest drafts). Some highlights: - -* A new CHATHISTORY API for listing direct message conversations (#1592) -* The latest proposal for IRC-over-websockets, which should be backwards-compatible with existing clients (#1558) -* The latest specification for the bot usermode (`+B` in our implementation) (#1562) - -This release includes changes to the config file format, all of which are fully backwards-compatible and do not require updating the file before upgrading. - -This release includes no changes to the embedded database format. If you are using MySQL for history storage, it adds a new table; this change is backwards and forwards-compatible and does not require any manual intervention. - -If you are using nginx as a reverse proxy for IRC-over-websockets, previous documentation did not recommend increasing `proxy_read_timeout`; the default value of `60s` is too low and can lead to user disconnections. The current recommended value is `proxy_read_timeout 600s;`; see the manual for an example configuration. - -Many thanks to [@ajaspers](https://github.com/ajaspers) and [@Mikaela](https://github.com/Mikaela) for contributing patches, to [@aster1sk](https://github.com/aster1sk), [@emersion](https://github.com/emersion), [@eskimo](https://github.com/eskimo), [@hhirtz](https://github.com/hhirtz), [@jlu5](https://github.com/jlu5), [@jwheare](https://github.com/jwheare), [@KoraggKnightWolf](https://github.com/KoraggKnightWolf), [@kylef](https://github.com/kylef), [@Mikaela](https://github.com/Mikaela), [@mogad0n](https://github.com/mogad0n), [@ProgVal](https://github.com/ProgVal), and [@szlend](https://github.com/szlend) for reporting issues and helping test, and to our translators for contributing translations. - -### Config changes -* Listeners now support multiple TLS certificates for use with SNI; see the manual for details (#875, thanks [@Mikaela](https://github.com/Mikaela)!) -* Added `server.compatibility.allow-truncation`, controlling whether the server accepts messages that are too long to be relayed intact; this value defaults to `true` when unset (#1577, #1586, thanks [@kylef](https://github.com/kylef)!) -* Added new `snomasks` operator capability; operators must have either the `ban` or `snomasks` capability to subscribe to additional snomasks (#1176) - -### Security -* Fixed several edge cases where Oragono might relay invalid UTF8 despite the `UTF8ONLY` guarantee, or to a text-mode websocket client (#1575, #1596, thanks [@ProgVal](https://github.com/ProgVal)!) -* All operator privilege checks now use the capabilities system, making it easier to define operators with restricted powers (#1176) -* Adding and removing bans with `UBAN` now produces snomasks and audit loglines (#1518, thanks [@mogad0n](https://github.com/mogad0n)!) - -### Fixed -* Fixed an edge case in line buffering that could result in client disconnections (#1572, thanks [@ProgVal](https://github.com/ProgVal)!) -* Upgraded buntdb, our embedded database library, fixing an edge case that could cause data corruption (#1603, thanks [@Mikaela](https://github.com/Mikaela), [@tidwall](https://github.com/tidwall)!) -* Improved compatibility with the published `draft/register` specification (#1568, thanks [@ProgVal](https://github.com/ProgVal)!) -* `433 ERR_NICKNAMEINUSE` is no longer sent when a fully connected ("registered") client fails to claim a reserved nickname, fixing a bad interaction with some client software (#1594, thanks [@ProgVal](https://github.com/ProgVal)!) -* Fixed `znc.in/playback` commands causing client disconnections when history is disabled (#1552, thanks [@szlend](https://github.com/szlend)!) -* Fixed syntactically invalid `696 ERR_INVALIDMODEPARAM` response for invalid channel keys (#1563, thanks [@ProgVal](https://github.com/ProgVal)!) -* User-set nickserv settings now display as "enabled" instead of "mandatory" (#1544, thanks [@Mikaela](https://github.com/Mikaela)!) -* Improved error messages for some invalid configuration cases (#1559, thanks [@aster1sk](https://github.com/aster1sk)!) -* Improved `CS TRANSFER` error messages (#1534, thanks burning!) -* Handle panics caused when rehashing with SIGHUP (#1570) - -### Changed -* Registered channels will always appear in `/LIST` output, even with no members (#1507) -* In the new recommended default configuration, Oragono will preemptively reject messages that are too long to be relayed to clients without truncation. This is controlled by the config variable `server.compatibility.allow-truncation`; this field defaults to `true` when unset, preserving the legacy behavior for older config files (#1577, #1586, thanks [@kylef](https://github.com/kylef)!) -* Auto-away behavior now respects individual clients; the user is not considered away unless all clients are away or disconnected (#1531, thanks [@kylef](https://github.com/kylef)!) -* Direct messages rejected due to the `+R` registered-only usermode now produce an error message (#1064, thanks [@KoraggKnightWolf](https://github.com/KoraggKnightWolf), [@ajaspers](https://github.com/ajaspers)!) -* RELAYMSG identifiers now respect bans and mutes (#1502) -* If end user message deletion is enabled, channel operators can now delete channel messages (#1565, thanks [@Mikaela](https://github.com/Mikaela)!) -* Halfops can change the channel topic (#1523) -* Snomask add/remove syntax now matches other ircds more closely (#1074) -* `CS OP` will regrant your channel `AMODE`, in case you removed it (#1516, #1307, thanks [@jlu5](https://github.com/jlu5)!) -* User passwords may no longer begin with `:` (#1571) -* Improved documentation of `CS AMODE` and `NS UNREGISTER` (#1524, #1545, thanks [@Mikaela](https://github.com/Mikaela)!) -* Disabling history disables history-related CAPs (#1549) - -### Added -* Implemented the new [CHATHISTORY TARGETS](https://github.com/ircv3/ircv3-specifications/pull/450) API for listing direct message conversations (#1592, thanks [@emersion](https://github.com/emersion), [@hhirtz](https://github.com/hhirtz), [@jwheare](https://github.com/jwheare), [@kylef](https://github.com/kylef)!) -* Implemented the new [IRC-over-websockets draft](https://github.com/ircv3/ircv3-specifications/pull/342), adding support for binary websockets and subprotocol negotiation (#1558, thanks [@jwheare](https://github.com/jwheare)!) -* Implemented the new [bot mode spec](https://github.com/ircv3/ircv3-specifications/pull/439) (#1562) -* Implemented the new [forward mode spec](https://github.com/ircv3/ircv3-specifications/pull/440) (#1612, thanks [@ProgVal](https://github.com/ProgVal)!) -* `WARN NICK ACCOUNT_REQUIRED` is sent on failed attempts to claim a reserved nickname (#1594) -* `NS CLIENTS LIST` displays enabled client capabilities (#1576) -* `CS INFO` with no arguments lists your registered channels (#765) -* `NS PASSWORD` is now accepted as an alias for `NS PASSWD` (#1547) - -### Internal -* Upgraded to Go 1.16 (#1510) - -## [2.5.1] - 2021-02-02 - -Oragono 2.5.1 is a bugfix release that fixes a significant security issue. We apologize for the oversight. - -This release includes no changes to the config file format or the database. - -Many thanks to [@xnaas](https://github.com/xnaas) for reporting the issue. - -### Security -* Fix an incorrect permissions check in NickServ (#1520, thanks [@xnaas](https://github.com/xnaas)!) - -## [2.5.0] - 2021-01-31 - -We're pleased to announce Oragono 2.5.0, a new stable release. - -This release includes enhancements based on the needs of real-world operators, as well as bug fixes. Highlights include: - -* `UBAN`, a new "unified ban" system for server operators, with a corresponding `CHANSERV HOWTOBAN` command for channel operators (#1447) -* A new forwarding/overflow channel mode `+f` (#1260) -* Support for PROXY protocol v2 (#1389) - -This release includes changes to the config file format, including two breaking changes. One is fairly significant: enabling a websocket listener now requires the use of `server.enforce-utf8`, as has been the recommended default since 2.2.0 (so continuing to accept legacy non-UTF-8 content will require disabling websockets). The other is that the "unban" operator capability has been removed (it is now included in the "ban" capability). Other config changes are backwards compatible and do not require updating the file before upgrading. - -This release includes a database change. If you have `datastore.autoupgrade` set to `true` in your configuration, it will be automatically applied when you restart Oragono. Otherwise, you can update the database manually by running `oragono upgradedb` (see the manual for complete instructions). - -Many thanks to [@jlu5](https://github.com/jlu5), [@kylef](https://github.com/kylef) and [@Mikaela](https://github.com/Mikaela) for contributing patches, to [@bogdomania](https://github.com/bogdomania), [@eskimo](https://github.com/eskimo), [@happyhater](https://github.com/happyhater), [@jlu5](https://github.com/jlu5), [@kylef](https://github.com/kylef), [@LukeHoersten](https://github.com/LukeHoersten), [@Mikaela](https://github.com/Mikaela), [@mogad0n](https://github.com/mogad0n), [@robinlemon](https://github.com/robinlemon), and [@vertisan](https://github.com/vertisan) for reporting issues and helping test, and to our translators for contributing translations. - -### Config changes -* Enabling websockets now requires `server.enforce-utf8 = true` (#1483) -* `proxy` is now a top-level field of the listener config block; in particular, the PROXY protocol (v1 or v2) can now be required ahead of a plaintext connection. The field is still accepted in its legacy position (inside the `tls` block). (#1389, thanks [@robinlemon](https://github.com/robinlemon)!) -* Added `accounts.multiclient.always-on-expiration`, allowing always-on clients to be timed out for inactivity (#810, thanks [@bogdomania](https://github.com/bogdomania)!) -* `local_` prefixes have been stripped from operator capability names, so that, e.g., `local_ban` is now just `ban`. The old names are still accepted. (#1442) -* The `local_unban` operator capability has been removed (unbanning is now contained in the `ban` permission). (#1442) -* The recommended value of `accounts.bcrypt-cost` is now `4`, the minimum acceptable value (#1497) -* `server.ip-limits.custom-limits` now accepts networks that contain multiple CIDRs; the old syntax is still accepted (#1421, thanks [@Mikaela](https://github.com/Mikaela)! -* A new field, `history.restrictions.query-cutoff`, generalizes the old `history.restrictions.enforce-registration-date` (the old field is still accepted) (#1490, thanks [@Mikaela](https://github.com/Mikaela)!) -* Added `server.override-services-hostname`, allowing the hostname of NickServ, ChanServ, etc. to be overridden (#1407, thanks [@Mikaela](https://github.com/Mikaela)!) -* Added a boolean `hide-sts` key to the listener block; this can be used to hide the STS CAP when the listener is secured at layer 3 or 4 (e.g., by a VPN or an E2E mixnet). It will still be necessary to add the relevant IPs to `secure-nets`. (#1428, thanks [@Mikaela](https://github.com/Mikaela)!) - -### Security -* Improved validation of names and encodings for client-only tags (#1385) -* Improved auditability of sensitive operator actions (#1443, thanks [@mogad0n](https://github.com/mogad0n)!) -* `DEFCON 4` and lower now require Tor users to authenticate with SASL (#1450) - -### Fixed -* Fixed `NS UNSUSPEND` requiring the casefolded / lowercase version of the account name (#1382, thanks [@mogad0n](https://github.com/mogad0n)!) -* Fixed client-only tags in direct (user-to-user) `PRIVMSG` not being replayed (#1411) -* Fixed many bugs in import of Anope and Atheme databases (#1403, #1423, #1424, #1431, #1435, #1439, #1444, thanks [@jlu5](https://github.com/jlu5), [@kylef](https://github.com/kylef), and [@Mikaela](https://github.com/Mikaela)!) -* Fixed case-handling bugs in `RENAME` (i.e., channel rename) (#1456, thanks [@mogad0n](https://github.com/mogad0n)!) -* Fixed incorrect processing of color code escapes in MOTD files (#1467, thanks [@mogad0n](https://github.com/mogad0n)!) -* STS is no longer advertised to Tor clients (#1428, thanks [@Mikaela](https://github.com/Mikaela)!) -* Fixed HELP/HELPOP numerics not including the nick as an argument (#1472, thanks [@kylef](https://github.com/kylef)!) -* Made connection registration snomasks less confusing (#1396, thanks [@eskimo](https://github.com/eskimo)!) -* Fixed duplicated nicks in `KLINE` response (#1379, thanks [@mogad0n](https://github.com/mogad0n)!) -* The `RELAYMSG` tag name is now `draft/relaymsg`, conforming to the amended draft specification (#1468, thanks [@jlu5](https://github.com/jlu5)!) -* Fixed `SAJOIN` not sending a `MODE` line to the originating client (#1383, thanks [@mogad0n](https://github.com/mogad0n)!) -* Improved consistency of message sources sent by `CS AMODE` (#1383, thanks [@mogad0n](https://github.com/mogad0n)!) -* Fixed duplicated `JOIN` line sent to some clients using the `draft/resume-0.5` extension (#1397, thanks [@kylef](https://github.com/kylef)!) -* Added a warning that MySQL cannot be enabled by rehash (#1452, thanks [@Mikaela](https://github.com/Mikaela)!) - -### Changed -* Channel-user modes (e.g., `+o`, `+v`) of always-on clients are now persisted in the database (#1345) -* `/CHANSERV PURGE` now takes `ADD`, `DEL`, and `LIST` subcommands; the separate `UNPURGE` command has been removed; `PURGE ADD` now requires a confirmation code (#1294, thanks [@mogad0n](https://github.com/mogad0n)!) -* The characters `<`, `>`, `'`, `"`, and `;` are no longer allowed in nicknames (previously registered account names containing these characters are still accepted) (#1436, thanks [@happyhater](https://github.com/happyhater)!) -* Authenticated clients from Tor now receive their (account-unique) always-on cloaked hostname; this allows channel operators to ban unauthenticated Tor users by banning `*!*@tor-network.onion` (#1479, thanks [@mogad0n](https://github.com/mogad0n)!) -* Included the network name in the human-readable final parameter of `001 RPL_WELCOME` (#1410) -* `RELAYMSG` can now take client-only tags (#1470) -* WebSocket listeners will attempt to negotiate the `text.ircv3.net` [subprotocol](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers#subprotocols); negotiating this is optional for clients (#1483) - -### Added -* Added `UBAN`, a new command giving server operators a unified interface to D-LINEs (IP bans), K-LINEs (NUH mask bans, which are now deprecated), and account suspensions (`NS SUSPEND`) (#1447) -* Added `CHANSERV HOWTOBAN`, a ChanServ subcommand that helps channel operators choose an appropriate ban (#1447) -* Added a new channel mode `+f`; users who cannot join the channel due to `+i` or `+l` will be forwarded to the channel specified by `+f`. (#1260) -* Added support for the PROXY protocol v2 (#1389, thanks [@robinlemon](https://github.com/robinlemon)!) -* Added support for `/JOIN 0` (part all channels), requiring a confirmation code (#1417, thanks [@Mikaela](https://github.com/Mikaela)!) -* Added support for grouped nicknames as SASL usernames (#1476, thanks [@eskimo](https://github.com/eskimo)!) -* Added history support for `INVITE` (#1409, thanks [@Mikaela](https://github.com/Mikaela)!) -* Added a new channel setting accessible via `/CS SET`: `history-cutoff`, allowing the channel owner more fine-grained control over who can see history (#1490, thanks [@Mikaela](https://github.com/Mikaela)!) -* Added the `UTF8ONLY` ISUPPORT token, allowing the server to advertise to clients that only UTF-8 content is accepted (#1483) -* Added `/NICKSERV RENAME`, an operator-only command that can change the case of an account name (#1380, thanks [@LukeHoersten](https://github.com/LukeHoersten)!) - -### Internal -* Added caching for serialized messages (#1387) -* Improved memory efficiency of line reading (#1231) - -## [2.4.0] - 2020-11-08 - -We're pleased to announce Oragono 2.4.0, a new stable release. - -This release includes a number of exciting enhancements and fixes. Here are some highlights: - -* Support for migrating an Anope or Atheme database to Oragono (#1042) -* A pluggable system for validating external IPs, e.g., via DNSBLs (#68, thanks [@moortens](https://github.com/moortens)!) -* [draft/relaymsg](https://github.com/ircv3/ircv3-specifications/pull/417), a new draft extension simplifying bridging with other chat systems (thanks [@jlu5](https://github.com/jlu5)!) -* New moderation tools: `+u` ("auditorium", #1300), `+U` ("op-moderated", #1178), `+M` ("moderated-registered", #1182, thanks [@ajaspers](https://github.com/ajaspers)!), and `+b m:` (an extban for muting users, #307) - -This release includes changes to the config file format, including one breaking change: `roleplay.enabled` now defaults to false (the new recommended default) instead of true when unset. Other config changes are backwards compatible and do not require updating the file before upgrading. - -This release includes a database change. If you have `datastore.autoupgrade` set to `true` in your configuration, it will be automatically applied when you restart Oragono. Otherwise, you can update the database manually by running `oragono upgradedb` (see the manual for complete instructions). - -Many thanks to [@ajaspers](https://github.com/ajaspers), [@jesopo](https://github.com/jesopo), [@moortens](https://github.com/moortens), and [@RunBarryRun](https://github.com/RunBarryRun) for contributing patches, to [@csmith](https://github.com/csmith) for contributing code reviews, to [@ajaspers](https://github.com/ajaspers), [@Amiga60077](https://github.com/Amiga60077), [@bogdomania](https://github.com/bogdomania), [@csmith](https://github.com/csmith), [@edk0](https://github.com/edk0), [@eskimo](https://github.com/eskimo), [@jlu5](https://github.com/jlu5), [@jwheare](https://github.com/jwheare), [@KoraggKnightWolf](https://github.com/KoraggKnightWolf), [@Mitaka8](https://github.com/Mitaka8), [@mogad0n](https://github.com/mogad0n), [@RyanSquared](https://github.com/RyanSquared), and [@vertisan](https://github.com/vertisan) for reporting issues and helping test, and to our translators for contributing translations. - -### Config changes -* Added `server.ip-cloaking.enabled-for-always-on`, which generates a unique hostname for each always-on client. The recommended default value of this field is `true` (#1312) -* Added `server.coerce-ident`; if this is set to a string value, all user/ident fields supplied by clients are ignored and replaced with this value. The recommended default value of this field is `~u`. This simplifies bans. (#1340) -* Simplified the config file format for email verification into a new `accounts.nick-reservation.email-verification` section. The old format (`callbacks`) is still accepted (#1075) -* The recommended value of `roleplay.enabled` is now `false`; this field now defaults to false when unset (#1240, #1271) -* Added `server.relaymsg` section for configuring the new `draft/relaymsg` capability; added the new `relaymsg` operator capability for exercising it (#1119) -* Added `allow-environment-overrides` config variable, allowing config options to be overridden by environment variables. See the manual for more details. (#1049, thanks [@csmith](https://github.com/csmith)!) -* Added `server.ip-check-script` for configuring IP check plugins (#68, #1267, thanks [@moortens](https://github.com/moortens)!) -* Added `max-concurrency` restriction to `accounts.auth-script` section. The recommended default value is `64` (`0` or unset disable the restriction) (#1267) -* Added `accounts.registration.allow-before-connect`; this allows the use of the new `REGISTER` command before connecting to the server (#1075) -* Added `hidden` option in operator blocks: if set to `true`, operator status is hidden from commands like `WHOIS` that would otherwise display it (#1194) -* Added `accounts.nick-reservation.forbid-anonymous-nick-changes`, which forbids anonymous users from changing their nicknames after initially connecting (#1337, thanks [@Amiga60077](https://github.com/Amiga60077)!) -* Added `channels.invite-expiration`, allowing invites to `+i` channels to expire after a given amount of time (#1171) - -### Security -* Added `/NICKSERV CLIENTS LOGOUT` command for disconnecting clients connected to a user account (#1072, #1272, thanks [@ajaspers](https://github.com/ajaspers)!) -* Disallowed the use of service nicknames during roleplaying (#1240, thanks [@Mitaka8](https://github.com/Mitaka8)!) -* Improved security properties of `INVITE` for invite-only channels, including an `UNINVITE` command (#1171) - -### Removed -* Removed the request queue system for HostServ, i.e., the `REQUEST`, `APPROVE`, and `REJECT` subcommands of `HOSTSERV` (#1346) - -### Fixed -* `PONG` is now sent with the server name as the first parameter, matching the behavior of other ircds (#1249, thanks [@jesopo](https://github.com/jesopo)!) -* It was not possible to set or unset the `+T` no-CTCP user mode; this has been fixed (#1299, thanks [@mogad0n](https://github.com/mogad0n)!) -* Fixed edge cases with `/NICKSERV SAREGISTER` of confusable nicknames (#1322, thanks [@mogad0n](https://github.com/mogad0n)!) -* Fixed websocket listeners with proxy-before-TLS enabled closing on invalid PROXY lines (#1269, thanks [@RyanSquared](https://github.com/RyanSquared)!) -* Fixed error responses and history for SANICK (#1277, #1278, thanks [@eskimo](https://github.com/eskimo)!) -* Ensured that stored realnames of always-on clients are deleted during account unregistration (#1330) -* Whitespace is now stripped from KLINEs (#1327, thanks [@mogad0n](https://github.com/mogad0n)!) -* Fixed incorrect `LUSERS` counts caused by KLINE (#1303, thanks [@mogad0n](https://github.com/mogad0n)!) -* `CHATHISTORY` queries for invalid channels now get an empty batch instead of a `FAIL` (#1322) -* `fakelag.messages-per-window = 0` no longer causes a panic (#861, thanks [@vertisan](https://github.com/vertisan)!) - -### Added -* Added `oragono importdb` command for importing a converted Anope or Atheme database; see the manual for details (#1042) -* Added support for the new [draft/relaymsg](https://github.com/ircv3/ircv3-specifications/pull/417) extension, which simplifies bridging IRC with other protocols relaymsg (#1119, thanks [@jlu5](https://github.com/jlu5)!) -* Added `ip-check-script`, a scripting API for restricting access by client IP. We provide [oragono-dnsbl](https://github.com/oragono/oragono-dnsbl), an external script that can query DNSBLs for this purpose (#68, #1267, thanks [@moortens](https://github.com/moortens)!) -* Added channel mode `+u`. This is an "auditorium" mode that prevents unprivileged users from seeing each other's `JOIN` and `PART` lines. It's useful for large public-announcement channels, possibly in conjunction with `+m` (#1300) -* Added channel mode `+U`. This is an "op-moderated" mode; messages from unprivileged users are sent only to channel operators, who can then choose to grant them `+v`. (#1178) -* Added a mute extban `+b m:`: users matching the ban expression (e.g., `+b m:*!*@j6dwi4vacx47y.irc`) will be able to join the channel, but will be unable to speak. (#307) -* Added support for the new [draft/register](https://gist.github.com/edk0/bf3b50fc219fd1bed1aa15d98bfb6495) extension, which exposes a cleaner account registration API to clients (#1075, thanks [@edk0](https://github.com/edk0)!) -* Added a `379 RPL_WHOISMODES` line to the `WHOIS` response, making it easier for operators to see other users' modes (#769, thanks [@Amiga60077](https://github.com/Amiga60077) and [@KoraggKnightWolf](https://github.com/KoraggKnightWolf)!) -* Added `/CHANSERV DEOP` command for removing channel operator privileges (#361, thanks [@RunBarryRun](https://github.com/RunBarryRun)!) -* Added `r` flag to `/WHO` responses for registered nicknames (#1366, thanks [@Amiga60077](https://github.com/Amiga60077)!) - -### Changed -* Always-on clients now receive a user/ident of `~u` by default, instead of `~user`; this can be changed by setting the `coerce-ident` field (#1340) -* `/NICKSERV SUSPEND` has been modified to take subcommands (`ADD`, `DEL`, and `LIST`); the `ADD` subcommand now accepts time duration and reason arguments. See `/msg NickServ HELP SUSPEND` for details. (#1274, thanks [@mogad0n](https://github.com/mogad0n)!) -* Only the channel founder can kick the channel founder, regardless of either party's modes (#1262) -* `/NICKSERV SESSIONS` is now `/NICKSERV CLIENTS LIST`, but the old command is still accepted (#1272, thanks [@ajaspers](https://github.com/ajaspers)!) -* Improved `SETNAME` behavior for legacy clients (#1358, thanks [@KoraggKnightWolf](https://github.com/KoraggKnightWolf)!) -* Halfops can set the channel topic (#1306) -* Full client certificates are now passed to auth scripts. This allows for more flexible checks on certificates, including verification against an internal CA (#414) - -### Internal -* Added a logline for debugging client disconnections (#1293) -* Renamed `conventional.yaml` to `traditional.yaml` (#1350) -* Integration tests are now run during CI (#1279) - - -## [2.3.0] - 2020-09-06 - -We're pleased to announce Oragono 2.3.0, a new stable release. - -This release contains primarily bug fixes, but includes one notable feature enhancement: a change contributed by [@hhirtz](https://github.com/hhirtz) that updates the `draft/rename` specification to correspond to the new (soon-to-be) published draft. - -Many thanks to [@hhirtz](https://github.com/hhirtz) for contributing patches, to [@bogdomania](https://github.com/bogdomania), [@digitalcircuit](https://github.com/digitalcircuit), [@ivan-avalos](https://github.com/ivan-avalos), [@jesopo](https://github.com/jesopo), [@kylef](https://github.com/kylef), [@Mitaka8](https://github.com/Mitaka8), [@mogad0n](https://github.com/mogad0n), and [@ProgVal](https://github.com/ProgVal) for reporting issues and helping test, and to our translators for contributing translations. - -This release includes no changes to the config file format or database changes. - -### Config changes -* The recommended value of `lookup-hostnames` for configurations that cloak IPs (as has been the default since 2.1.0) is now `false` (#1228) - -### Security -* Mitigated a potential DoS attack on websocket listeners (#1226) - -### Removed -* Removed `/HOSTSERV OFFERLIST` and related commands; this functionality is superseded by IP cloaking (#1190) - -### Fixed -* Fixed an edge case in handling no-op nick changes (#1242) -* Fixed edge cases with users transitioning in and out of always-on status (#1218, #1219, thanks [@bogdomania](https://github.com/bogdomania)!) -* Fixed a race condition related to the registration timeout (#1225, thanks [@hhirtz](https://github.com/hhirtz)!) -* Fixed incorrectly formatted account tags on some messages (#1254, thanks [@digitalcircuit](https://github.com/digitalcircuit)!) -* Improved checks for invalid config files (#1244, thanks [@ivan-avalos](https://github.com/ivan-avalos)!) -* Fixed messages to services and `*playback` not receiving echo-message when applicable (#1204, thanks [@kylef](https://github.com/kylef)!) -* Fixed a help string (#1237, thanks [@Mitaka8](https://github.com/Mitaka8)!) - -### Changed -* Updated `draft/rename` implementation to the latest draft (#1223, thanks [@hhirtz](https://github.com/hhirtz)!) - -### Internal -* Official release builds now use Go 1.15 (#1195) -* `/INFO` now includes the Go version (#1234) - -## [2.2.0] - 2020-07-26 - -We're pleased to announce Oragono 2.2.0, a new stable release. - -This release contains several notable enhancements, as well as bug fixes: - -* Support for tracking seen/missed messages across multiple devices (#843) -* WHOX support contributed by @jesopo (#938) -* Authentication of users via external scripts (#1107) - -Many thanks to [@clukawski](https://github.com/clukawski) and [@jesopo](https://github.com/jesopo) for contributing patches, to [@ajaspers](https://github.com/ajaspers), [@bogdomania](https://github.com/bogdomania), [@csmith](https://github.com/csmith), [@daurnimator](https://github.com/daurnimator), [@emersonveenstra](https://github.com/emersonveenstra), [@eskil](https://github.com/eskil), [@eskimo](https://github.com/eskimo), Geo-, [@happyhater](https://github.com/happyhater), [@jesopo](https://github.com/jesopo), [@jwheare](https://github.com/jwheare), [@k4bek4be](https://github.com/k4bek4be), [@KoraggKnightWolf](https://github.com/KoraggKnightWolf), [@kylef](https://github.com/kylef), [@LukeHoersten](https://github.com/LukeHoersten), [@mogad0n](https://github.com/mogad0n), r3m, [@RyanSquared](https://github.com/RyanSquared), savoyard, and [@wrmsr](https://github.com/wrmsr) for reporting issues and helping test, and to our translators for contributing translations. - -This release includes changes to the config file format, including one breaking change: `timeout` is no longer an acceptable value of `accounts.nick-reservation.method`. (If you were using it, we suggest `strict` as a replacement.) All other changes to the config file format are backwards compatible and do not require updating before restart. - -This release includes a database change. If you have `datastore.autoupgrade` set to `true` in your configuration, it will be automatically applied when you restart Oragono. Otherwise, you can update the database manually by running `oragono upgradedb` (see the manual for complete instructions). - -### Removed -* Timeout-based nickname enforcement has been removed. We recommend `strict` as the default enforcement method. Users who configured `timeout` for their account will be upgraded to `strict`. With `accounts.login-via-pass-command` enabled, clients lacking support for SASL can authenticate via the `PASS` (server password command) by sending `account_name:account_password` as the server password. (#1027) -* Native support for LDAP has been removed. LDAP is now supported via the external [oragono-ldap](https://github.com/oragono/oragono-ldap) plugin; see its repository page for details. (#1142, #1107) - -### Config changes -* Added `server.enforce-utf8`, controlling whether the server enforces that messages be valid UTF-8; a value of `true` for this is now the recommended default (#1151) -* Added `history.tagmsg-storage` for configuring which TAGMSG are stored in history; if this is not configured, TAGMSG will not be stored (#1172) -* All TLS certificate fingerprints in the config file are now named `certfp` instead of `fingerprint` (the old name of `fingerprint` is still accepted) (#1050, thanks [@RyanSquared](https://github.com/RyanSquared)!) -* Added `accounts.auth-script` section for configuring external authentication scripts (#1107, thanks [@daurnimator](https://github.com/daurnimator)!) -* Removed `accounts.ldap` section for configuring LDAP; LDAP is now available via the auth-script plugin interface (#1142) -* Added `defcon` operator capability, allowing use of the new `/DEFCON` command (#328) -* Default `awaylen`, `kicklen`, and `topiclen` limits now reflect the 512-character line limit (#1112, thanks [@k4bek4be](https://github.com/k4bek4be)!) -* Added `extjwt` section for configuring the EXTJWT extension (#948, #1136) -* `login-via-pass-command: true` is now a recommended default (#1186) - -### Added -* Added support for [WHOX](https://github.com/ircv3/ircv3-specifications/issues/81), contributed by [@jesopo](https://github.com/jesopo) (#938, thanks!) -* Added support for tracking missed messages across multiple devices; see the "history" section of the manual for details (#843, thanks [@jwheare](https://github.com/jwheare) and [@wrmsr](https://github.com/wrmsr)!) -* Added `/NICKSERV SUSPEND` and `/NICKSERV UNSUSPEND` commands, allowing operators to suspend access to an abusive user account (#1135) -* Added support for external authentication systems, via subprocess ("auth-script") invocation (#1107, thanks [@daurnimator](https://github.com/daurnimator)!) -* Added the `/DEFCON` command, allowing operators to respond to spam or DoS attacks by disabling features at runtime without a rehash. (This feature requires that the operator have a newly defined capability, named `defcon`; this can be added to the appropriate oper blocks in the config file.) (#328, thanks [@bogdomania](https://github.com/bogdomania)!) -* Added support for the [EXTJWT](https://github.com/ircv3/ircv3-specifications/pull/341) draft extension, allowing Oragono to be integrated with other systems like Jitsi (#948, #1136) -* Services (NickServ, ChanServ, etc.) now respond to CTCP VERSION messages (#1055, thanks [@jesopo](https://github.com/jesopo)!) -* Added `BOT` ISUPPORT token, plus a `B` flag for bots in `352 RPL_WHOREPLY` (#1117) -* Added support for the `+T` no-CTCP user mode (#1007, thanks [@clukawski](https://github.com/clukawski)!) -* Added support for persisting the realname of always-on clients (#1065, thanks [@clukawski](https://github.com/clukawski)!) -* Added a warning on incorrect arguments to `/NICKSERV REGISTER` (#1179, thanks [@LukeHoersten](https://github.com/LukeHoersten)!) -* `/NICKSERV SET PASSWORD` now sends a warning (#1208) - -### Fixed -* Fixed channels with only invisible users not being displayed in `/LIST` output (#1161, thanks [@bogdomania](https://github.com/bogdomania)!) -* Fixed `INVITE` not overriding a `+b` ban (#1168) -* Fixed incorrect `CHGHOST` lines during authentication with `/NICKSERV IDENTIFY` under some circumstances (#1108, thanks Geo-!) -* Fixed incorrect `CHGHOST` lines sent to users during connection registration (#1125, thanks [@jesopo](https://github.com/jesopo)!) -* Fixed a number of issues affecting the `znc.in/playback` capability, in particular restoring compatibility with Palaver (#1205, thanks [@kylef](https://github.com/kylef)!) -* Fixed interaction of auto-away with the regular `/AWAY` command (#1207) -* Fixed an incorrect interaction between always-on and `/NS SAREGISTER` (#1216) -* Fixed a race condition where nicknames of signed-out users could remain in the channel names list (#1166, thanks [@eskimo](https://github.com/eskimo)!) -* Fixed the last line of the MOTD being truncated in the absence of a terminating `\n` (#1167, thanks [@eskimo](https://github.com/eskimo)!) -* Fixed `away-notify` lines not being sent on channel JOIN (#1198, thanks savoyard!) -* Fixed incorrect source of some nickserv messages (#1185) -* Fixed idle time being updated on non-PRIVMSG commands (thanks r3m and [@happyhater](https://github.com/happyhater)!) -* Fixed `/NICKSERV UNREGISTER` and `/NICKSERV ERASE` not deleting stored user modes (#1157) - -### Security -* Connections to an STS-only listener no longer reveal the exact server version or server creation time (#802, thanks [@csmith](https://github.com/csmith)!) - -### Changed -* `/DLINE` now operates on individual client connections (#1135) -* When using the multiclient feature, each client now has its own independent `MONITOR` list (#1053, thanks [@ajaspers](https://github.com/ajaspers)!) -* `MONITOR L` now lists the nicknames in the form they were originally sent with `MONITOR +`, without casefolding (#1083) -* We now send the traditional `445 ERR_SUMMONDISABLED` and `446 ERR_USERSDISABLED` in response to the `SUMMON` and `USERS` commands (#1078, thanks [@KoraggKnightWolf](https://github.com/KoraggKnightWolf)!) -* RPL_ISUPPORT parameters with no values are now sent without an `=` (#1067, #1069, #1091, thanks [@KoraggKnightWolf](https://github.com/KoraggKnightWolf) and [@jesopo](https;//github.com/jesopo)!) -* TAGMSG storage is now controlled via the `history.tagmsg-storage` config block (#1172) -* `/NICKSERV CERT ADD` with no argument now adds the user's current TLS certificate fingerprint, when applicable (#1059, thanks [@emersonveenstra](https://github.com/emersonveenstra)!) - -### Internal -* The config file containing recommended defaults is now named `default.yaml`, instead of `oragono.yaml` (#1130, thanks [@k4bek4be](https://github.com/k4bek4be)!) -* The output of the `/INFO` command now includes the full git hash, when applicable (#1105) - -## [2.1.0] - 2020-06-01 -We're pleased to announce Oragono 2.1.0, a new stable release. - -Since the release of 2.0.0 in March, a number of new communities and organizations have adopted Oragono as a communications tool. This new release incorporates many improvements and fixes derived from the experiences of real-world operators and end users. Highlights include: - -* Native support for websockets contributed by [@hhirtz](https://github.com/hhirtz), eliminating the need for a separate websockets-to-IRC proxy server -* Tighter control over the relationship between account names and nicknames, eliminating the need for extbans -* Support for sending account verification emails directly from Oragono, including DKIM signatures - -Many thanks to [@ajaspers](https://github.com/ajaspers) and [@hhirtz](https://github.com/hhirtz) for contributing patches, to [@ajaspers](https://github.com/ajaspers), [@eklitzke](https://github.com/eklitzke), and [@hhirtz](https://github.com/hhirtz) for contributing code reviews, to [@ajaspers](https://github.com/ajaspers), [@bogdomania](https://github.com/bogdomania), [@clukawski](https://github.com/clukawski), Csibesz, [@csmith](https://github.com/csmith), [@eklitzke](https://github.com/eklitzke), [@nxths](https://github.com/nxths), [@hhirtz](https://github.com/hhirtz), [@jesopo](https://github.com/jesopo), [@jlnt](https://github.com/jlnt), [@justjanne](https://github.com/justjanne), [@jwheare](https://github.com/jwheare), [@k4bek4be](https://github.com/k4bek4be), [@KoraggKnightWolf](https://github.com/KoraggKnightWolf), [@kula](https://github.com/kula), [@kylef](https://github.com/kylef), [@Mitaka8](https://github.com/Mitaka8), [@petteri](https://github.com/petteri), [@PizzaLover2007](https://github.com/PizzaLover2007), [@prawnsalad](https://github.com/prawnsalad), [@RyanSquared](https://github.com/RyanSquared), savoyard, and [@xPaw](https://github.com/xPaw) for reporting issues, and to [@bogdomania](https://github.com/bogdomania), [@boppy](https://github.com/boppy), Nuve, stickytoffeepuddingwithcaramel, and [@vegax87](https://github.com/vegax87) for contributing translations. - -This release includes changes to the config file format, including one breaking change: support for `server.ip-cloaking.secret-environment-variable` has been removed. (See below for instructions on how to upgrade if you were using this feature.) All other changes to the config file format are backwards compatible and do not require updating before restart. - -This release includes a database change. If you have `datastore.autoupgrade` set to `true` in your configuration, it will be automatically applied when you restart Oragono. Otherwise, you can update the database manually by running `oragono upgradedb` (see the manual for complete instructions). - -This release includes a change to the MySQL schema. This change will be applied automatically when you restart Oragono. It is fully backwards compatible (i.e., if it is necessary for you to downgrade Oragono back to 2.0.0, it will not be necessary to downgrade the schema). - -### Config Changes -* Added `websocket` attribute of individual listeners, and a new `server.websockets` section, for configuring websocket listeners. (#967, thanks [@hhirtz](https://github.com/hhirtz)!) -* The recommended default is now to enable IP cloaking. In order to facilitate this, the cloaking secret is now stored in the database, instead of the config file. If you currently have a secret stored in the config file (as `server.ip-cloaking.secret`), it will be automatically imported into the database. If you were using `secret-environment-variable` to distribute your cloaking secret, you can import it manually after restart using the new `/HOSTSERV SETCLOAKSECRET` command. (#952) -* Added `accounts.nick-reservation.force-nick-equals-account`, which ensures that logged-in clients are using their account name as their nickname. This eliminates the need for extbans and is a new recommended default. (#864) -* Added `guest-nickname-format` and `force-guest-format`, which optionally add a prefix like `Guest-` to the nicknames of unauthenticated users (#749) -* The recommended default is now to enable history storage and playback, with messages expiring after 7 days. (As with all changes in recommended config values, applying this to an existing config file requires explicitly changing the values.) (#1030) -* Added `history.retention` section for controlling new features related to history storage and deletion (#858) -* The recommended default for `accounts.multiclient.always-on` is now `opt-in` (#919) -* Added `accounts.default-user-modes`; the recommended default is now to set `+i` on all users automatically (#942, thanks [@ajaspers](https://github.com/ajaspers)!) -* Added `channels.list-delay`, allowing restrictions on channel listings as a defence against spambots (#964) -* Added `accounts.multiclient.auto-away`, allowing always-on clients to be automatically marked as away when all their sessions disconnect -* Added `accounts.throttling` as a global throttle on the creation of new accounts (#913) -* New format for `accounts.callbacks.mailto`, allowing direct email sending and DKIM signing (#921) -* Added `accounts.login-via-pass-command`, providing a new mechanism for legacy clients to authenticate to accounts by sending `PASS account:password` pre-registration (#1020) -* Added `datastore.mysql.socket-path`, allowing MySQL connections over UNIX domain sockets (#1016, thanks savoyard and [@ajaspers](https://github.com/ajaspers)!) -* Added `roleplay` section for controlling the server's roleplay features (#865) -* The recommended default for `accounts.nick-reservation.allow-custom-enforcement` is now `false` (#918) -* The recommended default is now to allow PROXY and WEBIRC lines from localhost (#989, #1011) -* Added `channels.registration.operator-only`, optionally restricting channel registrations to operators (#685) -* Added `server.output-path` for controlling where the server writes output files (#1004) -* Operator capability names prefixed with `oper:` have been normalized to remove the prefix (the old names are still respected in the config file) (#868) -* The log category names `localconnect` and `localconnect-ip` have been changed to `connect` and `connect-ip` respectively (the old names are still respected in the config file) (#940) - -### Security -* Fixed incorrect enforcement of ban/invite/exception masks under some circumstances (#983) -* STATUSMSG were being stored in history without the relevant minimum-prefix information, so they could be replayed to unprivileged users. This was fixed by not storing them at all. (#959, thanks [@prawnsalad](https://github.com/prawnsalad)!) -* Fixed invisible users not being hidden from `WHO *` queries (#991, thanks [@ajaspers](https://github.com/ajaspers)!) -* Restricted nicknames of some additional common services: `OperServ`, `BotServ`, `MemoServ`, and `Global` (#1080, thanks [@KoraggKnightWolf](https://github.com/KoraggKnightWolf)!) - -### Fixed -* Fixed incorrect rejection of `draft/multiline` messages containing blank lines (#1005, thanks [@jwheare](https://github.com/jwheare)!) -* Fixed roleplay commands, which were completely broken from v1.1.0 through v2.0.0 (#865, thanks [@petteri](https://github.com/petteri) and [@Mitaka8](https://github.com/Mitaka8)!) -* Fixed `/SAMODE` applying user mode changes to the operator instead of the target user (#866, thanks [@csmith](https://github.com/csmith)!) -* Fixed some channels not being unregistered during account unregistration (#889) -* Fixed `/NICKSERV SET` and related commands being unavailable when account registration is disabled (#922, thanks [@PizzaLover2007](https://github.com/PizzaLover2007)!) -* Fixed `TAGMSG` not being replayed correctly in history (#1044) -* Fixed incorrect `401 ERR_NOSUCHNICK` responses on `TAGMSG` sent to a service (#1051, thanks [@ajaspers](https://github.com/ajaspers)!) -* Fixed `301 RPL_AWAY` not being sent in `WHOIS` responses when applicable (#850) -* `/OPER` with no password no longer disconnects the client (#951) -* Fixed failure to send extended-join responses after account unregistration (#933, thanks [@jesopo](https://github.com/jesopo)!) -* Improved validation of channel keys (#1021, thanks [@kylef](https://github.com/kylef)!) -* Fixed labeling of `421 ERR_UNKNOWNCOMMAND` responses (#994, thanks [@k4bek4be](https://github.com/k4bek4be)!) -* Fixed incorrect parsing of ident protocol responses (#1002, thanks [@justjanne](https://github.com/justjanne)!) -* Fixed registration completing after `NICK` and an ident response, without waiting for `USER` (#1057, thanks [@KoraggKnightWolf](https://github.com/KoraggKnightWolf)!) -* Fixed messages rejected by the `+R` mode being stored in history (#1061, thanks [@KoraggKnightWolf](https://github.com/KoraggKnightWolf)!) -* Fixed redundant `/INVITE` commands not sending `443 ERR_USERONCHANNEL` (#842, thanks [@hhirtz](https://github.com/hhirtz)!) -* Fixed `/NICKSERV REGISTER` response displaying `mailto:` out of context (#985, thanks [@eklitzke](https://github.com/eklitzke)!) -* Fixed nickname changes not sending `731 RPL_MONOFFLINE` when appropriate (#1076, thanks [@ajaspers](https://github.com/ajaspers)!) -* Fixed incorrect MONITOR responses in some cases (#1086, thanks [@ajaspers](https://github.com/ajaspers)!) -* Fixed HostServ approval and rejection notices being sent from the wrong source (#805) -* Error messages for invalid TLS certificate/key pairs are now more informative (#982) -* Fixed error message when attempting to attach a plaintext session to an always-on client (#955, thanks [@bogdomania](https://github.com/bogdomania) and [@xPaw](https://github.com/xPaw)!) -* Increased the TLS handshake timeout, increasing reliability under high CPU contention (#894) -* Fixed `CHANMODES` ISUPPORT token (#408, #874, thanks [@hhirtz](https://github.com/hhirtz)!) -* Fixed `002 RPL_MYINFO` parameters (#1058, thanks [@KoraggKnightWolf](https://github.com/KoraggKnightWolf)!) -* Fixed incorrect parameter limit for `MONITOR` in the `TARGMAX` isupport token (#1090, thanks [@KoraggKnightWolf](https://github.com/KoraggKnightWolf)!) -* Fixed edge cases in handling of the `+k` channel mode parameter (#874, thanks [@hhirtz](https://github.com/hhirtz)!) -* `account-notify` lines are now part of the labeled-response batch when applicable (#1018) -* Fixed incorrect help description of channel mode `+R` (#930, thanks [@PizzaLover2007](https://github.com/PizzaLover2007)!) -* Fixed `255 RPL_LUSERME` response to indicate that the number of federated peer servers is 0 (#846, thanks [@RyanSquared](https://github.com/RyanSquared)!) - -### Changed -* Account names are now permanent identifiers; they cannot be re-registered after unregistration, and applicable nickname protections remain in force. (#793) -* User modes of always-on clients now persist across server restarts (#819) -* Registered channels with no members remain present on the server, including their in-memory history messages when applicable (#704, thanks [@bogdomania](https://github.com/bogdomania)!) -* Updated the [setname](https://ircv3.net/specs/extensions/setname) IRCv3 capability to its ratified version (#1001) -* `/CHANSERV AMODE` now takes immediate effect (#729) -* The channel founder can now take any action that would require channel privileges without actually having the `+q` mode (#950, #998) -* Account unregistration now always disconnects the client (#1028) -* Fakelag is now temporarily disabled during the sending of a `draft/multiline` message batch (#817) -* Failed attempts to join a `+R` channel now send `477 ERR_NEEDREGGEDNICK` (#936, thanks [@PizzaLover2007](https://github.com/PizzaLover2007), [@jesopo](https://github.com/jesopo)!) -* `353 RPL_NAMREPLY` now always uses a trailing parameter, for compatibility with incorrect client implementations (#854, #862) -* Channels with persistent history can no longer be renamed with `/RENAME` (#827) -* The self-signed certificate generation command `oragono mkcerts` now generates a 2048-bit RSA certificate, instead of a NIST P-521 ECDSA certificate (#898) -* Cleaned up compatibility with an obsolete WEBIRC escaping convention (#869) -* The cloak secret is now stored in the database, so it can no longer be rotated by changing `server.ip-cloaking.secret`. To rotate the secret, use the new `/HOSTSERV SETCLOAKSECRET` command. (#952) - -### Added -* Added native support for websockets (#967, thanks [@hhirtz](https://github.com/hhirtz)!) -* Added support for sending verification emails directly (i.e., without a MTA/smarthost), including DKIM signing (#920, #921) -* Added `/NICKSERV LIST` and `/CHANSERV LIST`, allowing operators to list registered nicknames and channels (#974, thanks [@ajaspers](https://github.com/ajaspers)!) -* Added auto-away feature for marking always-on clients away when all their sessions are disconnected; see `accounts.multiclient.auto-away` and `/NICKSERV HELP SET` for more information (#824) -* Added `/HISTSERV PLAY`, which plays back history messages as NOTICEs from the `HistServ` service (#383, thanks [@nxths](https://github.com/nxths)!) -* Added `/HISTSERV DELETE` for deleting history messages (see the config option `history.retention.allow-individual-delete`) (#858) -* Added `/HISTSERV FORGET` for deleting all history messages associated with an account (see the config option `history.retention.enable-account-indexing`) (#858) -* Added `/HISTSERV EXPORT` for exporting all history messages associated with an account as JSON. This can be used at the user's request for regulatory compliance reasons (see the config option `history.retention.enable-account-indexing`) (#858) -* Added support for logging legacy clients into accounts via the `PASS` command, with the [account:password](https://freenode.net/kb/answer/registration#logging-in) syntax used by Freenode. To enable this feature, set `accounts.login-via-pass-command` to `true`. (#1020, thanks [@jlnt](https://github.com/jlnt)!) -* Added `/NICKSERV ERASE` as an escape hatch for operators, allowing an account to be erased and re-registered (#793) -* Added support for playing back `MODE` and `TOPIC` messages in history (#532) -* Added `conventional.yaml`, a version of the config file that provides a more traditional IRC experience. We recommend a config file based on `oragono.yaml` for production networks, and one based on `conventional.yaml` for IRCv3 conformance testing. (#918) -* Added an optional global throttle on the creation of new accounts (#913) -* Added support for restricting `/LIST` responses sent to anonymous clients (#964) -* Added support for the Plan 9 operating system and its derivatives, including testing on 9front (#1025, thanks [@clukawski](https://github.com/clukawski)!) - -### Removed -* Removed support for colored log output (#940, #939) -* Removed support for distributing the cloaking secret via environment variables (#952) - -### Internal -* `make build` now includes an abbreviated git hash in the `002 RPL_YOURHOST` and `004 RPL_MYINFO` version strings, when applicable (#1031) -* Official releases no longer contain the git hash, only the revision tag (#1031) -* Official releases are now built with `-trimpath` (#901) - -## [2.0.0] - 2020-03-08 -We're pleased to announce Oragono 2.0.0, a major update with a wide range of enhancements and fixes. Highlights include: - -* Support for storing chat history in a MySQL backend -* Full "bouncer" functionality, including "always-on" clients that remain present on the server even when disconnected -* LDAP support contributed by [@mattouille](https://github.com/mattouille) -* Support for the ratified [labeled-response](https://ircv3.net/specs/extensions/labeled-response.html) IRCv3 capability -* Enhanced support for Kubernetes -* Many new service commands, improving management of client certificates, vhosts, and channel ownership - -Many thanks to [@csmith](https://github.com/csmith), [@mattouille](https://github.com/mattouille), and [@xPaw](https://github.com/xPaw) for contributing patches, to [@csmith](https://github.com/csmith) and [@wrmsr](https://github.com/wrmsr) for contributing code reviews, to [@bogdomania](https://github.com/bogdomania), [@brenns10](https://github.com/brenns10), [@daurnimator](https://github.com/daurnimator), [@ekianjo](https://github.com/ekianjo), horseface, [@ivucica](https://github.com/ivucica), [@jesopo](https://github.com/jesopo), [@jwheare](https://github.com/jwheare), KoDi, lover, [@notbandali](https://github.com/notbandali), [@poVoq](https://github.com/poVoq), [@TETYYS](https://github.com/TETYYS), and [@zaher](https://github.com/zaher) for reporting issues, and to [@bogdomania](https://github.com/bogdomania) and Nuve for contributing translations. - -This release includes changes to the config file format, including two breaking changes: - -1. Backwards compatibility with the old `server.listen` format for configuring listeners has been removed; you must now use the `server.listeners` format that was introduced in 1.2.0. -2. The two sections `server.connection-limits` and `server.connection-throttling` have been consolidated into one new section, `server.ip-limits`. - -Other changes to the config file format are backwards compatible and do not require updating before restart. To minimize potential downtime, we suggest the following workflow: - -1. Without upgrading your `oragono` binary, edit your config file to add new `server.listeners` and `server.ip-limits` sections, based on the example config file -2. Rehash your server, confirming that the new config file is valid for for the previous version of the server -3. Upgrade your `oragono` binary to the new 2.0.x version and restart your server -4. Once your deployment is stable on 2.0.x, delete the old `server.listen`, `server.connection-limits`, and `server.connection-throttling` sections from your config, and rehash your server to confirm - -This release includes a database change. If you have `datastore.autoupgrade` set to `true` in your configuration, it will be automatically applied when you restart Oragono. Otherwise, you can update the database manually by running `oragono upgradedb` (see the manual for complete instructions). - -### Config Changes -* Desupported `server.listen` in favor of `server.listeners`, a breaking change (#794) -* Desupported `server.connection-limits` and `server.connection-throttling` in favor of `server.ip-limits`, a breaking change (#646) -* The recommended default is now to allow plaintext only on loopback interfaces (#801) -* Added `server.casemapping` option to control which Unicode nicknames and channels are allowed (#693) -* Added `server.lookup-hostnames` and `server.forward-confirm-hostnames` options to control hostname lookup (#688) -* Added new `limits.multiline` section to control the new `draft/multiline` capability -* Added sections for enabling the optional MySQL history storage backend: `datastore.mysql` for connecting to the server and `history.persistent` for controlling which messages are stored -* Added `history.restrictions` for preventing people from retrieving arbitrarily old history messages -* Added `history.znc-maxmessages`, allowing a higher history replay limit for bouncer emulation relative to CHATHISTORY -* Added `accounts.vhosts.offer-list`, allowing users to take pre-approved vhosts without operator approval (#737) -* Renamed `accounts.bouncer` to `accounts.multiclient` (the old name still works) (#787) -* New recommended values of `server.max-sendq`, `server.ip-cloaking.num-bits`, `accounts.registration.bcrypt-cost`, `accounts.nick-reservation.enabled` (now true), `accounts.multiclient.allowed-by-default` (now true) -* Added `server.ip-cloaking.secret-environment-variable`, allowing the cloaking secret to be deployed via an environment variable for use in Kubernetes (#741, thanks [@daurnimator](https://github.com/daurnimator)!) - -### Security -* Added forward confirmation of reverse DNS lookups for hostnames: to enable this, set `server.forward-confirm-hostnames` to true (#688) -* Added protection against confusable channel names (#581) -* Fixed cases where Tor users could receive CTCP messages, contrary to expectations (#752, #753) -* Fixed `NS INFO` displaying the local timezone (#710) -* Fixed `accounts.authentication-enabled` failing to disable the `NS IDENTIFY` command (#721) - -### Added -* Added support for persistent history storage in MySQL (#348) -* Added support for "always-on" clients that remain present on the server even when disconnected (#348, #701) -* Added support for LDAP (#690, thanks [@mattouille](https://github.com/mattouille), [@ivucica](https://github.com/ivucica), and [@mabgnu](https://github.com/mabgnu)!) -* Added support for the new [draft/multiline](https://github.com/ircv3/ircv3-specifications/pull/398) specification (#670, thanks [@jwheare](https://github.com/jwheare) and [@jesopo](https://github.com/jesopo)!) -* Added new modes for Unicode characters in nicknames and channel names: ASCII-only and "permissive" (allowing emoji) (#693) -* Added support for plaintext PROXY lines ahead of a TLS handshake, improving compatibility with some Kubernetes load balancers (#561, thanks [@RyanSquared](https://github.com/RyanSquared) and [@daurnimator](https://github.com/daurnimator)!) -* Added support for authenticating operators by TLS client certificates, and automatically applying operator privileges on login (#696, thanks [@RyanSquared](https://github.com/RyanSquared)!) -* Added `/DEOPER` command to remove operator privileges (#549, thanks [@bogdomania](https://github.com/bogdomania)!) -* Added `/CHANSERV TRANSFER`, allowing transfers of channel ownership (#684) -* Added `/NICKSERV CERT`, allowing users to manage their authorized client certificates (#530) -* Added `/HOSTSERV TAKE`, allowing users to take pre-approved vhosts without operator approval (#737) -* Added support for configuring connection limits and throttling for individual CIDRs (#646, thanks KoDi!) -* Added `/CHANSERV PURGE`, allowing server administrators to shut down channels (#683) -* Added `/CHANSERV CLEAR`, allowing channel founders to reset stored bans and privileges (#692) -* Added `/CHANSERV SET`, allowing channel founders to disable channel history (#379) -* Added account preference `AUTOREPLAY-JOINS`, allowing greater control over when joins and parts appear in history replay (#616, thanks [@zaher](https://github.com/zaher)!) -* Added `/DEBUG CRASHSERVER` command (#791) -* `znc.in/playback` now supports nicknames as targets (#830) -* Added channel mode `+C` to suppress CTCP messages to a channel (#756) -* Added some missing snomasks for events related to accounts and vhosts (`+s v` to enable vhost snomasks) (#347, #103) - -### Changed -* Updated CHATHISTORY support to the [latest draft](https://github.com/ircv3/ircv3-specifications/pull/393) (#621, thanks [@prawnsalad](https://github.com/prawnsalad)!) -* Updated to the ratified [labeled-response](https://ircv3.net/specs/extensions/labeled-response.html) specification from the earlier `draft/labeled-response-0.2` (#757) -* `/HISTORY` now defaults to returning 100 messages, and also takes time durations like `1h` as arguments (#621, thanks lover!) -* D-Lines are no longer enforced against loopback IPs (#671) -* Password length limit was reduced from 600 bytes to 300 bytes (#775) - -### Fixed -* Fixed a bug where `znc.in/playback` commands would play every channel, regardless of the target parameter (#760, thanks [@brenns10](https://github.com/brenns10)!) -* Fixed `MODE -o` not removing all operator permissions (#725, #549, thanks [@bogdomania](https://github.com/bogdomania)!) -* Fixed client-only tags being relayed in direct messages to users without the `message-tags` capability (#754, thanks [@jesopo](https://github.com/jesopo)!) -* Fixed the channel user limit (the `+l` mode) not persisting after server restart (#705, thanks [@bogdomania](https://github.com/bogdomania)!) -* Fixed response to `JOIN` lines with parameters ending in a comma (#679, thanks [@bogdomania](https://github.com/bogdomania)!) -* Fixed confusable protection not being removed from unregistered accounts (#745, thanks [@bogdomania](https://github.com/bogdomania)!) -* Fixed rehash not enabling nickname reservation, vhosts, or history under some circumstances (#702, thanks [@bogdomania](https://github.com/bogdomania)!) -* Fixed responses to the `USERHOST` command (#682) -* Fixed bad results when running `oragono upgradedb` against a missing database file (#715, thanks [@bogdomania](https://github.com/bogdomania)!) -* Fixed confusing `NS GHOST` behavior when nickname reservation is disabled (#727, thanks horseface!) -* Fixed validation of authzid during SASL (#716, thanks [@xPaw](https://github.com/xPaw)!) -* Non-ASCII characters are proactively disallowed in `ip-cloaking.netname` (#713, thanks [@bogdomania](https://github.com/bogdomania)!) -* Limited the time during which `znc.in/playback` affects channel joins (#829) - -### Removed -* Removed `oragono.io/maxline-2` capability in favor of the new `draft/multiline` capability (#670, #752) -* Removed `oragono.io/bnc` capability (multiclient functionality is now controllable only via server config and `/NS SET MULTICLIENT`) (#787) -* Removed `draft/acc` capability and related `ACC` command (#723) - -### Internal Notes -* Updated to Go 1.14 and modules, simplifying the build process (#699) - -## [1.2.0] - 2019-11-17 -We're pleased to announce Oragono 1.2.0. This version contains bug fixes and minor improvements. - -Many thanks to [@bogdomania](https://github.com/bogdomania), [@csmith](https://github.com/csmith), [@edmund-huber](https://github.com/edmund-huber), [@jesopo](https://github.com/jesopo), [@jwheare](https://github.com/jwheare), [@poVoq](https://github.com/oragono/oragono/issues/624), [@prawnsalad](https://github.com/prawnsalad), and stealthgin for reporting issues and contributing code reviews, and also to [@bogdomania](https://github.com/bogdomania), Forbidden (cptbl00dra1n), Nuve, [@streaps](https://github.com/streaps), and UnLokitoFeliz for contributing translations. - -This release includes a change to the config file format: the old `server.listen` format for configuring listeners has been replaced by a new `server.listeners` format. See the bundled `oragono.yaml` configuration file for a commented example. For now, Oragono maintains backwards compatibility with the old format. To minimize potential downtime, we recommend the following workflow: - -1. Without rewriting your config file, upgrade your `oragono` binary to the new 1.2.x version and restart your server -2. Rewrite your configuration file to use the new `server.listeners` format -3. Rehash your server, confirming that the rewritten config file is valid and correct - -This release includes a database change. If you have `datastore.autoupgrade` set to `true` in your configuration, it will be automatically applied when you restart Oragono. Otherwise, you can update the database manually by running `oragono upgradedb` (see the manual for complete instructions). - -### Config Changes -* Replaced `server.listen` section with `server.listeners`; see `oragono.yaml` for a commented example (#565) -* Added `history.autoresize_window` for automatically resizing history buffers (#349) - -### Added -* Added STS-only listeners; you can configure port 6667 so that it "redirects" clients to TLS on port 6697. See the manual for details. (#448) -* Added the `CHANLIMIT` ISUPPORT token (#625, thanks [@poVoq](https://github.com/oragono/oragono/issues/624)!) -* Added ban creator and creation time to banlist output (#644, thanks stealthgin!) - -### Changed -* Upgraded to the new `draft/labeled-response-0.2` capability (#555) -* `oragono mkcerts` no longer overwrites existing certificate files (#622, thanks [@poVoq](https://github.com/oragono/oragono/issues/624)!) -* Allowed Tor and non-Tor connections to attach to the same nickname via bouncer functionality (#632) - -### Fixed -* Fixed `CAP LS 302` response being potentially truncated (#594, #661) -* Fixed redundant output to some `MODE` commands (#649) -* Improved display of replies to `/msg NickServ verify` in some clients (#567, thanks [@edmund-huber](https://github.com/edmund-huber)!) -* Improved display of NickServ timeout warnings in some clients (#572, thanks [@bogdomania](https://github.com/bogdomania)!) -* `LUSERS` output is now sent at the end of connection registration (#526) -* Fixed operators not being able to `WHOIS` some Unicode nicknames (#331, thanks [@bogdomania](https://github.com/bogdomania)!) -* Fixed `RESUME` not clearing the `BRB` reason (#592, thanks [@jesopo](https://github.com/jesopo)!) -* Fixed an edge case where the `BRB` timestamp wasn't reset correctly (#642) -* Fixed behavior of `SAMODE` issued against a different user (#585) -* Fixed a false-positive error logline (#601) -* `oragono.io/bnc` is no longer advertised when disabled in the config (#595) -* Made the connection limiter and throttler more resilient against the failure to whitelist a reverse proxy IP (#197, thanks [@prawnsalad](https://github.com/prawnsalad)!) - -### Internal Notes -* Official builds now use Go 1.13, which includes native TLS 1.3 support (#626) -* Minor performance improvements (#640, #615) - -## [1.1.1] - 2019-07-21 -Oragono 1.1.1 is a bugfix release for flaws in message handling, including one with security implications. - -Many thanks to [@streaps](https://github.com/streaps) for reporting issues. - -### Upgrade notes - -This release does not change the database or configuration file format. - -### Security -* Previous releases of Oragono would incorrectly relay chat messages containing the `\r` byte. An attacker could use this to spoof protocol messages from the server (depending on the implementation of the victim's client). This has been fixed. (#610) - -### Fixed -* Fixed incorrect rejection of messages with multiple spaces (#602, thanks [@streaps](https://github.com/streaps)!) - -## [1.1.0] - 2019-06-27 -We're pleased to announce Oragono version 1.1.0. This version has a number of exciting improvements, including: - -* Simplified commands for registering new accounts with NickServ. -* Support for IP cloaking. -* Support for attaching multiple clients to the same nickname. -* Support for the newly ratified [message tags](https://ircv3.net/specs/extensions/message-tags.html) and [message ID](https://ircv3.net/specs/extensions/message-ids.html) IRCv3 specifications; client developers are invited to use Oragono as a reference when implementing these specifications. -* Support for running Oragono as a Tor hidden service. - -Many thanks to [@Ascrod](https://github.com/Ascrod), [@amyspark](https://github.com/amyspark), [@bogdomania](https://github.com/bogdomania), [@csmith](https://github.com/csmith), [@jesopo](https://github.com/jesopo), [@jwheare](https://github.com/jwheare), lover, and [@transitracer](https://github.com/oragono/oragono/issues/456) for reporting issues and contributing patches, and also to [@bogdomania](https://github.com/bogdomania), Elvedin Hušić, Nuve, and [@streaps](https://github.com/streaps) for contributing translations. - -### Upgrade notes - -This release includes a database change. If you have `datastore.autoupgrade` set to `true` in your configuration, it will be automatically applied when you restart Oragono. Otherwise, you can update the database manually by running `oragono upgradedb` (see the manual for complete instructions). - -No changes to your configuration file should be required for this upgrade. However, updating the file is necessary to enable some new functionality, as described below. - -### Config changes -* `tor-listeners` section added for configuring listeners for use with Tor. -* `compatibility` section added for toggling compatibility behaviors for legacy clients. -* `ip-cloaking` section added for configuring cloaking. -* `bouncer` section added for configuring bouncer-like features (in particular, whether multiple clients can use the same nickname). -* `check-ident` now has recommended value `false`. -* `nick-reservation.method` now has recommended value "strict"`. -* `fakelag.enabled` now has recommended value `true`. -* `limits.linelen.tags` removed due to ratification of the [message-tags spec](https://ircv3.net/specs/extensions/message-tags.html), which fixes the maximum tags length at 8191 bytes. -* `limits.registration-messages` added to restrict how many messages a user can send to the server during connection registration (while connecting to the server). -* `channels.operator-only-creation` added to optionally restrict creation of new channels to ircops (#537). - -### Security -* Users can no longer impersonate network services like ChanServ by using confusing nicks like "ChɑnServ" (#519, thanks [@csmith](https://github.com/csmith)!). -* Closed several loopholes in confusable nick detection (#562, #564, #570, thanks lover!) -* Secret channels (mode `+s`) now act more secret (#380, thanks [@csmith](https://github.com/csmith)!). -* The `+R` (registered-only) mode now prevents unregistered users from joining the channel, not just from speaking (#463, thanks [@bogdomania](https://github.com/bogdomania)!). -* Limited how many messages clients can send during connection registration to mitigate potential DoS attacks (#505). -* Attempting to reauthenticate with SASL now fails with `907 ERR_SASLALREADY` (#476). - -### Fixed -* Fixed `/ISON` command reporting users as always being online (#479). -* Fixed clients who negotiated CAP version 302 or higher not receiving cap-notify messages (#464). -* We now treat channel privileges such as halfop more consistently (#400). -* Fixed a bug where clients could receive message tags they hadn't enabled (#434). -* When replaying history, messages now have more consistent IDs and timestamps -* IDs and timestamps are now applied more consistently to messages (#388, #477, #483). -* Client-to-client tags are now stored and replayed in message history (#437). -* Fixed various error numerics that were being sent with incorrect parameters (#425, thanks [@Ascrod](https://github.com/Ascrod)!). -* Fixed STATUSMSG not adding the correct prefix to the channel when relaying the message (#467). -* Fixed `/RENAME` command not correctly renaming the channel for some users (#300, thanks [@jesopo](https://github.com/jesopo)!). -* History playback is now batched when applicable (#456, thanks [@transitracer](https://github.com/oragono/oragono/issues/456)!). -* Notices from NickServ/ChanServ/etc should display better in some clients (#496, thanks [@jwheare](https://github.com/jwheare)!). -* Fixed nickname timer warnings not displaying correctly sometimes (#449, thanks [@bogdomania](https://github.com/bogdomania)!). -* When history playback is disabled, the `/HISTORY` command now says so instead of silently failing (#429, thanks [@bogdomania](https://github.com/bogdomania)!). -* The `/HOSTSERV ON/OFF` commands now tell you when you don't have a vhost (#404, thanks [@bogdomania](https://github.com/bogdomania)!). -* When operators use the `/SANICK` command, the snomask now says which operator did it instead of saying the target changed their nickname themselves (#360, thanks [@bogdomania](https://github.com/bogdomania)!). -* History playback now includes messages that the user sent themselves (especially useful with the new bouncer-like capabilities) (#487). - -### Added -* IP cloaking is now supported (see the manual for details) (#108). -* Users can now attach multiple clients to the same nickname (see the manual for details) (#403). -* Oragono can now be used as a Tor hidden service (see the manual for details) (#369). -* The `znc.in/playback` capability is now supported, which can automate history playback for clients that support it (#486). -* User preference system controlling various behaviors (`/msg NickServ help set` for details) (#466). -* Support for the [draft/event-playback](https://github.com/DanielOaks/ircv3-specifications/blob/master+event-playback/extensions/batch/history.md) spec (#457). -* The `TAGMSG` and `NICK` messages are now replayable in history (#457). -* Added the draft IRCv3 [`SETNAME` command](https://ircv3.net/specs/extensions/setname) for changing your realname (#372). -* Added new Bosnian (bs-BA) translation (thanks to Elvedin Hušić!). -* Added new German (de-DE) translation (thanks to streaps!). - -### Changed -* Registering an account with NickServ is now `/msg NickServ register `, which registers the current nickname as an account, matching other services (#410). -* Added a compatibility hack to make SASL work with ZNC 1.6.x (#261). -* We now support the ratified [message-tags](https://ircv3.net/specs/extensions/message-tags.html) spec, replacing `draft/message-tags-0.2`. -* We now support the ratified [message IDs](https://ircv3.net/specs/extensions/message-ids.html) spec, replacing `draft/msgid`. -* The [`oragono.io/maxline-2`](https://oragono.io/maxline-2) capability has replaced `oragono.io/maxline`, the new version now working alongside the ratified message-tags spec (#433). -* We now support [`draft/resume-0.5`](https://github.com/ircv3/ircv3-specifications/pull/306) and the associated `BRB` command, replacing `draft/resume-0.3`. -* Upgraded support for the `/RENAME` command to the [latest draft of the specification](https://github.com/ircv3/ircv3-specifications/pull/308). -* Upgraded support for the `/ACC` command to the [latest draft of the specification](https://github.com/DanielOaks/ircv3-specifications/blob/register-and-verify/extensions/acc-core.md) (#453, #455). -* Removed the `+a` away mode as no other servers use it (#468, thanks [@jesopo](https://github.com/jesopo) and [@jwheare](https://github.com/jwheare)!). -* Forcing trailing parameters for legacy compatibility can now be disabled in config (#479). -* `autoreplay-on-join` no longer replays `JOIN` and `PART` lines by default (#474, thanks [@amyspark](https://github.com/amyspark)!). -* snomasks are no longer sent for unregistered clients (#362, thanks [@bogdomania](https://github.com/bogdomania)!). -* `WHOIS` responses no longer include the `690 RPL_WHOISLANGUAGE` numeric, as it doesn't show anything useful to other users (#516). -* `ISON` now reports services (ChanServ/NickServ/etc) as online (#488). -* All times are now reported in UTC (#480). -* `NICKSERV ENFORCE` is deprecated in favor of the new `NICKSERV SET ENFORCE` (the old syntax is still available as an alias). -* The `WHO` command is now treated like `PONG` in that it doesn't count as user activity, since client software often uses it automatically (#485). -* The `NAMES` command now only returns results for the first given channel (#534). -* Updated French (fr-FR) translation (thanks to Nuve!). -* Updated Română (ro-RO) translation (thanks to [@bogdomania](https://github.com/bogdomania)!). - -### Internal Notes -* Building Oragono is now easier (#409). -* Official builds now use Go 1.12 (#406). -* Our message building and parsing code is slightly faster now (#387). -* Added the [`oragono.io/nope`](https://oragono.io/nope) capability to encourage clients to request capabilities safely (#511). -* Made some previously untranslatable strings translatable (#407). -* Fixed portability issues with 32-bit architectures (#527). - - -## [1.0.0] - 2019-02-24 -We've finally made it to v1.0.0! With this release, our list of need-to-haves is rounded out, and we reckon the software's ready for production use in smaller networks. slingamn and I have been working with our contributors and translators to prepare a cracker of a release. Thanks to [@csmith](https://github.com/csmith) our [Docker builds](https://hub.docker.com/r/oragono/oragono/) have been updated, with automatic rebuilds as we develop the software. Thanks to [@bogdomania](https://github.com/bogdomania) our translation workflow has been improved a lot. - -Highlights include: - -* Optional support for storing and replaying message history with the [`draft/resume-0.3` capability](https://github.com/ircv3/ircv3-specifications/pull/306), the draft IRCv3 `CHATHISTORY` command, and a custom `HISTORY` command. -* Better detection of confusing nick/account/channel names. -* User-customizable nickname protection methods. -* An account-only mode in which all clients must have an account and login to it (using SASL) before they can join the server. - -Thanks to Mauropek, [@modinfo](https://github.com/modinfo), [@bogdomania](https://github.com/bogdomania), [@Shillos](https://github.com/Shillos), Tony Chen, and Remini for adding new translations. Thanks to [@Ascrod](https://github.com/Ascrod), [@bogdomania](https://github.com/bogdomania), [@csmith](https://github.com/csmith), [@jesopo](https://github.com/jesopo), [@jwheare](https://github.com/jwheare), [@remini1998](https://github.com/remini1998), [@enckse](https://github.com/enckse), and [@iNecas](https://github.com/iNecas) for finding bugs and/or writing new features. - -### Config Changes -* `allow-custom-enforcement` key added under `accounts`. -* `allow-plaintext-resume` key added under `server`. -* `history` section added. -* `identlen` key added under `limits`. -* `login-throttling` section added under `accounts`. -* `max-channels-per-account` key added under `channels.registration` (limiting the number of channels that can be registered). -* `max-channels-per-client` key added under `channels` (limiting the number of channels that can be joined). -* `method` key now under `accounts` now allows the value `"optional"`. -* Exemption lists now accept `localhost` as a value, meaning any loopback IPV4, loopback IPV6, or unix domain address. -* Logging type `server` has been added, replacing the `startup`, `rehash`, and `shutdown` types. -* The default logging configuration now logs to stderr only, rather than to both stderr and a file. -* We no longer listen on port `6668` by default (this fixes Docker installs). - -### Security -* Added a SASL-only mode in which all clients must authenticate with SASL. -* Added login throttling as a hardening measure against password guessing. -* Configurable limits are imposed on how many channels clients can join or register. - -### Added -* Added automagic datastore creation on `oragono run`. -* Added detection and prevention of confusing nicknames, account names, and channel names. -* Added limited message history for connection resuming (to be extended in future). -* Added new Español (es) translation (thanks to Mauropek!). -* Added new Polski (pl) translation (thanks to [@modinfo](https://github.com/modinfo)!). -* Added new Română (ro) translation (thanks to [@bogdomania](https://github.com/bogdomania)!). -* Added new Ελληνικά (el) translation (thanks to [@Shillos](https://github.com/Shillos)!). -* Added new 简体中文 (zh-CN) translation (thanks to Tony Chen and Remini!)). -* Added proposed IRCv3 capability [`draft/setname`](https://github.com/ircv3/ircv3-specifications/pull/361). -* Added subcommands to `NICKSERV`, including: - * `PASSWD` to change account passwords. - * `ENFORCE` to set a specific enforcement mechanism on your nick. - * `SAREGISTER` to allow operators to manually create new user accounts. - -### Changed -* `SASL PLAIN` logins now log more correctly. -* Database upgrade failures now provide information about the error that occurred. -* Halfops can now kick unprivileged users. -* Idents (sometimes called "usernames") are now restricted to ASCII, similar to other servers. -* Improved compatibility with ZNC's nickserv module. -* In addition to the founder, now auto-ops (halfop and higher) automatically bypass channel join restrictions. -* Log lines now display time down to milliseconds, instead of just seconds. -* Updated all translation files (thanks to our amazing translators!). -* Updated proposed IRCv3 capability `draft/resume` to [`draft/resume-0.3`](https://github.com/ircv3/ircv3-specifications/pull/306). -* When nick ownership is enabled, users can now select which enforcement mechanism to use with their nickname. - -### Fixed -* `INVITE`: Fixed bug where invited users could not join the channel they were invited to (thanks to [@unendingpattern](https://github.com/unendingpattern)!). -* [`oragono.io/maxline`](https://oragono.io/maxline) capability was accidentally disabled, and is now re-enabled. -* `oragono genpasswd` now works when piping input in (fixes Docker installs). -* `PRIVMSG`: Messages sent to multiple clients (such as channel messages) now share the same timestamp (previously each client got a very slightly different time). -* `WHOIS`: Now responds properly for NickServ, ChanServ, etc. -* Channel names with right-to-left characters are now casefolded correctly (thanks to [@remini1998](https://github.com/remini1998)!). -* Fixed handling of CIDR width in connection limiting/throttling. -* Fixed incorrect behavior of `CHANSERV OP` command. -* Fixed incorrect rejection of nickmasks with Unicode RTL nicknames. -* Fixed many responses that violated the specifications (thanks to [@Ascrod](https://github.com/Ascrod), [@bogdomania](https://github.com/bogdomania), [@csmith](https://github.com/csmith), [@jesopo](https://github.com/jesopo), and [@jwheare](https://github.com/jwheare)!). -* Fixed nickname sync issue which could cause clients to fail to see each other. -* Invalid `ISUPPORT` tokens are now explicitly rejected. -* Made `server-time` timestamp format more consistent and safer. -* Oragono now exits with status (1) if it fails to start. -* Prevent logging in multiple times when using `/NS IDENTIFY`. -* Prevented the db handler from automagically creating the database without initializing it (thanks [@enckse](https://github.com/enckse)!). We also now automatically create the datastore on `run`. - -### Internal Notes -* `DLINE` and `KLINE` refactored, and expired bans are now removed from the database. -* Command-line parsing was upgraded to match modern best practices (thanks to [@iNecas](https://github.com/iNecas)!). -* Direct responses to client commands are now sent "synchronously", bypassing the sendq. -* Logging system optimised. -* Services handlers refactored. -* Translations are now sent to/PR'd from CrowdIn automagically as we develop the software. - - -## [0.12.0] - 2018-10-15 -There's been a host of changes in the past six months, and this Halloween release has a number of very useful improvements. - -For example, passwords are now hashed in a _much_ better way than we did it before (jlatt's original method back from [Ergonomadic](https://github.com/edmund-huber/ergonomadic) was the right way to do things), the database now auto-upgrades for you when it detects a new version, thanks to Slingamn we now have vhosts, and there's been a ton of rewrites under-the-hood to improve stability and performance. - -If you have any trouble with this release, please let us know with an issue on our tracker, or by talking to us in `#oragono` on Freenode. - -Thanks to [slingamn](https://github.com/slingamn) for a lot of heavy lifting this release and to [vilmibm](https://github.com/vilmibm) for contributing a documentation fix! - -### Config Changes -* `allow-multiple-per-connection` key removed from `accounts`. -* `autoupgrade` key added under `datastore`, specifying whether to upgrade to new database versions automatically. -* `bcrypt-cost` key added under `accounts`, to control how strongly account passwords are hashed. -* `stackimpact` section removed from `debug`. -* `unix-bind-mode` key added under `server`, controlling the bind mode used for unix listening sockets. -* `vhosts` section added under `accounts`, configuring our new vhost support. -* new oper capabilities `accreg`, `sajoin`, `vhosts` and `chanreg` added. - -### Security -* Password hashing has been improved (with current passwords being automatically upgraded to use the new method). -* Various crashes have been resolved. - -### Added -* Added database auto-upgrades. -* Added new subcommands to `ChanServ` including: - * `AMODE` to allow setting persistent channel modes for users. - * `DROP` to unregister a channel. -* Added vhosts (virtual/vanity hosts), controlled via `HostServ`. - -### Changed -* `ChanServ` and `NickServ` now show in their help output when commands have been disabled. -* Channel keys and modes are now stored for registered channels. -* Client capability handling rewritten under-the-hood. -* Disabled services commands now show as disabled (rather than being completely hidden). -* Many under-the-hood optimisations (thanks @slingamn!). -* Rehashing is now more consistent and safe. - -### Removed -* Removed StackImpact debug support, as we don't find it useful these days. - -### Fixed -* Fixed `LUSERS` to make it display correct client count and output correct params (thanks [@moortens](https://github.com/moortens)!. -* Fixed `PROXY` support for IPv6 clients. -* Fixed `SAMODE` crash when using it on a channel you're not joined to. -* Fixed `WHOIS` so that `RPL_WHOISACCOUNT` is now sent correctly. -* Fixed fakelag timing to better match expected values. -* Fixed issue where incoming and outgoing private messages were being incorrectly modified (a space was being added to the end) due to a bug with our protocol handling. -* Fixed password hashing method, with existing passwords being auto-upgraded to use the new method. - - -## [0.11.0] - 2018-04-15 -And v0.11.0 finally comes along! This release has been in the works for almost four months now, with an alpha and beta helping square away the issues. - -We're adding a lot of features to improve debugging, better support international users, and make things better for network administrators. Among the new features, you can use the `LANGUAGE` command to set a custom server language (see our [CrowdIn](https://crowdin.com/project/ergochat) to contribute), expose a debugging `pprof` endpoint, reserve nicknames with `NickServ`, and force email verification for new user accounts. On the improvements side we have a `CAP REQ` fix, and we now have a manual that contains a nice overview of Oragono's documentation. - -If you have any trouble with this release, please let us know with an issue on our tracker, or by talking to us in `#oragono` on Freenode. - -Thanks a bunch to everyone for the help with this release – especially to our translators and to Slingamn for being an awesome co-maintainer! +## Unreleased +New release of Oragono! ### Config Changes * `callbacks` section added under `accounts/registration`, configuring our new email verification (disabled by default). * `fakelag` section added, configuring our new fakelag implementation. * `ips-per-subnet` key renamed to `connections-per-subnet`. -* `motd-formatting` is now enabled by default. * `nick-reservation` section added under `accounts`, configuring our new nickname ownership abilities. * `nofakelag` and `unregister` oper classes added. * `pprof-listener` key added under `debug` (disabled by default). * `skip-server-password` key added under `accounts`, to better support certain clients. * `verify-timeout` default value changed from 120 hours to 32 hours under `accounts/registration`. +### Security + ### Added -* Added 32-bit builds. * Added a debug pprof endpoint, which is disabled by default and can be exposed in the config. * Added a manual to our documentation! This is primarily where we'll be adding user-facing information and instructions from now on. -* Added current running git commit to the sent version string. * Added fakelag, so that the server can slow down clients hitting it too aggressively. Disabled by default while we work out the kinks and the specific settings (thanks @slingamn!). * Added IRCv3 capability [`batch`](https://ircv3.net/specs/extensions/batch-3.2.html) and draft capability [`draft/labeled-response`](https://ircv3.net/specs/extensions/labeled-response.html). * Added listening support for unix sockets. * Added new Brazilian Portuguese translation (thanks to [Alexandre Oliveira](https://github.com/RockyTV)!)). * Added new French translation (thanks to [Joshua](https://github.com/joshk0)!). -* Added new Norwegian translation (thanks to Morten!). * Added new subcommands to `CHANSERV`, including: * `OP` to op yourself or the given user (can only be run by channel founders). * Added new subcommands to `NICKSERV`, including: @@ -1312,9 +45,10 @@ Thanks a bunch to everyone for the help with this release – especially to our * `B`: Mark yourself as a bot, and display that you're a bot in WHOIS. ### Changed -* `genpasswd` now requires that you confirm the input passphrase. * Message IDs are now much shorter and easier to read – down from 39 characters to 16 while preserving a very similar gaurantee of uniqueness (thanks [@prawnsalad](https://github.com/prawnsalad) for bringing up this issue). +### Removed + ### Fixed * We now correctly suspend registration when receiving a `CAP REQ`, as per [the spec](https://ircv3.net/specs/core/capability-negotiation-3.1.html). * We now properly cut off clients who try to send us too much data at once. diff --git a/DEVELOPING.md b/DEVELOPING.md index 507905f9..af5c83a5 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -1,90 +1,34 @@ -# Developing Ergo +# Developing Oragono -This is a guide to modifying Ergo's code. If you're just trying to run your own Ergo, or use one, you shouldn't need to worry about these issues. +Most development happens on the `develop` branch, which is occasionally rebased + merged into `master` when it's not incredibly broken. When this happens, the `develop` branch is usually pruned until I feel like making 'unsafe' changes again. +I may also name the branch `develop+feature` if I'm developing multiple, or particularly unstable, features. -## Golang issues - -You should use the [latest distribution of the Go language for your OS and architecture](https://golang.org/dl/). (If `uname -m` on your Raspberry Pi reports `armv7l`, use the `armv6l` distribution of Go; if it reports v8, you may be able to use the `arm64` distribution.) - -Ergo vendors all its dependencies. Because of this, Ergo is self-contained and you should not need to fetch any dependencies with `go get`. Doing so is not recommended, since it may fetch incompatible versions of the dependencies. - -If you're upgrading the Go version used by Ergo, there are several places where it's hard-coded and must be changed: - -1. `.github/workflows/build.yml`, which controls the version that our CI test suite uses to build and test the code (e.g., for a PR) -2. `Dockerfile`, which controls the version that the Ergo binaries in our Docker images are built with -3. `go.mod`: this should be updated automatically by Go when you do module-related operations - - -## Branches - -The recommended workflow for development is to create a new branch starting from the current `master`. Even though `master` is not recommended for production use, we strive to keep it in a usable state. Starting from `master` increases the likelihood that your patches will be accepted. - -Long-running feature branches that aren't ready for merge into `master` may be maintained under a `devel+` prefix, e.g. `devel+metadata` for a feature branch implementing the IRCv3 METADATA extension. - - -## Workflow - -We have two test suites: - -1. `make test`, which runs some relatively shallow unit tests, checks `go vet`, and does some other internal consistency checks -1. `make irctest`, which runs the [irctest](https://github.com/ProgVal/irctest) integration test suite - -Barring special circumstances, both must pass for a PR to be accepted. irctest will test the `ergo` binary visible on `$PATH`; make sure your development version is the one being tested. (If you have `~/go/bin` on your `$PATH`, a successful `make install` will accomplish this.) - -The project style is [gofmt](https://go.dev/blog/gofmt); it is enforced by `make test`. You can fix any style issues automatically by running `make gofmt`. - - -## Updating dependencies - -Ergo vendors all dependencies using `go mod vendor`. To update a dependency, or add a new one: - -1. `go get -v bazbat.com/path/to/dependency` ; this downloads the new dependency -2. `go mod vendor` ; this writes the dependency's source files to the `vendor/` directory -3. `git add go.mod go.sum vendor/` ; this stages all relevant changes to the vendor directory, including file deletions. Take care that spurious changes (such as editor swapfiles) aren't added. -4. `git commit` +The intent is to keep `master` relatively stable. ## Releasing a new version -1. Ensure the tests pass, locally on travis (`make test`, `make smoke`, and `make irctest`) -1. Test backwards compatibility guarantees. Get an example config file and an example database from the previous stable release. Make sure the current build still works with them (modulo anything explicitly called out in the changelog as a breaking change). -1. Run the `ircstress` chanflood benchmark to look for data races (enable race detection) and performance regressions (disable it). -1. Update the changelog with new changes and write release notes. -1. Update the version number `irc/version.go` (either change `-unreleased` to `-rc1`, or remove `-rc1`, as appropriate). -1. Commit the new changelog and constants change. -1. Tag the release with `git tag --sign v0.0.0 -m "Release v0.0.0"` (`0.0.0` replaced with the real ver number). -1. Build binaries using `make release` -1. Sign the checksums file with `gpg --sign --detach-sig --local-user ` -1. Smoke-test a built binary locally -1. Point of no return: `git push origin master --tags` (this publishes the tag; any fixes after this will require a new point release) -1. Publish the release on GitHub (Releases -> "Draft a new release"); use the new tag, post the changelog entries, upload the binaries, the checksums file, and the signature of the checksums file -1. Update the `irctest_stable` branch with the new changes (this may be a force push). -1. If it's a production release (as opposed to a release candidate), update the `stable` branch with the new changes. (This may be a force push in the event that stable contained a backport. This is fine because all stable releases and release candidates are tagged.) -1. Similarly, for a production release, update the `irctest_stable` branch (this is the branch used by upstream irctest to integration-test against Ergo). -1. Make the appropriate announcements: - * For a release candidate: - 1. the channel topic - 1. any operators who may be interested - 1. update the testnet - * For a production release: - 1. everything applicable to a release candidate - 1. Twitter - 1. ergo.chat/news - 1. ircv3.net support tables, if applicable - 1. other social media? +1. Ensure dependencies are up-to-date. +2. Run [`irctest`]() over it to make sure nothing's severely broken. +3. Remove `-unreleased` from the version number in `irc/constants.go`. +4. Update the changelog with new changes. +5. Remove unused sections from the changelog, change the date/version number and write release notes. +6. Commit the new changelog and constants change. +7. Tag the release with `git tag v0.0.0 -m "Release v0.0.0"` (`0.0.0` replaced with the real ver number). +8. Build binaries using the Makefile, upload release to Github including the changelog and binaries. Once it's built and released, you need to setup the new development version. To do so: -1. Ensure dependencies are up-to-date. -1. Bump the version number in `irc/version.go`, typically by incrementing the second number in the 3-tuple, and add '-unreleased' (for instance, `2.2.0` -> `2.3.0-unreleased`). -1. Commit the new version number and changelog with the message `"Setup v0.0.1-unreleased devel ver"`. +1. In `irc/constants.go`, update the version number to `0.0.1-unreleased`, where `0.0.1` is the previous release number with the minor field incremented by one (for instance, `0.9.2` -> `0.9.3-unreleased`). +2. At the top of the changelog, paste a new section with the content below. +3. Commit the new version number and changelog with the message `"Setup v0.0.1-unreleased devel ver"`. **Unreleased changelog content** ```md ## Unreleased -New release of Ergo! +New release of Oragono! ### Config Changes @@ -101,28 +45,45 @@ New release of Ergo! -## Debugging +## Updating `vendor/` -It's helpful to enable all loglines while developing. Here's how to configure this: +The `vendor/` directory holds our dependencies. When we import new repos, we need to update this folder to contain these new deps. This is something that I'll mostly be handling. -```yaml -logging: - - - method: stderr - type: "*" - level: debug -``` +To update this folder: -To debug a hang, the best thing to do is to get a stack trace. The easiest way to get stack traces is with the [pprof listener](https://golang.org/pkg/net/http/pprof/), which can be enabled in the `debug` section of the config. Once it's enabled, you can navigate to `http://localhost:6060/debug/pprof/` in your browser and go from there. If that doesn't work, try: +1. Install https://github.com/golang/dep +2. `cd` to Oragono folder +3. `dep ensure -update` +4. `cd vendor` +5. Commit the changes with the message `"Updated packages"` +6. `cd ..` +4. Commit the result with the message `"vendor: Updated submodules"` + +This will make sure things stay nice and up-to-date for users. + + +## Fuzzing and Testing + +Fuzzing can be useful. We don't have testing done inside the IRCd itself, but this fuzzer I've written works alright and has helped shake out various bugs: [irc_fuzz.py](https://gist.github.com/DanielOaks/63ae611039cdf591dfa4). + +In addition, I've got the beginnings of a stress-tester here which is useful: +https://github.com/DanielOaks/irc-stress-test + +As well, there's a decent set of 'tests' here, which I like to run Oragono through now and then: +https://github.com/DanielOaks/irctest + + +## Debugging Hangs + +To debug a hang, the best thing to do is to get a stack trace. Go's nice, and you can do so by running this: $ kill -ABRT -This will kill Ergo and print out a stack trace for you to take a look at. - +This will kill Oragono and print out a stack trace for you to take a look at. ## Concurrency design -Ergo involves a fair amount of shared state. Here are some of the main points: +Oragono involves a fair amount of shared state. Here are some of the main points: 1. Each client has a separate goroutine that listens for incoming messages and synchronously processes them. 1. All sends to clients are asynchronous; `client.Send` appends the message to a queue, which is then processed on a separate goroutine. It is always safe to call `client.Send`. @@ -139,7 +100,6 @@ There are some mutexes that are "tier 0": anything in a subpackage of `irc` (e.g We are using `buntdb` for persistence; a `buntdb.DB` has an `RWMutex` inside it, with read-write transactions getting the `Lock()` and read-only transactions getting the `RLock()`. This mutex is considered tier 1. However, it's shared globally across all consumers, so if possible you should avoid acquiring it while holding ordinary application-level mutexes. - ## Command handlers and ResponseBuffer We support a lot of IRCv3 specs. Pretty much all of them, in fact. And a lot of proposed/draft ones. One of the draft specifications that we support is called ["labeled responses"](https://ircv3.net/specs/extensions/labeled-response.html). @@ -159,55 +119,3 @@ They receive the response with the same label, so they can match the sent comman In order to allow this, in command handlers we don't send responses directly back to the user. Instead, we buffer the responses in an object called a ResponseBuffer. When the command handler returns, the contents of the ResponseBuffer is sent to the user with the appropriate label (and batches, if they're required). Basically, if you're in a command handler and you're sending a response back to the requesting client, use `rb.Add*` instead of `client.Send*`. Doing this makes sure the labeled responses feature above works as expected. The handling around `PRIVMSG`/`NOTICE`/`TAGMSG` is strange, so simply defer to [irctest](https://github.com/DanielOaks/irctest)'s judgement about whether that's correct for the most part. - - -## Translated strings - -The function `client.t()` is used fairly widely throughout the codebase. This function translates the given string using the client's negotiated language. If the parameter of the function is a string, the translation update script below will grab that string and mark it for translation. - -In addition, throughout most of the codebase, if a string is created using the backtick characters ``(`)``, that string will also be marked for translation. This is really useful in the cases of general errors and other strings that are created far away from the final `client.t` function they are sent through. - - -## Updating Translations - -We support translating server strings using [CrowdIn](https://crowdin.com/project/ergochat)! To send updated source strings to CrowdIn, you should: - -1. `cd` to the base directory (the one this `DEVELOPING` file is in). -2. Install the `pyyaml` and `docopt` deps using `pip3 install pyyamp docopt`. -3. Run the `updatetranslations.py` script with: `./updatetranslations.py run irc languages` -4. Commit the changes - -CrowdIn's integration should grab the new translation files automagically. - -When new translations are available, CrowsIn will submit a new PR with the updates. The `INFO` command should be used to see whether the credits strings has been updated/translated properly, since that can be a bit of a sticking point for our wonderful translators :) - -### Updating Translations Manually - -You shouldn't need to do this, but to update 'em manually: - -1. `cd` to the base directory (the one this `DEVELOPING` file is in). -2. Install the `pyyaml` and `docopt` deps using `pip3 install pyyamp docopt`. -3. Run the `updatetranslations.py` script with: `./updatetranslations.py run irc languages` -4. Install the [CrowdIn CLI tool](https://support.crowdin.com/cli-tool/). -5. Make sure the CrowdIn API key is correct in `~/.crowdin.yaml` -6. Run `crowdin upload sources` - -We also support grabbing translations directly from CrowdIn. To do this: - -1. `cd` to the base directory (the one this `DEVELOPING` file is in). -2. Install the [CrowdIn CLI tool](https://support.crowdin.com/cli-tool/). -3. Make sure the CrowdIn API key is correct in `~/.crowdin.yaml` -4. Run `crowdin download` - -This will download a bunch of updated files and put them in the right place - - -## Adding a mode - -When adding a mode, keep in mind the following places it may need to be referenced: - -1. The mode needs to be defined in the `irc/modes` subpackage -1. It may need to be special-cased in `modes.RplMyInfo()` -1. It may need to be added to the `CHANMODES` ISUPPORT token -1. It may need special handling in `ApplyUserModeChanges` or `ApplyChannelModeChanges` -1. It may need special persistence handling code diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 22f566d0..00000000 --- a/Dockerfile +++ /dev/null @@ -1,36 +0,0 @@ -## build ergo binary -FROM docker.io/golang:1.23-alpine AS build-env - -RUN apk upgrade -U --force-refresh --no-cache -RUN apk add --no-cache --purge --clean-protected -l -u make git - -# copy ergo source -WORKDIR /go/src/github.com/ergochat/ergo -COPY . . - -# modify default config file so that it doesn't die on IPv6 -# and so it can be exposed via 6667 by default -RUN sed -i 's/^\(\s*\)\"127.0.0.1:6667\":.*$/\1":6667":/' /go/src/github.com/ergochat/ergo/default.yaml && \ - sed -i 's/^\s*\"\[::1\]:6667\":.*$//' /go/src/github.com/ergochat/ergo/default.yaml - -# compile -RUN make install - -## build ergo container -FROM docker.io/alpine:3.19 - -# ergo itself -COPY --from=build-env /go/bin/ergo \ - /go/src/github.com/ergochat/ergo/default.yaml \ - /go/src/github.com/ergochat/ergo/distrib/docker/run.sh \ - /ircd-bin/ -COPY --from=build-env /go/src/github.com/ergochat/ergo/languages /ircd-bin/languages/ - -# running volume holding config file, db, certs -VOLUME /ircd -WORKDIR /ircd - -# default motd -COPY --from=build-env /go/src/github.com/ergochat/ergo/ergo.motd /ircd/ergo.motd - -ENTRYPOINT ["/ircd-bin/run.sh"] diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 00000000..1d6ad607 --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,120 @@ +memo = "d665cbc0144a6e4c06ed8fcfbee8594a7203e47e654eeca63e11d651ae62a3a7" + +[[projects]] + name = "code.cloudfoundry.org/bytefmt" + packages = ["."] + revision = "cbe033486cf0620d3bb77d8ef7f22ab346ad3628" + +[[projects]] + branch = "master" + name = "github.com/docopt/docopt-go" + packages = ["."] + revision = "ee0de3bc6815ee19d4a46c7eb90f829db0e014b1" + +[[projects]] + branch = "master" + name = "github.com/goshuirc/e-nfa" + packages = ["."] + revision = "7071788e394065e6456458a5e9cb503cad545154" + +[[projects]] + branch = "master" + name = "github.com/goshuirc/irc-go" + packages = ["ircfmt","ircmatch","ircmsg"] + revision = "1cb16094f055aa5f5b49a1609aeada54d963433c" + +[[projects]] + branch = "master" + name = "github.com/mattn/go-colorable" + packages = ["."] + revision = "7dc3415be66d7cc68bf0182f35c8d31f8d2ad8a7" + +[[projects]] + name = "github.com/mattn/go-isatty" + packages = ["."] + revision = "0360b2af4f38e8d38c7fce2a9f4e702702d73a39" + version = "v0.0.3" + +[[projects]] + branch = "master" + name = "github.com/mgutz/ansi" + packages = ["."] + revision = "9520e82c474b0a04dd04f8a40959027271bab992" + +[[projects]] + branch = "master" + name = "github.com/oragono/go-ident" + packages = ["."] + revision = "337fed0fd21ad538725cfcb55053ea4cf8056abc" + +[[projects]] + branch = "master" + name = "github.com/stackimpact/stackimpact-go" + packages = [".","internal","internal/pprof/profile"] + revision = "8b5b02c181e477dafcf505342d8a79b5c8241da7" + +[[projects]] + branch = "master" + name = "github.com/tidwall/btree" + packages = ["."] + revision = "9876f1454cf0993a53d74c27196993e345f50dd1" + +[[projects]] + name = "github.com/tidwall/buntdb" + packages = ["."] + revision = "2da7c106683f522198cdf55ed8db42b374de50d7" + version = "v1.0.0" + +[[projects]] + name = "github.com/tidwall/gjson" + packages = ["."] + revision = "87033efcaec6215741137e8ca61952c53ef2685d" + version = "v1.0.6" + +[[projects]] + branch = "master" + name = "github.com/tidwall/grect" + packages = ["."] + revision = "ba9a043346eba55344e40d66a5e74cfda3a9d293" + +[[projects]] + branch = "master" + name = "github.com/tidwall/match" + packages = ["."] + revision = "1731857f09b1f38450e2c12409748407822dc6be" + +[[projects]] + branch = "master" + name = "github.com/tidwall/rtree" + packages = [".","base"] + revision = "6cd427091e0e662cb4f8e2c9eb1a41e1c46ff0d3" + +[[projects]] + branch = "master" + name = "github.com/tidwall/tinyqueue" + packages = ["."] + revision = "1feaf062ef04a231c9126f99a68eaa579fd0e390" + +[[projects]] + branch = "master" + name = "golang.org/x/crypto" + packages = ["bcrypt","blowfish","ssh/terminal"] + revision = "5119cf507ed5294cc409c092980c7497ee5d6fd2" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = ["unix","windows"] + revision = "37707fdb30a5b38865cfb95e5aab41707daec7fd" + +[[projects]] + branch = "master" + name = "golang.org/x/text" + packages = ["cases","collate","collate/build","internal","internal/colltab","internal/gen","internal/tag","internal/triegen","internal/ucd","language","runes","secure/bidirule","secure/precis","transform","unicode/bidi","unicode/cldr","unicode/norm","unicode/rangetable","width"] + revision = "4e4a3210bb54bb31f6ab2cdca2edcc0b50c420c1" + +[[projects]] + branch = "v2" + name = "gopkg.in/yaml.v2" + packages = ["."] + revision = "d670f9405373e636a5a2765eea47fac0c9bc91a4" diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 00000000..47c02714 --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,104 @@ + +## Gopkg.toml example (these lines may be deleted) + +## "required" lists a set of packages (not projects) that must be included in +## Gopkg.lock. This list is merged with the set of packages imported by the current +## project. Use it when your project needs a package it doesn't explicitly import - +## including "main" packages. +# required = ["github.com/user/thing/cmd/thing"] + +## "ignored" lists a set of packages (not projects) that are ignored when +## dep statically analyzes source code. Ignored packages can be in this project, +## or in a dependency. +# ignored = ["github.com/user/project/badpkg"] + +## Dependencies define constraints on dependent projects. They are respected by +## dep whether coming from the Gopkg.toml of the current project or a dependency. +# [[dependencies]] +## Required: the root import path of the project being constrained. +# name = "github.com/user/project" +# +## Recommended: the version constraint to enforce for the project. +## Only one of "branch", "version" or "revision" can be specified. +# version = "1.0.0" +# branch = "master" +# revision = "abc123" +# +## Optional: an alternate location (URL or import path) for the project's source. +# source = "https://github.com/myfork/package.git" + +## Overrides have the same structure as [[dependencies]], but supercede all +## [[dependencies]] declarations from all projects. Only the current project's +## [[overrides]] are applied. +## +## Overrides are a sledgehammer. Use them only as a last resort. +# [[overrides]] +## Required: the root import path of the project being constrained. +# name = "github.com/user/project" +# +## Optional: specifying a version constraint override will cause all other +## constraints on this project to be ignored; only the overriden constraint +## need be satisfied. +## Again, only one of "branch", "version" or "revision" can be specified. +# version = "1.0.0" +# branch = "master" +# revision = "abc123" +# +## Optional: specifying an alternate source location as an override will +## enforce that the alternate location is used for that project, regardless of +## what source location any dependent projects specify. +# source = "https://github.com/myfork/package.git" + + + +[[dependencies]] + name = "code.cloudfoundry.org/bytefmt" + revision = "cbe033486cf0620d3bb77d8ef7f22ab346ad3628" + +[[dependencies]] + branch = "master" + name = "github.com/DanielOaks/girc-go" + +[[dependencies]] + branch = "master" + name = "github.com/DanielOaks/go-ident" + +[[dependencies]] + branch = "master" + name = "github.com/docopt/docopt-go" + +[[dependencies]] + branch = "master" + name = "github.com/gorilla/mux" + +[[dependencies]] + branch = "master" + name = "github.com/gorilla/websocket" + +[[dependencies]] + branch = "master" + name = "github.com/mattn/go-colorable" + +[[dependencies]] + branch = "master" + name = "github.com/mgutz/ansi" + +[[dependencies]] + branch = "master" + name = "github.com/stackimpact/stackimpact-go" + +[[dependencies]] + branch = "master" + name = "github.com/tidwall/buntdb" + +[[dependencies]] + branch = "master" + name = "golang.org/x/crypto" + +[[dependencies]] + branch = "master" + name = "golang.org/x/text" + +[[dependencies]] + branch = "v2" + name = "gopkg.in/yaml.v2" diff --git a/LICENSE b/LICENSE index acbac131..958be4ae 100644 --- a/LICENSE +++ b/LICENSE @@ -1,9 +1,6 @@ The MIT License (MIT) -Copyright (c) 2012-2014 Jeremy Latt -Copyright (c) 2014-2015 Edmund Huber -Copyright (c) 2016-2020 Daniel Oaks -Copyright (c) 2017-2020 Shivaram Lingamneni +Copyright (c) 2014 Jeremy Latt Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile index fb6a6499..e7eb260f 100644 --- a/Makefile +++ b/Makefile @@ -1,41 +1,13 @@ -.PHONY: all install build release capdefs test smoke gofmt irctest - -GIT_COMMIT := $(shell git rev-parse HEAD 2> /dev/null) -GIT_TAG := $(shell git tag --points-at HEAD 2> /dev/null | head -n 1) - -# disable linking against native libc / libpthread by default; -# this can be overridden by passing CGO_ENABLED=1 to make -export CGO_ENABLED ?= 0 - -capdef_file = ./irc/caps/defs.go +.PHONY: all build all: build -install: - go install -mod=mod -v -ldflags "-X main.commit=$(GIT_COMMIT) -X main.version=$(GIT_TAG)" - build: - go build -mod=mod -v -ldflags "-X main.commit=$(GIT_COMMIT) -X main.version=$(GIT_TAG)" + goreleaser --snapshot --rm-dist -release: - goreleaser --skip=publish --clean - -capdefs: - python3 ./gencapdefs.py > ${capdef_file} +deps: + git submodule update --init test: - python3 ./gencapdefs.py | diff - ${capdef_file} - go test ./... - go vet ./... - ./.check-gofmt.sh - -smoke: install - ergo mkcerts --conf ./default.yaml || true - ergo run --conf ./default.yaml --smoke - -gofmt: - ./.check-gofmt.sh --fix - -irctest: install - git submodule update --init - cd irctest && make ergo + cd irc && go test . + cd irc && go vet . diff --git a/README b/README index 23d317f8..2e3abe2d 100644 --- a/README +++ b/README @@ -1,24 +1,20 @@ - ___ _ __ __ _ ___ - / _ \ '__/ _` |/ _ \ - | __/ | | (_| | (_) | - \___|_| \__, |\___/ - __/ | - |___/ + + ▄▄▄ ▄▄▄· ▄▄ • ▐ ▄ + ▪ ▀▄ █·▐█ ▀█ ▐█ ▀ ▪▪ •█▌▐█▪ + ▄█▀▄ ▐▀▀▄ ▄█▀▀█ ▄█ ▀█▄ ▄█▀▄▪▐█▐▐▌ ▄█▀▄ + ▐█▌.▐▌▐█•█▌▐█ ▪▐▌▐█▄▪▐█▐█▌ ▐▌██▐█▌▐█▌.▐▌ + ▀█▄▀▪.▀ ▀ ▀ ▀ ·▀▀▀▀ ▀█▄▀ ▀▀ █▪ ▀█▄▀▪ + ----------------------------------------------------------------------------------------------- -Ergo is a modern IRC server written in Go. Its core design principles are: +Oragono is a modern, experimental IRC server written in Go. It's designed to be simple to setup +and use, and to provide the majority of features that IRC users expect today. -* Being simple to set up and use -* Combining the features of an ircd, a services framework, and a bouncer: - * Integrated account management - * History storage - * Bouncer functionality -* Bleeding-edge IRCv3 support -* High customizability via a rehashable (i.e., reloadable at runtime) YAML config +It includes features such as UTF-8 nicks and channel names, client accounts and SASL, and other +assorted IRCv3 support. - https://ergo.chat/ - https://github.com/ergochat/ergo - #ergo on irc.ergo.chat or irc.libera.chat + https://oragono.io/ + https://github.com/oragono/oragono ----------------------------------------------------------------------------------------------- @@ -27,36 +23,44 @@ Ergo is a modern IRC server written in Go. Its core design principles are: Copy the example config file to ircd.yaml with a command like: - $ cp default.yaml ircd.yaml + $ cp oragono.yaml ircd.yaml -Modify the config file as needed (the recommendations at the top may be helpful). +Modify the config file as you like. In particular, the `connection-throttling` and +`connection-limits` sections are working looking over and tuning for your network's needs. To generate passwords for opers and connect passwords, you can use this command: - $ ./ergo genpasswd + $ oragono genpasswd -If you need to generate self-signed TLS certificates, use this command: +Run these commands in order -- these will setup each section of the server: - $ ./ergo mkcerts + $ oragono initdb + $ oragono mkcerts + $ oragono run -You are now ready to start Ergo! +And you should now be running Oragono! - $ ./ergo run - -For further instructions, consult the manual. A copy of the manual should be -included in your release under `docs/MANUAL.md`. Or you can view it on the -Web: https://ergo.chat/manual.html === Updating === -If you're updating from a previous version of Ergo, check out the CHANGELOG for a list +If you're updating from a previous version of Oragono, checkout the CHANGELOG for a shortlist of important changes you'll want to take a look at. The change log details config changes, fixes, new features and anything else you'll want to be aware of! +If there's been a database update, you'll also need to run this command: + + $ oragono upgradedb + + === Credits === -* Jeremy Latt (2012-2014) -* Edmund Huber (2014-2015) -* Daniel Oaks (2016-present) -* Shivaram Lingamneni (2017-present) -* Many other contributors and friends of the project <3 +* Jeremy Latt, creator of Ergonomadic, +* Edmund Huber, maintainer of Ergonomadic, +* Niels Freier, added WebSocket support to Ergonomadic, +* Daniel Oakley, maintainer of Oragono, +* Euan Kemp, contributor to Oragono and lots of useful fixes, +* Shivaram Lingamneni, has contributed a ton of fixes, refactoring, and general improvements, +* James Mills, contributed Docker support, +* Vegax, implementing some commands and helping when Oragono was just getting started, +* Sean Enck, transitioned us from using a custom script to a proper Makefile, +* apologies to anyone I forgot. diff --git a/README.md b/README.md index 2c72b39a..95eebe42 100644 --- a/README.md +++ b/README.md @@ -1,127 +1,145 @@ -![Ergo logo](docs/logo.png) +![Oragono logo](docs/logo.png) -Ergo (formerly known as Oragono) is a modern IRC server written in Go. Its core design principles are: +Oragono is a modern, experimental IRC server written in Go. It's designed to be simple to setup and use, and it includes features such as UTF-8 nicks / channel names, client accounts with SASL, and other assorted IRCv3 support. -* Being simple to set up and use -* Combining the features of an ircd, a services framework, and a bouncer (integrated account management, history storage, and bouncer functionality) -* Bleeding-edge [IRCv3 support](https://ircv3.net/software/servers.html), suitable for use as an IRCv3 reference implementation -* High customizability via a rehashable (i.e., reloadable at runtime) YAML config - -Ergo is a fork of the [Ergonomadic](https://github.com/jlatt/ergonomadic) IRC daemon <3 +Oragono is a fork of the [Ergonomadic](https://github.com/edmund-huber/ergonomadic) IRC daemon <3 --- -[![Go Report Card](https://goreportcard.com/badge/github.com/ergochat/ergo)](https://goreportcard.com/report/github.com/ergochat/ergo) -[![build](https://github.com/ergochat/ergo/actions/workflows/build.yml/badge.svg)](https://github.com/ergochat/ergo/actions/workflows/build.yml) -[![Download Latest Release](https://img.shields.io/badge/downloads-latest%20release-green.svg)](https://github.com/ergochat/ergo/releases/latest) -[![Crowdin](https://d322cqt584bo4o.cloudfront.net/ergochat/localized.svg)](https://crowdin.com/project/ergochat) +[![Go Report Card](https://goreportcard.com/badge/github.com/oragono/oragono)](https://goreportcard.com/report/github.com/oragono/oragono) +[![Build Status](https://travis-ci.org/oragono/oragono.svg?branch=master)](https://travis-ci.org/oragono/oragono) +[![Download Latest Release](https://img.shields.io/badge/downloads-latest%20release-green.svg)](https://github.com/oragono/oragono/releases/latest) +[![Freenode #oragono](https://img.shields.io/badge/Freenode-%23oragono-1e72ff.svg?style=flat)](https://www.irccloud.com/invite?channel=%23oragono&hostname=irc.freenode.net&port=6697&ssl=1) +[![Crowdin](https://d322cqt584bo4o.cloudfront.net/oragono/localized.svg)](https://crowdin.com/project/oragono) -If you want to take a look at a running Ergo instance or test some client code, feel free to play with [testnet.ergo.chat](https://testnet.ergo.chat/) (TLS on port 6697 or plaintext on port 6667). +[darwin.network](https://irc.darwin.network/) and [testnet.oragono.io](ircs://testnet.oragono.io:6697/#chat) are running Oragono in production if you want to take a look. --- ## Features -* integrated services: NickServ for user accounts, ChanServ for channel registration, and HostServ for vanity hosts -* bouncer-like features: storing and replaying history, allowing multiple clients to use the same nickname -* native TLS/SSL support, including support for client certificates -* [IRCv3 support](https://ircv3.net/software/servers.html) -* [yaml](https://yaml.org/) configuration -* updating server config and TLS certificates on-the-fly (rehashing) -* SASL authentication -* [LDAP support](https://github.com/ergochat/ergo-ldap) -* supports [multiple languages](https://crowdin.com/project/ergochat) (you can also set a default language for your network) -* optional support for UTF-8 nick and channel names with RFC 8265 (PRECIS) -* advanced security and privacy features (support for requiring SASL for all logins, cloaking IPs, and running as a Tor hidden service) +* UTF-8 nick and channel names with rfc7613 (PRECIS) +* [yaml](http://yaml.org/) configuration +* native TLS/SSL support +* server password (`PASS` command) * an extensible privilege system for IRC operators * ident lookups for usernames * automated client connection limits -* passwords stored with [bcrypt](https://godoc.org/golang.org/x/crypto) -* `UBAN`, a unified ban system that can target IPs, networks, masks, and registered accounts (`KLINE` and `DLINE` are also supported) -* a focus on developing with [specifications](https://ergo.chat/specs.html) +* on-the-fly updating server config and TLS certificates (rehashing) +* client accounts and SASL +* passwords stored with [bcrypt](https://godoc.org/golang.org/x/crypto) (client account passwords also salted) +* banning ips/nets and masks with `KLINE` and `DLINE` +* supports [multiple languages](https://crowdin.com/project/oragono) (you can also set a default language for your network) +* [IRCv3 support](http://ircv3.net/software/servers.html) +* a heavy focus on developing with [specifications](https://oragono.io/specs.html) -For more detailed information on Ergo's functionality, see: +## Installation -* [MANUAL.md, the operator manual](https://github.com/ergochat/ergo/blob/stable/docs/MANUAL.md) -* [USERGUIDE.md, the guide for end users](https://github.com/ergochat/ergo/blob/stable/docs/USERGUIDE.md) - -## Quick start guide - -Download the latest release from this page: https://github.com/ergochat/ergo/releases/latest +To go through the standard installation, download the latest release from this page: https://github.com/oragono/oragono/releases/latest Extract it into a folder, then run the following commands: ```sh -cp default.yaml ircd.yaml +cp oragono.yaml ircd.yaml vim ircd.yaml # modify the config file to your liking -./ergo mkcerts -./ergo run # server should be ready to go! +oragono initdb +oragono mkcerts ``` -**Note:** See the [productionizing guide in our manual](https://github.com/ergochat/ergo/blob/stable/docs/MANUAL.md#productionizing-with-systemd) for recommendations on how to run a production network, including obtaining valid TLS certificates. +**Note:** This installation will give you self-signed certificates suitable for testing purposes. +For real certs, look into [Let's Encrypt](https://letsencrypt.org/)! ### Platform Packages -Some platforms/distros also have Ergo packages maintained for them: +Some platforms/distros also have Oragono packages maintained for them: -* Arch Linux [AUR](https://aur.archlinux.org/packages/ergochat/) - Maintained by [Jason Papakostas (@vith)](https://github.com/vith). -* [Gentoo Linux](https://packages.gentoo.org/packages/net-irc/ergo) - Maintained by [Sam James (@thesamesam)](https://github.com/thesamesam). - -### Using Docker - -A Dockerfile and example docker-compose recipe are available in the `distrib/docker` directory. Ergo is automatically published -to the GitHub Container Registry at [ghcr.io/ergochat/ergo](https://ghcr.io/ergochat/ergo). For more information, see the distrib/docker -[README file](https://github.com/ergochat/ergo/blob/master/distrib/docker/README.md). +* Arch Linux [AUR](https://aur.archlinux.org/packages/oragono/) - Maintained by [Sean Enck (@enckse)](https://github.com/enckse). ### From Source -You can also clone this repository and build from source. Typical deployments should use the `stable` branch, which points to the latest stable release. In general, `stable` should coincide with the latest published tag that is not designated as a beta or release candidate (for example, `v2.7.0-rc1` was an unstable release candidate and `v2.7.0` was the corresponding stable release), so you can also identify the latest stable release tag on the [releases page](https://github.com/ergochat/ergo/releases) and build that. +You can also install this repo and use that instead! However, keep some things in mind if you go that way: -The `master` branch is not recommended for production use since it may contain bugs, and because the forwards compatibility guarantees for the config file and the database that apply to releases do not apply to master. That is to say, running master may result in changes to your database that end up being incompatible with future versions of Ergo. +`devel` branches are intentionally unstable, containing fixes that may not work, and they may be rebased or reworked extensively. -For information on contributing to Ergo, see [DEVELOPING.md](https://github.com/ergochat/ergo/blob/master/DEVELOPING.md). +The `master` branch _should_ usually be stable, but may contain database changes that either have not been finalised or not had database upgrade code written yet. Don't run `master` on a live production network. + +The `stable` branch contains the latest release. You can run this for a production version without any trouble. #### Building -You'll need an [up-to-date distribution of the Go language for your OS and architecture](https://golang.org/dl/). Once that's installed (check the output of `go version`), just check out your desired branch or tag and run `make`. This will produce an executable binary named `ergo` in the base directory of the project. (Ergo vendors all its dependencies, so you will not need to fetch any dependencies remotely.) +[![Build Status](https://travis-ci.org/oragono/oragono.svg?branch=master)](https://travis-ci.org/oragono/oragono) + +Clone the appropriate branch. If necessary, do `git submodule update --init` to set up vendored dependencies. From the root folder, run `make` to generate all release files for all of our target OSes: +``` +make +``` + +You can also only build the release files for a specific system: +``` +# for windows +make windows + +# for linux +make linux + +# for osx +make osx + +# for arm6 +make arm6 +``` + +Once you have made the release files, you can find them in the `build` directory. Uncompress these to an empty directory and continue as usual. ## Configuration -The default config file [`default.yaml`](default.yaml) helps walk you through what each option means and changes. +The default config file [`oragono.yaml`](oragono.yaml) helps walk you through what each option means and changes. The configuration's intended to be sparse, so if there are options missing it's either because that feature isn't written/configurable yet or because we don't think it should be configurable. -You can use the `--conf` parameter when launching Ergo to control where it looks for the config file. For instance: `ergo run --conf /path/to/ircd.yaml`. The configuration file also stores where the log, database, certificate, and other files are opened. Normally, all these files use relative paths, but you can change them to be absolute (such as `/var/log/ircd.log`) when running Ergo as a service. +You can use the `--conf` parameter when launching Oragono to control where it looks for the config file. For instance: `oragono run --conf /path/to/ircd.yaml`. The configuration file also stores where the log, database, certificate, and other files are opened. Normally, all these files use relative paths, but you can change them to be absolute (such as `/var/log/ircd.log`) when running Oragono as a service. ### Logs -By default, logs go to stderr only. They can be configured to go to a file, or you can use systemd to direct the stderr to the system journal (see the manual for details). The configuration format of logs is designed to be easily pluggable, and is inspired by the logging config provided by InspIRCd. +By default, logs are stored in the file `ircd.log`. The configuration format of logs is designed to be easily pluggable, and is inspired by the logging config provided by InspIRCd. ### Passwords Passwords (for both `PASS` and oper logins) are stored using bcrypt. To generate encrypted strings for use in the config, use the `genpasswd` subcommand as such: ```sh -ergo genpasswd +oragono genpasswd ``` With this, you receive a blob of text which you can plug into your configuration file. -### Nickname and channel registration +## Running -Ergo relies heavily on user accounts to enable its distinctive features (such as allowing multiple clients per nickname). As a user, you can register your current nickname as an account using `/msg NickServ register `. Once you have done so, you should [enable SASL in your clients](https://libera.chat/guides/sasl), ensuring that you will be automatically logged into your account on each connection. This will prevent [problems claiming your registered nickname](https://github.com/ergochat/ergo/blob/master/docs/MANUAL.md#nick-equals-account). +After this, running the server is easy! Simply run the below command and you should see the relevant startup information pop up. -Once you have registered your nickname, you can use it to register channels: +```sh +oragono run +``` -1. Join the channel with `/join #channel` -2. Register the channel with `/CS REGISTER #channel` +### How to register a channel + +1. Register your account with `/NS REGISTER []` +2. Join the channel with `/join #channel` +3. Register the channel with `/CS REGISTER #channel` After this, your channel will remember the fact that you're the owner, the topic, and any modes set on it! +Make sure to setup [SASL](https://freenode.net/kb/answer/sasl) in your client to automatically login to your account when you next join the server. + # Credits -* Jeremy Latt (2012-2014) -* Edmund Huber (2014-2015) -* Daniel Oaks (2016-present) -* Shivaram Lingamneni (2017-present) -* [Many other contributors and friends of the project <3](https://github.com/ergochat/ergo/blob/master/CHANGELOG.md) +* Jeremy Latt, creator of Ergonomadic, +* Edmund Huber, maintainer of Ergonomadic, +* Niels Freier, added WebSocket support to Ergonomadic, +* Daniel Oakley, maintainer of Oragono, +* Euan Kemp, contributor to Oragono and lots of useful fixes, +* Shivaram Lingamneni, has contributed a ton of fixes, refactoring, and general improvements, +* James Mills, contributed Docker support, +* Vegax, implementing some commands and helping when Oragono was just getting started, +* Sean Enck, transitioned us from using a custom script to a proper Makefile, +* apologies to anyone I forgot. diff --git a/crowdin.yml b/crowdin.yml deleted file mode 100644 index c6a05c86..00000000 --- a/crowdin.yml +++ /dev/null @@ -1,53 +0,0 @@ -# -# Your crowdin's credentials -# -"project_identifier" : "oragono" -# "api_key" : "" -# "base_path" : "" -#"base_url" : "" - -# -# Choose file structure in crowdin -# e.g. true or false -# -"preserve_hierarchy": true - -# -# Files configuration -# -files: [ - { - "source" : "/languages/example/translation.lang.yaml", - "translation" : "/languages/%locale%.lang.yaml", - "dest" : "translation.lang.yaml" - }, - { - "source" : "/languages/example/irc.lang.json", - "translation" : "/languages/%locale%-irc.lang.json", - "dest" : "irc.lang.json" - }, - { - "source" : "/languages/example/help.lang.json", - "translation" : "/languages/%locale%-help.lang.json", - "dest" : "help.lang.json", - "update_option" : "update_as_unapproved", - }, - { - "source" : "/languages/example/chanserv.lang.json", - "translation" : "/languages/%locale%-chanserv.lang.json", - "dest" : "services/chanserv.lang.json", - "update_option" : "update_as_unapproved", - }, - { - "source" : "/languages/example/nickserv.lang.json", - "translation" : "/languages/%locale%-nickserv.lang.json", - "dest" : "services/nickserv.lang.json", - "update_option" : "update_as_unapproved", - }, - { - "source" : "/languages/example/hostserv.lang.json", - "translation" : "/languages/%locale%-hostserv.lang.json", - "dest" : "services/hostserv.lang.json", - "update_option" : "update_as_unapproved", - }, -] \ No newline at end of file diff --git a/default.yaml b/default.yaml deleted file mode 100644 index 5fd22b6d..00000000 --- a/default.yaml +++ /dev/null @@ -1,1075 +0,0 @@ -# This is the default config file for Ergo. -# It contains recommended defaults for all settings, including some behaviors -# that differ from conventional ircd+services setups. See traditional.yaml -# for a config with more "mainstream" behavior. -# -# If you are setting up a new Ergo server, you should copy this file -# to a new one named 'ircd.yaml', then look through the file to see which -# settings you want to customize. If you don't understand a setting, or -# aren't sure what behavior you want, most of the defaults are fine -# to start with (you can change them later, even on a running server). -# However, there are a few that you should probably change up front: -# 1. network.name (a human-readable name that identifies your network, -# no spaces or special characters) and server.name (consider using the -# domain name of your server) -# 2. if you have valid TLS certificates (for example, from letsencrypt.org), -# you should enable them in server.listeners in place of the default -# self-signed certificates -# 3. the operator password in the 'opers' section -# 4. by default, message history is enabled, using in-memory history storage -# and with messages expiring after 7 days. depending on your needs, you may -# want to disable history entirely, remove the expiration time, switch to -# persistent history stored in MySQL, or do something else entirely. See -# the 'history' section of the config. - -# network configuration -network: - # name of the network - name: ErgoTest - -# server configuration -server: - # server name - name: ergo.test - - # addresses to listen on - listeners: - # The standard plaintext port for IRC is 6667. Allowing plaintext over the - # public Internet poses serious security and privacy issues. Accordingly, - # we recommend using plaintext only on local (loopback) interfaces: - "127.0.0.1:6667": # (loopback ipv4, localhost-only) - "[::1]:6667": # (loopback ipv6, localhost-only) - # If you need to serve plaintext on public interfaces, comment out the above - # two lines and uncomment the line below (which listens on all interfaces): - # ":6667": - # Alternately, if you have a TLS certificate issued by a recognized CA, - # you can configure port 6667 as an STS-only listener that only serves - # "redirects" to the TLS port, but doesn't allow chat. See the manual - # for details. - - # The standard SSL/TLS port for IRC is 6697. This will listen on all interfaces: - ":6697": - # this is a standard TLS configuration with a single certificate; - # see the manual for instructions on how to configure SNI - tls: - cert: fullchain.pem - key: privkey.pem - # 'proxy' should typically be false. It's for cloud load balancers that - # always send a PROXY protocol header ahead of the connection. See the - # manual ("Reverse proxies") for more details. - proxy: false - # set the minimum TLS version: - min-tls-version: 1.2 - - # Example of a Unix domain socket for proxying: - # "/tmp/ergo_sock": - - # Example of a Tor listener: any connection that comes in on this listener will - # be considered a Tor connection. It is strongly recommended that this listener - # *not* be on a public interface --- it should be on 127.0.0.0/8 or unix domain: - # "/hidden_service_sockets/ergo_tor_sock": - # tor: true - - # Example of a WebSocket listener: - # ":8097": - # websocket: true - # tls: - # cert: fullchain.pem - # key: privkey.pem - - # sets the permissions for Unix listen sockets. on a typical Linux system, - # the default is 0775 or 0755, which prevents other users/groups from connecting - # to the socket. With 0777, it behaves like a normal TCP socket - # where anyone can connect. - unix-bind-mode: 0777 - - # configure the behavior of Tor listeners (ignored if you didn't enable any): - tor-listeners: - # if this is true, connections from Tor must authenticate with SASL - require-sasl: false - - # what hostname should be displayed for Tor connections? - vhost: "tor-network.onion" - - # allow at most this many connections at once (0 for no limit): - max-connections: 64 - - # connection throttling (limit how many connection attempts are allowed at once): - throttle-duration: 10m - # set to 0 to disable throttling: - max-connections-per-duration: 64 - - # strict transport security, to get clients to automagically use TLS - sts: - # whether to advertise STS - # - # to stop advertising STS, leave this enabled and set 'duration' below to "0". this will - # advertise to connecting users that the STS policy they have saved is no longer valid - enabled: false - - # how long clients should be forced to use TLS for. - # setting this to a too-long time will mean bad things if you later remove your TLS. - # the default duration below is 1 month, 2 days and 5 minutes. - duration: 1mo2d5m - - # tls port - you should be listening on this port above - port: 6697 - - # should clients include this STS policy when they ship their inbuilt preload lists? - preload: false - - websockets: - # Restrict the origin of WebSocket connections by matching the "Origin" HTTP - # header. This setting causes ergo to reject websocket connections unless - # they originate from a page on one of the whitelisted websites in this list. - # This prevents malicious websites from making their visitors connect to your - # ergo instance without their knowledge. An empty list means there are no - # restrictions. - allowed-origins: - # - "https://ergo.chat" - # - "https://*.ergo.chat" - - # casemapping controls what kinds of strings are permitted as identifiers (nicknames, - # channel names, account names, etc.), and how they are normalized for case. - # the recommended default is 'ascii' (traditional ASCII-only identifiers). - # the other options are 'precis', which allows UTF8 identifiers that are "sane" - # (according to UFC 8265), with additional mitigations for homoglyph attacks, - # 'permissive', which allows identifiers containing unusual characters like - # emoji, at the cost of increased vulnerability to homoglyph attacks and potential - # client compatibility problems, and the legacy mappings 'rfc1459' and - # 'rfc1459-strict'. we recommend leaving this value at its default; - # however, note that changing it once the network is already up and running is - # problematic. - casemapping: "ascii" - - # enforce-utf8 controls whether the server will preemptively discard non-UTF8 - # messages (since they cannot be relayed to websocket clients), or will allow - # them and relay them to non-websocket clients (as in traditional IRC). - enforce-utf8: true - - # whether to look up user hostnames with reverse DNS. there are 3 possibilities: - # 1. lookup-hostnames enabled, IP cloaking disabled; users will see each other's hostnames - # 2. lookup-hostnames disabled, IP cloaking disabled; users will see each other's numeric IPs - # 3. [the default] IP cloaking enabled; users will see cloaked hostnames - lookup-hostnames: false - # whether to confirm hostname lookups using "forward-confirmed reverse DNS", i.e., for - # any hostname returned from reverse DNS, resolve it back to an IP address and reject it - # unless it matches the connecting IP - forward-confirm-hostnames: true - - # use ident protocol to get usernames - check-ident: false - - # ignore the supplied user/ident string from the USER command, always setting user/ident - # to the following literal value; this can potentially reduce confusion and simplify bans. - # the value must begin with a '~' character. comment out / omit to disable: - coerce-ident: '~u' - - # 'password' allows you to require a global, shared password (the IRC `PASS` command) - # to connect to the server. for operator passwords, see the `opers` section of the - # config. for a more secure way to create a private server, see the `require-sasl` - # section. you must hash the password with `ergo genpasswd`, then enter the hash here: - #password: "$2a$04$0123456789abcdef0123456789abcdef0123456789abcdef01234" - - # motd filename - # if you change the motd, you should move it to ircd.motd - motd: ergo.motd - - # motd formatting codes - # if this is true, the motd is escaped using formatting codes like $c, $b, and $i - motd-formatting: true - - # relaying using the RELAYMSG command - relaymsg: - # is relaymsg enabled at all? - enabled: true - - # which character(s) are reserved for relayed nicks? - separators: "/" - - # can channel operators use RELAYMSG in their channels? - # our implementation of RELAYMSG makes it safe for chanops to use without the - # possibility of real users being silently spoofed - available-to-chanops: true - - # IPs/CIDRs the PROXY command can be used from - # This should be restricted to localhost (127.0.0.1/8, ::1/128, and unix sockets). - # Unless you have a good reason. you should also add these addresses to the - # connection limits and throttling exemption lists. - proxy-allowed-from: - - localhost - # - "192.168.1.1" - # - "192.168.10.1/24" - - # controls the use of the WEBIRC command (by IRC<->web interfaces, bouncers and similar) - webirc: - # one webirc block -- should correspond to one set of gateways - - - # SHA-256 fingerprint of the TLS certificate the gateway must use to connect - # (comment this out to use passwords only) - certfp: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" - - # password the gateway uses to connect, made with `ergo genpasswd` - password: "$2a$04$abcdef0123456789abcdef0123456789abcdef0123456789abcde" - - # IPs/CIDRs that can use this webirc command - # you should also add these addresses to the connection limits and throttling exemption lists - hosts: - - localhost - # - "192.168.1.1" - # - "192.168.10.1/24" - - # whether to accept the hostname parameter on the WEBIRC line as the IRC hostname - # (the default/recommended Ergo configuration will use cloaks instead) - accept-hostname: false - - # maximum length of clients' sendQ in bytes - # this should be big enough to hold bursts of channel/direct messages - max-sendq: 96k - - # compatibility with legacy clients - compatibility: - # many clients require that the final parameter of certain messages be an - # RFC1459 trailing parameter, i.e., prefixed with :, whether or not this is - # actually required. this forces Ergo to send those parameters - # as trailings. this is recommended unless you're testing clients for conformance; - # defaults to true when unset for that reason. - force-trailing: true - - # some clients (ZNC 1.6.x and lower, Pidgin 2.12 and lower) do not - # respond correctly to SASL messages with the server name as a prefix: - # https://github.com/znc/znc/issues/1212 - # this works around that bug, allowing them to use SASL. - send-unprefixed-sasl: true - - # traditionally, IRC servers will truncate and send messages that are - # too long to be relayed intact. this behavior can be disabled by setting - # allow-truncation to false, in which case Ergo will reject the message - # and return an error to the client. (note that this option defaults to true - # when unset.) - allow-truncation: false - - # IP-based DoS protection - ip-limits: - # whether to limit the total number of concurrent connections per IP/CIDR - count: true - # maximum concurrent connections per IP/CIDR - max-concurrent-connections: 16 - - # whether to restrict the rate of new connections per IP/CIDR - throttle: true - # how long to keep track of connections for - window: 10m - # maximum number of new connections per IP/CIDR within the given duration - max-connections-per-window: 32 - - # how wide the CIDR should be for IPv4 (a /32 is a fully specified IPv4 address) - cidr-len-ipv4: 32 - # how wide the CIDR should be for IPv6 (a /64 is the typical prefix assigned - # by an ISP to an individual customer for their LAN) - cidr-len-ipv6: 64 - - # IPs/networks which are exempted from connection limits - exempted: - - "localhost" - # - "192.168.1.1" - # - "2001:0db8::/32" - - # custom connection limits for certain IPs/networks. - custom-limits: - #"irccloud": - # nets: - # - "192.184.9.108" # highgate.irccloud.com - # - "192.184.9.110" # ealing.irccloud.com - # - "192.184.9.112" # charlton.irccloud.com - # - "192.184.10.118" # brockwell.irccloud.com - # - "192.184.10.9" # tooting.irccloud.com - # - "192.184.8.73" # hathersage.irccloud.com - # - "192.184.8.103" # stonehaven.irccloud.com - # - "5.254.36.57" # tinside.irccloud.com - # - "5.254.36.56/29" # additional ipv4 net - # - "2001:67c:2f08::/48" - # - "2a03:5180:f::/64" - # max-concurrent-connections: 2048 - # max-connections-per-window: 2048 - - # pluggable IP ban mechanism, via subprocess invocation - # this can be used to check new connections against a DNSBL, for example - # see the manual for details on how to write an IP ban checking script - ip-check-script: - enabled: false - command: "/usr/local/bin/check-ip-ban" - # constant list of args to pass to the command; the actual query - # and result are transmitted over stdin/stdout: - args: [] - # timeout for process execution, after which we send a SIGTERM: - timeout: 9s - # how long after the SIGTERM before we follow up with a SIGKILL: - kill-timeout: 1s - # how many scripts are allowed to run at once? 0 for no limit: - max-concurrency: 64 - # if true, only check anonymous connections (not logged into an account) - # at the very end of the handshake: - exempt-sasl: false - - # IP cloaking hides users' IP addresses from other users and from channel admins - # (but not from server admins), while still allowing channel admins to ban - # offending IP addresses or networks. In place of hostnames derived from reverse - # DNS, users see fake domain names like pwbs2ui4377257x8.irc. These names are - # generated deterministically from the underlying IP address, but if the underlying - # IP is not already known, it is infeasible to recover it from the cloaked name. - # If you disable this, you should probably enable lookup-hostnames in its place. - ip-cloaking: - # whether to enable IP cloaking - enabled: true - - # whether to use these cloak settings (specifically, `netname` and `num-bits`) - # to produce unique hostnames for always-on clients. you can enable this even if - # you disabled IP cloaking for normal clients above. if this is disabled, - # always-on clients will all have an identical hostname (the server name). - enabled-for-always-on: true - - # fake TLD at the end of the hostname, e.g., pwbs2ui4377257x8.irc - # you may want to use your network name here - netname: "irc" - - # the cloaked hostname is derived only from the CIDR (most significant bits - # of the IP address), up to a configurable number of bits. this is the - # granularity at which bans will take effect for IPv4. Note that changing - # this value will invalidate any stored bans. - cidr-len-ipv4: 32 - - # analogous granularity for IPv6 - cidr-len-ipv6: 64 - - # number of bits of hash output to include in the cloaked hostname. - # more bits means less likelihood of distinct IPs colliding, - # at the cost of a longer cloaked hostname. if this value is set to 0, - # all users will receive simply `netname` as their cloaked hostname. - num-bits: 64 - - # secure-nets identifies IPs and CIDRs which are secure at layer 3, - # for example, because they are on a trusted internal LAN or a VPN. - # plaintext connections from these IPs and CIDRs will be considered - # secure (clients will receive the +Z mode and be allowed to resume - # or reattach to secure connections). note that loopback IPs are always - # considered secure: - secure-nets: - # - "10.0.0.0/8" - - # Ergo will write files to disk under certain circumstances, e.g., - # CPU profiling or data export. by default, these files will be written - # to the working directory. set this to customize: - #output-path: "/home/ergo/out" - - # the hostname used by "services", e.g., NickServ, defaults to "localhost", - # e.g., `NickServ!NickServ@localhost`. uncomment this to override: - #override-services-hostname: "example.network" - - # in a "closed-loop" system where you control the server and all the clients, - # you may want to increase the maximum (non-tag) length of an IRC line from - # the default value of 512. DO NOT change this on a public server: - max-line-len: 2048 - - # send all 0's as the LUSERS (user counts) output to non-operators; potentially useful - # if you don't want to publicize how popular the server is - suppress-lusers: false - -# account options -accounts: - # is account authentication enabled, i.e., can users log into existing accounts? - authentication-enabled: true - - # account registration - registration: - # can users register new accounts for themselves? if this is false, operators with - # the `accreg` capability can still create accounts with `/NICKSERV SAREGISTER` - enabled: true - - # can users use the REGISTER command to register before fully connecting? - allow-before-connect: true - - # global throttle on new account creation - throttling: - enabled: true - # window - duration: 10m - # number of attempts allowed within the window - max-attempts: 30 - - # this is the bcrypt cost we'll use for account passwords - # (note that 4 is the lowest value allowed by the bcrypt library) - bcrypt-cost: 4 - - # length of time a user has to verify their account before it can be re-registered - verify-timeout: "32h" - - # options for email verification of account registrations - email-verification: - enabled: false - sender: "admin@my.network" - require-tls: true - helo-domain: "my.network" # defaults to server name if unset - # set to `tcp4` to force sending over IPv4, `tcp6` to force IPv6: - # protocol: "tcp4" - # set to force a specific source/local IPv4 or IPv6 address: - # local-address: "1.2.3.4" - # options to enable DKIM signing of outgoing emails (recommended, but - # requires creating a DNS entry for the public key): - # dkim: - # domain: "my.network" - # selector: "20200229" - # key-file: "dkim.pem" - # to use an MTA/smarthost instead of sending email directly: - # mta: - # server: localhost - # port: 25 - # username: "admin" - # password: "hunter2" - # implicit-tls: false # TLS from the first byte, typically on port 465 - # addresses that are not accepted for registration: - address-blacklist: - # - "*@mailinator.com" - address-blacklist-syntax: "glob" # change to "regex" for regular expressions - # file of newline-delimited address blacklist entries (no enclosing quotes) - # in the above syntax (i.e. either globs or regexes). supersedes - # address-blacklist if set: - # address-blacklist-file: "/path/to/address-blacklist-file" - timeout: 60s - # email-based password reset: - password-reset: - enabled: false - # time before we allow resending the email - cooldown: 1h - # time for which a password reset code is valid - timeout: 1d - - # throttle account login attempts (to prevent either password guessing, or DoS - # attacks on the server aimed at forcing repeated expensive bcrypt computations) - login-throttling: - enabled: true - - # window - duration: 1m - - # number of attempts allowed within the window - max-attempts: 3 - - # some clients (notably Pidgin and Hexchat) offer only a single password field, - # which makes it impossible to specify a separate server password (for the PASS - # command) and SASL password. if this option is set to true, a client that - # successfully authenticates with SASL will not be required to send - # PASS as well, so it can be configured to authenticate with SASL only. - skip-server-password: false - - # enable login to accounts via the PASS command, e.g., PASS account:password - # this is useful for compatibility with old clients that don't support SASL - login-via-pass-command: true - - # advertise the SCRAM-SHA-256 authentication method. set to false in case of - # compatibility issues with certain clients: - advertise-scram: true - - # require-sasl controls whether clients are required to have accounts - # (and sign into them using SASL) to connect to the server - require-sasl: - # if this is enabled, all clients must authenticate with SASL while connecting. - # WARNING: for a private server, you MUST set accounts.registration.enabled - # to false as well, in order to prevent non-administrators from registering - # accounts. - enabled: false - - # IPs/CIDRs which are exempted from the account requirement - exempted: - - "localhost" - # - '10.10.0.0/16' - - # nick-reservation controls how, and whether, nicknames are linked to accounts - nick-reservation: - # is there any enforcement of reserved nicknames? - enabled: true - - # how many nicknames, in addition to the account name, can be reserved? - # (note that additional nicks are unusable under force-nick-equals-account - # or if the client is always-on) - additional-nick-limit: 0 - - # method describes how nickname reservation is handled - # strict: users must already be logged in to their account (via - # SASL, PASS account:password, or /NickServ IDENTIFY) - # in order to use their reserved nickname(s) - # optional: no enforcement by default, but allow users to opt in to - # the enforcement level of their choice - method: strict - - # allow users to set their own nickname enforcement status, e.g., - # to opt out of strict enforcement - allow-custom-enforcement: false - - # format for guest nicknames: - # 1. these nicknames cannot be registered or reserved - # 2. if a client is automatically renamed by the server, - # this is the template that will be used (e.g., Guest-nccj6rgmt97cg) - # 3. if enforce-guest-format (see below) is enabled, clients without - # a registered account will have this template applied to their - # nicknames (e.g., 'katie' will become 'Guest-katie') - guest-nickname-format: "Guest-*" - - # when enabled, forces users not logged into an account to use - # a nickname matching the guest template. a caveat: this may prevent - # users from choosing nicknames in scripts different from the guest - # nickname format. - force-guest-format: false - - # when enabled, forces users logged into an account to use the - # account name as their nickname. when combined with strict nickname - # enforcement, this lets users treat nicknames and account names - # as equivalent for the purpose of ban/invite/exception lists. - force-nick-equals-account: true - - # parallel setting to force-nick-equals-account: if true, this forbids - # anonymous users (i.e., users not logged into an account) to change their - # nickname after the initial connection is complete - forbid-anonymous-nick-changes: false - - # multiclient controls whether Ergo allows multiple connections to - # attach to the same client/nickname identity; this is part of the - # functionality traditionally provided by a bouncer like ZNC - multiclient: - # when disabled, each connection must use a separate nickname (as is the - # typical behavior of IRC servers). when enabled, a new connection that - # has authenticated with SASL can associate itself with an existing - # client - enabled: true - - # if this is disabled, clients have to opt in to bouncer functionality - # using nickserv or the cap system. if it's enabled, they can opt out - # via nickserv - allowed-by-default: true - - # whether to allow clients that remain on the server even - # when they have no active connections. The possible values are: - # "disabled", "opt-in", "opt-out", or "mandatory". - always-on: "opt-in" - - # whether to mark always-on clients away when they have no active connections: - auto-away: "opt-in" - - # QUIT always-on clients from the server if they go this long without connecting - # (use 0 or omit for no expiration): - #always-on-expiration: 90d - - # vhosts controls the assignment of vhosts (strings displayed in place of the user's - # hostname/IP) by the HostServ service - vhosts: - # are vhosts enabled at all? - enabled: true - - # maximum length of a vhost - max-length: 64 - - # regexp for testing the validity of a vhost - # (make sure any changes you make here are RFC-compliant) - valid-regexp: '^[0-9A-Za-z.\-_/]+$' - - # modes that are set by default when a user connects - # if unset, no user modes will be set by default - # +i is invisible (a user's channels are hidden from whois replies) - # see /QUOTE HELP umodes for more user modes - default-user-modes: +i - - # pluggable authentication mechanism, via subprocess invocation - # see the manual for details on how to write an authentication plugin script - auth-script: - enabled: false - command: "/usr/local/bin/authenticate-irc-user" - # constant list of args to pass to the command; the actual authentication - # data is transmitted over stdin/stdout: - args: [] - # should we automatically create users if the plugin returns success? - autocreate: true - # timeout for process execution, after which we send a SIGTERM: - timeout: 9s - # how long after the SIGTERM before we follow up with a SIGKILL: - kill-timeout: 1s - # how many scripts are allowed to run at once? 0 for no limit: - max-concurrency: 64 - - # support for login via OAuth2 bearer tokens - oauth2: - enabled: false - # should we automatically create users on presentation of a valid token? - autocreate: true - # enable this to use auth-script for validation: - auth-script: false - introspection-url: "https://example.com/api/oidc/introspection" - introspection-timeout: 10s - # omit for auth method `none`; required for auth method `client_secret_basic`: - client-id: "ergo" - client-secret: "4TA0I7mJ3fUUcW05KJiODg" - - # support for login via JWT bearer tokens - jwt-auth: - enabled: false - # should we automatically create users on presentation of a valid token? - autocreate: true - # any of these token definitions can be accepted, allowing for key rotation - tokens: - - - algorithm: "hmac" # either 'hmac', 'rsa', or 'eddsa' (ed25519) - # hmac takes a symmetric key, rsa and eddsa take PEM-encoded public keys; - # either way, the key can be specified either as a YAML string: - key: "nANiZ1De4v6WnltCHN2H7Q" - # or as a path to the file containing the key: - #key-file: "jwt_pubkey.pem" - # list of JWT claim names to search for the user's account name (make sure the format - # is what you expect, especially if using "sub"): - account-claims: ["preferred_username"] - # if a claim is formatted as an email address, require it to have the following domain, - # and then strip off the domain and use the local-part as the account name: - #strip-domain: "example.com" - -# channel options -channels: - # modes that are set when new channels are created - # +n is no-external-messages, +t is op-only-topic, - # +C is no CTCPs (besides ACTION) - # see /QUOTE HELP cmodes for more channel modes - default-modes: +ntC - - # how many channels can a client be in at once? - max-channels-per-client: 100 - - # if this is true, new channels can only be created by operators with the - # `chanreg` operator capability - operator-only-creation: false - - # channel registration - requires an account - registration: - # can users register new channels? - enabled: true - - # restrict new channel registrations to operators only? - # (operators can then transfer channels to regular users using /CS TRANSFER) - operator-only: false - - # how many channels can each account register? - max-channels-per-account: 15 - - # as a crude countermeasure against spambots, anonymous connections younger - # than this value will get an empty response to /LIST (a time period of 0 disables) - list-delay: 0s - - # INVITE to an invite-only channel expires after this amount of time - # (0 or omit for no expiration): - invite-expiration: 24h - - # channels that new clients will automatically join. this should be used with - # caution, since traditional IRC users will likely view it as an antifeature. - # it may be useful in small community networks that have a single "primary" channel: - #auto-join: - # - "#lounge" - -# operator classes: -# an operator has a single "class" (defining a privilege level), which can include -# multiple "capabilities" (defining privileged actions they can take). all -# currently available operator capabilities are associated with either the -# 'chat-moderator' class (less privileged) or the 'server-admin' class (full -# privileges) below: you can mix and match to create new classes. -oper-classes: - # chat moderator: can ban/unban users from the server, join channels, - # fix mode issues and sort out vhosts. - "chat-moderator": - # title shown in WHOIS - title: Chat Moderator - - # capability names - capabilities: - - "kill" # disconnect user sessions - - "ban" # ban IPs, CIDRs, NUH masks, and suspend accounts (UBAN / DLINE / KLINE) - - "nofakelag" # exempted from "fakelag" restrictions on rate of message sending - - "relaymsg" # use RELAYMSG in any channel (see the `relaymsg` config block) - - "vhosts" # add and remove vhosts from users - - "sajoin" # join arbitrary channels, including private channels - - "samode" # modify arbitrary channel and user modes - - "snomasks" # subscribe to arbitrary server notice masks - - "roleplay" # use the (deprecated) roleplay commands in any channel - - # server admin: has full control of the ircd, including nickname and - # channel registrations - "server-admin": - # title shown in WHOIS - title: Server Admin - - # oper class this extends from - extends: "chat-moderator" - - # capability names - capabilities: - - "rehash" # rehash the server, i.e. reload the config at runtime - - "accreg" # modify arbitrary account registrations - - "chanreg" # modify arbitrary channel registrations - - "history" # modify or delete history messages - - "defcon" # use the DEFCON command (restrict server capabilities) - - "massmessage" # message all users on the server - -# ircd operators -opers: - # default operator named 'admin'; log in with /OPER admin - admin: - # which capabilities this oper has access to - class: "server-admin" - - # traditionally, operator status is visible to unprivileged users in - # WHO and WHOIS responses. this can be disabled with 'hidden'. - hidden: true - - # custom whois line (if `hidden` is enabled, visible only to other operators) - whois-line: is the server administrator - - # custom hostname (ignored if `hidden` is enabled) - #vhost: "staff" - - # modes are modes to auto-set upon opering-up. uncomment this to automatically - # enable snomasks ("server notification masks" that alert you to server events; - # see `/quote help snomasks` while opered-up for more information): - #modes: +is acdjknoqtuxv - - # operators can be authenticated either by password (with the /OPER command), - # or by certificate fingerprint, or both. if a password hash is set, then a - # password is required to oper up (e.g., /OPER dan mypassword). to generate - # the hash, use `ergo genpasswd`. - password: "$2a$04$0123456789abcdef0123456789abcdef0123456789abcdef01234" - - # if a SHA-256 certificate fingerprint is configured here, then it will be - # required to /OPER. if you comment out the password hash above, then you can - # /OPER without a password. - #certfp: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" - # if 'auto' is set (and no password hash is set), operator permissions will be - # granted automatically as soon as you connect with the right fingerprint. - #auto: true - - # example of a moderator named 'alice' - # (log in with /OPER alice ): - #alice: - # class: "chat-moderator" - # whois-line: "can help with moderation issues!" - # password: "$2a$04$0123456789abcdef0123456789abcdef0123456789abcdef01234" - -# logging, takes inspiration from Insp -logging: - - - # how to log these messages - # - # file log to a file - # stdout log to stdout - # stderr log to stderr - # (you can specify multiple methods, e.g., to log to both stderr and a file) - method: stderr - - # filename to log to, if file method is selected - # filename: ircd.log - - # type(s) of logs to keep here. you can use - to exclude those types - # - # exclusions take precedent over inclusions, so if you exclude a type it will NEVER - # be logged, even if you explicitly include it - # - # useful types include: - # * everything (usually used with exclusing some types below) - # server server startup, rehash, and shutdown events - # accounts account registration and authentication - # channels channel creation and operations - # opers oper actions, authentication, etc - # services actions related to NickServ, ChanServ, etc. - # internal unexpected runtime behavior, including potential bugs - # userinput raw lines sent by users - # useroutput raw lines sent to users - type: "* -userinput -useroutput" - - # one of: debug info warn error - level: info - #- - # # example of a file log that avoids logging IP addresses - # method: file - # filename: ircd.log - # type: "* -userinput -useroutput -connect-ip" - # level: debug - -# debug options -debug: - # when enabled, Ergo will attempt to recover from certain kinds of - # client-triggered runtime errors that would normally crash the server. - # this makes the server more resilient to DoS, but could result in incorrect - # behavior. deployments that would prefer to "start from scratch", e.g., by - # letting the process crash and auto-restarting it with systemd, can set - # this to false. - recover-from-errors: true - - # optionally expose a pprof http endpoint: https://golang.org/pkg/net/http/pprof/ - # it is strongly recommended that you don't expose this on a public interface; - # if you need to access it remotely, you can use an SSH tunnel. - # set to `null`, "", leave blank, or omit to disable - # pprof-listener: "localhost:6060" - -# lock file preventing multiple instances of Ergo from accidentally being -# started at once. comment out or set to the empty string ("") to disable. -# this path is relative to the working directory; if your datastore.path -# is absolute, you should use an absolute path here as well. -lock-file: "ircd.lock" - -# datastore configuration -datastore: - # path to the database file (used to store account and channel registrations): - path: ircd.db - - # if the database schema requires an upgrade, `autoupgrade` will attempt to - # perform it automatically on startup. the database will be backed - # up, and if the upgrade fails, the original database will be restored. - autoupgrade: true - - # connection information for MySQL (currently only used for persistent history): - mysql: - enabled: false - host: "localhost" - port: 3306 - # if socket-path is set, it will be used instead of host:port - #socket-path: "/var/run/mysqld/mysqld.sock" - user: "ergo" - password: "hunter2" - history-database: "ergo_history" - timeout: 3s - max-conns: 4 - # this may be necessary to prevent middleware from closing your connections: - #conn-max-lifetime: 180s - -# languages config -languages: - # whether to load languages - enabled: true - - # default language to use for new clients - # 'en' is the default English language in the code - default: en - - # which directory contains our language files - path: languages - -# limits - these need to be the same across the network -limits: - # nicklen is the max nick length allowed - nicklen: 32 - - # identlen is the max ident length allowed - identlen: 20 - - # realnamelen is the maximum realname length allowed - realnamelen: 150 - - # channellen is the max channel length allowed - channellen: 64 - - # awaylen is the maximum length of an away message - awaylen: 390 - - # kicklen is the maximum length of a kick message - kicklen: 390 - - # topiclen is the maximum length of a channel topic - topiclen: 390 - - # maximum number of monitor entries a client can have - monitor-entries: 100 - - # whowas entries to store - whowas-entries: 100 - - # maximum length of channel lists (beI modes) - chan-list-modes: 100 - - # maximum number of messages to accept during registration (prevents - # DoS / resource exhaustion attacks): - registration-messages: 1024 - - # message length limits for the new multiline cap - multiline: - max-bytes: 4096 # 0 means disabled - max-lines: 100 # 0 means no limit - -# fakelag: prevents clients from spamming commands too rapidly -fakelag: - # whether to enforce fakelag - enabled: true - - # time unit for counting command rates - window: 1s - - # clients can send this many commands without fakelag being imposed - burst-limit: 5 - - # once clients have exceeded their burst allowance, they can send only - # this many commands per `window`: - messages-per-window: 2 - - # client status resets to the default state if they go this long without - # sending any commands: - cooldown: 2s - - # exempt a certain number of command invocations per session from fakelag; - # this is to speed up "resynchronization" of client state during reattach - command-budgets: - "CHATHISTORY": 16 - "MARKREAD": 16 - "MONITOR": 1 - "WHO": 4 - -# the roleplay commands are semi-standardized extensions to IRC that allow -# sending and receiving messages from pseudo-nicknames. this can be used either -# for actual roleplaying, or for bridging IRC with other protocols. -roleplay: - # are roleplay commands enabled at all? (channels and clients still have to - # opt in individually with the +E mode) - enabled: false - - # require the "roleplay" oper capability to send roleplay messages? - require-oper: false - - # require channel operator permissions to send roleplay messages? - require-chanops: false - - # add the real nickname, in parentheses, to the end of every roleplay message? - add-suffix: true - -# external services can integrate with the ircd using JSON Web Tokens (https://jwt.io). -# in effect, the server can sign a token attesting that the client is present on -# the server, is a member of a particular channel, etc. -extjwt: - # # default service config (for `EXTJWT #channel`). - # # expiration time for the token: - # expiration: 45s - # # you can configure tokens to be signed either with HMAC and a symmetric secret: - # secret: "65PHvk0K1_sM-raTsCEhatVkER_QD8a0zVV8gG2EWcI" - # # or with an RSA private key: - # #rsa-private-key-file: "extjwt.pem" - - # # named services (for `EXTJWT #channel service_name`): - # services: - # "jitsi": - # expiration: 30s - # secret: "qmamLKDuOzIzlO8XqsGGewei_At11lewh6jtKfSTbkg" - -# history message storage: this is used by CHATHISTORY, HISTORY, znc.in/playback, -# various autoreplay features, and the resume extension -history: - # should we store messages for later playback? - # by default, messages are stored in RAM only; they do not persist - # across server restarts. however, you may want to understand how message - # history interacts with the GDPR and/or any data privacy laws that apply - # in your country and the countries of your users. - enabled: true - - # how many channel-specific events (messages, joins, parts) should be tracked per channel? - channel-length: 2048 - - # how many direct messages and notices should be tracked per user? - client-length: 256 - - # how long should we try to preserve messages? - # if `autoresize-window` is 0, the in-memory message buffers are preallocated to - # their maximum length. if it is nonzero, the buffers are initially small and - # are dynamically expanded up to the maximum length. if the buffer is full - # and the oldest message is older than `autoresize-window`, then it will overwrite - # the oldest message rather than resize; otherwise, it will expand if possible. - autoresize-window: 3d - - # number of messages to automatically play back on channel join (0 to disable): - autoreplay-on-join: 0 - - # maximum number of CHATHISTORY messages that can be - # requested at once (0 disables support for CHATHISTORY) - chathistory-maxmessages: 1000 - - # maximum number of messages that can be replayed at once during znc emulation - # (znc.in/playback, or automatic replay on initial reattach to a persistent client): - znc-maxmessages: 2048 - - # options to delete old messages, or prevent them from being retrieved - restrictions: - # if this is set, messages older than this cannot be retrieved by anyone - # (and will eventually be deleted from persistent storage, if that's enabled) - expire-time: 1w - - # this restricts access to channel history (it can be overridden by channel - # owners). options are: 'none' (no restrictions), 'registration-time' - # (logged-in users cannot retrieve messages older than their account - # registration date, and anonymous users cannot retrieve messages older than - # their sign-on time, modulo the grace-period described below), and - # 'join-time' (users cannot retrieve messages older than the time they - # joined the channel, so only always-on clients can view history). - query-cutoff: 'none' - - # if query-cutoff is set to 'registration-time', this allows retrieval - # of messages that are up to 'grace-period' older than the above cutoff. - # if you use 'registration-time', this is recommended to allow logged-out - # users to query history after disconnections. - grace-period: 1h - - # options to store history messages in a persistent database (currently only MySQL). - # in order to enable any of this functionality, you must configure a MySQL server - # in the `datastore.mysql` section. enabling persistence overrides the history - # size limits above (`channel-length`, `client-length`, etc.); persistent - # history has no limits other than those imposed by expire-time. - persistent: - enabled: false - - # store unregistered channel messages in the persistent database? - unregistered-channels: false - - # for a registered channel, the channel owner can potentially customize - # the history storage setting. as the server operator, your options are - # 'disabled' (no persistent storage, regardless of per-channel setting), - # 'opt-in', 'opt-out', and 'mandatory' (force persistent storage, ignoring - # per-channel setting): - registered-channels: "opt-out" - - # direct messages are only stored in the database for logged-in clients; - # you can control how they are stored here (same options as above). - # if you enable this, strict nickname reservation is strongly recommended - # as well. - direct-messages: "opt-out" - - # options to control how messages are stored and deleted: - retention: - # allow users to delete their own messages from history, - # and channel operators to delete messages in their channel? - allow-individual-delete: false - - # if persistent history is enabled, create additional index tables, - # allowing deletion of JSON export of an account's messages. this - # may be needed for compliance with data privacy regulations. - enable-account-indexing: false - - # options to control storage of TAGMSG - tagmsg-storage: - # by default, should TAGMSG be stored? - default: false - - # if `default` is false, store TAGMSG containing any of these tags: - whitelist: - - "+draft/react" - - "+react" - - # if `default` is true, don't store TAGMSG containing any of these tags: - #blacklist: - # - "+draft/typing" - # - "typing" - -# whether to allow customization of the config at runtime using environment variables, -# e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details. -allow-environment-overrides: true - -cef: - imagor: - url: "https://example.com/embed/" - secret: "secretgoeshere" - redis: "redis://user:password@localhost:6379/0?protocol=3" \ No newline at end of file diff --git a/distrib/SMF/README b/distrib/SMF/README deleted file mode 100644 index 2e408459..00000000 --- a/distrib/SMF/README +++ /dev/null @@ -1,26 +0,0 @@ -Created 22/11/2021 by georg@lysergic.dev. - -This directory contains Service Management Facility service files for ergo. -These files should be compatible with current OpenSolaris / Illumos based operating systems. Tested on OpenIndiana. - -Prerequesites: - - ergo binary located at /opt/ergo/ergo - - ergo configuration located at /opt/ergo/ircd.yaml (hardcoded) - - ergo languages located at /opt/ergo/languages (to be compatible with default.yaml - you may adjust this path or disable languages in your custom ircd.yaml) - - ergo certificate and key located at /opt/ergo/fullchain.pem /opt/ergo/privkey.pem (to be compatible with default.yaml - you may adjust these paths in your custom ircd.yaml) - - `ergo` role user and `ergo` role group owning all of the above - -Installation: - - cp ergo.xml /lib/svc/manifest/network/ - - cp ergo /lib/svc/method/ - - svcadm restart manifest-import - -Usage: - - svcadm enable ergo (Start) - - tail /var/svc/log/network-ergo:default.log (Check ergo log and SMF output) - - svcs ergo (Check status) - - svcadm refresh ergo (Reload manifest and ergo configuration) - - svcadm disable ergo (Stop) - -Notes: - - Does not support multiple instances - spawns instance :default diff --git a/distrib/SMF/ergo b/distrib/SMF/ergo deleted file mode 100755 index dae6bcfd..00000000 --- a/distrib/SMF/ergo +++ /dev/null @@ -1,26 +0,0 @@ -#!/sbin/sh -# -# SMF method script for ergo - used by manifest file ergo.xml -# Created 22/11/2021 by georg@lysergic.dev - -. /lib/svc/share/smf_include.sh - -case $1 in -'start') - exec /opt/ergo/ergo run --conf /opt/ergo/ircd.yaml - ;; - -'refresh' ) - exec pkill -1 -U ergo -x ergo - ;; -'stop' ) - exec pkill -U ergo -x ergo - ;; - -*) - echo "Usage: $0 { start | refresh | stop }" - exit 1 - ;; -esac - -exit $? diff --git a/distrib/SMF/ergo.xml b/distrib/SMF/ergo.xml deleted file mode 100644 index 345d51e9..00000000 --- a/distrib/SMF/ergo.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/distrib/anope/anope2json.py b/distrib/anope/anope2json.py deleted file mode 100755 index d047b83b..00000000 --- a/distrib/anope/anope2json.py +++ /dev/null @@ -1,206 +0,0 @@ -#!/usr/bin/python3 - -import binascii -import json -import logging -import re -import sys -from collections import defaultdict, namedtuple - -AnopeObject = namedtuple('AnopeObject', ('type', 'kv')) - -MASK_MAGIC_REGEX = re.compile(r'[*?!@]') - -def access_level_to_amode(level): - # https://wiki.anope.org/index.php/2.0/Modules/cs_xop - if level == 'QOP': - return 'q' - elif level == 'SOP': - return 'a' - elif level == 'AOP': - return 'o' - elif level == 'HOP': - return 'h' - elif level == 'VOP': - return 'v' - - try: - level = int(level) - except: - return None - if level >= 10000: - return 'q' - elif level >= 9999: - return 'a' - elif level >= 5: - return 'o' - elif level >= 4: - return 'h' - elif level >= 3: - return 'v' - else: - return None - -def to_unixnano(timestamp): - return int(timestamp) * (10**9) - -def file_to_objects(infile): - result = [] - obj = None - while True: - line = infile.readline() - if not line: - break - line = line.rstrip(b'\r\n') - try: - line = line.decode('utf-8') - except UnicodeDecodeError: - line = line.decode('utf-8', 'replace') - logging.warning("line contained invalid utf8 data " + line) - pieces = line.split(' ', maxsplit=2) - if len(pieces) == 0: - logging.warning("skipping blank line in db") - continue - if pieces[0] == 'END': - result.append(obj) - obj = None - elif pieces[0] == 'OBJECT': - obj = AnopeObject(pieces[1], {}) - elif pieces[0] == 'DATA': - obj.kv[pieces[1]] = pieces[2] - elif pieces[0] == 'ID': - # not sure what these do? - continue - else: - raise ValueError("unknown command found in anope db", pieces[0]) - return result - -ANOPE_MODENAME_TO_MODE = { - 'NOEXTERNAL': 'n', - 'TOPIC': 't', - 'INVITE': 'i', - 'NOCTCP': 'C', - 'AUDITORIUM': 'u', - 'SECRET': 's', -} - -# verify that a certfp appears to be a hex-encoded SHA-256 fingerprint; -# if it's anything else, silently ignore it -def validate_certfps(certobj): - certfps = [] - for fingerprint in certobj.split(): - try: - dec = binascii.unhexlify(fingerprint) - except: - continue - if len(dec) == 32: - certfps.append(fingerprint) - return certfps - -def convert(infile): - out = { - 'version': 1, - 'source': 'anope', - 'users': defaultdict(dict), - 'channels': defaultdict(dict), - } - - objects = file_to_objects(infile) - - lastmode_channels = set() - - for obj in objects: - if obj.type == 'NickCore': - username = obj.kv['display'] - userdata = {'name': username, 'hash': obj.kv['pass'], 'email': obj.kv['email']} - certobj = obj.kv.get('cert') - if certobj: - userdata['certfps'] = validate_certfps(certobj) - out['users'][username] = userdata - elif obj.type == 'NickAlias': - username = obj.kv['nc'] - nick = obj.kv['nick'] - userdata = out['users'][username] - if username.lower() == nick.lower(): - userdata['registeredAt'] = to_unixnano(obj.kv['time_registered']) - else: - if 'additionalNicks' not in userdata: - userdata['additionalNicks'] = [] - userdata['additionalNicks'].append(nick) - elif obj.type == 'ChannelInfo': - chname = obj.kv['name'] - founder = obj.kv['founder'] - chdata = { - 'name': chname, - 'founder': founder, - 'registeredAt': to_unixnano(obj.kv['time_registered']), - 'topic': obj.kv['last_topic'], - 'topicSetBy': obj.kv['last_topic_setter'], - 'topicSetAt': to_unixnano(obj.kv['last_topic_time']), - 'amode': {founder: 'q',} - } - # DATA last_modes INVITE KEY,hunter2 NOEXTERNAL REGISTERED TOPIC - last_modes = obj.kv.get('last_modes') - if last_modes: - modes = [] - for mode_desc in last_modes.split(): - if ',' in mode_desc: - mode_name, mode_value = mode_desc.split(',', maxsplit=1) - else: - mode_name, mode_value = mode_desc, None - if mode_name == 'KEY': - chdata['key'] = mode_value - else: - modes.append(ANOPE_MODENAME_TO_MODE.get(mode_name, '')) - chdata['modes'] = ''.join(modes) - # prevent subsequent ModeLock objects from modifying the mode list further: - lastmode_channels.add(chname) - out['channels'][chname] = chdata - elif obj.type == 'ModeLock': - if obj.kv.get('set') != '1': - continue - chname = obj.kv['ci'] - if chname in lastmode_channels: - continue - chdata = out['channels'][chname] - modename = obj.kv['name'] - if modename == 'KEY': - chdata['key'] = obj.kv['param'] - else: - oragono_mode = ANOPE_MODENAME_TO_MODE.get(modename) - if oragono_mode is not None: - stored_modes = chdata.get('modes', '') - stored_modes += oragono_mode - chdata['modes'] = stored_modes - elif obj.type == 'ChanAccess': - chname = obj.kv['ci'] - target = obj.kv['mask'] - mode = access_level_to_amode(obj.kv['data']) - if mode is None: - continue - if MASK_MAGIC_REGEX.search(target): - continue - chdata = out['channels'][chname] - amode = chdata.setdefault('amode', {}) - amode[target] = mode - chdata['amode'] = amode - - # do some basic integrity checks - for chname, chdata in out['channels'].items(): - founder = chdata.get('founder') - if founder not in out['users']: - raise ValueError("no user corresponding to channel founder", chname, chdata.get('founder')) - - return out - -def main(): - if len(sys.argv) != 3: - raise Exception("Usage: anope2json.py anope.db output.json") - with open(sys.argv[1], 'rb') as infile: - output = convert(infile) - with open(sys.argv[2], 'w') as outfile: - json.dump(output, outfile) - -if __name__ == '__main__': - logging.basicConfig() - sys.exit(main()) diff --git a/distrib/apparmor/ergo b/distrib/apparmor/ergo deleted file mode 100644 index 3a5f13d4..00000000 --- a/distrib/apparmor/ergo +++ /dev/null @@ -1,34 +0,0 @@ -include - -# Georg Pfuetzenreuter -# AppArmor confinement for ergo and ergo-ldap - -profile ergo /usr/bin/ergo { - include - include - include - - /etc/ergo/ircd.{motd,yaml} r, - /etc/ssl/irc/{crt,key} r, - /etc/ssl/ergo/{crt,key} r, - /usr/bin/ergo mr, - /proc/sys/net/core/somaxconn r, - /sys/kernel/mm/transparent_hugepage/hpage_pmd_size r, - /usr/share/ergo/languages/{,*.lang.json,*.yaml} r, - owner /run/ergo/ircd.lock rwk, - owner /var/lib/ergo/ircd.db rw, - - include if exists - -} - -profile ergo-ldap /usr/bin/ergo-ldap { - include - include - - /usr/bin/ergo-ldap rm, - /etc/ergo/ldap.yaml r, - - include if exists - -} diff --git a/distrib/atheme/atheme2json.py b/distrib/atheme/atheme2json.py deleted file mode 100755 index 1a4b3b17..00000000 --- a/distrib/atheme/atheme2json.py +++ /dev/null @@ -1,209 +0,0 @@ -#!/usr/bin/python3 - -import binascii -import json -import logging -import re -import sys -from collections import defaultdict - -MASK_MAGIC_REGEX = re.compile(r'[*?!@$]') - -def to_unixnano(timestamp): - return int(timestamp) * (10**9) - -# include/atheme/channels.h -CMODE_FLAG_TO_MODE = { - 0x001: 'i', # CMODE_INVITE - 0x010: 'n', # CMODE_NOEXT - 0x080: 's', # CMODE_SEC - 0x100: 't', # CMODE_TOPIC -} - -# attempt to interpret certfp as a hex-encoded SHA-256 fingerprint -def validate_certfp(certfp): - try: - dec = binascii.unhexlify(certfp) - except: - return False - return len(dec) == 32 - -def convert(infile): - out = { - 'version': 1, - 'source': 'atheme', - 'users': defaultdict(dict), - 'channels': defaultdict(dict), - } - - group_to_founders = defaultdict(list) - - channel_to_founder = defaultdict(lambda: (None, None)) - - while True: - line = infile.readline() - if not line: - break - line = line.rstrip(b'\r\n') - try: - line = line.decode('utf-8') - except UnicodeDecodeError: - line = line.decode('utf-8', 'replace') - logging.warning("line contained invalid utf8 data " + line) - parts = line.split(' ') - category = parts[0] - - if category == 'GACL': - # Note: all group definitions precede channel access entries (token CA) by design, so it - # should be safe to read this in using one pass. - groupname = parts[1] - user = parts[2] - flags = parts[3] - if 'F' in flags: - group_to_founders[groupname].append(user) - elif category == 'MU': - # user account - # MU AAAAAAAAB shivaram $1$hcspif$nCm4r3S14Me9ifsOPGuJT. user@example.com 1600134392 1600467343 +sC default - name = parts[2] - user = {'name': name, 'hash': parts[3], 'email': parts[4], 'registeredAt': to_unixnano(parts[5])} - out['users'][name].update(user) - pass - elif category == 'MN': - # grouped nick - # MN shivaram slingamn 1600218831 1600467343 - username, groupednick = parts[1], parts[2] - if username != groupednick: - user = out['users'][username] - user.setdefault('additionalnicks', []).append(groupednick) - elif category == 'MDU': - if parts[2] == 'private:usercloak': - username = parts[1] - out['users'][username]['vhost'] = parts[3] - elif category == 'MCFP': - username, certfp = parts[1], parts[2] - if validate_certfp(certfp): - user = out['users'][username] - user.setdefault('certfps', []).append(certfp.lower()) - elif category == 'MC': - # channel registration - # MC #mychannel 1600134478 1600467343 +v 272 0 0 - # MC #NEWCHANNELTEST 1602270889 1602270974 +vg 1 0 0 jaeger4 - chname = parts[1] - chdata = out['channels'][chname] - # XXX just give everyone +nt, regardless of lock status; they can fix it later - chdata.update({'name': chname, 'registeredAt': to_unixnano(parts[2])}) - if parts[8] != '': - chdata['key'] = parts[8] - modes = {'n', 't'} - mlock_on, mlock_off = int(parts[5]), int(parts[6]) - for flag, mode in CMODE_FLAG_TO_MODE.items(): - if flag & mlock_on != 0: - modes.add(mode) - elif flag & mlock_off != 0 and mode in modes: - modes.remove(mode) - chdata['modes'] = ''.join(sorted(modes)) - chdata['limit'] = int(parts[7]) - elif category == 'MDC': - # auxiliary data for a channel registration - # MDC #mychannel private:topic:setter s - # MDC #mychannel private:topic:text hi again - # MDC #mychannel private:topic:ts 1600135864 - chname = parts[1] - category = parts[2] - if category == 'private:topic:text': - out['channels'][chname]['topic'] = line.split(maxsplit=3)[3] - elif category == 'private:topic:setter': - out['channels'][chname]['topicSetBy'] = parts[3] - elif category == 'private:topic:ts': - out['channels'][chname]['topicSetAt'] = to_unixnano(parts[3]) - elif category == 'private:mlockext': - # the channel forward mode is +L on insp/unreal, +f on charybdis - # charybdis has a +L ("large banlist") taking no argument - # and unreal has a +f ("flood limit") taking two colon-delimited numbers, - # so check for an argument that starts with a # - if parts[3].startswith('L#') or parts[3].startswith('f#'): - out['channels'][chname]['forward'] = parts[3][1:] - elif category == 'CA': - # channel access lists - # CA #mychannel shivaram +AFORafhioqrstv 1600134478 shivaram - chname, username, flags, set_at = parts[1], parts[2], parts[3], int(parts[4]) - chname = parts[1] - chdata = out['channels'][chname] - flags = parts[3] - set_at = int(parts[4]) - if 'amode' not in chdata: - chdata['amode'] = {} - # see libathemecore/flags.c: +o is op, +O is autoop, etc. - if 'F' in flags: - # If the username starts with "!", it's actually a GroupServ group. - if username.startswith('!'): - group_founders = group_to_founders.get(username) - if not group_founders: - # skip this and warn about it later - continue - # attempt to promote the first group founder to channel founder - username = group_founders[0] - # but everyone gets the +q flag - for founder in group_founders: - chdata['amode'][founder] = 'q' - # there can only be one founder - preexisting_founder, preexisting_set_at = channel_to_founder[chname] - if preexisting_founder is None or set_at < preexisting_set_at: - chdata['founder'] = username - channel_to_founder[chname] = (username, set_at) - # but multiple people can receive the 'q' amode - chdata['amode'][username] = 'q' - continue - if MASK_MAGIC_REGEX.search(username): - # ignore groups, masks, etc. for any field other than founder - continue - # record the first appearing successor, if necessary - if 'S' in flags: - if not chdata.get('successor'): - chdata['successor'] = username - # finally, handle amodes - if 'q' in flags: - chdata['amode'][username] = 'q' - elif 'a' in flags: - chdata['amode'][username] = 'a' - elif 'o' in flags or 'O' in flags: - chdata['amode'][username] = 'o' - elif 'h' in flags or 'H' in flags: - chdata['amode'][username] = 'h' - elif 'v' in flags or 'V' in flags: - chdata['amode'][username] = 'v' - else: - pass - - # do some basic integrity checks - def validate_user(name): - if not name: - return False - return bool(out['users'].get(name)) - - invalid_channels = [] - - for chname, chdata in out['channels'].items(): - if not validate_user(chdata.get('founder')): - if validate_user(chdata.get('successor')): - chdata['founder'] = chdata['successor'] - else: - invalid_channels.append(chname) - - for chname in invalid_channels: - logging.warning("Unable to find a valid founder for channel %s, discarding it", chname) - del out['channels'][chname] - - return out - -def main(): - if len(sys.argv) != 3: - raise Exception("Usage: atheme2json.py atheme_db output.json") - with open(sys.argv[1], 'rb') as infile: - output = convert(infile) - with open(sys.argv[2], 'w') as outfile: - json.dump(output, outfile) - -if __name__ == '__main__': - logging.basicConfig() - sys.exit(main()) diff --git a/distrib/bsd-rc/README.md b/distrib/bsd-rc/README.md deleted file mode 100644 index b6cf10ca..00000000 --- a/distrib/bsd-rc/README.md +++ /dev/null @@ -1,29 +0,0 @@ -Ergo init script for bsd-rc -=== - -Written for and tested using FreeBSD. - -## Installation -Copy the `ergo` file from this folder to `/etc/rc.d/ergo`, -permissions should be `555`. - -You should create a system user for Ergo. -This script defaults to running Ergo as a user named `ergo`, -but that can be changed using `/etc/rc.conf`. - -Here are all `rc.conf` variables and their defaults: -- `ergo_enable`, defaults to `NO`. Whether to run `ergo` at system start. -- `ergo_user`, defaults to `ergo`. Run using this user. -- `ergo_group`, defaults to `ergo`. Run using this group. -- `ergo_chdir`, defaults to `/var/db/ergo`. Path to the working directory for the server. Should be writable for `ergo_user`. -- `ergo_conf`, defaults to `/usr/local/etc/ergo/ircd.yaml`. Config file path. Make sure `ergo_user` can read it. - -This script assumes ergo to be installed at `/usr/local/bin/ergo`. - -## Usage - -```shell -/etc/rc.d/ergo -``` -In addition to the obvious `start` and `stop` commands, this -script also has a `reload` command that sends `SIGHUP` to the Ergo process. diff --git a/distrib/bsd-rc/ergo b/distrib/bsd-rc/ergo deleted file mode 100644 index 031cd2c1..00000000 --- a/distrib/bsd-rc/ergo +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/sh - -# PROVIDE: ergo -# REQUIRE: DAEMON -# KEYWORD: shutdown - -# -# Add the following lines to /etc/rc.conf to enable Ergo -# -# ergo_enable (bool): Set to YES to enable ergo. -# Default is "NO". -# ergo_user (user): Set user to run ergo. -# Default is "ergo". -# ergo_group (group): Set group to run ergo. -# Default is "ergo". -# ergo_config (file): Set ergo config file path. -# Default is "/usr/local/etc/ergo/config.yaml". -# ergo_chdir (dir): Set ergo working directory -# Default is "/var/db/ergo". - -. /etc/rc.subr - -name=ergo -rcvar=ergo_enable -desc="Ergo IRCv3 server" - -load_rc_config "$name" - -: ${ergo_enable:=NO} -: ${ergo_user:=ergo} -: ${ergo_group:=ergo} -: ${ergo_chdir:=/var/db/ergo} -: ${ergo_conf:=/usr/local/etc/ergo/ircd.yaml} - -# If you don't define a custom reload function, -# rc automagically sends SIGHUP to the process on reload. -# But you have to list reload as an extra_command for that. -extra_commands="reload" - -procname="/usr/local/bin/${name}" -command=/usr/sbin/daemon -command_args="-S -T ${name} ${procname} run --conf ${ergo_conf}" - -run_rc_command "$1" - diff --git a/distrib/docker/README.md b/distrib/docker/README.md deleted file mode 100644 index 9d4feed6..00000000 --- a/distrib/docker/README.md +++ /dev/null @@ -1,113 +0,0 @@ -# Ergo Docker - -This folder holds Ergo's Docker compose file. The Dockerfile is in the root -directory. Ergo is published automatically to the GitHub Container Registry at -[ghcr.io/ergochat/ergo](https://ghcr.io/ergochat/ergo). - -Most users should use either the `stable` tag (corresponding to the -`stable` branch in git, which tracks the latest stable release), or -a tag corresponding to a tagged version (e.g. `v2.8.0`). The `master` -tag corresponds to the `master` branch, which is not recommended for -production use. The `latest` tag is not recommended. - -## Quick start - -The Ergo docker image is designed to work out of the box - it comes with a -usable default config and will automatically generate self-signed TLS -certificates. To get a working ircd, all you need to do is run the image and -expose the ports: - -```shell -docker run --init --name ergo -d -p 6667:6667 -p 6697:6697 ghcr.io/ergochat/ergo:stable -``` - -This will start Ergo and listen on ports 6667 (plain text) and 6697 (TLS). -The first time Ergo runs it will create a config file with a randomised -oper password. This is output to stdout, and you can view it with the docker -logs command: - -```shell -# Assuming your container is named `ergo`; use `docker container ls` to -# find the name if you're not sure. -docker logs ergo -``` - -You should see a line similar to: - -``` -Oper username:password is admin:cnn2tm9TP3GeI4vLaEMS -``` - -We recommend the use of `--init` (`init: true` in docker-compose) to solve an -edge case involving unreaped zombie processes when Ergo's script API is used -for authentication or IP validation. For more details, see -[krallin/tini#8](https://github.com/krallin/tini/issues/8). - -## Persisting data - -Ergo has a persistent data store, used to keep account details, channel -registrations, and so on. To persist this data across restarts, you can mount -a volume at /ircd. - -For example, to create a new docker volume and then mount it: - -```shell -docker volume create ergo-data -docker run --init -d -v ergo-data:/ircd -p 6667:6667 -p 6697:6697 ghcr.io/ergochat/ergo:stable -``` - -Or to mount a folder from your host machine: - -```shell -mkdir ergo-data -docker run --init -d -v $(PWD)/ergo-data:/ircd -p 6667:6667 -p 6697:6697 ghcr.io/ergochat/ergo:stable -``` - -## Customising the config - -Ergo's config file is stored at /ircd/ircd.yaml. If the file does not -exist, the default config will be written out. You can copy the config from -the container, edit it, and then copy it back: - -```shell -# Assuming that your container is named `ergo`, as above. -docker cp ergo:/ircd/ircd.yaml . -vim ircd.yaml # edit the config to your liking -docker cp ircd.yaml ergo:/ircd/ircd.yaml -``` - -You can use the `/rehash` command to make Ergo reload its config, or -send it the HUP signal: - -```shell -docker kill -s SIGHUP ergo -``` - -## Using custom TLS certificates - -TLS certs will by default be read from /ircd/tls.crt, with a private key -in /ircd/tls.key. You can customise this path in the ircd.yaml file if -you wish to mount the certificates from another volume. For information -on using Let's Encrypt certificates, see -[this manual entry](https://github.com/ergochat/ergo/blob/master/docs/MANUAL.md#using-valid-tls-certificates). - -## Using docker-compose - -This folder contains a sample docker-compose file which can be used -to start an Ergo instance with ports exposed and data persisted in -a docker volume. Simply download the file and then bring it up: - -```shell -curl -O https://raw.githubusercontent.com/ergochat/ergo/master/distrib/docker/docker-compose.yml -docker-compose up -d -``` - -## Building - -If you wish to manually build the docker image, you need to do so from -the root of the Ergo repository (not the `distrib/docker` directory): - -```shell -docker build . -``` - diff --git a/distrib/docker/docker-compose.yml b/distrib/docker/docker-compose.yml deleted file mode 100644 index aec0ade3..00000000 --- a/distrib/docker/docker-compose.yml +++ /dev/null @@ -1,21 +0,0 @@ -version: "3.8" - -services: - ergo: - init: true - image: ghcr.io/ergochat/ergo:stable - ports: - - "6667:6667/tcp" - - "6697:6697/tcp" - volumes: - - data:/ircd - deploy: - placement: - constraints: - - "node.role == manager" - restart_policy: - condition: on-failure - replicas: 1 - -volumes: - data: diff --git a/distrib/docker/run.sh b/distrib/docker/run.sh deleted file mode 100755 index 197db37a..00000000 --- a/distrib/docker/run.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/sh - -# make config file -if [ ! -f "/ircd/ircd.yaml" ]; then - awk '{gsub(/path: languages/,"path: /ircd-bin/languages")}1' /ircd-bin/default.yaml > /tmp/ircd.yaml - - # change default oper passwd - OPERPASS=$(< /dev/urandom tr -dc _A-Z-a-z-0-9 | head -c20) - echo "Oper username:password is admin:$OPERPASS" - ENCRYPTEDPASS=$(echo "$OPERPASS" | /ircd-bin/ergo genpasswd) - ORIGINALPASS='\$2a\$04\$0123456789abcdef0123456789abcdef0123456789abcdef01234' - - awk "{gsub(/password: \\\"$ORIGINALPASS\\\"/,\"password: \\\"$ENCRYPTEDPASS\\\"\")}1" /tmp/ircd.yaml > /tmp/ircd2.yaml - - unset OPERPASS - unset ENCRYPTEDPASS - unset ORIGINALPASS - - mv /tmp/ircd2.yaml /ircd/ircd.yaml -fi - -# make self-signed certs if they don't already exist -/ircd-bin/ergo mkcerts - -# run! -exec /ircd-bin/ergo run diff --git a/distrib/init/rc.ergo b/distrib/init/rc.ergo deleted file mode 100644 index c1455b80..00000000 --- a/distrib/init/rc.ergo +++ /dev/null @@ -1,57 +0,0 @@ -#!/bin/sh - -# Init script for the ergo IRCd -# Created 14/06/2021 by georg@lysergic.dev -# Desgigned for and tested on Slackware -current -# Depends on `daemon` (installable using slackpkg) -# In its stock configuration ergo will be jailed to /opt/ergo - all paths are relative from there. Consider this in your ergo configuration file (i.e. certificate, database and log locations) - -NAME=ergo -DIR=/opt/ergo -ERGO=/ergo -DAEMONIZER=/usr/bin/daemon -CONFIG=ircd.yaml -USER=ergo -GROUP=ergo - -daemon_start() { - $DAEMONIZER -n $NAME -v -- chroot --userspec=$USER --groups=$USER -- $DIR $ERGO run --conf $CONFIG -} - -daemon_stop() { - $DAEMONIZER --stop -n $NAME -v -} - -daemon_restart() { - $DAEMONIZER --restart -n $NAME -v -} - -daemon_reload() { - $DAEMONIZER --signal=SIGHUP -n $NAME -v -} - -daemon_status() { - $DAEMONIZER --running -n $NAME -v -} - -case "$1" in - start) - daemon_start - ;; - stop) - daemon_stop - ;; - restart) - daemon_restart - ;; - reload) - daemon_reload - ;; - status) - daemon_status - ;; - *) - echo "Source: https://github.com/ergochat/ergo" - echo "Usage: $0 {start|stop|restart|reload|status}" - exit 1 -esac diff --git a/distrib/openrc/ergo.confd b/distrib/openrc/ergo.confd deleted file mode 100644 index b9f917c0..00000000 --- a/distrib/openrc/ergo.confd +++ /dev/null @@ -1,3 +0,0 @@ -# /etc/conf.d/ergo: config file for /etc/init.d/ergo -ERGO_CONFIGFILE="/etc/ergo/ircd.yaml" -ERGO_USERNAME="ergo" diff --git a/distrib/openrc/ergo.initd b/distrib/openrc/ergo.initd deleted file mode 100644 index 8bd1aba2..00000000 --- a/distrib/openrc/ergo.initd +++ /dev/null @@ -1,32 +0,0 @@ -#!/sbin/openrc-run -name=${RC_SVCNAME} -description="ergo IRC daemon" - -command=/usr/bin/ergo -command_args="run --conf ${ERGO_CONFIGFILE:-'/etc/ergo/ircd.yaml'}" -command_user=${ERGO_USERNAME:-ergo} -command_background=true - -pidfile=/var/run/${RC_SVCNAME}.pid - -output_log="/var/log/${RC_SVCNAME}.out" -error_log="/var/log/${RC_SVCNAME}.err" -# --wait: to wait 1 second after launching to see if it survived startup -start_stop_daemon_args="--wait 1000" - -extra_started_commands="reload" - -depend() { - use dns - provide ircd -} - -start_pre() { - checkpath --owner ${command_user}:${command_user} --mode 0640 --file /var/log/${RC_SVCNAME}.out /var/log/${RC_SVCNAME}.err -} - -reload() { - ebegin "Reloading ${RC_SVCNAME}" - start-stop-daemon --signal HUP --pidfile "${pidfile}" - eend $? -} diff --git a/distrib/s6/README b/distrib/s6/README deleted file mode 100644 index e496a98c..00000000 --- a/distrib/s6/README +++ /dev/null @@ -1,8 +0,0 @@ -This directory contains s6 srv and log services for ergo. - -These services expect that ergo is installed to /opt/ergo, -and an ergo system user that owns /opt/ergo. - -To install: - cp -r ergo-srv ergo-log /etc/s6/sv/ - cp ergo.conf /etc/s6/config/ diff --git a/distrib/s6/ergo-log/consumer-for b/distrib/s6/ergo-log/consumer-for deleted file mode 100644 index 1955a6f5..00000000 --- a/distrib/s6/ergo-log/consumer-for +++ /dev/null @@ -1 +0,0 @@ -ergo-srv diff --git a/distrib/s6/ergo-log/notification-fd b/distrib/s6/ergo-log/notification-fd deleted file mode 100644 index 00750edc..00000000 --- a/distrib/s6/ergo-log/notification-fd +++ /dev/null @@ -1 +0,0 @@ -3 diff --git a/distrib/s6/ergo-log/pipeline-name b/distrib/s6/ergo-log/pipeline-name deleted file mode 100644 index b57f1aa0..00000000 --- a/distrib/s6/ergo-log/pipeline-name +++ /dev/null @@ -1 +0,0 @@ -ergo diff --git a/distrib/s6/ergo-log/run b/distrib/s6/ergo-log/run deleted file mode 100644 index bdda2ee6..00000000 --- a/distrib/s6/ergo-log/run +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/execlineb -P -envfile /etc/s6/config/ergo.conf -importas -sCiu DIRECTIVES DIRECTIVES -ifelse { test -w /var/log } { - foreground { install -d -o s6log -g s6log /var/log/ergo } - s6-setuidgid s6log exec -c s6-log -d3 -b -- ${DIRECTIVES} /var/log/ergo -} -foreground { install -d -o s6log -g s6log /run/log/ergo } -s6-setuidgid s6log exec -c s6-log -d3 -b -- ${DIRECTIVES} /run/log/ergo diff --git a/distrib/s6/ergo-log/type b/distrib/s6/ergo-log/type deleted file mode 100644 index 5883cff0..00000000 --- a/distrib/s6/ergo-log/type +++ /dev/null @@ -1 +0,0 @@ -longrun diff --git a/distrib/s6/ergo-srv/producer-for b/distrib/s6/ergo-srv/producer-for deleted file mode 100644 index c3430ce5..00000000 --- a/distrib/s6/ergo-srv/producer-for +++ /dev/null @@ -1 +0,0 @@ -ergo-log diff --git a/distrib/s6/ergo-srv/run b/distrib/s6/ergo-srv/run deleted file mode 100644 index 41218239..00000000 --- a/distrib/s6/ergo-srv/run +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/execlineb -P -fdmove -c 2 1 -execline-cd /opt/ergo -s6-setuidgid ergo ./ergo run diff --git a/distrib/s6/ergo-srv/type b/distrib/s6/ergo-srv/type deleted file mode 100644 index 5883cff0..00000000 --- a/distrib/s6/ergo-srv/type +++ /dev/null @@ -1 +0,0 @@ -longrun diff --git a/distrib/s6/ergo.conf b/distrib/s6/ergo.conf deleted file mode 100644 index 60c88acf..00000000 --- a/distrib/s6/ergo.conf +++ /dev/null @@ -1,2 +0,0 @@ -# This configures the directives used for s6-log in the log service. -DIRECTIVES="n3 s2000000" diff --git a/distrib/systemd/ergo.service b/distrib/systemd/ergo.service deleted file mode 100644 index 6355980f..00000000 --- a/distrib/systemd/ergo.service +++ /dev/null @@ -1,23 +0,0 @@ -[Unit] -Description=ergo -After=network.target -# If you are using MySQL for history storage, comment out the above line -# and uncomment these two instead (you must independently install and configure -# MySQL for your system): -# Wants=mysql.service -# After=network.target mysql.service - -[Service] -Type=notify -User=ergo -WorkingDirectory=/home/ergo -ExecStart=/home/ergo/ergo run --conf /home/ergo/ircd.yaml -ExecReload=/bin/kill -HUP $MAINPID -Restart=on-failure -LimitNOFILE=1048576 -NotifyAccess=main -# Uncomment this for a hidden service: -# PrivateNetwork=true - -[Install] -WantedBy=multi-user.target diff --git a/docs/INFO.md b/docs/INFO.md new file mode 100644 index 00000000..8560431a --- /dev/null +++ b/docs/INFO.md @@ -0,0 +1,92 @@ +# Oragono Information + +Here's a bunch of misc info about the Oragono server! This can include questions, plans on +how I'm going forward, how to properly use features, or why Oragono does/doesn't do +something. + +Essentially, this document acts as a braindump about Oragono while we figure out a better +place to put all this information. + + +## Accounts and Channels + +Most IRC servers out there offer IRC account and channel registration through external +services such as NickServ and ChanServ. In Oragono, we bundle accounts and channel ownership +in as a native server feature instead! + +Because there's a lot of aspects of accounts/channels that haven't been specified as native +commands and all yet, Oragono includes the pseudo-clients NickServ and ChanServ to roughly +mimic the functionality that other IRCds get from services packages, in a user-facing set +of commands that's familiar to everyone. + +The plan is to move more features and functionality (such as channel registration, channel +permissions and all) over to native commands first and to use the NickServ/ChanServ as +legacy interfaces to access these functions. However, it's gonna be a while before all of +this is specified by someone like the IRCv3 WG. + + +## PROXY + +The PROXY command, specified by [HAProxy's PROXY v1 specifications](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt), +allows someone to setup HAProxy in front of Oragono. This allows them to use HAProxy for +TLS negotiation (allowing older versions of SSL/TLS than Go's inbuilt TLS support does). +However, it also allows them to update TLS certificates by updating them with HAProxy, +rather than relying on our `REHASH` command (which is less-well-tested than I'd like +right now). + +This is a toss-up of course – allowing older versions of TLS might be seen as undesired, +and I wouldn't use the feature myself, but it's useful for real-world installations which +is why it exists. The command is only allowed from specific hosts which should restrict it +appropriately. + + +## Server-to-Server Linking (or Federation) + +Right now Oragono doesn't support linking multiple servers together. It's certainly planned, +but it's a fair while away. + +When I do add S2S linking to Oragono, I want to use it as a testbed for a new sort of +linking protocol. Mostly, I want a meshy protocol that minimises the effects of netsplits +while still ensuring that messages get delivered, and preserves the AP nature of IRC +reliability (in terms of the CAP theorem), which is something that traditional solutions +based on the Raft protocol don't do. + +Basically, I'm going to continue working on my [DCMI](https://github.com/DanielOaks/dcmi) +protocol, get that to a point where I'm happy with it and _then_ start looking at S2S +linking properly. If anyone is interested in server protocols and wants to look at this with +me, please feel free to reach out! + + +## Rehashing + +Rehashing is reloading the config files and TLS certificates. Of course, you can rehash the +server by connect, opering-up and using the `/REHASH` command. However, similar to other +IRCds, you can also make the server rehash by sending an appropriate signal to it! + +To make the server rehash from the command line, send it a `SIGHUP` signal. In *nix and OSX, +you can do this by performing the following command: + + killall -HUP oragono + +This will make the server rehash its configuration files and TLS certificates, and so can be +useful if you're automatically updating your TLS certs! + + +## Rejected Features + +'Rejected' sounds harsh, but basically these are features I've decided I'm not gonna +implement in Oragono (at least, not until someone convinces me they're worth doing). + +### Force/Auto-Join Channels on Connect + +When a user connects, some IRC servers let you force-join them to a given channel. For +instance, this could be a channel like `#coolnet` for a network named CoolNet, a lobby +channel, or something similar. + +My main objection to having this feature is just that I don't like it that much. It doesn't +seem nice to forcibly join clients to a channel, and I know I'm always annoyed when networks +do it to me. + +To network operators that want to do this, I'd suggest instead mentioning the channel(s) in +your MOTD so that your users know the channels exist! If they want to join in, they can do +it from there :) diff --git a/docs/MANUAL.md b/docs/MANUAL.md index b38e905a..e7bb5c1c 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -1,101 +1,66 @@ - __ __ ______ ___ ______ ___ - __/ // /_/ ____/ __ \/ ____/ __ \ - /_ // __/ __/ / /_/ / / __/ / / / - /_ // __/ /___/ _, _/ /_/ / /_/ / - /_//_/ /_____/_/ |_|\____/\____/ - Ergo IRCd Manual - https://ergo.chat/ + ▄▄▄ ▄▄▄· ▄▄ • ▐ ▄ + ▪ ▀▄ █·▐█ ▀█ ▐█ ▀ ▪▪ •█▌▐█▪ + ▄█▀▄ ▐▀▀▄ ▄█▀▀█ ▄█ ▀█▄ ▄█▀▄▪▐█▐▐▌ ▄█▀▄ + ▐█▌.▐▌▐█•█▌▐█ ▪▐▌▐█▄▪▐█▐█▌ ▐▌██▐█▌▐█▌.▐▌ + ▀█▄▀▪.▀ ▀ ▀ ▀ ·▀▀▀▀ ▀█▄▀ ▀▀ █▪ ▀█▄▀▪ + Oragono IRCd Manual 2018-04-01 + https://oragono.io/ -_Copyright © Daniel Oaks , Shivaram Lingamneni _ +_Copyright © 2018 Daniel Oaks _ -------------------------------------------------------------------------------------------- - Table of Contents +# Table of Contents + +- Introduction + - Project Basics +- Installing + - Windows + - macOS / Linux / Raspberry Pi +- Features + - User Accounts + - Channel Registration +- Modes + - User Modes + - Channel Modes + - Channel Prefixes +- Commands +- Acknowledgements -- [Introduction](#introduction) - - [Project Basics](#project-basics) - - [Scalability](#scalability) -- [Installing](#installing) - - [Windows](#windows) - - [macOS / Linux / Raspberry Pi](#macos--linux--raspberry-pi) - - [Docker](#docker) - - [Becoming an operator](#becoming-an-operator) - - [Rehashing](#rehashing) - - [Environment variables](#environment-variables) - - [Productionizing with systemd](#productionizing-with-systemd) - - [Using valid TLS certificates](#using-valid-tls-certificates) - - [Upgrading to a new version of Ergo](#upgrading-to-a-new-version-of-ergo) -- [Features](#features) - - [User Accounts](#user-accounts) - - [Account/Nick Modes](#accountnick-modes) - - [Nick equals account](#nick-equals-account) - - [Lenient nick reservation](#lenient-nick-reservation) - - [No nick reservation](#no-nick-reservation) - - [SASL-only mode](#sasl-only-mode) - - [Email verification](#email-verification) - - [Channel Registration](#channel-registration) - - [Language](#language) - - [Multiclient ("Bouncer")](#multiclient-bouncer) - - [History](#history) - - [Persistent history with MySQL](#persistent-history-with-mysql) - - [IP cloaking](#ip-cloaking) - - [Moderation](#moderation) -- [Frequently Asked Questions](#frequently-asked-questions) -- [IRC over TLS](#irc-over-tls) - - [Redirect from plaintext to TLS](#how-can-i-redirect-users-from-plaintext-to-tls) - - [Reverse proxies](#reverse-proxies) - - [Client certificates](#client-certificates) - - [SNI](#sni) -- [Modes](#modes) - - [User Modes](#user-modes) - - [Channel Modes](#channel-modes) - - [Channel Prefixes](#channel-prefixes) -- [Commands](#commands) -- [Working with other software](#working-with-other-software) - - [Kiwi IRC and Gamja](#kiwi-irc-and-gamja) - - [Migrating from Anope or Atheme](#migrating-from-anope-or-atheme) - - [HOPM](#hopm) - - [Tor](#tor) - - [I2P](#i2p) - - [ZNC](#znc) - - [External authentication systems](#external-authentication-systems) - - [DNSBLs and other IP checking systems](#dnsbls-and-other-ip-checking-systems) -- [Acknowledgements](#acknowledgements) -------------------------------------------------------------------------------------------- # Introduction -This document goes over the Ergo IRC server, how to get it running and how to use it once it is up and running! +This document goes over the Oragono IRC server, how to get it running and how to use it once it is up and running! -If you have any suggestions, issues or questions, feel free to submit an issue on our [GitHub repo](https://github.com/ergochat/ergo) or ask in our channel [`#ergo` on irc.ergo.chat](ircs://irc.ergo.chat:6697/#ergo) or [`#ergo` on irc.libera.chat](ircs://irc.libera.chat:6697/#ergo). +If you have any suggestions, issues or questions, feel free to submit an issue on our [GitHub repo](https://github.com/oragono/oragono/) or ask in our channel [`#oragono` on Freenode](ircs://irc.freenode.net:6697/#oragono). ## Project Basics -Ergo is an ircd written "from scratch" in the [Go](https://en.wikipedia.org/wiki/Go_%28programming_language%29) language, i.e., it [shares no code](https://github.com/grawity/irc-docs/blob/master/family-tree.txt) with the original ircd implementation or any other major ircd. It began as [ergonomadic](https://github.com/jlatt/ergonomadic), which was developed by Jeremy Latt between 2012 and 2014. In 2016, Daniel Oaks forked the project under the name Oragono, in order to prototype [IRCv3](https://ircv3.net/) features and for use as a reference implementation of the [Modern IRC specification](https://modern.ircdocs.horse). Oragono 1.0.0 was released in February 2019; the project switched to its current name of Ergo in June 2021. +Let's go over some basics, for those new to Oragono. My name's Daniel, and I started the project (it was forked off a server called [Ergonomadic](https://github.com/edmund-huber/ergonomadic) that'd been around for a few years). In addition to Oragono, I also do a lot of IRC specification work with the [various](https://modern.ircdocs.horse) [ircdocs](https://defs.ircdocs.horse) [projects](https://ircdocs.horse/specs/) and with the [IRCv3 WG](https://ircv3.net/). -Ergo's core design goals are: +Oragono's a new IRC server, written from scratch. My main goals when starting the project was to write a server that: -* Being simple to set up and use -* Combining the features of an ircd, a services framework, and a bouncer (integrated account management, history storage, and bouncer functionality) -* Bleeding-edge [IRCv3 support](http://ircv3.net/software/servers.html), suitable for use as an IRCv3 reference implementation -* High customizability via a rehashable (i.e., reloadable at runtime) YAML config +- Is fully-functional (most of my attempts in the past which had been 'toy' quality). +- I could easily prototype new [IRCv3](https://ircv3.net/) proposals and features in. +- I could consider a reference implementation for the [Modern spec](https://modern.ircdocs.horse). -In addition to its unique features (integrated services and bouncer, comprehensive internationalization), Ergo also strives for feature parity with other major servers. Ergo is a mature project with multiple communities using it as a day-to-day chat server --- we encourage you to consider it for your organization or community! +All in all, these have gone pretty well. The server has relatively extensive command coverage, it prototypes a whole lot of the IRCv3 proposals and accepted/draft specs, and I pretty regularly update it to match new behaviour written into the Modern spec. -## Scalability +Some of the features that sets Oragono apart from other servers are: -We believe Ergo should scale comfortably to 10,000 clients and 2,000 clients per channel, making it suitable for small to medium-sized teams and communities. Ergo does not currently support server-to-server linking (federation), meaning that all clients must connect to the same instance. However, since Ergo is implemented in Go, it is reasonably effective at distributing work across multiple cores on a single server; in other words, it should "scale up" rather than "scaling out". (Horizontal scalability is [planned](https://github.com/ergochat/ergo/issues/1532) but is not scheduled for development in the near term.) - -Even though it runs as a single instance, Ergo can be deployed for high availability (i.e., with no single point of failure) using Kubernetes. This technique uses a k8s [LoadBalancer](https://kubernetes.io/docs/tasks/access-application-cluster/create-external-load-balancer/) to receive external traffic and a [Volume](https://kubernetes.io/docs/concepts/storage/volumes/) to store the embedded database file. See [Hashbang's implementation](https://github.com/hashbang/gitops/tree/master/ircd) for a "worked example". - -If you're interested in deploying Ergo at scale or for high availability, or want performance tuning advice, come find us on [`#ergo` on Libera](ircs://irc.libera.chat:6697/#ergo), we're very interested in what our software can do! +- Extensive IRCv3 support (more than any other server, currently). +- Extensive logging and oper privilege levels. +- Integrated user account and channel registration system (no services required!). +- Native Unicode support (including casemapping for that Unicode). +- Support for [multiple languages](https://crowdin.com/project/oragono). -------------------------------------------------------------------------------------------- @@ -103,138 +68,39 @@ If you're interested in deploying Ergo at scale or for high availability, or wan # Installing -In this section, we'll explain how to install and use the Ergo IRC server. +In this section, we'll explain how to install and use the Oragono IRC server. ## Windows -To get started with Ergo on Windows: +To get started with Oragono on Windows: -1. Make sure you have the [latest release](https://github.com/ergochat/ergo/releases/latest) downloaded. -1. Extract the zip file to a folder. -1. Copy and rename `default.yaml` to `ircd.yaml`. -1. Open up `ircd.yaml` using any text editor, and then save it once you're happy. -1. Open up a `cmd.exe` window, then `cd` to where you have Ergo extracted. -1. Run `ergo mkcerts` if you want to generate new self-signed SSL/TLS certificates (note that you can't enable STS if you use self-signed certs). +1. Make sure you have the [latest release](https://github.com/oragono/oragono/releases/latest) downloaded. +2. Extract the zip file to a folder. +3. Copy and rename `oragono.yaml` to `ircd.yaml`. +4. Open up `ircd.yaml` using any text editor, and then save it once you're happy. +5. Open up a `cmd.exe` window, then `cd` to where you have Oragono extracted. +6. Run `oragono.exe initdb` (this creates the database). +7. Run `oragono.exe mkcerts` if you want to generate new self-signed SSL/TLS certificates (note that you can't enable STS if you use self-signed certs). -To start the server, type `ergo run` and hit enter, and the server should start! +To start the server, type `oragono.exe run` and hit enter, and the server should start! ## macOS / Linux / Raspberry Pi -To get started with Ergo on macOS, Linux, or on a Raspberry Pi: +To get started with Oragono on macOS, Linux, or on a Raspberry Pi: -1. Make sure you have the [latest release](https://github.com/ergochat/ergo/releases/latest) for your OS/distro downloaded. -1. Extract the tar.gz file to a folder. -1. Copy and rename `default.yaml` to `ircd.yaml`. -1. Open up `ircd.yaml` using any text editor, and then save it once you're happy. -1. Open up a Terminal window, then `cd` to where you have Ergo extracted. -1. Run `./ergo mkcerts` if you want to generate new self-signed SSL/TLS certificates (note that you can't enable STS if you use self-signed certs). +1. Make sure you have the [latest release](https://github.com/oragono/oragono/releases/latest) for your OS/distro downloaded. +2. Extract the tar.gz file to a folder. +3. Copy and rename `oragono.yaml` to `ircd.yaml`. +4. Open up `ircd.yaml` using any text editor, and then save it once you're happy. +5. Open up a Terminal window, then `cd` to where you have Oragono extracted. +6. Run `./oragono initdb` (this creates the database). +7. Run `./oragono mkcerts` if you want to generate new self-signed SSL/TLS certificates (note that you can't enable STS if you use self-signed certs). -To start the server, type `./ergo run` and hit enter, and the server should be ready to use! +To start the server, type `./oragono run` and hit enter, and the server should be ready to use! - -## Docker - -1. Pull the latest version of Ergo: `docker pull ghcr.io/ergochat/ergo:stable` -1. Create a volume for persistent data: `docker volume create ergo-data` -1. Run the container, exposing the default ports: `docker run -d --name ergo -v ergo-data:/ircd -p 6667:6667 -p 6697:6697 ghcr.io/ergochat/ergo:stable` - -For further information and a sample docker-compose file see the separate [Docker documentation](https://github.com/ergochat/ergo/blob/master/distrib/docker/README.md). - - -## Building from source - -You'll need an [up-to-date distribution of the Go language for your OS and architecture](https://golang.org/dl/). Once you have that, just clone the repository and run `make build`. If everything goes well, you should now have an executable named `ergo` in the base directory of the project. - - -## Becoming an operator - -Many administrative actions on an IRC server are performed "in-band" as IRC commands sent from a client. The client in question must be an IRC operator ("oper", "ircop"). The easiest way to become an operator on your new Ergo instance is first to pick a strong, secure password, then "hash" it using the `ergo genpasswd` command (run `ergo genpasswd` from the command line, then enter your password twice), then copy the resulting hash into the `opers` section of your `ircd.yaml` file. Then you can become an operator by issuing the IRC command: `/oper admin mysecretpassword`. - -The operator defined in the default configuration file is named `admin` and has full administrative privileges on the server; see the `oper-classes` and `opers` blocks for information on how to define additional operators, or less privileged operators. - - -## Rehashing - -The primary way of configuring Ergo is by modifying the configuration file. Most changes to the configuration file can be applied at runtime by "rehashing", i.e., reloading the configuration file without restarting the server process. This has the advantage of not disconnecting users. There are two ways to rehash Ergo: - -1. If you are an operator with the `rehash` capability, you can issue the `/REHASH` command (you may have to `/quote rehash`, depending on your client) -1. You can send the `SIGHUP` signal to Ergo, e.g., via `killall -HUP ergo` - -Rehashing also reloads TLS certificates and the MOTD. Some configuration settings cannot be altered by rehash. You can monitor either the response to the `/REHASH` command, or the server logs, to see if your rehash was successful. - - -## Environment variables - -Ergo can also be configured using environment variables, using the following technique: - -1. Find the "path" of the config variable you want to override in the YAML file, e.g., `server.websockets.allowed-origins` -1. Convert each path component from "kebab case" to "screaming snake case", e.g., `SERVER`, `WEBSOCKETS`, and `ALLOWED_ORIGINS`. -1. Prepend `ERGO` to the components, then join them all together using `__` as the separator, e.g., `ERGO__SERVER__WEBSOCKETS__ALLOWED_ORIGINS`. -1. Set the environment variable of this name to a JSON (or YAML) value that will be deserialized into this config field, e.g., `export ERGO__SERVER__WEBSOCKETS__ALLOWED_ORIGINS='["https://irc.example.com", "https://chat.example.com"]'` - -However, settings that were overridden using this technique cannot be rehashed --- changing them will require restarting the server. - - -## Productionizing with systemd - -The recommended way to operate ergo as a service on Linux is via systemd. This provides a standard interface for starting, stopping, and rehashing (via `systemctl reload`) the service. It also captures ergo's loglines (sent to stderr in the default configuration) and writes them to the system journal. - -The only major distribution that currently packages Ergo is Arch Linux; the aforementioned AUR package includes a systemd unit file. However, it should be fairly straightforward to set up a productionized Ergo on any Linux distribution. Here's a quickstart guide for Debian/Ubuntu: - -1. Create a dedicated, unprivileged role user who will own the ergo process and all its associated files: `adduser --system --group --home=/home/ergo ergo`. This user now has a home directory at `/home/ergo`. To prevent other users from viewing Ergo's configuration file, database, and certificates, restrict the permissions on the home directory: `chmod 0700 /home/ergo`. -1. Copy the executable binary `ergo`, the config file `ircd.yaml`, the database `ircd.db`, and the self-signed TLS certificate (`fullchain.pem` and `privkey.pem`) to `/home/ergo`. (If you don't have an `ircd.db`, it will be auto-created as `/home/ergo/ircd.db` on first launch.) Ensure that they are all owned by the new ergo role user: `sudo chown -R ergo:ergo /home/ergo`. Ensure that the configuration file logs to stderr. -1. Install our example [ergo.service](https://github.com/ergochat/ergo/blob/stable/distrib/systemd/ergo.service) file to `/etc/systemd/system/ergo.service`. -1. Enable and start the new service with the following commands: - 1. `systemctl daemon-reload` - 1. `systemctl enable ergo.service` - 1. `systemctl start ergo.service` - 1. Confirm that the service started correctly with `systemctl status ergo.service` - - -On a non-systemd system, ergo can be configured to log to a file and used [logrotate(8)](https://linux.die.net/man/8/logrotate), since it will reopen its log files (as well as rehashing the config file) upon receiving a SIGHUP. To rehash manually outside the context of log rotation, you can use `killall -HUP ergo` or `pkill -HUP ergo`. See [distrib/init](https://github.com/ergochat/ergo/tree/master/distrib/init) for init scripts and related tools for non-systemd systems. - - -## Using valid TLS certificates - -The other major hurdle for productionizing (but one well worth the effort) is obtaining valid TLS certificates for your domain, if you haven't already done so: - -1. The simplest way to get valid TLS certificates is from [Let's Encrypt](https://letsencrypt.org/) with [Certbot](https://certbot.eff.org/). The correct procedure will depend on whether you are already running a web server on port 80. If you are, follow the guides on the Certbot website; if you aren't, you can use `certbot certonly --standalone --preferred-challenges http -d example.com` (replace `example.com` with your domain). -1. At this point, you should have certificates available at `/etc/letsencrypt/live/example.com` (replacing `example.com` with your domain). You should serve `fullchain.pem` as the certificate and `privkey.pem` as its private key. However, these files are owned by root and the private key is not readable by the ergo role user, so you won't be able to use them directly in their current locations. You can write a renewal deploy hook for certbot to make copies of these certificates accessible to the ergo role user. For example, install the following script as `/etc/letsencrypt/renewal-hooks/deploy/install-ergo-certificates` (which will update the certificate and key after a successful renewal), again replacing `example.com` with your domain name, and chmod it 0755: - -````bash -#!/bin/bash - -set -eu - -umask 077 -cp /etc/letsencrypt/live/example.com/fullchain.pem /home/ergo/ -cp /etc/letsencrypt/live/example.com/privkey.pem /home/ergo/ -chown ergo:ergo /home/ergo/*.pem -# rehash ergo, which will reload the certificates: -systemctl reload ergo.service -```` - -Executing this script manually will install the certificates for the first time and perform a rehash, enabling them. - -If you are using Certbot 0.29.0 or higher, you can also change the ownership of the files under `/etc/letsencrypt` so that the ergo user can read them, as described in the [UnrealIRCd documentation](https://www.unrealircd.org/docs/Setting_up_certbot_for_use_with_UnrealIRCd#Tweaking_permissions_on_the_key_file). - - -## Upgrading to a new version of Ergo - -As long as you are using official releases or release candidates of Ergo, any backwards-incompatible changes should be described in the changelog. - -In general, the config file format should be fully backwards and forwards compatible. Unless otherwise noted, no config file changes should be necessary when upgrading Ergo. However, the "config changes" section of the changelog will typically describe new sections that can be added to your config to enable new functionality, as well as changes in the recommended values of certain fields. - -The database is versioned; upgrades that involve incompatible changes to the database require updating the database. If you have `datastore.autoupgrade` enabled in your config, the database will be backed up and upgraded when you restart your server when required. Otherwise, you can apply upgrades manually: - -1. Stop your server -1. Make a backup of your database file -1. Run `ergo upgradedb` (from the same working directory and with the same arguments that you would use when running `ergo run`) -1. Start the server again - -If you want to run our master branch as opposed to our releases, come find us in our channel and we can guide you around any potential pitfalls. +If you're using Arch Linux, you can also install the [`oragono` package](https://aur.archlinux.org/packages/oragono/) from the AUR. This lets you bypass the above process and bundles a systemd service file for easily starting the server. -------------------------------------------------------------------------------------------- @@ -242,109 +108,33 @@ If you want to run our master branch as opposed to our releases, come find us in # Features -In this section, we'll explain and go through using various features of the Ergo IRC server. +In this section, we'll explain and go through using various features of the Oragono IRC server. ## User Accounts -In most IRC servers you can use `NickServ` to register an account. You can do the same thing with Ergo, by default, with no other software needed! +In most IRC servers you can use `NickServ` to register an account. You can do the same thing with Oragono, by default, with no other software needed! -To register an account, use: +However, there are some differences between how Oragono handles accounts and how most other servers do. Some of these differences are that: - /NS REGISTER +- In Oragono, account names are completely unrelated to nicknames. +- In Oragono, there's no nickname ownership unless you configure a config section. -or +With nickname ownership and account names, on most IRC servers your nickname and your account name are one and the same thing. This isn't the case with Oragono. When using Oragono, your nickname and account name are totally unrelated. However, you can enable nickname ownership with the `nick-reservation` section in the config. - /msg nickserv register +These are the two ways you can register an account: -This is the way to go if you want to use a regular password. `` is your password, your current nickname will become your username. Your password cannot contain spaces, but make sure to use a strong one anyway. + /QUOTE ACC REGISTER * passphrase : + /NS REGISTER -If you want to use a TLS client certificate instead of a password to authenticate (`SASL EXTERNAL`), then you can use the command below to do so. (If you're not sure what this is, don't worry – just use the above password method to register an account.) +This is the way to go if you want to use a regular password. `` and `` are your username and password, respectively (make sure the leave that one `:` before your actual password!). - /NS REGISTER * + /QUOTE ACC REGISTER * certfp * + /NS REGISTER -Once you've registered, you'll need to set up SASL to login. One of the more complete SASL instruction pages is libera.chat's page [here](https://libera.chat/guides/sasl). Open up that page, find your IRC client and then setup SASL with your chosen username and password! +If you want to use a TLS client certificate to authenticate (`SASL CERTFP`), then you can use the above method to do so. If you're not sure what this is, don't worry – just use the above password method to register an account. -If your client doesn't support SASL, you can typically use the "server password" (`PASS`) field in your client to log into your account automatically when connecting. Set the server password to `accountname:accountpassword`, where `accountname` is your account name and `accountpassword` is your account password. - -## Account/Nick Modes - -Ergo supports several different modes of operation with respect to accounts and nicknames. - -### Nick equals account - -In this mode (the default), registering an account gives you privileges over the use of the account name as a nickname. The server will then enforce several invariants with regard to your nickname: - -1. Only you can use your nickname, i.e., clients cannot use your nickname unless they are logged into your account -1. You must use your nickname, i.e., if you are logged into your account, then the server will require you to use your account name as your nickname -1. If you unregister your account, your nickname will be permanently unreclaimable (thus preventing people from impersonating you) - -In this mode, it is very important that end users authenticate to their accounts as part of the initial IRC handshake (traditionally referred to as "connection registration"); otherwise they will not be able to use their registered nicknames. The preferred mechanism for this is [SASL](https://libera.chat/guides/sasl), which is supported by most modern clients. As a fallback, this can also be done via the `PASS` (server password) command; set the "server password" field of the client to `AzureDiamond:hunter2`, where `AzureDiamond` is the account name and `hunter2` is the account password. - -As an end user, if you want to change your nickname, you can register a new account and transfer any channel ownerships to it using `/msg ChanServ transfer`. - -To enable this mode as the server operator, set the following configs (note that they are already set in `default.yaml`): - -* `accounts.registration.enabled = true` -* `accounts.authentication-enabled = true` -* `accounts.nick-reservation.enabled = true` -* `accounts.nick-reservation.method = strict` -* `accounts.nick-reservation.allow-custom-enforcement = false` -* `accounts.nick-reservation.force-nick-equals-account = true` - -### Lenient nick reservation - -In this mode (implemented in the `traditional.yaml` config file example), nickname reservation is available, but end users must opt into it using `/msg NickServ set enforce strict`. Moreover, you need not use your nickname; even while logged in to your account, you can change nicknames to anything that is not reserved by another user. You can reserve some of your alternate nicknames using `/msg NickServ group`. - -To enable this mode as the server operator, set the following configs (they are set in `traditional.yaml`): - -* `accounts.registration.enabled = true` -* `accounts.authentication-enabled = true` -* `accounts.nick-reservation.enabled = true` -* `accounts.nick-reservation.method = optional` -* `accounts.nick-reservation.allow-custom-enforcement = true` -* `accounts.nick-reservation.force-nick-equals-account = false` - -### No nick reservation - -This makes Ergo's services act similar to Quakenet's Q bot. In this mode, users cannot own or reserve nicknames. In other words, there is no connection between account names and nicknames. Anyone can use any nickname (as long as it's not already in use by another running client). However, accounts are still useful: they can be used to register channels (see below), and some IRCv3-capable clients (with the `account-tag` or `extended-join` capabilities) may be able to take advantage of them. - -To enable this mode, set the following configs: - -* `accounts.registration.enabled = true` -* `accounts.authentication-enabled = true` -* `accounts.nick-reservation.enabled = false` - -### SASL-only mode - -This mode is comparable to Slack, Mattermost, or similar products intended as internal chat servers for an organization or team. In this mode, clients cannot connect to the server unless they log in with SASL as part of the initial handshake. This allows Ergo to be deployed facing the public Internet, with fine-grained control over who can log in. - -In this mode, clients must not be allowed to register their own accounts, so user-initiated account registration must be disabled. Accordingly, an operator must do the initial account creation, using the `SAREGISTER` command of NickServ. (For more details, `/msg NickServ help saregister`.) To bootstrap this process, you can make an initial connection from localhost, which is exempt (by default) from the requirement, or temporarily add your own IP to the exemption list. You can also use a more permissive configuration for bootstrapping, then switch to this one once you have your account. Another possibility is permanently exempting an internal network, e.g., `10.0.0.0/8`, that only trusted people can access. - -To enable this mode, use the configs from the "nick equals account" section (i.e., start from `default.yaml`) and make these modifications: - -* `accounts.registration.enabled = false` -* `accounts.require-sasl.enabled = true` - -## Email verification - -By default, account registrations complete immediately and do not require a verification step. However, like other service frameworks, Ergo's NickServ can be configured to require email verification of registrations. The main challenge here is to prevent your emails from being marked as spam, which you can do by configuring [SPF](https://en.wikipedia.org/wiki/Sender_Policy_Framework), [DKIM](https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail), and [DMARC](https://en.wikipedia.org/wiki/DMARC). For example, this configuration (when added to the `accounts.registration` section) enables email verification, with the emails being signed with a DKIM key and sent directly from Ergo: - -```yaml - email-verification: - enabled: true - sender: "admin@my.network" - require-tls: true - helo-domain: "my.network" # defaults to server name if unset - dkim: - domain: "my.network" - selector: "20200229" - key-file: "dkim.pem" -``` - -You must create the corresponding TXT record `20200229._domainkey.my.network` to hold your public key. - -You can also use an external SMTP server ("MTA", "relay", or "smarthost") to send the email, in which case DKIM signing can be deferred to that server; see the `mta` section of the example config for details. +Once you've registered, you'll need to setup SASL to login (or use NickServ IDENTIFY). One of the more complete SASL instruction pages is Freenode's page [here](https://freenode.net/kb/answer/sasl). Open up that page, find your IRC client and then setup SASL with your chosen username and password! ## Channel Registration @@ -355,319 +145,8 @@ To register a channel, make sure you're joined to it and logged into your accoun /CS REGISTER #channelname -For example, `/CS REGISTER #channel` will register the channel `#channel` to my account. If you have a registered channel, you can use `/CS OP #channel` to regain ops in it. Right now, the options for a registered channel are pretty sparse, but we'll add more as we go along. +For example, `/CS REGISTER #channel` will register the channel `#test` to my account. If you have a registered channel, you can use `/CS OP #channel` to regain ops in it. Right now, the options for a registered channel are pretty sparse, but we'll add more as we go along. -If your friends have registered accounts, you can automatically grant them operator permissions when they join the channel. For more details, see `/CS HELP AMODE`. - - -## Language - -Ergo supports multiple languages! Specifically, once you connect you're able to get server messages in other languages (messages from other users will still be in their original languages, though). - -To see which languages are supported, run this command: - - /QUOTE CAP LS 302 - -In the resulting text, you should see a token that looks something like this: - - draft/languages=11,en,~ro,~tr-TR,~el,~fr-FR,~pl,~pt-BR,~zh-CN,~en-AU,~es,~no - -That's the list of languages we support. For the token above, the supported languages are: - -- `en`: English -- `en-AU`: Australian English -- `el`: Greek -- `es`: Spanish -- `fr-FR`: French -- `no`: Norwegian -- `pl`: Polish -- `pt-BR`: Brazilian Portugese -- `ro`: Romanian -- `tr-TR`: Turkish -- `zh-CN`: Chinese - -To change to a specific language, you can use the `LANGUAGE` command like this: - - /LANGUAGE ro zh-CN - -The above will change the server language to Romanian, with a fallback to Chinese. English will always be the final fallback, if there's a line that is not translated. Substitute any of the other language codes in to select other languages, and run `/LANGUAGE en` to get back to standard English. - -Our language and translation functionality is very early, so feel free to let us know if there are any troubles with it! If you know another language and you'd like to contribute, we've got a CrowdIn project here: [https://crowdin.com/project/ergochat](https://crowdin.com/project/ergochat) - - -## Multiclient ("Bouncer") - -Traditionally, every connection to an IRC server is separate must use a different nickname. [Bouncers](https://en.wikipedia.org/wiki/BNC_%28software%29#IRC) are used to work around this, by letting multiple clients connect to a single nickname. With Ergo, if the server is configured to allow it, multiple clients can share a single nickname without needing a bouncer. To use this feature, both connections must authenticate with SASL to the same user account and then use the same nickname during connection registration (while connecting to the server) – once you've logged-in, you can't share another nickname. - -To enable this functionality, set `accounts.multiclient.enabled` to `true`. Setting `accounts.multiclient.allowed-by-default` to `true` will allow this for everyone. If `allowed-by-default` is `false` (but `enabled` is still `true`), users can opt in to shared connections using `/msg NickServ SET multiclient true`. - -You can see a list of your active sessions and their idle times with `/msg NickServ sessions` (network operators can use `/msg NickServ sessions nickname` to see another user's sessions). - -Ergo now supports "always-on clients" that remain present on the server (holding their nickname, subscribed to channels, able to receive DMs, etc.) even when no actual clients are connected. To enable this as a server operator, set `accounts.multiclient.always-on` to either `opt-in`, `opt-out`, or `mandatory`. To enable or disable it as a client (if the server setting is `opt-in` or `opt-out` respectively), use `/msg NickServ set always-on true` (or `false`). - - -## History - -Ergo supports two methods of storing history, an in-memory buffer with a configurable maximum number of messages, and persistent history stored in MySQL (with no fixed limits on message capacity). To enable in-memory history, configure `history.enabled` and associated settings in the `history` section. To enable persistent history, enter your MySQL server information in `datastore.mysql` and then enable persistent history storage in `history.persistent`. - -Unfortunately, client support for history playback is still patchy. In descending order of support: - -1. The [IRCv3 chathistory specification](https://ircv3.net/specs/extensions/chathistory) offers the most fine-grained control over history replay. It is supported by [Gamja](https://git.sr.ht/~emersion/gamja), [Goguma](https://sr.ht/~emersion/goguma/), and [Kiwi IRC](https://github.com/kiwiirc/kiwiirc), and hopefully other clients soon. -1. We emulate the [ZNC playback module](https://wiki.znc.in/Playback) for clients that support it. You may need to enable support for it explicitly in your client (see the "ZNC" section below). -1. If you set your client to always-on (see the previous section for details), you can set a "device ID" for each device you use. Ergo will then remember the last time your device was present on the server, and each time you sign on, it will attempt to replay exactly those messages you missed. There are a few ways to set your device ID when connecting: - - You can add it to your SASL username with an `@`, e.g., if your SASL username is `alice` you can send `alice@phone` - - You can add it in a similar way to your IRC protocol username ("ident"), e.g., `alice@phone` - - If login to user accounts via the `PASS` command is enabled on the server, you can provide it there, e.g., by sending `alice@phone:hunter2` as the server password -1. If you only have one device, you can set your client to be always-on and furthermore `/msg NickServ set autoreplay-missed true`. This will replay missed messages, with the caveat that you must be connecting with at most one client at a time. -1. You can manually request history using `/history #channel 1h` (the parameter is either a message count or a time duration). (Depending on your client, you may need to use `/QUOTE history` instead.) -1. You can autoreplay a fixed number of lines (e.g., 25) each time you join a channel using `/msg NickServ set autoreplay-lines 25`. - - -## Persistent history with MySQL - -On most Linux and POSIX systems, it's straightforward to set up MySQL (or MariaDB) as a backend for persistent history. This increases the amount of history that can be stored, and ensures that message data will be retained on server restart (you can still use the configuration options to set a time limit for retention). Here's a quick start guide for Ubuntu based on [Digital Ocean's documentation](https://www.digitalocean.com/community/tutorials/how-to-install-mysql-on-ubuntu-20-04): - -1. Install the `mysql-server` package -1. Run `mysql_secure_installation` as root; this corrects some insecure package defaults -1. Connect to your new MySQL server as root with `mysql --user root` -1. In the MySQL prompt, create a new `ergo` user (substitute a strong password of your own for `hunter2`): `CREATE USER 'ergo'@'localhost' IDENTIFIED BY 'hunter2';` -1. Create the database that history will be stored in: `CREATE DATABASE ergo_history;` -1. Grant privileges on the database to the new user: `GRANT ALL PRIVILEGES ON ergo_history.* to 'ergo'@'localhost';` -1. Enable persistent history in your Ergo config file. At a minimum, you must set `history.persistent.enabled = true`. You may want to modify the other options under `history.persistent` and `history`. -1. Configure Ergo to talk to MySQL (again, substitute the strong password you chose previously for `hunter2`): - -```yaml - mysql: - enabled: true - socket-path: "/var/run/mysqld/mysqld.sock" - user: "ergo" - password: "hunter2" - history-database: "ergo_history" - timeout: 3s -``` - - -## IP cloaking - -Unlike many other chat and web platforms, IRC traditionally exposes the user's IP and hostname information to other users. This is in part because channel owners and operators (who have privileges over a single channel, but not over the server as a whole) need to be able to ban spammers and abusers from their channels, including via hostnames in cases where the abuser tries to evade the ban. - -IP cloaking is a way of balancing these concerns about abuse with concerns about user privacy. With cloaking, the user's IP address is deterministically "scrambled", typically via a cryptographic [MAC](https://en.wikipedia.org/wiki/Message_authentication_code), to form a "cloaked" hostname that replaces the usual reverse-DNS-based hostname. Users cannot reverse the scrambling to learn each other's IPs, but can ban a scrambled address the same way they would ban a regular hostname. - -Ergo supports cloaking, which is enabled by default (via the `server.ip-cloaking` section of the config). However, Ergo's cloaking behavior differs from other IRC software. Rather than scrambling each of the 4 bytes of the IPv4 address (or each 2-byte pair of the 8 such pairs of the IPv6 address) separately, the server administrator configures a CIDR length (essentially, a fixed number of most-significant-bits of the address). The CIDR (i.e., only the most significant portion of the address) is then scrambled atomically to produce the cloaked hostname. This errs on the side of user privacy, since knowing the cloaked hostname for one CIDR tells you nothing about the cloaked hostnames of other CIDRs --- the scheme reveals only whether two users are coming from the same CIDR. We suggest using 32-bit CIDRs for IPv4 (i.e., the whole address) and 64-bit CIDRs for IPv6, since these are the typical assignments made by ISPs to individual customers. - -Setting `server.ip-cloaking.num-bits` to 0 gives users cloaks that don't depend on their IP address information at all, which is an option for deployments where privacy is a more pressing concern than abuse. Holders of registered accounts can also use the vhost system (for details, `/msg HostServ HELP`.) - - -## Moderation - -Ergo shares some server operator moderation tools with other ircds. In particular: - -1. `/SAMODE` can be used to grant or remove channel privileges. For example, to create an operator in a channel that has no operators: `/SAMODE #channel +o nickname` -2. `/SAJOIN` lets operators join channels despite restrictions, or forcibly join another user to a channel. For example, `/SAJOIN #channel` or `/SAJOIN nickname #channel`. - -However, Ergo's multiclient and always-on features mean that abuse prevention (at the server operator level) requires different techniques than a traditional IRC network. Server operators have two principal tools for abuse prevention: - -1. `/UBAN`, which can disable user accounts and/or ban offending IPs and networks -2. `/DEFCON`, which can impose emergency restrictions on user activity in response to attacks - -See the `/HELP` (or `/HELPOP`) entries for these commands for more information, but here's a rough workflow for mitigating spam or other attacks: - -1. Given abusive traffic from a nickname, use `/UBAN INFO ` to find out information about their connection -2. If they are using an account, suspend the account with `/UBAN ADD `, which will disconnect them -3. If they are not using an account, or if they're spamming new registrations from an IP, you can add a temporary ban on their IP/network with `/UBAN ADD ` -4. Subscribe to the `a` snomask to monitor for abusive registration attempts (`/mode mynick +s u`) -5. When facing a flood of abusive registrations that cannot be stemmed with `/DLINE`, use `/DEFCON 4` to temporarily restrict registrations. (At `/DEFCON 2`, all new connections to the server will require SASL, but this will likely be disruptive to legitimate users as well.) - -These techniques require operator privileges: `UBAN` requires the `ban` operator capability, subscribing to snomasks requires `snomasks`, and `DEFCON` requires `defcon`. All three of these capabilities are included by default in the `server-admin` role. - -For channel operators, `/msg ChanServ HOWTOBAN #channel nickname` will provide similar information about the best way to ban a user from a channel. - - -------------------------------------------------------------------------------------------- - - -# Frequently Asked Questions - - -## How do I submit a suggestion? - -Awesome! We love getting new suggestions for features, ways to improve the server and the tooling around it, everything. - -There are two ways to make suggestions, either: - -- Submit an issue on our [bug tracker](https://github.com/ergochat/ergo/issues). -- Talk to us in the `#ergo` channel on irc.ergo.chat or irc.libera.chat. - - -## Why can't I connect? - -If your client or bot is failing to connect to Ergo, here are some things to check: - -1. If your server has a firewall, does it allow traffic to the relevant IRC port? Try `nc -v yourdomain.name 6697` from the client machine to test connectivity to the server's default TLS port. -1. Is your client trying to connect in plaintext to Ergo's TLS port, or vice versa? If your client is configured to use TLS, ensure that it is also configured to connect to Ergo's TLS port (6697 by default). If your client is configured to use plaintext, ensure that it is also configured to connect to the plaintext port (by default, 6667 and only via a loopback IP address such as `127.0.0.1`). -1. Look for error messages on the client side (you may need to enable your client's "raw log"). -1. If all else fails, try turning on debug logging on the server side. Find the `logging` section of your Ergo config and change the default `level: info` to `level: debug`. Then rehash or restart Ergo. - - -## Why can't I oper? - -If you try to oper unsuccessfully, Ergo will disconnect you from the network. If you're unable to oper, here are some things to double-check: - -1. Did you correctly generate the hashed password with `ergo genpasswd`? -1. Did you add the password hash to the correct config file, then save the file? -1. Did you rehash or restart Ergo after saving the file? - -The config file accepts hashed passwords, not plaintext passwords. You must run `ergo genpasswd`, type your actual password in, and then receive a hashed blob back (it will look like `$2a$04$GvCFlShLZQjId3dARzwOWu9Nvq6lndXINw2Sdm6mUcwxhtx1U/hIm`). Enter that into the relevant `opers` block in your config file, then save the file. - -Although it's theoretically possible to use an operator password that contains spaces, your client may not support it correctly, so it's advisable to choose a password without spaces. (The period character `.` is an acceptable alternative separator if your password is based on randomly chosen words.) - -After that, you must rehash or restart Ergo to apply the config change. If a rehash didn't accomplish the desired effects, you might want to try a restart instead. - - -## Why is Ergo ignoring my ident response / USER command? - -The default/recommended configuration of Ergo does not query remote ident servers, and furthermore ignores any user/ident sent with the `USER` command. All user/ident fields are set to a constant `~u`. There are a few reasons for this: - -1. Remote ident lookups slow down connection initiation and pose privacy and security concerns (since they transmit usernames over the Internet in plaintext). -2. Ident is commonly used to distinguish users connecting from the same trusted shell host or shared bouncer. This is less important with Ergo, which can act as a bouncer itself. -3. Ignoring user/ident simplifies bans; in general, a channel ban in Ergo should target either the nickname or the hostname. As a channel operator, `/msg ChanServ HOWTOBAN #channel nick` will recommend a way of banning any given user. -4. Elaborating on this rationale somewhat: of the various pieces of information we could try to convey in the user/ident field (traditional user/ident, account name, nickname, or host/IP information), any choice would involve either ambiguity (since, e.g. account names can be present or absent) or would be redundant with information we already expose in the nickname or hostname. Coercing the field to `~u` is deterministic, unambiguous, and compatible with typical client behavior (clients should assume that any tilde-prefixed value is untrusted data and can collide arbitrarily with other values from the same hostname). -5. Because of limitations of the IRC protocol, every character of the user/ident field counts against the maximum size of a message that can be sent. - -As an operator, you can modify this behavior if desired; see the `check-ident` and `coerce-ident` settings in the config file. - - -## Why can't I change nicknames? - -The default/recommended configuration of Ergo does not allow authenticated users to change their nicknames; an authenticated user must use their account name as their nickname. There are a few reasons for this: - -1. Assigning a consistent nickname prevents certain "split-brain" scenarios that break Ergo's "multiclient" functionality. In brief, if two clients are connecting to the same account/identity, but only one of them issues a `/NICK` command, and then one of them subsequently loses and regains its connection to the server, they "break apart": they will have separate identities and channel memberships on the network, and it's difficult to bring them back together again. -2. The use of a consistent nickname reduces the possibility of edge cases in history playback. -3. The use of a consistent nickname simplifies offline messaging (which is a first-class concept for always-on clients). -4. Ergo eliminates the cases in conventional IRC servers that necessitate nickname changes. In particular, you can always claim your nickname, even if the server is still waiting for an old client to time out, and you can connect arbitrarily many clients to the same nickname. - -As an operator, you can disable this behavior using the `force-nick-equals-account` setting, but this is discouraged because it has no effect on always-on clients; always-on clients must use their account names as their nicknames regardless of this setting. - - -## How do I make a private channel? - -We recommend that server administrators set the following recommended defaults: - -1. `nick-reservation-method: strict` -1. `force-nick-equals-account: true` - -These settings imply that any registered account name can be treated as synonymous with a nickname; anyone using the nickname is necessarily logged into the account, and anyone logged into the account is necessarily using the nickname. - -Under these circumstances, users can follow the following steps: - -1. Register a channel (`/msg ChanServ register #example`) -1. Set it to be invite-only (`/mode #example +i`) -1. Add the desired nick/account names to the invite exception list (`/mode #example +I alice`) -1. Anyone with persistent voice or higher prefix will also be able to join without an invite (`/msg ChanServ amode #example +v alice`) - -Similarly, for a public channel (one without `+i`), users can ban nick/account names with `/mode #example +b bob`. (To restrict the channel to users with valid accounts, set it to registered-only with `/mode #example +R`.) - -## What special privileges do AMODEs contain? - -Some persistent modes contain persistent privileges over temporary modes. These are cumulative, meaning that +o will get privileges of +h which again gets privileges of +v. - -* AMODE +v will be able to join when the channel is `invite-only` (without being on the +I list). -* AMODE +h will be able to join even if a ban matches them (without being on the +e list). - -## How do I send an announcement to all connected users? - -Ergo supports a simplified form of the "global notice" or "wallops" capabilities found in other ircds. With the `massmessage` operator capability, you can `/NOTICE $$* text of your announcement`, and it will be sent to all connected users. If you have human-readable hostnames enabled (in the default/recommended configuration they are not), you can also `/NOTICE $#wild*card.host.name`. - -## Why does Ergo say my connection is insecure when I'm connected using TLS? - -If the client you are connecting with uses the [WebIRC](https://ircv3.net/specs/extensions/webirc.html) command then it is responsible for saying whether the connection is "secure" or not, even if the connection to ergo is made over TLS. For example, a web-based client would mark connections as secure if you used HTTPS but not if you used plain HTTP. Older versions of the WebIRC specification didn't include the secure parameter at all; any connections from software using the older protocol will therefore be treated as insecure by Ergo. - -If you are using a reverse proxy (such as stunnel, nginx, Traefik, or Caddy) to terminate TLS, but the connection between the reverse proxy and Ergo is using a non-loopback IP (i.e. outside the `127.0.0.0/8` or `0::1/128` ranges), then Ergo will view the connection as being "insecure". If the network is in fact secure against passive monitoring and active manipulation (e.g. a trusted LAN, a VPN link, or a Docker internal IP), you can add it to `server.secure-nets`, which will cause the connection to be treated as "secure". - -------------------------------------------------------------------------------------------- - - -# IRC over TLS - -IRC has traditionally been available over both plaintext (on port 6667) and SSL/TLS (on port 6697). We recommend that you make your server available exclusively via TLS, since exposing plaintext access allows for unauthorized interception or modification of user data or passwords. The default config file no longer exposes a plaintext port, so if you haven't modified your `listeners` section, you're good to go. - -For a quickstart guide to obtaining valid TLS certificates from Let's Encrypt, see the "productionizing" section of the manual above. - -## How can I "redirect" users from plaintext to TLS? - -The [STS specification](https://ircv3.net/specs/extensions/sts) can be used to redirect clients from plaintext to TLS automatically. If you set `server.sts.enabled` to `true`, clients with specific support for STS that connect in plaintext will disconnect and reconnect over TLS. To use STS, you must be using certificates issued by a generally recognized certificate authority, such as Let's Encrypt. - -Many clients do not have this support. However, you can designate port 6667 as an "STS-only" listener: any client that connects to such a listener will receive both the machine-readable STS policy and a human-readable message instructing them to reconnect over TLS, and will then be disconnected by the server before they can send or receive any chat data. Here is an example of how to configure this behavior: - -```yaml - listeners: - ":6667": - sts-only: true - - # These are loopback-only plaintext listeners on port 6668: - "127.0.0.1:6668": # (loopback ipv4, localhost-only) - "[::1]:6668": # (loopback ipv6, localhost-only) - - ":6697": - tls: - cert: fullchain.pem - key: privkey.pem - - sts: - enabled: true - - # how long clients should be forced to use TLS for. - duration: 1mo2d5m -``` - -## Reverse proxies - -Ergo supports the use of reverse proxies (such as nginx, or a Kubernetes [LoadBalancer](https://kubernetes.io/docs/concepts/services-networking/service/#loadbalancer)) that sit between it and the client. In these deployments, the [PROXY protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt) is used to pass the end user's IP through to Ergo. These proxies can be used to terminate TLS externally to Ergo, e.g., if you need to support versions of the TLS protocol that are not implemented natively by Go, or if you want to consolidate your certificate management into a single nginx instance. - -### IRC Sockets - -The first step is to add the reverse proxy's IP to `proxy-allowed-from` and `ip-limits.exempted`. (Use `localhost` to exempt all loopback IPs and Unix domain sockets.) - -After that, there are two possibilities: - -* If you're using a proxy like nginx or stunnel that terminates TLS, then forwards a PROXY v1 (ASCII) header ahead of a plaintext connection, no further Ergo configuration is required. You need only configure your proxy to send the PROXY header. Here's an [example nginx config](https://github.com/ergochat/testnet.ergo.chat/blob/master/nginx_stream.conf). -* If you're using a cloud load balancer that either sends a PROXY v1 header ahead of unterminated TLS (like [DigitalOcean](https://www.digitalocean.com/docs/networking/load-balancers/#proxy-protocol)) or sends a PROXY v2 (binary) header (like the [AWS "Network Load Balancer"](https://docs.aws.amazon.com/elasticloadbalancing/latest/network/load-balancer-target-groups.html#proxy-protocol)), Ergo must be configured to expect a PROXY header ahead of the connection. Add `proxy: true` to the listener config block, e.g., - -```yaml - ":6697": - tls: - cert: fullchain.pem - key: privkey.pem - proxy: true -``` - -### Websockets through HTTP reverse proxies - -Ergo will honor the `X-Forwarded-For` headers on incoming websocket connections, if the peer IP address appears in `proxy-allowed-from`. For these connections, set `proxy: false`, or omit the `proxy` option. - - -## Client certificates - -Ergo supports authenticating to user accounts via TLS client certificates. The end user must enable the client certificate in their client and also enable SASL with the `EXTERNAL` method. To register an account using only a client certificate for authentication, connect with the client certificate and use `/NS REGISTER *` (or `/NS REGISTER * email@example.com` if email verification is enabled on the server). To add a client certificate to an existing account, obtain the SHA-256 fingerprint of the certificate (either by connecting with it and looking at your own `/WHOIS` response, in particular the `276 RPL_WHOISCERTFP` line, or using the openssl command `openssl x509 -noout -fingerprint -sha256 -in example_client_cert.pem`), then use the `/NS CERT` command). - -Client certificates are not supported over websockets due to a [Chrome bug](https://bugs.chromium.org/p/chromium/issues/detail?id=329884). - -## SNI - -Ergo supports [SNI](https://en.wikipedia.org/wiki/Server_Name_Indication); this is useful if you have multiple domain names for your server, with different certificates covering different domain names. Configure your TLS listener like this: - -```yaml - ":6697": - tls-certificates: - - - cert: cert1.pem - key: key1.pem - - - cert: cert2.pem - key: key2.pem -``` - -If multiple certificates are applicable, or the client does not send SNI, the server will offer the first applicable certificate in the list. -------------------------------------------------------------------------------------------- @@ -676,13 +155,17 @@ If multiple certificates are applicable, or the client does not send SNI, the se On IRC, you can set modes on users and on channels. Modes are basically extra information that changes how users and channels work. -In this section, we give an overview of the modes Ergo supports. +In this section, we give an overview of the modes Oragono supports. ## User Modes These are the modes which can be set on you when you're connected. +### +a - Away + +If this mode is set, you're marked as 'away'. To set and unset this mode, you use the `/AWAY` command. + ### +i - Invisible If this mode is set, you're marked as 'invisible'. This means that your channels won't be shown when users `/WHOIS` you (except for IRC operators, they can see all the channels you're in). @@ -707,37 +190,18 @@ To unset this mode and let anyone speak to you: /mode dan -R -### +s - Server Notice Masks ("snomasks") +### +s - Server Notice Masks -This is a special 'list mode'. If you're an IRC operator, this mode lets you see special server notices that get sent out. See `/helpop snomasks` (as an operator) for more information on this mode. +This is a special 'list mode'. If you're an IRC operator, this mode lets you see special server notices that get sent out. See the Server Notice Masks section for more information on this mode. ### +Z - TLS This mode is automatically set if you're connecting using SSL/TLS. There's no way to set this yourself, and it's automatically set or not set when you connect to the server. -### +B - Bot - -If this mode is set, you are marked as a 'Bot'. The bot can set this mode on itself. This adds additional information in response to WHOIS - - /WHOIS Bot - -will return an extra response of `RPL_WHOISBOT` with the numeric `335` which can be used to identify said Bot. - -### +T - No CTCPs - -If this mode is set, you will not recieve CTCP messages. - -To set this mode on yourself: - - /mode dan +T - -To unset this mode and recieve CTCP messages: - - /mode dan -T ## Channel Modes -These are the modes that can be set on channels when you're a channel operator! +These are the modes that can be set on channels when you're an oper! ### +b - Ban @@ -759,14 +223,6 @@ To view the bans that exist on the channel, you can do this instead: /MODE #test b -#### Extended Bans - -Users can be muted rather than banned by prefixing a ban mask with `m:`. This prevents matching users from speaking in the channel, without kicking them or preventing them from joining the channel. For example, to mute a user named **bob** in the channel #test: - - /MODE #test +b m:bob!*@* - -The mute can be removed with `-b` instead of `+b`. - ### +e - Ban-Exempt With this channel mode, you can change who's allowed to bypass bans. For example, let's say you set these modes on the channel: @@ -778,25 +234,15 @@ This means that **bob** will always be able to join, even if he's connecting fro For everything else, this mode acts like the `+b - Ban` mode. -### +f - Forward - -This channel mode takes another channel as its parameter. Users who are unable to join this channel are forwarded to the provided channel instead. - -You need be a channel operator in both channels in order to set this mode. - - /MODE #test +f #foo - -This means that users who attempt to join `#test`, but cannot due to another channel mode like `+i` or `+l`, will be forwarded to `#foo` instead. - ### +i - Invite-Only -If this channel mode is set on a channel, users will only be able to join if a channel operator has `/INVITE`'d them first. +If this channel mode is set on a channel, users will only be able to join if someone has `/INVITE`'d them first. To set a channel to invite-only: /MODE #test +i -To unset the mode and let anyone join (and invite): +To unset the mode and let anyone join: /MODE #test -i @@ -857,11 +303,9 @@ This mode is enabled by default, and means that only users who are joined to the If this mode is unset, users who aren't on your channel can send messages to it. This can be useful with, for example, GitHub or notification bots if you want them to send messages to your channel but don't want them to clutter your channel with by joining and leaving it. -### +R - Only Registered Users Can Join +### +R - Registered Only -If this mode is set, only users that have logged into an account will be able to join the channel. If this is set and a regular, un-logged-in user tries to join, they will be rejected. - -Unregistered users already joined to the channel will not be kicked automatically. They will still be able to speak, unless they are restricted from doing so by another channel mode like +M. If they leave, they will not be allowed to rejoin. +If this mode is set, only users that have logged into an account will be able to join and speak on the channel. If this is set and a regular, un-logged-in user tries to join, they will be rejected. To set this mode: @@ -871,21 +315,9 @@ To unset this mode: /MODE #test -R -### +M - Only Registered Users Can Speak - -If this mode is set, only users that have logged into an account will be able to speak on the channel. Unregistered users may still join the channel, unless they are restricted from doing so by another channel mode like +R. When an unregistered user tries to speak, they will be rejected. Users who have been voiced (+v) are excepted from this restriction. - -To set this mode: - - /MODE #test +M - -To unset this mode: - - /MODE #test -M - ### +s - Secret -If this mode is set, it means that your channel should be marked as 'secret'. Your channel won't show up in `/LIST` or `/WHOIS`, and non-members won't be able to see its members with `/NAMES` or `/WHO`. +If this mode is set, it means that your channel should be marked as 'secret'. Your channel won't show up in `/LIST` or `/WHOIS`. To set this mode: @@ -901,17 +333,6 @@ This mode is enabled by default, and means that only channel operators can chang If this mode is unset, anyone will be able to change the channel topic. -### +C - No CTCPs - -This mode means that [client-to-client protocol](https://tools.ietf.org/id/draft-oakley-irc-ctcp-02.html) messages other than `ACTION` (`/me`) cannot be sent to the channel. - -### +u - Auditorium - -This mode means that `JOIN`, `PART`, and `QUIT` lines for unprivileged users (i.e., users without a channel prefix like `+v` or `+o`) re not sent to other unprivileged users. In conjunction with `+m`, this is suitable for "public announcements" channels. - -### +U - Op-Moderated - -This mode means that messages from unprivileged users are only sent to channel operators (who can then decide whether to grant the user `+v`). ## Channel Prefixes @@ -921,31 +342,31 @@ Users on a channel can have different permission levels, which are represented b This prefix means that the given user is the founder of the channel. For example, if `~dan` is on a channel it means that **dan** founded the channel. The 'founder' prefix only appears on channels that are registered. -Founders have complete administrative control of the channel. They can take any action and modify anyone else's privileges. +Founders are able to do anything, and have complete administrative control of the channel. ### +a (&) - Admin This prefix means that the given user is an admin on the channel. For example, if `&tom` is on a channel, then **tom** is an admin on it. The 'admin' prefix only appears on channels that are registered. -Admins have the same moderation privileges as channel operators (see below), but they can't be kicked or demoted by other admins or channel operators. +Admins can do anything channel operators can do, and they also cannot get kicked by other chanops or admins. ### +o (@) - Channel Operator This prefix means that the given user is an operator on the channel (chanop, for short). For example, if `@ruby` is on a channel, then **ruby** is an op. -Chanops are the default type of channel moderators. They can change the channel modes, ban/kick users, and add or remove chanop (or lower) privileges from users. They can also invite users to invite-only channels. +Chanops are the regular type of channel moderators. They can set the topic, change modes, ban/kick users, etc. ### +h (%) - Halfop This prefix means that the given user is a halfop on the channel (half-operator). For example, if `%twi` is on a channel, then **twi** is a halfop. -Halfops have some moderation privileges: they can kick users (but not ban them), change the channel topic, and grant voice privileges (see below). +Halfops can do some of what channel operators can do, and can't do other things. They can help moderate a channel. ### +v (+) - Voice This prefix means that the given user is 'voiced' on the channel. For example, if `+faust` is on a channel, then **faust** is voiced on that channel. -Voiced users can speak when the channel has `+m` (moderated) mode enabled. They get no other special privileges or any moderation abilities. +Voiced users can speak when the channel has `+m - Moderated` mode enabled. They get no other special privs or any moderation abilities. -------------------------------------------------------------------------------------------- @@ -953,9 +374,9 @@ Voiced users can speak when the channel has `+m` (moderated) mode enabled. They # Commands -The best place to look for command help is on a running copy or Ergo itself! +The best place to look for command help is on a running copy or Oragono itself! -To see the integrated command help, simply spin up a copy of Ergo and then run this command: +To see the integrated command help, simply spin up a copy of Oragono and then run this command: /HELPOP @@ -969,254 +390,10 @@ We may add some additional notes here for specific commands down the line, but r -------------------------------------------------------------------------------------------- -# Working with other software - -Ergo should interoperate with most IRC-based software, including bots. If you have problems getting your preferred software to work with Ergo, feel free to report it to us. If the root cause is a bug in Ergo, we'll fix it. - -One exception is services frameworks like [Anope](https://github.com/anope/anope) or [Atheme](https://github.com/atheme/atheme); we have our own services implementations built directly into the server, and since we don't support federation, there's no place to plug in an alternative implementation. (If you are already using Anope or Atheme, we support migrating your database --- see below.) - -If you're looking for a bot that supports modern IRCv3 features, check out [bitbot](https://github.com/jesopo/bitbot/)! - -## Kiwi IRC and Gamja - -We recommend two web-based clients for use with Ergo: [Kiwi IRC](https://github.com/kiwiirc/kiwiirc/) and [Gamja](https://git.sr.ht/~emersion/gamja). Both clients are 100% static files (HTML and Javascript), running entirely in the end user's browser without the need for a separate server-side backend. They can connect directly to Ergo, using Ergo's support for native websockets. Both clients support Ergo's server-side history features; for a demonstration, see the Ergo testnet (click [here for Kiwi](https://testnet.ergo.chat/kiwi/) or [here for Gamja](https://testnet.ergo.chat/gamja)). - -For best interoperability with firewalls, you should run an externally facing web server on port 443 that can serve both the static files and the websocket path, then have it reverse-proxy the websocket path to Ergo. For example, configure the following listener in ircd.yaml: - -```yaml - "127.0.0.1:8067": - websocket: true -``` - -then the following location block in your nginx config (this proxies only `/webirc` on your server to Ergo's websocket listener): - -``` - location /webirc { - proxy_pass http://127.0.0.1:8067; - proxy_read_timeout 600s; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "Upgrade"; - proxy_set_header X-Forwarded-For $remote_addr; - proxy_set_header X-Forwarded-Proto $scheme; - } -``` - -then add the following `startupOptions` to Kiwi's `static/config.json` file (see the [Ergo testnet's config.json](https://testnet.ergo.chat/kiwi/static/config.json) for a fully functional example): - -``` - "startupOptions" : { - "websocket": "wss://domain.example.com/webirc", - "channel": "#chat", - "nick": "kiwi-n?" - }, -``` - -or with Gamja, create a new `config.json` (in the base directory of the Gamja install, alongside Gamja's `index.html`) file along these lines: - -``` -{ - "server": { - "url": "wss://domain.example.com/webirc", - "autojoin": "#chat", - "auth": "optional" - } -} -``` - -On Apache 2.4.47 or higher, websocket proxying can be configured with: - -``` -RequestHeader setifempty X-Forwarded-Proto https - -ProxyPreserveHost On -ProxyPass /webirc http://127.0.0.1:8067 upgrade=websocket -ProxyPassReverse /webirc http://127.0.0.1:8067 -``` - -On Caddy, websocket proxying can be configured with: - -``` -handle_path /webirc { - reverse_proxy 127.0.0.1:8067 -} -``` - -## Migrating from Anope or Atheme - -You can import user and channel registrations from an Anope or Atheme database into a new Ergo database (not all features are supported). Use the following steps: - -1. Obtain the relevant migration tool from the latest stable release: [anope2json.py](https://github.com/ergochat/ergo/blob/stable/distrib/anope/anope2json.py) or [atheme2json.py](https://github.com/ergochat/ergo/blob/stable/distrib/atheme/atheme2json.py) respectively. -1. Make a copy of your Anope or Atheme database file. (You may have to stop and start the services daemon to get it to commit all its changes.) -1. Convert the database to JSON, e.g., with `python3 ./anope2json.py anope.db output.json` -1. Copy your desired Ergo config to `./ircd.yaml` (make any desired edits) -1. Run `ergo importdb ./output.json` -1. Run `ergo mkcerts` if necessary to generate self-signed TLS certificates -1. Run `ergo run` to bring up your new Ergo instance - -## Hybrid Open Proxy Monitor (HOPM) - -[hopm](https://github.com/ircd-hybrid/hopm) can be used to monitor your server for connections from open proxies, then automatically ban them. To configure hopm to work with Ergo, add operator blocks like this to your Ergo config file, which grant hopm the necessary privileges: - -````yaml -# operator classes -oper-classes: - # hopm - "hopm": - # title shown in WHOIS - title: Proxy Monitor - - # capability names - capabilities: - - "kill" - - "ban" - - "nofakelag" - -# ircd operators -opers: - # operator named 'hopm' - hopm: - # which capabilities this oper has access to - class: "hopm" - - # custom hostname - vhost: "proxymonitor.hopm" - - # modes are the modes to auto-set upon opering-up - modes: +is c - - # password to login with /OPER command - # generated using "ergo genpasswd" - password: "$2a$04$JmsYDY6kX3/wwyK3ao0L7.aGJEto0Xm4DyL6/6zOmCpzeweIb8kdO" -```` - -Then configure hopm like this: - -```` -/* ergo */ -connregex = ".+-.+CONNECT.+-.+ Client Connected \\[([^ ]+)\\] \\[u:([^ ]+)\\] \\[h:([^ ]+)\\] \\[ip:([^ ]+)\\] .+"; - -/* A DLINE example for ergo */ -kline = "DLINE ANDKILL 2h %i :Open proxy found on your host."; -```` - -## Tor - -Ergo has code support for adding an .onion address to an IRC server, or operating an IRC server as a Tor onion service ("hidden service"). This is subtle, so you should be familiar with the [Tor Project](https://www.torproject.org/) and the concept of an [onion service](https://www.torproject.org/docs/tor-onion-service.html.en). - -There are two possible ways to serve Ergo over Tor. One is to add a .onion address to a server that also serves non-Tor clients, and whose IP address is public information. This is relatively straightforward. Add a separate listener, for example `127.0.0.2:6668`, to Ergo's `server.listeners`, then configure it with `tor: true`. Then configure Tor like this: - -```` -HiddenServiceDir /var/lib/tor/ergo_hidden_service -HiddenServicePort 6667 127.0.0.2:6668 - -# these are optional, but can be used to speed up the circuits in the case -# where the server's own IP is public information (clients will remain anonymous): -HiddenServiceNonAnonymousMode 1 -HiddenServiceSingleHopMode 1 -```` - -Tor provides end-to-end encryption for onion services, so there's no need to enable TLS in Ergo for the listener (`127.0.0.2:6668` in this example). Doing so is not recommended, given the difficulty in obtaining a TLS certificate valid for an .onion address. - -The second way is to run Ergo as a true hidden service, where the server's actual IP address is a secret. This requires hardening measures on the Ergo side: - -* Ergo should not accept any connections on its public interfaces. You should remove any listener that starts with the address of a public interface, or with `:`, which means "listen on all available interfaces". You should listen only on `127.0.0.1:6667` and a Unix domain socket such as `/hidden_service_sockets/ergo_tor_sock`. -* In this mode, it is especially important that all operator passwords are strong and all operators are trusted (operators have a larger attack surface to deanonymize the server). -* Onion services are at risk of being deanonymized if a client can trick the server into performing a non-Tor network request. Ergo should not perform any such requests (such as hostname resolution or ident lookups) in response to input received over a correctly configured Tor listener. However, Ergo has not been thoroughly audited against such deanonymization attacks --- therefore, Ergo should be deployed with additional sandboxing to protect against this: - * Ergo should run with no direct network connectivity, e.g., by running in its own Linux network namespace. systemd implements this with the [PrivateNetwork](https://www.freedesktop.org/software/systemd/man/systemd.exec.html) configuration option: add `PrivateNetwork=true` to Ergo's systemd unit file. - * Since the loopback adapters are local to a specific network namespace, and the Tor daemon will run in the root namespace, Tor will be unable to connect to Ergo over loopback TCP. Instead, Ergo must listen on a named Unix domain socket that the Tor daemon can connect to. However, distributions typically package Tor with its own hardening profiles, which restrict which sockets it can access. Below is a recipe for configuring this with the official Tor packages for Debian: - -1. Create a directory with `0777` permissions such as `/hidden_service_sockets`. -1. Configure Ergo to listen on `/hidden_service_sockets/ergo_tor_sock`, with `tor: true`. -1. Ensure that Ergo has no direct network access as described above, e.g., with `PrivateNetwork=true`. -1. Next, modify Tor's apparmor profile so that it can connect to this socket, by adding the line ` /hidden_service_sockets/** rw,` to `/etc/apparmor.d/local/system_tor`. -1. Finally, configure Tor with: - -```` -HiddenServiceDir /var/lib/tor/ergo_hidden_service -HiddenServicePort 6667 unix:/hidden_service_sockets/ergo_tor_sock -# DO NOT enable HiddenServiceNonAnonymousMode -```` - -Instructions on how client software should connect to an .onion address are outside the scope of this manual. However: - -1. [Hexchat](https://hexchat.github.io/) is known to support .onion addresses, once it has been configured to use a local Tor daemon as a SOCKS proxy (Settings -> Preferences -> Network Setup -> Proxy Server). -1. Pidgin should work with [torsocks](https://trac.torproject.org/projects/tor/wiki/doc/torsocks). - -## I2P - -I2P is an anonymizing overlay network similar to Tor. The recommended configuration for I2P is to treat it similarly to Tor: have the i2pd reverse proxy its connections to an Ergo listener configured with `tor: true`. See the [i2pd configuration guide](https://i2pd.readthedocs.io/en/latest/tutorials/irc/#running-anonymous-irc-server) for more details; note that the instructions to separate I2P traffic from other localhost traffic are unnecessary for a `tor: true` listener. - -I2P can additionally expose an opaque client identifier (the user's "b32 address"). Exposing this identifier via Ergo is not recommended, but if you wish to do so, you can use the following procedure: - -1. Enable WEBIRC support in the i2pd configuration by adding the `webircpassword` key to the [i2pd server block](https://i2pd.readthedocs.io/en/latest/tutorials/irc/#running-anonymous-irc-server) -1. Remove `tor: true` from the relevant Ergo listener config -1. Enable WEBIRC support in Ergo (starting from the default/recommended configuration, find the existing webirc block, delete the `certfp` configuration, change `password` to use the output of `ergo genpasswd` on the password you configured i2pd to send, and set `accept-hostname: true`) -1. To prevent Ergo from overwriting the hostname as passed from i2pd, set the following options: `server.ip-cloaking.enabled: false` and `server.lookup-hostnames: false`. (There is currently no support for applying cloaks to regular IP traffic but displaying the b32 address for I2P traffic). - -## ZNC - -ZNC 1.6.x (still pretty common in distros that package old versions of IRC software) has a [bug](https://github.com/znc/znc/issues/1212) where it fails to recognize certain SASL messages. Ergo supports a compatibility mode that works around this to let ZNC complete the SASL handshake: this can be enabled with `server.compatibility.send-unprefixed-sasl`. - -Ergo can emulate certain capabilities of the ZNC bouncer for the benefit of clients, in particular the third-party [playback](https://wiki.znc.in/Playback) module. This enables clients with specific support for ZNC to receive selective history playback automatically. To configure this in [Textual](https://www.codeux.com/textual/), go to "Server properties", select "Vendor specific", uncheck "Do not automatically join channels on connect", and check "Only play back messages you missed". Other clients with support are listed on ZNC's wiki page. - -## External authentication systems - -Ergo can be configured to call arbitrary scripts to authenticate users; see the `auth-script` section of the config. The API for these scripts is as follows: Ergo will invoke the script with a configurable set of arguments, then send it the authentication data as JSON on the first line (`\n`-terminated) of stdin. The input is a JSON dictionary with the following keys: - -* `accountName`: during passphrase-based authentication, this is a string, otherwise omitted -* `passphrase`: during passphrase-based authentication, this is a string, otherwise omitted -* `certfp`: during certfp-based authentication, this is a string, otherwise omitted -* `peerCerts`: during certfp-based authentication, this is a list of the PEM-encoded peer certificates (starting from the leaf), otherwise omitted -* `ip`: a string representation of the client's IP address - -The script must print a single line (`\n`-terminated) to its output and exit. This line must be a JSON dictionary with the following keys: - -* `success`, a boolean indicating whether the authentication was successful -* `accountName`, a string containing the normalized account name (in the case of passphrase-based authentication, it is permissible to return the empty string or omit the value) -* `error`, containing a human-readable description of the authentication error to be logged if applicable - -Here is a toy example of an authentication script in Python that checks that the account name and the password are equal (and rejects any attempts to authenticate via certfp): - -```python3 -#!/usr/bin/python3 - -import sys, json - -raw_input = sys.stdin.readline() -input = json.loads(raw_input) -account_name = input.get("accountName") -passphrase = input.get("passphrase") -success = bool(account_name) and bool(passphrase) and account_name == passphrase -print(json.dumps({"success": success})) -``` - -Note that after a failed script invocation, Ergo will proceed to check the credentials against its local database. - -## DNSBLs and other IP checking systems - -Similarly, Ergo can be configured to call arbitrary scripts to validate user IPs. These scripts can either reject the connection, or require that the user log in with SASL. In particular, we provide an [ergo-dnsbl](https://github.com/ergochat/ergo-dnsbl) plugin for querying DNSBLs. - -The API is similar to the auth-script API described above (one line of JSON in, one line of JSON out). The input is a JSON dictionary with the following keys: - -* `ip`: the IP in a standard human-readable notation, e.g., `1.1.1.1` or `2001::0db8` - -The output is a JSON dictionary with the following keys: - -* `result`: an integer indicating the result of the check (1 for "accepted", 2 for "banned", 3 for "SASL required") -* `banMessage`: a message to send to the user indicating why they are banned -* `error`, containing a human-readable description of the authentication error to be logged if applicable - --------------------------------------------------------------------------------------------- - - # Acknowledgements -Ergo's past and present maintainers and core contributors are: +Always, thanks to Jeremy Latt for creating Ergonomadic. Thanks for Edmund Huber for maintaining Ergonomadic and providing useful help while transitioning. -* Jeremy Latt (2012-2014) -* Edmund Huber (2014-2015) -* Daniel Oaks (2016-present) -* Shivaram Lingamneni (2017-present) +Thanks to Euan Kemp (euank) for the contributions and help with this, along with other projects, and to James Mills, Vegax and Sean Enck for various other help and contributions on the server. -In addition, Ergo has benefited tremendously from its community of contributors, users, and translators, not to mention collaborations with the wider IRCv3 community. There are too many people to name here --- but we try to credit people for individual contributions in the changelog, please reach out to us if we forgot you :-) +And a massive thanks to Shivaram Lingamneni (slingamn) for being an awesome co-maintainer of Oragono! You really convinced me to step up with this and take it forward in a big way, and I'm grateful for that. diff --git a/docs/MOTDFORMATTING.md b/docs/MOTDFORMATTING.md index 8d9e7ac8..8b9b86d4 100644 --- a/docs/MOTDFORMATTING.md +++ b/docs/MOTDFORMATTING.md @@ -54,7 +54,3 @@ Here are the color names we support, and which IRC colors they map to: 14 | grey 15 | light grey -------------------- - -In addition, some newer clients can make use of the colour codes 16-98, though they don't -have any names assigned. Take a look at this table to see which colours these numbers are: -https://modern.ircdocs.horse/formatting.html#colors-16-98 diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md deleted file mode 100644 index e424e88b..00000000 --- a/docs/USERGUIDE.md +++ /dev/null @@ -1,123 +0,0 @@ - __ __ ______ ___ ______ ___ - __/ // /_/ ____/ __ \/ ____/ __ \ - /_ // __/ __/ / /_/ / / __/ / / / - /_ // __/ /___/ _, _/ /_/ / /_/ / - /_//_/ /_____/_/ |_|\____/\____/ - - Ergo IRCd User Guide - https://ergo.chat/ - -_Copyright © Daniel Oaks , Shivaram Lingamneni _ - - --------------------------------------------------------------------------------------------- - - - Table of Contents - -- [Introduction](#introduction) -- [About IRC](#about-irc) -- [How Ergo is different](#how-ergo-is-different) -- [Account registration](#account-registration) -- [Channel registration](#channel-registration) -- [Always-on](#always-on) -- [Multiclient](#multiclient) -- [History](#history) - --------------------------------------------------------------------------------------------- - - -# Introduction - -Welcome to Ergo, a modern IRC server! - -This guide is for end users of Ergo (people using Ergo to chat). If you're installing your own Ergo instance, you should consult the official manual instead (a copy should be bundled with your release, in the `docs/` directory). - -This guide assumes that Ergo is in its default or recommended configuration; Ergo server administrators can change settings to make the server behave differently. If something isn't working as expected, ask your server administrator for help. - -# About IRC - -Before continuing, you should be familiar with basic features of the IRC platform. If you're comfortable with IRC, you can skip this section. - -[IRC](https://en.wikipedia.org/wiki/Internet_Relay_Chat) is a chat platform invented in 1988, which makes it older than the World Wide Web! At its most basic level, IRC is a chat system composed of chat rooms; these are called "channels" and their names begin with a `#` character (this is actually the origin of the [hashtag](https://www.cmu.edu/homepage/computing/2014/summer/originstory.shtml)!). As a user, you "join" the channels you're interested in, enabling you to participate in those discussions. - -Here are some guides covering the basics of IRC: - -* [Fedora Magazine: Beginner's Guide to IRC](https://fedoramagazine.org/beginners-guide-irc/) -* [IRCHelp's IRC Tutorial](https://www.irchelp.org/faq/irctutorial.html) (in particular, section 3, "Beyond the Basics") - -# How Ergo is different - -Ergo differs in many ways from conventional IRC servers. If you're *not* familiar with other IRC servers, you may want to skip this section. Here are some of the most salient differences: - -* Ergo integrates a "bouncer" into the server. In particular: - * Ergo stores message history for later retrieval. - * You can be "present" on the server (joined to channels, able to receive DMs) without having an active client connection to the server. - * Conversely, you can use multiple clients to view / control the same presence (nickname) on the server, as long as you authenticate with SASL when connecting. -* Ergo integrates "services" into the server. In particular: - * Nicknames are strictly reserved: once you've registered your nickname, you must log in in order to use it. Consequently, SASL is more important when using Ergo than in other systems. - * All properties of registered channels are protected without the need for `ChanServ` to be joined to the channel. -* Ergo "cloaks", i.e., cryptographically scrambles, end user IPs so that they are not displayed publicly. -* By default, the user/ident field is inoperative in Ergo: it is always set to `~u`, regardless of the `USER` command or the client's support for identd. This is because it is not in general a reliable or trustworthy way to distinguish users coming from the same IP. Ergo's integrated bouncer features should reduce the need for shared shell hosts and hosted bouncers (one of the main remaining use cases for identd). -* By default, Ergo is only accessible via TLS. - -# Account registration - -Although (as in other IRC systems) basic chat functionality is available without creating an account, most of Ergo's features require an account. You can create an account by sending a direct message to `NickServ`. (In IRC jargon, `NickServ` is a "network service", but if you're not familiar with the concept you can just think of it as a bot or a text user interface.) In a typical client, this will be: - -``` -/msg NickServ register mySecretPassword validEmailAddress@example.com -``` - -This registers your current nickname as your account name, with the password `mySecretPassword` (replace this with your own secret password!) - -Once you have registered your account, you must configure SASL in your client, so that you will be logged in automatically on each connection. [libera.chat's SASL guide](https://libera.chat/guides/sasl) covers most popular clients. - -If your client doesn't support SASL, you can typically use the "server password" (`PASS`) field in your client to log into your account automatically when connecting. Set the server password to `accountname:accountpassword`, where `accountname` is your account name and `accountpassword` is your account password. - -For information on how to use a client certificate for authentication, see the [operator manual](https://github.com/ergochat/ergo/blob/stable/docs/MANUAL.md#client-certificates). - -# Channel registration - -Once you've registered your nickname, you can use it to register channels. By default, channels are ephemeral; they go away when there are no longer any users in the channel, or when the server is restarted. Registering a channel gives you permanent control over it, and ensures that its settings will persist. To register a channel, send a message to `ChanServ`: - -``` -/msg ChanServ register #myChannel -``` - -You must already be an operator (have the `+o` channel mode --- your client may display this as an `@` next to your nickname). If you're not a channel operator in the channel you want to register, ask your server administrator for help. - -# Always-on - -By default, if you lose your connection to the IRC server, you are no longer present on the server; other users will see that you have "quit", you will no longer appear in channel lists, and you will not be able to receive direct messages. Ergo supports "always-on clients", where you remain on the server even when you are disconnected. To enable this, you can send a message to `NickServ`: - -``` -/msg NickServ set always-on true -``` - -# Multiclient - -Ergo natively supports attaching multiple clients to the same nickname (this normally requires the use of an external bouncer, like ZNC or WeeChat's "relay" functionality). To use this feature, simply authenticate with SASL (or the PASS workaround, if necessary) when connecting. In the recommended configuration of Ergo, you will receive the nickname associated with your account, even if you have other clients already using it. - -# History - -Ergo stores message history on the server side (typically not an unlimited amount --- consult your server's FAQ, or your server administrator, to find out how much is being stored and how long it's being retained). - -1. The [IRCv3 chathistory specification](https://ircv3.net/specs/extensions/chathistory) offers the most fine-grained control over history replay. It is supported by [Gamja](https://git.sr.ht/~emersion/gamja), [Goguma](https://sr.ht/~emersion/goguma/), and [Kiwi IRC](https://github.com/kiwiirc/kiwiirc), and hopefully other clients soon. -1. We emulate the [ZNC playback module](https://wiki.znc.in/Playback) for clients that support it. You may need to enable support for it explicitly in your client. For example, in [Textual](https://www.codeux.com/textual/), go to "Server properties", select "Vendor specific", uncheck "Do not automatically join channels on connect", and check "Only play back messages you missed". ZNC's wiki page covers other common clients (although if the feature is only supported via a script or third-party extension, the following option may be easier). -1. If you set your client to always-on (see the previous section for details), you can set a "device ID" for each device you use. Ergo will then remember the last time your device was present on the server, and each time you sign on, it will attempt to replay exactly those messages you missed. There are a few ways to set your device ID when connecting: - - You can add it to your SASL username with an `@`, e.g., if your SASL username is `alice` you can send `alice@phone` - - You can add it in a similar way to your IRC protocol username ("ident"), e.g., `alice@phone` - - If login to user accounts via the `PASS` command is enabled on the server, you can provide it there, e.g., by sending `alice@phone:hunter2` as the server password -1. If you only have one device, you can set your client to be always-on and furthermore `/msg NickServ set autoreplay-missed true`. This will replay missed messages, with the caveat that you must be connecting with at most one client at a time. -1. You can manually request history using `/history #channel 1h` (the parameter is either a message count or a time duration). (Depending on your client, you may need to use `/QUOTE history` instead.) -1. You can autoreplay a fixed number of lines (e.g., 25) each time you join a channel using `/msg NickServ set autoreplay-lines 25`. - -# Private channels - -If you have registered a channel, you can make it private. The best way to do this is with the `+i` ("invite-only") mode: - -1. Set your channel to be invite-only (`/mode #example +i`) -1. Identify the users you want to be able to access the channel. Ensure that they have registered their accounts (you should be able to see their registration status if you `/WHOIS` their nicknames). -1. Add the desired nick/account names to the invite exception list (`/mode #example +I alice`) or give them persistent voice (`/msg ChanServ AMODE #example +v alice`) -1. If you want to grant a persistent channel privilege to a user, you can do it with `CS AMODE` (`/msg ChanServ AMODE #example +o bob`) diff --git a/docs/logo.png b/docs/logo.png index 56e2a1d6..391994ca 100644 Binary files a/docs/logo.png and b/docs/logo.png differ diff --git a/docs/logo.svg b/docs/logo.svg index 4ab1cae1..643a78cd 100644 --- a/docs/logo.svg +++ b/docs/logo.svg @@ -1,2 +1 @@ - - +logo \ No newline at end of file diff --git a/ergo.go b/ergo.go deleted file mode 100644 index 500c5cf2..00000000 --- a/ergo.go +++ /dev/null @@ -1,203 +0,0 @@ -// Copyright (c) 2012-2014 Jeremy Latt -// Copyright (c) 2014-2015 Edmund Huber -// Copyright (c) 2016-2017 Daniel Oaks -// released under the MIT license - -package main - -import ( - "bufio" - _ "embed" - "fmt" - "log" - "os" - "strings" - "syscall" - - "golang.org/x/crypto/bcrypt" - "golang.org/x/term" - - "github.com/docopt/docopt-go" - "github.com/ergochat/ergo/irc" - "github.com/ergochat/ergo/irc/logger" - "github.com/ergochat/ergo/irc/mkcerts" -) - -// set via linker flags, either by make or by goreleaser: -var commit = "" // git hash -var version = "" // tagged version - -//go:embed default.yaml -var defaultConfig string - -// get a password from stdin from the user -func getPasswordFromTerminal() string { - bytePassword, err := term.ReadPassword(int(syscall.Stdin)) - if err != nil { - log.Fatal("Error reading password:", err.Error()) - } - return string(bytePassword) -} - -func fileDoesNotExist(file string) bool { - if _, err := os.Stat(file); os.IsNotExist(err) { - return true - } - return false -} - -// implements the `ergo mkcerts` command -func doMkcerts(configFile string, quiet bool) { - config, err := irc.LoadRawConfig(configFile) - if err != nil { - log.Fatal(err) - } - if !quiet { - log.Println("making self-signed certificates") - } - - certToKey := make(map[string]string) - for name, conf := range config.Server.Listeners { - if conf.TLS.Cert == "" { - continue - } - existingKey, ok := certToKey[conf.TLS.Cert] - if ok { - if existingKey == conf.TLS.Key { - continue - } else { - log.Fatal("Conflicting TLS key files for ", conf.TLS.Cert) - } - } - if !quiet { - log.Printf(" making cert for %s listener\n", name) - } - host := config.Server.Name - cert, key := conf.TLS.Cert, conf.TLS.Key - if !(fileDoesNotExist(cert) && fileDoesNotExist(key)) { - log.Fatalf("Preexisting TLS cert and/or key files: %s %s", cert, key) - } - err := mkcerts.CreateCert("Ergo", host, cert, key) - if err == nil { - if !quiet { - log.Printf(" Certificate created at %s : %s\n", cert, key) - } - certToKey[cert] = key - } else { - log.Fatal(" Could not create certificate:", err.Error()) - } - } -} - -func main() { - irc.SetVersionString(version, commit) - usage := `ergo. -Usage: - ergo initdb [--conf ] [--quiet] - ergo upgradedb [--conf ] [--quiet] - ergo importdb [--conf ] [--quiet] - ergo genpasswd [--conf ] [--quiet] - ergo mkcerts [--conf ] [--quiet] - ergo defaultconfig - ergo run [--conf ] [--quiet] [--smoke] - ergo -h | --help - ergo --version -Options: - --conf Configuration file to use [default: ircd.yaml]. - --quiet Don't show startup/shutdown lines. - -h --help Show this screen. - --version Show version.` - - arguments, _ := docopt.ParseArgs(usage, nil, irc.Ver) - - // don't require a config file for genpasswd - if arguments["genpasswd"].(bool) { - var password string - if term.IsTerminal(int(syscall.Stdin)) { - fmt.Print("Enter Password: ") - password = getPasswordFromTerminal() - fmt.Print("\n") - fmt.Print("Reenter Password: ") - confirm := getPasswordFromTerminal() - fmt.Print("\n") - if confirm != password { - log.Fatal("passwords do not match") - } - } else { - reader := bufio.NewReader(os.Stdin) - text, _ := reader.ReadString('\n') - password = strings.TrimSpace(text) - } - if err := irc.ValidatePassphrase(password); err != nil { - log.Printf("WARNING: this password contains characters that may cause problems with your IRC client software.\n") - log.Printf("We strongly recommend choosing a different password.\n") - } - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) - if err != nil { - log.Fatal("encoding error:", err.Error()) - } - fmt.Println(string(hash)) - return - } else if arguments["defaultconfig"].(bool) { - fmt.Print(defaultConfig) - return - } else if arguments["mkcerts"].(bool) { - doMkcerts(arguments["--conf"].(string), arguments["--quiet"].(bool)) - return - } - - configfile := arguments["--conf"].(string) - config, err := irc.LoadConfig(configfile) - if err != nil { - _, isCertError := err.(*irc.CertKeyError) - if !(isCertError && arguments["mkcerts"].(bool)) { - log.Fatal("Config file did not load successfully: ", err.Error()) - } - } - - logman, err := logger.NewManager(config.Logging) - if err != nil { - log.Fatal("Logger did not load successfully:", err.Error()) - } - - if arguments["initdb"].(bool) { - err = irc.InitDB(config.Datastore.Path) - if err != nil { - log.Fatal("Error while initializing db:", err.Error()) - } - if !arguments["--quiet"].(bool) { - log.Println("database initialized: ", config.Datastore.Path) - } - } else if arguments["upgradedb"].(bool) { - err = irc.UpgradeDB(config) - if err != nil { - log.Fatal("Error while upgrading db:", err.Error()) - } - if !arguments["--quiet"].(bool) { - log.Println("database upgraded: ", config.Datastore.Path) - } - } else if arguments["importdb"].(bool) { - err = irc.ImportDB(config, arguments[""].(string)) - if err != nil { - log.Fatal("Error while importing db:", err.Error()) - } - } else if arguments["run"].(bool) { - if !arguments["--quiet"].(bool) { - logman.Info("server", fmt.Sprintf("%s starting", irc.Ver)) - } - - // warning if running a non-final version - if strings.Contains(irc.Ver, "unreleased") { - logman.Warning("server", "You are currently running an unreleased beta version of Ergo that may be unstable and could corrupt your database.\nIf you are running a production network, please download the latest build from https://ergo.chat/downloads.html and run that instead.") - } - - server, err := irc.NewServer(config, logman) - if err != nil { - logman.Error("server", fmt.Sprintf("Could not load server: %s", err.Error())) - os.Exit(1) - } - if !arguments["--smoke"].(bool) { - server.Run() - } - } -} diff --git a/gencapdefs.py b/gencapdefs.py deleted file mode 100644 index a64ef143..00000000 --- a/gencapdefs.py +++ /dev/null @@ -1,291 +0,0 @@ -#!/usr/bin/env python3 - -""" -Updates the capability definitions at irc/caps/defs.go - -To add a capability, add it to the CAPDEFS list below, -then run `make capdefs` from the project root. -""" - -import io -import subprocess -import sys -from collections import namedtuple - -CapDef = namedtuple("CapDef", ['identifier', 'name', 'url', 'standard']) - -CAPDEFS = [ - CapDef( - identifier="AccountNotify", - name="account-notify", - url="https://ircv3.net/specs/extensions/account-notify-3.1.html", - standard="IRCv3", - ), - CapDef( - identifier="AccountTag", - name="account-tag", - url="https://ircv3.net/specs/extensions/account-tag-3.2.html", - standard="IRCv3", - ), - CapDef( - identifier="AwayNotify", - name="away-notify", - url="https://ircv3.net/specs/extensions/away-notify-3.1.html", - standard="IRCv3", - ), - CapDef( - identifier="Batch", - name="batch", - url="https://ircv3.net/specs/extensions/batch-3.2.html", - standard="IRCv3", - ), - CapDef( - identifier="CapNotify", - name="cap-notify", - url="https://ircv3.net/specs/extensions/cap-notify-3.2.html", - standard="IRCv3", - ), - CapDef( - identifier="ChgHost", - name="chghost", - url="https://ircv3.net/specs/extensions/chghost-3.2.html", - standard="IRCv3", - ), - CapDef( - identifier="EchoMessage", - name="echo-message", - url="https://ircv3.net/specs/extensions/echo-message-3.2.html", - standard="IRCv3", - ), - CapDef( - identifier="ExtendedJoin", - name="extended-join", - url="https://ircv3.net/specs/extensions/extended-join-3.1.html", - standard="IRCv3", - ), - CapDef( - identifier="ExtendedMonitor", - name="extended-monitor", - url="https://ircv3.net/specs/extensions/extended-monitor.html", - standard="IRCv3", - ), - CapDef( - identifier="InviteNotify", - name="invite-notify", - url="https://ircv3.net/specs/extensions/invite-notify-3.2.html", - standard="IRCv3", - ), - CapDef( - identifier="LabeledResponse", - name="labeled-response", - url="https://ircv3.net/specs/extensions/labeled-response.html", - standard="IRCv3", - ), - CapDef( - identifier="Languages", - name="draft/languages", - url="https://gist.github.com/DanielOaks/8126122f74b26012a3de37db80e4e0c6", - standard="proposed IRCv3", - ), - CapDef( - identifier="MessageRedaction", - name="draft/message-redaction", - url="https://github.com/progval/ircv3-specifications/blob/redaction/extensions/message-redaction.md", - standard="proposed IRCv3", - ), - CapDef( - identifier="MessageTags", - name="message-tags", - url="https://ircv3.net/specs/extensions/message-tags.html", - standard="IRCv3", - ), - CapDef( - identifier="MultiPrefix", - name="multi-prefix", - url="https://ircv3.net/specs/extensions/multi-prefix-3.1.html", - standard="IRCv3", - ), - CapDef( - identifier="Relaymsg", - name="draft/relaymsg", - url="https://github.com/ircv3/ircv3-specifications/pull/417", - standard="proposed IRCv3", - ), - CapDef( - identifier="ChannelRename", - name="draft/channel-rename", - url="https://ircv3.net/specs/extensions/channel-rename", - standard="draft IRCv3", - ), - CapDef( - identifier="SASL", - name="sasl", - url="https://ircv3.net/specs/extensions/sasl-3.2.html", - standard="IRCv3", - ), - CapDef( - identifier="ServerTime", - name="server-time", - url="https://ircv3.net/specs/extensions/server-time-3.2.html", - standard="IRCv3", - ), - CapDef( - identifier="SetName", - name="setname", - url="https://ircv3.net/specs/extensions/setname.html", - standard="IRCv3", - ), - CapDef( - identifier="STS", - name="sts", - url="https://ircv3.net/specs/extensions/sts.html", - standard="IRCv3", - ), - CapDef( - identifier="UserhostInNames", - name="userhost-in-names", - url="https://ircv3.net/specs/extensions/userhost-in-names-3.2.html", - standard="IRCv3", - ), - CapDef( - identifier="ZNCSelfMessage", - name="znc.in/self-message", - url="https://wiki.znc.in/Query_buffers", - standard="ZNC vendor", - ), - CapDef( - identifier="EventPlayback", - name="draft/event-playback", - url="https://github.com/ircv3/ircv3-specifications/pull/362", - standard="proposed IRCv3", - ), - CapDef( - identifier="ZNCPlayback", - name="znc.in/playback", - url="https://wiki.znc.in/Playback", - standard="ZNC vendor", - ), - CapDef( - identifier="Nope", - name="ergo.chat/nope", - url="https://ergo.chat/nope", - standard="Ergo vendor", - ), - CapDef( - identifier="Multiline", - name="draft/multiline", - url="https://github.com/ircv3/ircv3-specifications/pull/398", - standard="proposed IRCv3", - ), - CapDef( - identifier="Chathistory", - name="draft/chathistory", - url="https://github.com/ircv3/ircv3-specifications/pull/393", - standard="proposed IRCv3", - ), - CapDef( - identifier="AccountRegistration", - name="draft/account-registration", - url="https://github.com/ircv3/ircv3-specifications/pull/435", - standard="draft IRCv3", - ), - CapDef( - identifier="ReadMarker", - name="draft/read-marker", - url="https://github.com/ircv3/ircv3-specifications/pull/489", - standard="draft IRCv3", - ), - CapDef( - identifier="Persistence", - name="draft/persistence", - url="https://github.com/ircv3/ircv3-specifications/pull/503", - standard="proposed IRCv3", - ), - CapDef( - identifier="Preaway", - name="draft/pre-away", - url="https://github.com/ircv3/ircv3-specifications/pull/514", - standard="proposed IRCv3", - ), - CapDef( - identifier="StandardReplies", - name="standard-replies", - url="https://github.com/ircv3/ircv3-specifications/pull/506", - standard="IRCv3", - ), - CapDef( - identifier="NoImplicitNames", - name="draft/no-implicit-names", - url="https://github.com/ircv3/ircv3-specifications/pull/527", - standard="proposed IRCv3", - ), - CapDef( - identifier="ExtendedISupport", - name="draft/extended-isupport", - url="https://github.com/ircv3/ircv3-specifications/pull/543", - standard="proposed IRCv3", - ), -] - -def validate_defs(): - CAPDEFS.sort(key=lambda d: d.name) - numCaps = len(CAPDEFS) - numNames = len(set(capdef.name for capdef in CAPDEFS)) - if numCaps != numNames: - raise Exception("defs must have unique names, but found duplicates") - numIdentifiers = len(set(capdef.identifier for capdef in CAPDEFS)) - if numCaps != numIdentifiers: - raise Exception("defs must have unique identifiers, but found duplicates") - -def main(): - validate_defs() - output = io.StringIO() - print(""" -package caps - -/* - WARNING: this file is autogenerated by `make capdefs` - DO NOT EDIT MANUALLY. -*/ - - - """, file=output) - - - numCapabs = len(CAPDEFS) - bitsetLen = numCapabs // 32 - if numCapabs % 32 > 0: - bitsetLen += 1 - print (""" -const ( - // number of recognized capabilities: - numCapabs = %d - // length of the uint32 array that represents the bitset: - bitsetLen = %d -) - """ % (numCapabs, bitsetLen), file=output) - - print("const (", file=output) - for capdef in CAPDEFS: - print("// %s is the %s capability named \"%s\":" % (capdef.identifier, capdef.standard, capdef.name), file=output) - print("// %s" % (capdef.url,), file=output) - print("%s Capability = iota" % (capdef.identifier,), file=output) - print(file=output) - print(")", file=output) - - print("// `capabilityNames[capab]` is the string name of the capability `capab`", file=output) - print("""var ( capabilityNames = [numCapabs]string{""", file=output) - for capdef in CAPDEFS: - print("\"%s\"," % (capdef.name,), file=output) - print("})", file=output) - - # run the generated code through `gofmt -s`, which will print it to stdout - gofmt = subprocess.Popen(['gofmt', '-s'], stdin=subprocess.PIPE) - gofmt.communicate(input=output.getvalue().encode('utf-8')) - if gofmt.poll() != 0: - print(output.getvalue()) - raise Exception("gofmt failed") - return 0 - -if __name__ == '__main__': - sys.exit(main()) diff --git a/go.mod b/go.mod deleted file mode 100644 index d3c8f952..00000000 --- a/go.mod +++ /dev/null @@ -1,50 +0,0 @@ -module github.com/ergochat/ergo - -go 1.23 - -require ( - code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48 - github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 - github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 - github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1 - github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881 - github.com/ergochat/irc-go v0.5.0-rc2 - github.com/go-sql-driver/mysql v1.7.0 - github.com/go-test/deep v1.0.6 // indirect - github.com/gofrs/flock v0.8.1 - github.com/gorilla/websocket v1.4.2 - github.com/okzk/sdnotify v0.0.0-20180710141335-d9becc38acbd - github.com/onsi/ginkgo v1.12.0 // indirect - github.com/onsi/gomega v1.9.0 // indirect - github.com/tidwall/buntdb v1.3.1 - github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 - github.com/xdg-go/scram v1.0.2 - golang.org/x/crypto v0.25.0 - golang.org/x/term v0.22.0 - golang.org/x/text v0.16.0 - gopkg.in/yaml.v2 v2.4.0 -) - -require ( - github.com/cshum/imagor v1.4.13 - github.com/golang-jwt/jwt/v5 v5.2.1 - github.com/redis/go-redis/v9 v9.6.1 -) - -require ( - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/tidwall/btree v1.4.2 // indirect - github.com/tidwall/gjson v1.14.3 // indirect - github.com/tidwall/grect v0.1.4 // indirect - github.com/tidwall/match v1.1.1 // indirect - github.com/tidwall/pretty v1.2.0 // indirect - github.com/tidwall/rtred v0.1.2 // indirect - github.com/tidwall/tinyqueue v0.1.1 // indirect - github.com/xdg-go/pbkdf2 v1.0.0 // indirect - golang.org/x/sys v0.22.0 // indirect -) - -replace github.com/gorilla/websocket => github.com/ergochat/websocket v1.4.2-oragono1 - -replace github.com/xdg-go/scram => github.com/ergochat/scram v1.0.2-ergo1 diff --git a/go.sum b/go.sum deleted file mode 100644 index cfe45432..00000000 --- a/go.sum +++ /dev/null @@ -1,113 +0,0 @@ -code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48 h1:/EMHruHCFXR9xClkGV/t0rmHrdhX4+trQUcBqjwc9xE= -code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48/go.mod h1:wN/zk7mhREp/oviagqUXY3EwuHhWyOvAdsn5Y4CzOrc= -github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 h1:KeNholpO2xKjgaaSyd+DyQRrsQjhbSeS7qe4nEw8aQw= -github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962/go.mod h1:kC29dT1vFpj7py2OvG1khBdQpo3kInWP+6QipLbdngo= -github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= -github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= -github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= -github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cshum/imagor v1.4.13 h1:BFcSpsTUOJj+Wv5SzDeXa8bhsT/Ehw7EcrFD0UTdpmU= -github.com/cshum/imagor v1.4.13/go.mod h1:LHxXgks6Y06GzEHitnlO8vcD5gznxIHWPdvGsnlGpMo= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ= -github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= -github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1 h1:WLHTOodthVyv5NvYLIvWl112kSFv5IInKKrRN2qpons= -github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1/go.mod h1:mov+uh1DPWsltdQnOdzn08UO9GsJ3MEvhtu0Ci37fdk= -github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881 h1:+J5m88nvybxB5AnBVGzTXM/yHVytt48rXBGcJGzSbms= -github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881/go.mod h1:ASYJtQujNitna6cVHsNQTGrfWvMPJ5Sa2lZlmsH65uM= -github.com/ergochat/irc-go v0.5.0-rc1 h1:kFoIHExoNFQ2CV+iShAVna/H4xrXQB4t4jK5Sep2j9k= -github.com/ergochat/irc-go v0.5.0-rc1/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0= -github.com/ergochat/irc-go v0.5.0-rc2 h1:VuSQJF5K4hWvYSzGa4b8vgL6kzw8HF6LSOejE+RWpAo= -github.com/ergochat/irc-go v0.5.0-rc2/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0= -github.com/ergochat/scram v1.0.2-ergo1 h1:2bYXiRFQH636pT0msOG39fmEYl4Eq+OuutcyDsCix/g= -github.com/ergochat/scram v1.0.2-ergo1/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs= -github.com/ergochat/websocket v1.4.2-oragono1 h1:plMUunFBM6UoSCIYCKKclTdy/TkkHfUslhOfJQzfueM= -github.com/ergochat/websocket v1.4.2-oragono1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= -github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= -github.com/go-test/deep v1.0.6 h1:UHSEyLZUwX9Qoi99vVwvewiMC8mM2bf7XEM2nqvzEn8= -github.com/go-test/deep v1.0.6/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8= -github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= -github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/okzk/sdnotify v0.0.0-20180710141335-d9becc38acbd h1:+iAPaTbi1gZpcpDwe/BW1fx7Xoesv69hLNGPheoyhBs= -github.com/okzk/sdnotify v0.0.0-20180710141335-d9becc38acbd/go.mod h1:4soZNh0zW0LtYGdQ416i0jO0EIqMGcbtaspRS4BDvRQ= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.0 h1:Iw5WCbBcaAAd0fpRb1c9r5YCylv4XDoCSigm1zLevwU= -github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.9.0 h1:R1uwffexN6Pr340GtYRIdZmAiN4J+iw6WG4wog1DUXg= -github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4= -github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/tidwall/assert v0.1.0 h1:aWcKyRBUAdLoVebxo95N7+YZVTFF/ASTr7BN4sLP6XI= -github.com/tidwall/assert v0.1.0/go.mod h1:QLYtGyeqse53vuELQheYl9dngGCJQ+mTtlxcktb+Kj8= -github.com/tidwall/btree v1.4.2 h1:PpkaieETJMUxYNADsjgtNRcERX7mGc/GP2zp/r5FM3g= -github.com/tidwall/btree v1.4.2/go.mod h1:LGm8L/DZjPLmeWGjv5kFrY8dL4uVhMmzmmLYmsObdKE= -github.com/tidwall/buntdb v1.3.1 h1:HKoDF01/aBhl9RjYtbaLnvX9/OuenwvQiC3OP1CcL4o= -github.com/tidwall/buntdb v1.3.1/go.mod h1:lZZrZUWzlyDJKlLQ6DKAy53LnG7m5kHyrEHvvcDmBpU= -github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw= -github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/grect v0.1.4 h1:dA3oIgNgWdSspFzn1kS4S/RDpZFLrIxAZOdJKjYapOg= -github.com/tidwall/grect v0.1.4/go.mod h1:9FBsaYRaR0Tcy4UwefBX/UDcDcDy9V5jUcxHzv2jd5Q= -github.com/tidwall/lotsa v1.0.2 h1:dNVBH5MErdaQ/xd9s769R31/n2dXavsQ0Yf4TMEHHw8= -github.com/tidwall/lotsa v1.0.2/go.mod h1:X6NiU+4yHA3fE3Puvpnn1XMDrFZrE9JO2/w+UMuqgR8= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/rtred v0.1.2 h1:exmoQtOLvDoO8ud++6LwVsAMTu0KPzLTUrMln8u1yu8= -github.com/tidwall/rtred v0.1.2/go.mod h1:hd69WNXQ5RP9vHd7dqekAz+RIdtfBogmglkZSRxCHFQ= -github.com/tidwall/tinyqueue v0.1.1 h1:SpNEvEggbpyN5DIReaJ2/1ndroY8iyEGxPYxoSaymYE= -github.com/tidwall/tinyqueue v0.1.1/go.mod h1:O/QNHwrnjqr6IHItYrzoHAKYhBkLI67Q096fQP5zMYw= -github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM= -github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= -github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= -github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= -github.com/xdg-go/stringprep v1.0.2 h1:6iq84/ryjjeRmMJwxutI51F2GIPlP5BfTvXHeYjyhBc= -github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM= -golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= -golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= -golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= -golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/irc/accept.go b/irc/accept.go deleted file mode 100644 index e54bd2e4..00000000 --- a/irc/accept.go +++ /dev/null @@ -1,76 +0,0 @@ -package irc - -import ( - "sync" - - "github.com/ergochat/ergo/irc/utils" -) - -// tracks ACCEPT relationships, i.e., `accepter` is willing to receive DMs from -// `accepted` despite some restriction (currently the only relevant restriction -// is that `accepter` is +R and `accepted` is not logged in) - -type AcceptManager struct { - sync.RWMutex - - // maps recipient -> whitelist of permitted senders: - // this is what we actually check - clientToAccepted map[*Client]utils.HashSet[*Client] - // this is the reverse mapping, it's needed so we can - // clean up the forward mapping during (*Client).destroy(): - clientToAccepters map[*Client]utils.HashSet[*Client] -} - -func (am *AcceptManager) Initialize() { - am.clientToAccepted = make(map[*Client]utils.HashSet[*Client]) - am.clientToAccepters = make(map[*Client]utils.HashSet[*Client]) -} - -func (am *AcceptManager) MaySendTo(sender, recipient *Client) (result bool) { - am.RLock() - defer am.RUnlock() - return am.clientToAccepted[recipient].Has(sender) -} - -func (am *AcceptManager) Accept(accepter, accepted *Client) { - am.Lock() - defer am.Unlock() - - var m utils.HashSet[*Client] - - m = am.clientToAccepted[accepter] - if m == nil { - m = make(utils.HashSet[*Client]) - am.clientToAccepted[accepter] = m - } - m.Add(accepted) - - m = am.clientToAccepters[accepted] - if m == nil { - m = make(utils.HashSet[*Client]) - am.clientToAccepters[accepted] = m - } - m.Add(accepter) -} - -func (am *AcceptManager) Unaccept(accepter, accepted *Client) { - am.Lock() - defer am.Unlock() - - delete(am.clientToAccepted[accepter], accepted) - delete(am.clientToAccepters[accepted], accepter) -} - -func (am *AcceptManager) Remove(client *Client) { - am.Lock() - defer am.Unlock() - - for accepter := range am.clientToAccepters[client] { - delete(am.clientToAccepted[accepter], client) - } - for accepted := range am.clientToAccepted[client] { - delete(am.clientToAccepters[accepted], client) - } - delete(am.clientToAccepters, client) - delete(am.clientToAccepted, client) -} diff --git a/irc/accept_test.go b/irc/accept_test.go deleted file mode 100644 index fad39151..00000000 --- a/irc/accept_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package irc - -import ( - "testing" -) - -func TestAccept(t *testing.T) { - var am AcceptManager - am.Initialize() - - alice := new(Client) - bob := new(Client) - eve := new(Client) - - // must not panic: - am.Unaccept(eve, bob) - - assertEqual(am.MaySendTo(alice, bob), false) - assertEqual(am.MaySendTo(bob, alice), false) - assertEqual(am.MaySendTo(alice, eve), false) - assertEqual(am.MaySendTo(eve, alice), false) - assertEqual(am.MaySendTo(bob, eve), false) - assertEqual(am.MaySendTo(eve, bob), false) - - am.Accept(alice, bob) - - assertEqual(am.MaySendTo(alice, bob), false) - assertEqual(am.MaySendTo(bob, alice), true) - assertEqual(am.MaySendTo(alice, eve), false) - assertEqual(am.MaySendTo(eve, alice), false) - assertEqual(am.MaySendTo(bob, eve), false) - assertEqual(am.MaySendTo(eve, bob), false) - - am.Accept(bob, alice) - - assertEqual(am.MaySendTo(alice, bob), true) - assertEqual(am.MaySendTo(bob, alice), true) - assertEqual(am.MaySendTo(alice, eve), false) - assertEqual(am.MaySendTo(eve, alice), false) - assertEqual(am.MaySendTo(bob, eve), false) - assertEqual(am.MaySendTo(eve, bob), false) - - am.Accept(bob, eve) - - assertEqual(am.MaySendTo(alice, bob), true) - assertEqual(am.MaySendTo(bob, alice), true) - assertEqual(am.MaySendTo(alice, eve), false) - assertEqual(am.MaySendTo(eve, alice), false) - assertEqual(am.MaySendTo(bob, eve), false) - assertEqual(am.MaySendTo(eve, bob), true) - - am.Accept(eve, bob) - - assertEqual(am.MaySendTo(alice, bob), true) - assertEqual(am.MaySendTo(bob, alice), true) - assertEqual(am.MaySendTo(alice, eve), false) - assertEqual(am.MaySendTo(eve, alice), false) - assertEqual(am.MaySendTo(bob, eve), true) - assertEqual(am.MaySendTo(eve, bob), true) - - am.Unaccept(eve, bob) - - assertEqual(am.MaySendTo(alice, bob), true) - assertEqual(am.MaySendTo(bob, alice), true) - assertEqual(am.MaySendTo(alice, eve), false) - assertEqual(am.MaySendTo(eve, alice), false) - assertEqual(am.MaySendTo(bob, eve), false) - assertEqual(am.MaySendTo(eve, bob), true) - - am.Remove(alice) - - assertEqual(am.MaySendTo(alice, bob), false) - assertEqual(am.MaySendTo(bob, alice), false) - assertEqual(am.MaySendTo(alice, eve), false) - assertEqual(am.MaySendTo(eve, alice), false) - assertEqual(am.MaySendTo(bob, eve), false) - assertEqual(am.MaySendTo(eve, bob), true) - - am.Remove(bob) - - assertEqual(am.MaySendTo(alice, bob), false) - assertEqual(am.MaySendTo(bob, alice), false) - assertEqual(am.MaySendTo(alice, eve), false) - assertEqual(am.MaySendTo(eve, alice), false) - assertEqual(am.MaySendTo(bob, eve), false) - assertEqual(am.MaySendTo(eve, bob), false) -} - -func TestAcceptInternal(t *testing.T) { - var am AcceptManager - am.Initialize() - - alice := new(Client) - bob := new(Client) - eve := new(Client) - - am.Accept(alice, bob) - am.Accept(bob, alice) - am.Accept(bob, eve) - am.Remove(alice) - am.Remove(bob) - - // assert that there is no memory leak - for _, client := range []*Client{alice, bob, eve} { - assertEqual(len(am.clientToAccepted[client]), 0) - assertEqual(len(am.clientToAccepters[client]), 0) - } -} diff --git a/irc/accounts.go b/irc/accounts.go index 87a1d0c2..585c26e8 100644 --- a/irc/accounts.go +++ b/irc/accounts.go @@ -4,55 +4,33 @@ package irc import ( - "context" "crypto/rand" - "crypto/x509" + "crypto/subtle" + "encoding/hex" "encoding/json" + "errors" "fmt" - "sort" + "net/smtp" "strconv" "strings" "sync" "time" - "unicode" - "github.com/ergochat/irc-go/ircutils" + "github.com/oragono/oragono/irc/caps" + "github.com/oragono/oragono/irc/passwd" "github.com/tidwall/buntdb" - "github.com/xdg-go/scram" - - "github.com/ergochat/ergo/irc/connection_limits" - "github.com/ergochat/ergo/irc/email" - "github.com/ergochat/ergo/irc/migrations" - "github.com/ergochat/ergo/irc/modes" - "github.com/ergochat/ergo/irc/oauth2" - "github.com/ergochat/ergo/irc/passwd" - "github.com/ergochat/ergo/irc/utils" ) const ( keyAccountExists = "account.exists %s" keyAccountVerified = "account.verified %s" - keyAccountUnregistered = "account.unregistered %s" + keyAccountCallback = "account.callback %s" keyAccountVerificationCode = "account.verificationcode %s" keyAccountName = "account.name %s" // stores the 'preferred name' of the account, not casemapped keyAccountRegTime = "account.registered.time %s" keyAccountCredentials = "account.credentials %s" keyAccountAdditionalNicks = "account.additionalnicks %s" - keyAccountSettings = "account.settings %s" - keyAccountVHost = "account.vhost %s" keyCertToAccount = "account.creds.certfp %s" - keyAccountLastSeen = "account.lastseen %s" - keyAccountReadMarkers = "account.readmarkers %s" - keyAccountModes = "account.modes %s" // user modes for the always-on client as a string - keyAccountRealname = "account.realname %s" // client realname stored as string - keyAccountSuspended = "account.suspended %s" // client realname stored as string - keyAccountPwReset = "account.pwreset %s" - keyAccountEmailChange = "account.emailchange %s" - // for an always-on client, a map of channel names they're in to their current modes - // (not to be confused with their amodes, which a non-always-on client can have): - keyAccountChannelToModes = "account.channeltomodes %s" - - maxCertfpsPerAccount = 5 ) // everything about accounts is persistent; therefore, the database is the authoritative @@ -63,91 +41,27 @@ type AccountManager struct { server *Server // track clients logged in to accounts - accountToClients map[string][]*Client - nickToAccount map[string]string - skeletonToAccount map[string]string - accountToMethod map[string]NickEnforcementMethod - registerThrottle connection_limits.GenericThrottle + accountToClients map[string][]*Client + nickToAccount map[string]string } -func (am *AccountManager) Initialize(server *Server) { - am.accountToClients = make(map[string][]*Client) - am.nickToAccount = make(map[string]string) - am.skeletonToAccount = make(map[string]string) - am.accountToMethod = make(map[string]NickEnforcementMethod) - am.server = server - - config := server.Config() - am.buildNickToAccountIndex(config) - am.createAlwaysOnClients(config) - am.resetRegisterThrottle(config) -} - -func (am *AccountManager) resetRegisterThrottle(config *Config) { - am.Lock() - defer am.Unlock() - - am.registerThrottle = connection_limits.GenericThrottle{ - Duration: config.Accounts.Registration.Throttling.Duration, - Limit: config.Accounts.Registration.Throttling.MaxAttempts, +func NewAccountManager(server *Server) *AccountManager { + am := AccountManager{ + accountToClients: make(map[string][]*Client), + nickToAccount: make(map[string]string), + server: server, } + + am.buildNickToAccountIndex() + return &am } -func (am *AccountManager) touchRegisterThrottle() (throttled bool) { - am.Lock() - defer am.Unlock() - throttled, _ = am.registerThrottle.Touch() - return -} - -func (am *AccountManager) createAlwaysOnClients(config *Config) { - if config.Accounts.Multiclient.AlwaysOn == PersistentDisabled { +func (am *AccountManager) buildNickToAccountIndex() { + if !am.server.AccountConfig().NickReservation.Enabled { return } - verifiedPrefix := fmt.Sprintf(keyAccountVerified, "") - - am.serialCacheUpdateMutex.Lock() - defer am.serialCacheUpdateMutex.Unlock() - - var accounts []string - - am.server.store.View(func(tx *buntdb.Tx) error { - err := tx.AscendGreaterOrEqual("", verifiedPrefix, func(key, value string) bool { - if !strings.HasPrefix(key, verifiedPrefix) { - return false - } - account := strings.TrimPrefix(key, verifiedPrefix) - accounts = append(accounts, account) - return true - }) - return err - }) - - for _, accountName := range accounts { - account, err := am.LoadAccount(accountName) - if err == nil && (account.Verified && account.Suspended == nil) && - persistenceEnabled(config.Accounts.Multiclient.AlwaysOn, account.Settings.AlwaysOn) { - am.server.AddAlwaysOnClient( - account, - am.loadChannels(accountName), - am.loadTimeMap(keyAccountLastSeen, accountName), - am.loadTimeMap(keyAccountReadMarkers, accountName), - am.loadModes(accountName), - am.loadRealname(accountName), - ) - } - } -} - -func (am *AccountManager) buildNickToAccountIndex(config *Config) { - if !config.Accounts.NickReservation.Enabled { - return - } - - nickToAccount := make(map[string]string) - skeletonToAccount := make(map[string]string) - accountToMethod := make(map[string]NickEnforcementMethod) + result := make(map[string]string) existsPrefix := fmt.Sprintf(keyAccountExists, "") am.serialCacheUpdateMutex.Lock() @@ -158,70 +72,30 @@ func (am *AccountManager) buildNickToAccountIndex(config *Config) { if !strings.HasPrefix(key, existsPrefix) { return false } - - account := strings.TrimPrefix(key, existsPrefix) - if _, err := tx.Get(fmt.Sprintf(keyAccountVerified, account)); err == nil { - nickToAccount[account] = account - accountName, err := tx.Get(fmt.Sprintf(keyAccountName, account)) - if err != nil { - am.server.logger.Error("internal", "missing account name for", account) - } else { - skeleton, _ := Skeleton(accountName) - skeletonToAccount[skeleton] = account - } + accountName := strings.TrimPrefix(key, existsPrefix) + if _, err := tx.Get(fmt.Sprintf(keyAccountVerified, accountName)); err == nil { + result[accountName] = accountName } - if rawNicks, err := tx.Get(fmt.Sprintf(keyAccountAdditionalNicks, account)); err == nil { + if rawNicks, err := tx.Get(fmt.Sprintf(keyAccountAdditionalNicks, accountName)); err == nil { additionalNicks := unmarshalReservedNicks(rawNicks) for _, nick := range additionalNicks { - cfnick, _ := CasefoldName(nick) - nickToAccount[cfnick] = account - skeleton, _ := Skeleton(nick) - skeletonToAccount[skeleton] = account + result[nick] = accountName } } - - if rawPrefs, err := tx.Get(fmt.Sprintf(keyAccountSettings, account)); err == nil && rawPrefs != "" { - var prefs AccountSettings - err := json.Unmarshal([]byte(rawPrefs), &prefs) - if err == nil && prefs.NickEnforcement != NickEnforcementOptional { - accountToMethod[account] = prefs.NickEnforcement - } else if err != nil { - am.server.logger.Error("internal", "corrupt account settings", account, err.Error()) - } - } - return true }) return err }) - if config.Accounts.NickReservation.Method == NickEnforcementStrict { - unregisteredPrefix := fmt.Sprintf(keyAccountUnregistered, "") - am.server.store.View(func(tx *buntdb.Tx) error { - tx.AscendGreaterOrEqual("", unregisteredPrefix, func(key, value string) bool { - if !strings.HasPrefix(key, unregisteredPrefix) { - return false - } - account := strings.TrimPrefix(key, unregisteredPrefix) - accountName := value - nickToAccount[account] = account - skeleton, _ := Skeleton(accountName) - skeletonToAccount[skeleton] = account - return true - }) - return nil - }) - } - if err != nil { - am.server.logger.Error("internal", "couldn't read reserved nicks", err.Error()) + am.server.logger.Error("internal", fmt.Sprintf("couldn't read reserved nicks: %v", err)) } else { am.Lock() - am.nickToAccount = nickToAccount - am.skeletonToAccount = skeletonToAccount - am.accountToMethod = accountToMethod + am.nickToAccount = result am.Unlock() } + + return } func (am *AccountManager) NickToAccount(nick string) string { @@ -229,202 +103,56 @@ func (am *AccountManager) NickToAccount(nick string) string { if err != nil { return "" } - skel, err := Skeleton(nick) - if err != nil { - return "" - } am.RLock() defer am.RUnlock() - account := am.nickToAccount[cfnick] - if account != "" { - return account - } - return am.skeletonToAccount[skel] -} - -// given an account, combine stored enforcement method with the config settings -// to compute the actual enforcement method -func configuredEnforcementMethod(config *Config, storedMethod NickEnforcementMethod) (result NickEnforcementMethod) { - if !config.Accounts.NickReservation.Enabled { - return NickEnforcementNone - } - result = storedMethod - // if they don't have a custom setting, or customization is disabled, use the default - if result == NickEnforcementOptional || !config.Accounts.NickReservation.AllowCustomEnforcement { - result = config.Accounts.NickReservation.Method - } - if result == NickEnforcementOptional { - // enforcement was explicitly enabled neither in the config or by the user - result = NickEnforcementNone - } - return -} - -// Given a nick, looks up the account that owns it and the method (none/timeout/strict) -// used to enforce ownership. -func (am *AccountManager) EnforcementStatus(cfnick, skeleton string) (account string, method NickEnforcementMethod) { - config := am.server.Config() - if !config.Accounts.NickReservation.Enabled { - return "", NickEnforcementNone - } - - am.RLock() - defer am.RUnlock() - - finalEnforcementMethod := func(account_ string) (result NickEnforcementMethod) { - storedMethod := am.accountToMethod[account_] - return configuredEnforcementMethod(config, storedMethod) - } - - nickAccount := am.nickToAccount[cfnick] - skelAccount := am.skeletonToAccount[skeleton] - if nickAccount == "" && skelAccount == "" { - return "", NickEnforcementNone - } else if nickAccount != "" && (skelAccount == nickAccount || skelAccount == "") { - return nickAccount, finalEnforcementMethod(nickAccount) - } else if skelAccount != "" && nickAccount == "" { - return skelAccount, finalEnforcementMethod(skelAccount) - } else { - // nickAccount != skelAccount and both are nonempty: - // two people have competing claims on (this casefolding of) this nick! - nickMethod := finalEnforcementMethod(nickAccount) - skelMethod := finalEnforcementMethod(skelAccount) - switch { - case skelMethod == NickEnforcementNone: - return nickAccount, nickMethod - case nickMethod == NickEnforcementNone: - return skelAccount, skelMethod - default: - // nobody can use this nick - return "!", NickEnforcementStrict - } - } -} - -// Sets a custom enforcement method for an account and stores it in the database. -func (am *AccountManager) SetEnforcementStatus(account string, method NickEnforcementMethod) (finalSettings AccountSettings, err error) { - config := am.server.Config() - if !(config.Accounts.NickReservation.Enabled && config.Accounts.NickReservation.AllowCustomEnforcement) { - err = errFeatureDisabled - return - } - - setter := func(in AccountSettings) (out AccountSettings, err error) { - out = in - out.NickEnforcement = method - return out, nil - } - - _, err = am.ModifyAccountSettings(account, setter) - if err != nil { - return - } - - // this update of the data plane is racey, but it's probably fine - am.Lock() - defer am.Unlock() - - if method == NickEnforcementOptional { - delete(am.accountToMethod, account) - } else { - am.accountToMethod[account] = method - } - - return -} - -func (am *AccountManager) AccountToClients(account string) (result []*Client) { - cfaccount, err := CasefoldName(account) - if err != nil { - return - } - - am.RLock() - defer am.RUnlock() - return am.accountToClients[cfaccount] + return am.nickToAccount[cfnick] } func (am *AccountManager) Register(client *Client, account string, callbackNamespace string, callbackValue string, passphrase string, certfp string) error { casefoldedAccount, err := CasefoldName(account) - skeleton, skerr := Skeleton(account) - if err != nil || skerr != nil || account == "" || account == "*" { + if err != nil || account == "" || account == "*" { return errAccountCreation } - if restrictedCasefoldedNicks.Has(casefoldedAccount) || restrictedSkeletons.Has(skeleton) { - return errAccountAlreadyRegistered - } - - config := am.server.Config() - - // final "is registration allowed" check: - if callbackNamespace != "admin" && (!config.Accounts.Registration.Enabled || am.server.Defcon() <= 4) { - return errFeatureDisabled - } - - if client != nil && client.Account() != "" { - return errAccountAlreadyLoggedIn - } - - if client != nil && am.touchRegisterThrottle() { - am.server.logger.Warning("accounts", "global registration throttle exceeded by client", client.Nick()) - return errLimitExceeded - } - - // if nick reservation is enabled, don't let people reserve nicknames - // that they would not be eligible to take, e.g., - // 1. a nickname that someone else is currently holding - // 2. a nickname confusable with an existing reserved nickname - // this has a lot of weird edge cases because of force-guest-format - // and the possibility of registering a nickname on an "unregistered connection" - // (i.e., pre-handshake). - if client != nil && config.Accounts.NickReservation.Enabled { - _, nickAcquireError, _ := am.server.clients.SetNick(client, nil, account, true) - if !(nickAcquireError == nil || nickAcquireError == errNoop) { - return errAccountMustHoldNick - } - } - // can't register a guest nickname - if config.Accounts.NickReservation.guestRegexpFolded.MatchString(casefoldedAccount) { + renamePrefix := strings.ToLower(am.server.AccountConfig().NickReservation.RenamePrefix) + if renamePrefix != "" && strings.HasPrefix(casefoldedAccount, renamePrefix) { return errAccountAlreadyRegistered } accountKey := fmt.Sprintf(keyAccountExists, casefoldedAccount) - unregisteredKey := fmt.Sprintf(keyAccountUnregistered, casefoldedAccount) accountNameKey := fmt.Sprintf(keyAccountName, casefoldedAccount) + callbackKey := fmt.Sprintf(keyAccountCallback, casefoldedAccount) registeredTimeKey := fmt.Sprintf(keyAccountRegTime, casefoldedAccount) credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccount) verificationCodeKey := fmt.Sprintf(keyAccountVerificationCode, casefoldedAccount) - settingsKey := fmt.Sprintf(keyAccountSettings, casefoldedAccount) certFPKey := fmt.Sprintf(keyCertToAccount, certfp) var creds AccountCredentials - creds.Version = 1 - err = creds.SetPassphrase(passphrase, am.server.Config().Accounts.Registration.BcryptCost) - if err != nil { - return err - } - creds.AddCertfp(certfp) - credStr, err := creds.Serialize() - if err != nil { - return err - } - - var settingsStr string - if callbackNamespace == "mailto" { - settings := AccountSettings{Email: callbackValue} - j, err := json.Marshal(settings) - if err == nil { - settingsStr = string(j) + // it's fine if this is empty, that just means no certificate is authorized + creds.Certificate = certfp + if passphrase != "" { + creds.PassphraseHash, err = passwd.GenerateEncodedPasswordBytes(passphrase) + creds.PassphraseIsV2 = true + if err != nil { + am.server.logger.Error("internal", fmt.Sprintf("could not hash password: %v", err)) + return errAccountCreation } } - registeredTimeStr := strconv.FormatInt(time.Now().UnixNano(), 10) + credText, err := json.Marshal(creds) + if err != nil { + am.server.logger.Error("internal", fmt.Sprintf("could not marshal credentials: %v", err)) + return errAccountCreation + } + credStr := string(credText) + + registeredTimeStr := strconv.FormatInt(time.Now().Unix(), 10) + callbackSpec := fmt.Sprintf("%s:%s", callbackNamespace, callbackValue) var setOptions *buntdb.SetOptions - ttl := time.Duration(config.Accounts.Registration.VerifyTimeout) + ttl := am.server.AccountConfig().Registration.VerifyTimeout if ttl != 0 { setOptions = &buntdb.SetOptions{Expires: true, TTL: ttl} } @@ -434,16 +162,12 @@ func (am *AccountManager) Register(client *Client, account string, callbackNames defer am.serialCacheUpdateMutex.Unlock() // can't register an account with the same name as a registered nick - if am.NickToAccount(account) != "" { - return errNameReserved + if am.NickToAccount(casefoldedAccount) != "" { + return errAccountAlreadyRegistered } return am.server.store.Update(func(tx *buntdb.Tx) error { - if _, err := tx.Get(unregisteredKey); err == nil { - return errAccountAlreadyUnregistered - } - - _, err = am.loadRawAccount(tx, casefoldedAccount) + _, err := am.loadRawAccount(tx, casefoldedAccount) if err != errAccountDoesNotExist { return errAccountAlreadyRegistered } @@ -460,7 +184,7 @@ func (am *AccountManager) Register(client *Client, account string, callbackNames tx.Set(accountNameKey, account, setOptions) tx.Set(registeredTimeKey, registeredTimeStr, setOptions) tx.Set(credentialsKey, credStr, setOptions) - tx.Set(settingsKey, settingsStr, setOptions) + tx.Set(callbackKey, callbackSpec, setOptions) if certfp != "" { tx.Set(certFPKey, casefoldedAccount, setOptions) } @@ -472,15 +196,11 @@ func (am *AccountManager) Register(client *Client, account string, callbackNames return err } - code, err := am.dispatchCallback(client, account, callbackNamespace, callbackValue) + code, err := am.dispatchCallback(client, casefoldedAccount, callbackNamespace, callbackValue) if err != nil { - am.Unregister(casefoldedAccount, true) - return ®istrationCallbackError{underlying: err} + am.Unregister(casefoldedAccount) + return errCallbackFailed } else { - if client != nil && code != "" { - am.server.logger.Info("accounts", - fmt.Sprintf("nickname %s registered account %s, pending verification", client.Nick(), account)) - } return am.server.store.Update(func(tx *buntdb.Tx) error { _, _, err = tx.Set(verificationCodeKey, code, setOptions) return err @@ -488,359 +208,72 @@ func (am *AccountManager) Register(client *Client, account string, callbackNames } } -type registrationCallbackError struct { - underlying error -} - -func (r *registrationCallbackError) Error() string { - return `Account verification could not be sent` -} - -func registrationCallbackErrorText(config *Config, client *Client, err error) string { - if callbackErr, ok := err.(*registrationCallbackError); ok { - // only expose a user-visible error if we are doing direct sending - if config.Accounts.Registration.EmailVerification.DirectSendingEnabled() { - errorText := ircutils.SanitizeText(callbackErr.underlying.Error(), 350) - return fmt.Sprintf(client.t("Could not dispatch registration e-mail: %s"), errorText) - } else { - return client.t("Could not dispatch registration e-mail") - } - } else { - return "" - } -} - -// ValidatePassphrase checks whether a passphrase is allowed by our rules -func ValidatePassphrase(passphrase string) error { - // sanity check the length - if len(passphrase) == 0 || len(passphrase) > 300 { - return errAccountBadPassphrase - } - // we use * as a placeholder in some places, if it's gotten this far then fail - if passphrase == "*" { - return errAccountBadPassphrase - } - // validate that the passphrase contains no spaces, and furthermore is valid as a - // non-final IRC parameter. we already checked that it is nonempty: - if passphrase[0] == ':' { - return errAccountBadPassphrase - } - for _, r := range passphrase { - if unicode.IsSpace(r) { - return errAccountBadPassphrase - } - } - return nil -} - -// changes the password for an account -func (am *AccountManager) setPassword(accountName string, password string, hasPrivs bool) (err error) { - cfAccount, err := CasefoldName(accountName) - if err != nil { - return errAccountDoesNotExist - } - - credKey := fmt.Sprintf(keyAccountCredentials, cfAccount) - var credStr string - am.server.store.View(func(tx *buntdb.Tx) error { - // no need to check verification status here or below; - // you either need to be auth'ed to the account or be an oper to do this - credStr, err = tx.Get(credKey) - return nil - }) - - if err != nil { - return errAccountDoesNotExist - } - - var creds AccountCredentials - err = json.Unmarshal([]byte(credStr), &creds) - if err != nil { - return err - } - - if !hasPrivs && creds.Empty() { - return errCredsExternallyManaged - } - - err = creds.SetPassphrase(password, am.server.Config().Accounts.Registration.BcryptCost) - if err != nil { - return err - } - - if creds.Empty() && !hasPrivs { - return errEmptyCredentials - } - - newCredStr, err := creds.Serialize() - if err != nil { - return err - } - - err = am.server.store.Update(func(tx *buntdb.Tx) error { - curCredStr, err := tx.Get(credKey) - if credStr != curCredStr { - return errCASFailed - } - _, _, err = tx.Set(credKey, newCredStr, nil) - return err - }) - - return err -} - -type alwaysOnChannelStatus struct { - Modes string - JoinTime int64 -} - -func (am *AccountManager) saveChannels(account string, channelToModes map[string]alwaysOnChannelStatus) { - j, err := json.Marshal(channelToModes) - if err != nil { - am.server.logger.Error("internal", "couldn't marshal channel-to-modes", account, err.Error()) - return - } - jStr := string(j) - key := fmt.Sprintf(keyAccountChannelToModes, account) - am.server.store.Update(func(tx *buntdb.Tx) error { - tx.Set(key, jStr, nil) - return nil - }) -} - -func (am *AccountManager) loadChannels(account string) (channelToModes map[string]alwaysOnChannelStatus) { - key := fmt.Sprintf(keyAccountChannelToModes, account) - var channelsStr string - am.server.store.View(func(tx *buntdb.Tx) error { - channelsStr, _ = tx.Get(key) - return nil - }) - if channelsStr == "" { - return nil - } - err := json.Unmarshal([]byte(channelsStr), &channelToModes) - if err != nil { - am.server.logger.Error("internal", "couldn't marshal channel-to-modes", account, err.Error()) - return nil - } - return -} - -func (am *AccountManager) saveModes(account string, uModes modes.Modes) { - modeStr := uModes.String() - key := fmt.Sprintf(keyAccountModes, account) - am.server.store.Update(func(tx *buntdb.Tx) error { - tx.Set(key, modeStr, nil) - return nil - }) -} - -func (am *AccountManager) loadModes(account string) (uModes modes.Modes) { - key := fmt.Sprintf(keyAccountModes, account) - var modeStr string - am.server.store.View(func(tx *buntdb.Tx) error { - modeStr, _ = tx.Get(key) - return nil - }) - for _, m := range modeStr { - uModes = append(uModes, modes.Mode(m)) - } - return -} - -func (am *AccountManager) saveLastSeen(account string, lastSeen map[string]time.Time) { - key := fmt.Sprintf(keyAccountLastSeen, account) - am.saveTimeMap(account, key, lastSeen) -} - -func (am *AccountManager) saveReadMarkers(account string, readMarkers map[string]time.Time) { - key := fmt.Sprintf(keyAccountReadMarkers, account) - am.saveTimeMap(account, key, readMarkers) -} - -func (am *AccountManager) saveTimeMap(account, key string, timeMap map[string]time.Time) { - var val string - if len(timeMap) != 0 { - text, _ := json.Marshal(timeMap) - val = string(text) - } - err := am.server.store.Update(func(tx *buntdb.Tx) error { - if val != "" { - tx.Set(key, val, nil) - } else { - tx.Delete(key) - } - return nil - }) - if err != nil { - am.server.logger.Error("internal", "error persisting timeMap", key, err.Error()) - } -} - -func (am *AccountManager) loadTimeMap(baseKey, account string) (lastSeen map[string]time.Time) { - key := fmt.Sprintf(baseKey, account) - var lsText string - am.server.store.Update(func(tx *buntdb.Tx) error { - lsText, _ = tx.Get(key) - return nil - }) - if lsText == "" { - return nil - } - err := json.Unmarshal([]byte(lsText), &lastSeen) - if err != nil { - return nil - } - return -} - -func (am *AccountManager) saveRealname(account string, realname string) { - key := fmt.Sprintf(keyAccountRealname, account) - am.server.store.Update(func(tx *buntdb.Tx) error { - if realname != "" { - tx.Set(key, realname, nil) - } else { - tx.Delete(key) - } - return nil - }) -} - -func (am *AccountManager) loadRealname(account string) (realname string) { - key := fmt.Sprintf(keyAccountRealname, account) - am.server.store.Update(func(tx *buntdb.Tx) error { - realname, _ = tx.Get(key) - return nil - }) - return -} - -func (am *AccountManager) addRemoveCertfp(account, certfp string, add bool, hasPrivs bool) (err error) { - certfp, err = utils.NormalizeCertfp(certfp) - if err != nil { - return err - } - - cfAccount, err := CasefoldName(account) - if err != nil { - return errAccountDoesNotExist - } - - credKey := fmt.Sprintf(keyAccountCredentials, cfAccount) - var credStr string - am.server.store.View(func(tx *buntdb.Tx) error { - credStr, err = tx.Get(credKey) - return nil - }) - - if err != nil { - return errAccountDoesNotExist - } - - var creds AccountCredentials - err = json.Unmarshal([]byte(credStr), &creds) - if err != nil { - return err - } - - if !hasPrivs && creds.Empty() { - return errCredsExternallyManaged - } - - if add { - err = creds.AddCertfp(certfp) - } else { - err = creds.RemoveCertfp(certfp) - } - if err != nil { - return err - } - - if creds.Empty() && !hasPrivs { - return errEmptyCredentials - } - - newCredStr, err := creds.Serialize() - if err != nil { - return err - } - - certfpKey := fmt.Sprintf(keyCertToAccount, certfp) - err = am.server.store.Update(func(tx *buntdb.Tx) error { - curCredStr, err := tx.Get(credKey) - if credStr != curCredStr { - return errCASFailed - } - if add { - _, err = tx.Get(certfpKey) - if err != buntdb.ErrNotFound { - return errCertfpAlreadyExists - } - tx.Set(certfpKey, cfAccount, nil) - } else { - tx.Delete(certfpKey) - } - _, _, err = tx.Set(credKey, newCredStr, nil) - return err - }) - - return err -} - -func (am *AccountManager) dispatchCallback(client *Client, account string, callbackNamespace string, callbackValue string) (string, error) { - if callbackNamespace == "*" || callbackNamespace == "none" || callbackNamespace == "admin" { +func (am *AccountManager) dispatchCallback(client *Client, casefoldedAccount string, callbackNamespace string, callbackValue string) (string, error) { + if callbackNamespace == "*" || callbackNamespace == "none" { return "", nil } else if callbackNamespace == "mailto" { - return am.dispatchMailtoCallback(client, account, callbackValue) + return am.dispatchMailtoCallback(client, casefoldedAccount, callbackValue) } else { - return "", fmt.Errorf("Callback not implemented: %s", callbackNamespace) + return "", errors.New(fmt.Sprintf("Callback not implemented: %s", callbackNamespace)) } } -func (am *AccountManager) dispatchMailtoCallback(client *Client, account string, callbackValue string) (code string, err error) { - config := am.server.Config().Accounts.Registration.EmailVerification - code = utils.GenerateSecretToken() +func (am *AccountManager) dispatchMailtoCallback(client *Client, casefoldedAccount string, callbackValue string) (code string, err error) { + config := am.server.AccountConfig().Registration.Callbacks.Mailto + buf := make([]byte, 16) + rand.Read(buf) + code = hex.EncodeToString(buf) subject := config.VerifyMessageSubject if subject == "" { subject = fmt.Sprintf(client.t("Verify your account on %s"), am.server.name) } + messageStrings := []string{ + fmt.Sprintf("From: %s\r\n", config.Sender), + fmt.Sprintf("To: %s\r\n", callbackValue), + fmt.Sprintf("Subject: %s\r\n", subject), + "\r\n", // end headers, begin message body + fmt.Sprintf(client.t("Account: %s"), casefoldedAccount) + "\r\n", + fmt.Sprintf(client.t("Verification code: %s"), code) + "\r\n", + "\r\n", + client.t("To verify your account, issue one of these commands:") + "\r\n", + fmt.Sprintf("/ACC VERIFY %s %s", casefoldedAccount, code) + "\r\n", + fmt.Sprintf("/MSG NickServ VERIFY %s %s", casefoldedAccount, code) + "\r\n", + } - message := email.ComposeMail(config, callbackValue, subject) - fmt.Fprintf(&message, client.t("Account: %s"), account) - message.WriteString("\r\n") - fmt.Fprintf(&message, client.t("Verification code: %s"), code) - message.WriteString("\r\n") - message.WriteString("\r\n") - message.WriteString(client.t("To verify your account, issue the following command:")) - message.WriteString("\r\n") - fmt.Fprintf(&message, "/MSG NickServ VERIFY %s %s\r\n", account, code) + var message []byte + for i := 0; i < len(messageStrings); i++ { + message = append(message, []byte(messageStrings[i])...) + } + addr := fmt.Sprintf("%s:%d", config.Server, config.Port) + var auth smtp.Auth + if config.Username != "" && config.Password != "" { + auth = smtp.PlainAuth("", config.Username, config.Password, config.Server) + } - err = email.SendMail(config, callbackValue, message.Bytes()) + // TODO: this will never send the password in plaintext over a nonlocal link, + // but it might send the email in plaintext, regardless of the value of + // config.TLS.InsecureSkipVerify + err = smtp.SendMail(addr, auth, config.Sender, []string{callbackValue}, message) if err != nil { - am.server.logger.Error("internal", "Failed to dispatch e-mail to", callbackValue, err.Error()) + am.server.logger.Error("internal", fmt.Sprintf("Failed to dispatch e-mail: %v", err)) } return } -func (am *AccountManager) Verify(client *Client, account string, code string, admin bool) error { +func (am *AccountManager) Verify(client *Client, account string, code string) error { casefoldedAccount, err := CasefoldName(account) - var skeleton string if err != nil || account == "" || account == "*" { return errAccountVerificationFailed } - if client != nil && client.Account() != "" { - return errAccountAlreadyLoggedIn - } - verifiedKey := fmt.Sprintf(keyAccountVerified, casefoldedAccount) accountKey := fmt.Sprintf(keyAccountExists, casefoldedAccount) accountNameKey := fmt.Sprintf(keyAccountName, casefoldedAccount) registeredTimeKey := fmt.Sprintf(keyAccountRegTime, casefoldedAccount) verificationCodeKey := fmt.Sprintf(keyAccountVerificationCode, casefoldedAccount) + callbackKey := fmt.Sprintf(keyAccountCallback, casefoldedAccount) credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccount) - settingsKey := fmt.Sprintf(keyAccountSettings, casefoldedAccount) var raw rawClientAccount @@ -848,34 +281,6 @@ func (am *AccountManager) Verify(client *Client, account string, code string, ad am.serialCacheUpdateMutex.Lock() defer am.serialCacheUpdateMutex.Unlock() - // do a final check for confusability (in case someone already verified - // a confusable identifier): - var unfoldedName string - err = am.server.store.View(func(tx *buntdb.Tx) error { - unfoldedName, err = tx.Get(accountNameKey) - return err - }) - if err != nil { - err = errAccountDoesNotExist - return - } - skeleton, err = Skeleton(unfoldedName) - if err != nil { - err = errAccountDoesNotExist - return - } - err = func() error { - am.RLock() - defer am.RUnlock() - if _, ok := am.skeletonToAccount[skeleton]; ok { - return errConfusableIdentifier - } - return nil - }() - if err != nil { - return - } - err = am.server.store.Update(func(tx *buntdb.Tx) error { raw, err = am.loadRawAccount(tx, casefoldedAccount) if err == errAccountDoesNotExist { @@ -886,21 +291,19 @@ func (am *AccountManager) Verify(client *Client, account string, code string, ad return errAccountAlreadyVerified } - if !admin { - // actually verify the code - // a stored code of "" means a none callback / no code required - success := false - storedCode, err := tx.Get(verificationCodeKey) - if err == nil { - // this is probably unnecessary - if storedCode == "" || utils.SecretTokensMatch(storedCode, code) { - success = true - } - } - if !success { - return errAccountVerificationInvalidCode + // actually verify the code + // a stored code of "" means a none callback / no code required + success := false + storedCode, err := tx.Get(verificationCodeKey) + if err == nil { + // this is probably unnecessary + if storedCode == "" || subtle.ConstantTimeCompare([]byte(code), []byte(storedCode)) == 1 { + success = true } } + if !success { + return errAccountVerificationInvalidCode + } // verify the account tx.Set(verifiedKey, "1", nil) @@ -910,15 +313,15 @@ func (am *AccountManager) Verify(client *Client, account string, code string, ad tx.Set(accountKey, "1", nil) tx.Set(accountNameKey, raw.Name, nil) tx.Set(registeredTimeKey, raw.RegisteredAt, nil) + tx.Set(callbackKey, raw.Callback, nil) tx.Set(credentialsKey, raw.Credentials, nil) - tx.Set(settingsKey, raw.Settings, nil) var creds AccountCredentials // XXX we shouldn't do (de)serialization inside the txn, // but this is like 2 usec on my system json.Unmarshal([]byte(raw.Credentials), &creds) - for _, cert := range creds.Certfps { - certFPKey := fmt.Sprintf(keyCertToAccount, cert) + if creds.Certificate != "" { + certFPKey := fmt.Sprintf(keyCertToAccount, creds.Certificate) tx.Set(certFPKey, casefoldedAccount, nil) } @@ -928,7 +331,6 @@ func (am *AccountManager) Verify(client *Client, account string, code string, ad if err == nil { am.Lock() am.nickToAccount[casefoldedAccount] = casefoldedAccount - am.skeletonToAccount[skeleton] = casefoldedAccount am.Unlock() } }() @@ -937,250 +339,10 @@ func (am *AccountManager) Verify(client *Client, account string, code string, ad return err } - nick := "[server admin]" - if client != nil { - nick = client.Nick() - } - am.server.logger.Info("accounts", "client", nick, "registered account", account) - raw.Verified = true - clientAccount, err := am.deserializeRawAccount(raw, casefoldedAccount) - if err != nil { - return err - } - if client != nil { - am.Login(client, clientAccount) - if client.AlwaysOn() { - client.markDirty(IncludeRealname) - } - } - // we may need to do nick enforcement here: - _, method := am.EnforcementStatus(casefoldedAccount, skeleton) - if method == NickEnforcementStrict { - currentClient := am.server.clients.Get(casefoldedAccount) - if currentClient != nil && currentClient != client && currentClient.Account() != casefoldedAccount { - am.server.RandomlyRename(currentClient) - } - } + am.Login(client, raw.Name) return nil } -// register and verify an account, for internal use -func (am *AccountManager) SARegister(account, passphrase string) (err error) { - err = am.Register(nil, account, "admin", "", passphrase, "") - if err == nil { - err = am.Verify(nil, account, "", true) - } - return -} - -type EmailChangeRecord struct { - TimeCreated time.Time - Code string - Email string -} - -func (am *AccountManager) NsSetEmail(client *Client, emailAddr string) (err error) { - casefoldedAccount := client.Account() - if casefoldedAccount == "" { - return errAccountNotLoggedIn - } - - if am.touchRegisterThrottle() { - am.server.logger.Warning("accounts", "global registration throttle exceeded by client changing email", client.Nick()) - return errLimitExceeded - } - - config := am.server.Config() - if !config.Accounts.Registration.EmailVerification.Enabled { - return errFeatureDisabled // redundant check, just in case - } - record := EmailChangeRecord{ - TimeCreated: time.Now().UTC(), - Code: utils.GenerateSecretToken(), - Email: emailAddr, - } - recordKey := fmt.Sprintf(keyAccountEmailChange, casefoldedAccount) - recordBytes, _ := json.Marshal(record) - recordVal := string(recordBytes) - am.server.store.Update(func(tx *buntdb.Tx) error { - tx.Set(recordKey, recordVal, nil) - return nil - }) - - if err != nil { - return err - } - - message := email.ComposeMail(config.Accounts.Registration.EmailVerification, - emailAddr, - fmt.Sprintf(client.t("Verify your change of e-mail address on %s"), am.server.name)) - message.WriteString(fmt.Sprintf(client.t("To confirm your change of e-mail address on %s, issue the following command:"), am.server.name)) - message.WriteString("\r\n") - fmt.Fprintf(&message, "/MSG NickServ VERIFYEMAIL %s\r\n", record.Code) - - err = email.SendMail(config.Accounts.Registration.EmailVerification, emailAddr, message.Bytes()) - if err == nil { - am.server.logger.Info("services", - fmt.Sprintf("email change verification sent for account %s", casefoldedAccount)) - return - } else { - am.server.logger.Error("internal", "Failed to dispatch e-mail change verification to", emailAddr, err.Error()) - return ®istrationCallbackError{err} - } -} - -func (am *AccountManager) NsVerifyEmail(client *Client, code string) (err error) { - casefoldedAccount := client.Account() - if casefoldedAccount == "" { - return errAccountNotLoggedIn - } - - var record EmailChangeRecord - success := false - key := fmt.Sprintf(keyAccountEmailChange, casefoldedAccount) - ttl := time.Duration(am.server.Config().Accounts.Registration.VerifyTimeout) - am.server.store.Update(func(tx *buntdb.Tx) error { - rawStr, err := tx.Get(key) - if err == nil && rawStr != "" { - err := json.Unmarshal([]byte(rawStr), &record) - if err == nil { - if (ttl == 0 || time.Since(record.TimeCreated) < ttl) && utils.SecretTokensMatch(record.Code, code) { - success = true - tx.Delete(key) - } - } - } - return nil - }) - - if !success { - return errAccountVerificationInvalidCode - } - - munger := func(in AccountSettings) (out AccountSettings, err error) { - out = in - out.Email = record.Email - return - } - - _, err = am.ModifyAccountSettings(casefoldedAccount, munger) - return -} - -func (am *AccountManager) NsSendpass(client *Client, accountName string) (err error) { - config := am.server.Config() - if !(config.Accounts.Registration.EmailVerification.Enabled && config.Accounts.Registration.EmailVerification.PasswordReset.Enabled) { - return errFeatureDisabled - } - - account, err := am.LoadAccount(accountName) - if err != nil { - return err - } - if !account.Verified { - return errAccountUnverified - } - if account.Suspended != nil { - return errAccountSuspended - } - if account.Settings.Email == "" { - return errValidEmailRequired - } - - record := PasswordResetRecord{ - TimeCreated: time.Now().UTC(), - Code: utils.GenerateSecretToken(), - } - recordKey := fmt.Sprintf(keyAccountPwReset, account.NameCasefolded) - recordBytes, _ := json.Marshal(record) - recordVal := string(recordBytes) - - am.server.store.Update(func(tx *buntdb.Tx) error { - recStr, recErr := tx.Get(recordKey) - if recErr == nil && recStr != "" { - var existing PasswordResetRecord - jErr := json.Unmarshal([]byte(recStr), &existing) - cooldown := time.Duration(config.Accounts.Registration.EmailVerification.PasswordReset.Cooldown) - if jErr == nil && time.Since(existing.TimeCreated) < cooldown { - err = errLimitExceeded - return nil - } - } - tx.Set(recordKey, recordVal, &buntdb.SetOptions{ - Expires: true, - TTL: time.Duration(config.Accounts.Registration.EmailVerification.PasswordReset.Timeout), - }) - return nil - }) - - if err != nil { - return - } - - subject := fmt.Sprintf(client.t("Reset your password on %s"), am.server.name) - message := email.ComposeMail(config.Accounts.Registration.EmailVerification, account.Settings.Email, subject) - fmt.Fprintf(&message, client.t("We received a request to reset your password on %[1]s for account: %[2]s"), am.server.name, account.Name) - message.WriteString("\r\n") - fmt.Fprintf(&message, client.t("If you did not initiate this request, you can safely ignore this message.")) - message.WriteString("\r\n") - message.WriteString("\r\n") - message.WriteString(client.t("Otherwise, to reset your password, issue the following command (replace `new_password` with your desired password):")) - message.WriteString("\r\n") - fmt.Fprintf(&message, "/MSG NickServ RESETPASS %s %s new_password\r\n", account.Name, record.Code) - - err = email.SendMail(config.Accounts.Registration.EmailVerification, account.Settings.Email, message.Bytes()) - if err == nil { - am.server.logger.Info("services", - fmt.Sprintf("client %s sent a password reset email for account %s", client.Nick(), account.Name)) - } else { - am.server.logger.Error("internal", "Failed to dispatch e-mail to", account.Settings.Email, err.Error()) - } - return - -} - -func (am *AccountManager) NsResetpass(client *Client, accountName, code, password string) (err error) { - if ValidatePassphrase(password) != nil { - return errAccountBadPassphrase - } - account, err := am.LoadAccount(accountName) - if err != nil { - return - } - if !account.Verified { - return errAccountUnverified - } - if account.Suspended != nil { - return errAccountSuspended - } - - success := false - key := fmt.Sprintf(keyAccountPwReset, account.NameCasefolded) - am.server.store.Update(func(tx *buntdb.Tx) error { - rawStr, err := tx.Get(key) - if err == nil && rawStr != "" { - var record PasswordResetRecord - err := json.Unmarshal([]byte(rawStr), &record) - if err == nil && utils.SecretTokensMatch(record.Code, code) { - success = true - tx.Delete(key) - } - } - return nil - }) - - if success { - return am.setPassword(accountName, password, true) - } else { - return errAccountInvalidCredentials - } -} - -type PasswordResetRecord struct { - TimeCreated time.Time - Code string -} - func marshalReservedNicks(nicks []string) string { return strings.Join(nicks, ",") } @@ -1194,10 +356,9 @@ func unmarshalReservedNicks(nicks string) (result []string) { func (am *AccountManager) SetNickReserved(client *Client, nick string, saUnreserve bool, reserve bool) error { cfnick, err := CasefoldName(nick) - skeleton, skerr := Skeleton(nick) // garbage nick, or garbage options, or disabled - nrconfig := am.server.Config().Accounts.NickReservation - if err != nil || skerr != nil || cfnick == "" || (reserve && saUnreserve) || !nrconfig.Enabled { + nrconfig := am.server.AccountConfig().NickReservation + if err != nil || cfnick == "" || (reserve && saUnreserve) || !nrconfig.Enabled { return errAccountNickReservationFailed } @@ -1209,11 +370,7 @@ func (am *AccountManager) SetNickReserved(client *Client, nick string, saUnreser account := client.Account() if saUnreserve { // unless this is a sadrop: - account := func() string { - am.RLock() - defer am.RUnlock() - return am.nickToAccount[cfnick] - }() + account = am.NickToAccount(cfnick) if account == "" { // nothing to do return nil @@ -1223,15 +380,8 @@ func (am *AccountManager) SetNickReserved(client *Client, nick string, saUnreser return errAccountNotLoggedIn } - am.Lock() - accountForNick := am.nickToAccount[cfnick] - var accountForSkeleton string - if reserve { - accountForSkeleton = am.skeletonToAccount[skeleton] - } - am.Unlock() - - if reserve && (accountForNick != "" || accountForSkeleton != "") { + accountForNick := am.NickToAccount(cfnick) + if reserve && accountForNick != "" { return errNicknameReserved } else if !reserve && !saUnreserve && accountForNick != account { return errNicknameReserved @@ -1262,18 +412,12 @@ func (am *AccountManager) SetNickReserved(client *Client, nick string, saUnreser if len(nicks) >= nrconfig.AdditionalNickLimit { return errAccountTooManyNicks } - nicks = append(nicks, nick) + nicks = append(nicks, cfnick) } else { - // compute (original reserved nicks) minus cfnick var newNicks []string for _, reservedNick := range nicks { - cfreservednick, _ := CasefoldName(reservedNick) - if cfreservednick != cfnick { + if reservedNick != cfnick { newNicks = append(newNicks, reservedNick) - } else { - // found the original, unfolded version of the nick we're dropping; - // recompute the true skeleton from it - skeleton, _ = Skeleton(reservedNick) } } nicks = newNicks @@ -1295,241 +439,72 @@ func (am *AccountManager) SetNickReserved(client *Client, nick string, saUnreser defer am.Unlock() if reserve { am.nickToAccount[cfnick] = account - am.skeletonToAccount[skeleton] = account } else { delete(am.nickToAccount, cfnick) - delete(am.skeletonToAccount, skeleton) } return nil } -func (am *AccountManager) checkPassphrase(accountName, passphrase string) (account ClientAccount, err error) { - account, err = am.LoadAccount(accountName) - // #1476: if grouped nicks are allowed, attempt to interpret accountName as a grouped nick - if err == errAccountDoesNotExist && !am.server.Config().Accounts.NickReservation.ForceNickEqualsAccount { - cfnick, cfErr := CasefoldName(accountName) - if cfErr != nil { - return - } - accountName = func() string { - am.RLock() - defer am.RUnlock() - return am.nickToAccount[cfnick] - }() - if accountName != "" { - account, err = am.LoadAccount(accountName) - } - } - if err != nil { - return - } - - if !account.Verified { - err = errAccountUnverified - return - } else if account.Suspended != nil { - err = errAccountSuspended - return - } - - switch account.Credentials.Version { - case 0: - err = am.checkLegacyPassphrase(migrations.CheckOragonoPassphraseV0, accountName, account.Credentials.PassphraseHash, passphrase) - case 1: - if passwd.CompareHashAndPassword(account.Credentials.PassphraseHash, []byte(passphrase)) != nil { - err = errAccountInvalidCredentials - } - if err == nil && account.Credentials.SCRAMCreds.Iters == 0 { - // XXX: if the account was created prior to 2.8, it doesn't have SCRAM credentials; - // since we temporarily have access to a valid plaintext password, create them: - am.rehashPassword(account.Name, passphrase) - } - case -1: - err = am.checkLegacyPassphrase(migrations.CheckAthemePassphrase, accountName, account.Credentials.PassphraseHash, passphrase) - case -2: - err = am.checkLegacyPassphrase(migrations.CheckAnopePassphrase, accountName, account.Credentials.PassphraseHash, passphrase) - default: - err = errAccountInvalidCredentials - } - return -} - -func (am *AccountManager) checkLegacyPassphrase(check migrations.PassphraseCheck, account string, hash []byte, passphrase string) (err error) { - err = check(hash, []byte(passphrase)) - if err != nil { - if err == migrations.ErrHashInvalid { - am.server.logger.Error("internal", "invalid legacy credentials for account", account) - } - return errAccountInvalidCredentials - } - // re-hash the passphrase with the latest algorithm - am.rehashPassword(account, passphrase) - return nil -} - -func (am *AccountManager) rehashPassword(accountName, passphrase string) { - err := am.setPassword(accountName, passphrase, true) - if err != nil { - am.server.logger.Error("internal", "could not upgrade user password", accountName, err.Error()) - } -} - -func (am *AccountManager) loadWithAutocreation(accountName string, autocreate bool) (account ClientAccount, err error) { - account, err = am.LoadAccount(accountName) - if err == errAccountDoesNotExist && autocreate { - err = am.SARegister(accountName, "") - if err != nil { - return - } - account, err = am.LoadAccount(accountName) - } - return -} - -func (am *AccountManager) AuthenticateByPassphrase(client *Client, accountName string, passphrase string) (err error) { - // XXX check this now, so we don't allow a redundant login for an always-on client - // even for a brief period. the other potential source of nick-account conflicts - // is from force-nick-equals-account, but those will be caught later by - // fixupNickEqualsAccount and if there is a conflict, they will be logged out. - if client.registered { - if clientAlready := am.server.clients.Get(accountName); clientAlready != nil && clientAlready.AlwaysOn() { - return errNickAccountMismatch - } - } - - if throttled, remainingTime := client.checkLoginThrottle(); throttled { - return &ThrottleError{remainingTime} - } - - var account ClientAccount - - defer func() { - if err == nil { - am.Login(client, account) - } - }() - - config := am.server.Config() - if config.Accounts.AuthScript.Enabled { - var output AuthScriptOutput - output, err = CheckAuthScript(am.server.semaphores.AuthScript, config.Accounts.AuthScript.ScriptConfig, - AuthScriptInput{AccountName: accountName, Passphrase: passphrase, IP: client.IP().String()}) - if err != nil { - am.server.logger.Error("internal", "failed shell auth invocation", err.Error()) - } else if output.Success { - if output.AccountName != "" { - accountName = output.AccountName - } - account, err = am.loadWithAutocreation(accountName, config.Accounts.AuthScript.Autocreate) - return - } - } - - account, err = am.checkPassphrase(accountName, passphrase) - return err -} - -func (am *AccountManager) AuthenticateByBearerToken(client *Client, tokenType, token string) (err error) { - switch tokenType { - case "oauth2": - return am.AuthenticateByOAuthBearer(client, oauth2.OAuthBearerOptions{Token: token}) - case "jwt": - return am.AuthenticateByJWT(client, token) - default: - return errInvalidBearerTokenType - } -} - -func (am *AccountManager) AuthenticateByOAuthBearer(client *Client, opts oauth2.OAuthBearerOptions) (err error) { - config := am.server.Config() - - if !config.Accounts.OAuth2.Enabled { - return errFeatureDisabled - } - - if throttled, remainingTime := client.checkLoginThrottle(); throttled { - return &ThrottleError{remainingTime} - } - - var username string - if config.Accounts.AuthScript.Enabled && config.Accounts.OAuth2.AuthScript { - username, err = am.authenticateByOAuthBearerScript(client, config, opts) - } else { - username, err = config.Accounts.OAuth2.Introspect(context.Background(), opts.Token) - } +func (am *AccountManager) AuthenticateByPassphrase(client *Client, accountName string, passphrase string) error { + account, err := am.LoadAccount(accountName) if err != nil { return err } - account, err := am.loadWithAutocreation(username, config.Accounts.OAuth2.Autocreate) - if err == nil { - am.Login(client, account) + if !account.Verified { + return errAccountUnverified } - return err -} -func (am *AccountManager) AuthenticateByJWT(client *Client, token string) (err error) { - config := am.server.Config() - // enabled check is encapsulated here: - accountName, err := config.Accounts.JWTAuth.Validate(token) - if err != nil { - am.server.logger.Debug("accounts", "invalid JWT token", err.Error()) - return errAccountInvalidCredentials - } - account, err := am.loadWithAutocreation(accountName, config.Accounts.JWTAuth.Autocreate) - if err == nil { - am.Login(client, account) - } - return err -} - -func (am *AccountManager) authenticateByOAuthBearerScript(client *Client, config *Config, opts oauth2.OAuthBearerOptions) (username string, err error) { - output, err := CheckAuthScript(am.server.semaphores.AuthScript, config.Accounts.AuthScript.ScriptConfig, - AuthScriptInput{OAuthBearer: &opts, IP: client.IP().String()}) - - if err != nil { - am.server.logger.Error("internal", "failed shell auth invocation", err.Error()) - return "", oauth2.ErrInvalidToken - } else if output.Success { - return output.AccountName, nil + if account.Credentials.PassphraseIsV2 { + err = passwd.ComparePassword(account.Credentials.PassphraseHash, []byte(passphrase)) } else { - return "", oauth2.ErrInvalidToken - } -} + // compare using legacy method + err = am.server.passwords.CompareHashAndPassword(account.Credentials.PassphraseHash, account.Credentials.PassphraseSalt, passphrase) + if err == nil { + // passphrase worked! silently upgrade them to use v2 hashing going forward. + //TODO(dan): in future, replace this with an am.updatePassphrase(blah) function, which we can reuse in /ns update pass? + err = am.server.store.Update(func(tx *buntdb.Tx) error { + var creds AccountCredentials + creds.Certificate = account.Credentials.Certificate + creds.PassphraseHash, err = passwd.GenerateEncodedPasswordBytes(passphrase) + creds.PassphraseIsV2 = true + if err != nil { + am.server.logger.Error("internal", fmt.Sprintf("could not hash password (updating existing hash version): %v", err)) + return errAccountCredUpdate + } -// AllNicks returns the uncasefolded nicknames for all accounts, including additional (grouped) nicks. -func (am *AccountManager) AllNicks() (result []string) { - accountNamePrefix := fmt.Sprintf(keyAccountName, "") - accountAdditionalNicksPrefix := fmt.Sprintf(keyAccountAdditionalNicks, "") + credText, err := json.Marshal(creds) + if err != nil { + am.server.logger.Error("internal", fmt.Sprintf("could not marshal credentials (updating existing hash version): %v", err)) + return errAccountCredUpdate + } + credStr := string(credText) - am.server.store.View(func(tx *buntdb.Tx) error { - // Account names - err := tx.AscendGreaterOrEqual("", accountNamePrefix, func(key, value string) bool { - if !strings.HasPrefix(key, accountNamePrefix) { - return false - } - result = append(result, value) - return true - }) + // we know the account name is valid if this line is reached, otherwise the + // above would have failed. as such, chuck out and ignore err on casefolding + casefoldedAccountName, _ := CasefoldName(accountName) + credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccountName) + + //TODO(dan): sling, can you please checkout this mutex usage, see if it + // makes sense or not? bleh + am.serialCacheUpdateMutex.Lock() + defer am.serialCacheUpdateMutex.Unlock() + + tx.Set(credentialsKey, credStr, nil) + + return nil + }) + } if err != nil { return err } + } + if err != nil { + return errAccountInvalidCredentials + } - // Additional nicks - return tx.AscendGreaterOrEqual("", accountAdditionalNicksPrefix, func(key, value string) bool { - if !strings.HasPrefix(key, accountAdditionalNicksPrefix) { - return false - } - additionalNicks := unmarshalReservedNicks(value) - for _, additionalNick := range additionalNicks { - result = append(result, additionalNick) - } - return true - }) - }) - - sort.Strings(result) - return + am.Login(client, account.Name) + return nil } func (am *AccountManager) LoadAccount(accountName string) (result ClientAccount, err error) { @@ -1548,85 +523,17 @@ func (am *AccountManager) LoadAccount(accountName string) (result ClientAccount, return } - result, err = am.deserializeRawAccount(raw, casefoldedAccount) - return -} - -func (am *AccountManager) accountWasUnregistered(accountName string) (result bool) { - casefoldedAccount, err := CasefoldName(accountName) - if err != nil { - return false - } - - unregisteredKey := fmt.Sprintf(keyAccountUnregistered, casefoldedAccount) - am.server.store.View(func(tx *buntdb.Tx) error { - if _, err := tx.Get(unregisteredKey); err == nil { - result = true - } - return nil - }) - return -} - -// look up the unfolded version of an account name, possibly after deletion -func (am *AccountManager) AccountToAccountName(account string) (result string) { - casefoldedAccount, err := CasefoldName(account) - if err != nil { - return - } - - unregisteredKey := fmt.Sprintf(keyAccountUnregistered, casefoldedAccount) - accountNameKey := fmt.Sprintf(keyAccountName, casefoldedAccount) - - am.server.store.View(func(tx *buntdb.Tx) error { - if name, err := tx.Get(accountNameKey); err == nil { - result = name - return nil - } - if name, err := tx.Get(unregisteredKey); err == nil { - result = name - } - return nil - }) - - return -} - -func (am *AccountManager) deserializeRawAccount(raw rawClientAccount, cfName string) (result ClientAccount, err error) { result.Name = raw.Name - result.NameCasefolded = cfName regTimeInt, _ := strconv.ParseInt(raw.RegisteredAt, 10, 64) - result.RegisteredAt = time.Unix(0, regTimeInt).UTC() + result.RegisteredAt = time.Unix(regTimeInt, 0) e := json.Unmarshal([]byte(raw.Credentials), &result.Credentials) if e != nil { - am.server.logger.Error("internal", "could not unmarshal credentials", e.Error()) + am.server.logger.Error("internal", fmt.Sprintf("could not unmarshal credentials: %v", e)) err = errAccountDoesNotExist return } result.AdditionalNicks = unmarshalReservedNicks(raw.AdditionalNicks) result.Verified = raw.Verified - if raw.VHost != "" { - e := json.Unmarshal([]byte(raw.VHost), &result.VHost) - if e != nil { - am.server.logger.Warning("internal", "could not unmarshal vhost for account", result.Name, e.Error()) - // pretend they have no vhost and move on - } - } - if raw.Settings != "" { - e := json.Unmarshal([]byte(raw.Settings), &result.Settings) - if e != nil { - am.server.logger.Warning("internal", "could not unmarshal settings for account", result.Name, e.Error()) - } - } - if raw.Suspended != "" { - sus := new(AccountSuspension) - e := json.Unmarshal([]byte(raw.Suspended), sus) - if e != nil { - am.server.logger.Error("internal", "corrupt suspension data", result.Name, e.Error()) - } else { - result.Suspended = sus - } - } return } @@ -1636,10 +543,8 @@ func (am *AccountManager) loadRawAccount(tx *buntdb.Tx, casefoldedAccount string registeredTimeKey := fmt.Sprintf(keyAccountRegTime, casefoldedAccount) credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccount) verifiedKey := fmt.Sprintf(keyAccountVerified, casefoldedAccount) + callbackKey := fmt.Sprintf(keyAccountCallback, casefoldedAccount) nicksKey := fmt.Sprintf(keyAccountAdditionalNicks, casefoldedAccount) - vhostKey := fmt.Sprintf(keyAccountVHost, casefoldedAccount) - settingsKey := fmt.Sprintf(keyAccountSettings, casefoldedAccount) - suspendedKey := fmt.Sprintf(keyAccountSuspended, casefoldedAccount) _, e := tx.Get(accountKey) if e == buntdb.ErrNotFound { @@ -1650,10 +555,8 @@ func (am *AccountManager) loadRawAccount(tx *buntdb.Tx, casefoldedAccount string result.Name, _ = tx.Get(accountNameKey) result.RegisteredAt, _ = tx.Get(registeredTimeKey) result.Credentials, _ = tx.Get(credentialsKey) + result.Callback, _ = tx.Get(callbackKey) result.AdditionalNicks, _ = tx.Get(nicksKey) - result.VHost, _ = tx.Get(vhostKey) - result.Settings, _ = tx.Get(settingsKey) - result.Suspended, _ = tx.Get(suspendedKey) if _, e = tx.Get(verifiedKey); e == nil { result.Verified = true @@ -1662,164 +565,7 @@ func (am *AccountManager) loadRawAccount(tx *buntdb.Tx, casefoldedAccount string return } -type AccountSuspension struct { - AccountName string `json:"AccountName,omitempty"` - TimeCreated time.Time - Duration time.Duration - OperName string - Reason string -} - -func (am *AccountManager) Suspend(accountName string, duration time.Duration, operName, reason string) (err error) { - account, err := CasefoldName(accountName) - if err != nil { - return errAccountDoesNotExist - } - - suspension := AccountSuspension{ - TimeCreated: time.Now().UTC(), - Duration: duration, - OperName: operName, - Reason: reason, - } - suspensionStr, err := json.Marshal(suspension) - if err != nil { - am.server.logger.Error("internal", "suspension json unserializable", err.Error()) - return errAccountDoesNotExist - } - - existsKey := fmt.Sprintf(keyAccountExists, account) - suspensionKey := fmt.Sprintf(keyAccountSuspended, account) - var setOptions *buntdb.SetOptions - if duration != time.Duration(0) { - setOptions = &buntdb.SetOptions{Expires: true, TTL: duration} - } - err = am.server.store.Update(func(tx *buntdb.Tx) error { - _, err := tx.Get(existsKey) - if err != nil { - return errAccountDoesNotExist - } - _, _, err = tx.Set(suspensionKey, string(suspensionStr), setOptions) - return err - }) - - if err == errAccountDoesNotExist { - return err - } else if err != nil { - am.server.logger.Error("internal", "couldn't persist suspension", account, err.Error()) - } // keep going - - am.Lock() - clients := am.accountToClients[account] - delete(am.accountToClients, account) - am.Unlock() - - // kill clients, sending them the reason - suspension.AccountName = accountName - for _, client := range clients { - client.Logout() - client.Quit(suspensionToString(client, suspension), nil) - client.destroy(nil) - } - return nil -} - -func (am *AccountManager) killClients(clients []*Client) { - for _, client := range clients { - client.Logout() - client.Quit(client.t("You are no longer authorized to be on this server"), nil) - client.destroy(nil) - } -} - -func (am *AccountManager) Unsuspend(accountName string) (err error) { - cfaccount, err := CasefoldName(accountName) - if err != nil { - return errAccountDoesNotExist - } - - existsKey := fmt.Sprintf(keyAccountExists, cfaccount) - suspensionKey := fmt.Sprintf(keyAccountSuspended, cfaccount) - err = am.server.store.Update(func(tx *buntdb.Tx) error { - _, err := tx.Get(existsKey) - if err != nil { - return errAccountDoesNotExist - } - _, err = tx.Delete(suspensionKey) - if err != nil { - return errNoop - } - return nil - }) - - return err -} - -func (am *AccountManager) ListSuspended() (result []AccountSuspension) { - var names []string - var raw []string - - prefix := fmt.Sprintf(keyAccountSuspended, "") - am.server.store.View(func(tx *buntdb.Tx) error { - err := tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { - if !strings.HasPrefix(key, prefix) { - return false - } - raw = append(raw, value) - cfname := strings.TrimPrefix(key, prefix) - name, _ := tx.Get(fmt.Sprintf(keyAccountName, cfname)) - names = append(names, name) - return true - }) - return err - }) - - result = make([]AccountSuspension, 0, len(raw)) - for i := 0; i < len(raw); i++ { - var sus AccountSuspension - err := json.Unmarshal([]byte(raw[i]), &sus) - if err != nil { - am.server.logger.Error("internal", "corrupt data for suspension", names[i], err.Error()) - continue - } - sus.AccountName = names[i] - result = append(result, sus) - } - return -} - -// renames an account (within very restrictive limits); see #1380 -func (am *AccountManager) Rename(oldName, newName string) (err error) { - accountData, err := am.LoadAccount(oldName) - if err != nil { - return - } - newCfName, err := CasefoldName(newName) - if err != nil { - return errNicknameInvalid - } - if newCfName != accountData.NameCasefolded { - return errInvalidAccountRename - } - key := fmt.Sprintf(keyAccountName, accountData.NameCasefolded) - err = am.server.store.Update(func(tx *buntdb.Tx) error { - tx.Set(key, newName, nil) - return nil - }) - if err != nil { - return err - } - - am.RLock() - defer am.RUnlock() - for _, client := range am.accountToClients[accountData.NameCasefolded] { - client.setAccountName(newName) - } - return nil -} - -func (am *AccountManager) Unregister(account string, erase bool) error { - config := am.server.Config() +func (am *AccountManager) Unregister(account string) error { casefoldedAccount, err := CasefoldName(account) if err != nil { return errAccountDoesNotExist @@ -1829,35 +575,12 @@ func (am *AccountManager) Unregister(account string, erase bool) error { accountNameKey := fmt.Sprintf(keyAccountName, casefoldedAccount) registeredTimeKey := fmt.Sprintf(keyAccountRegTime, casefoldedAccount) credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccount) + callbackKey := fmt.Sprintf(keyAccountCallback, casefoldedAccount) verificationCodeKey := fmt.Sprintf(keyAccountVerificationCode, casefoldedAccount) verifiedKey := fmt.Sprintf(keyAccountVerified, casefoldedAccount) nicksKey := fmt.Sprintf(keyAccountAdditionalNicks, casefoldedAccount) - settingsKey := fmt.Sprintf(keyAccountSettings, casefoldedAccount) - vhostKey := fmt.Sprintf(keyAccountVHost, casefoldedAccount) - joinedChannelsKey := fmt.Sprintf(keyAccountChannelToModes, casefoldedAccount) - lastSeenKey := fmt.Sprintf(keyAccountLastSeen, casefoldedAccount) - readMarkersKey := fmt.Sprintf(keyAccountReadMarkers, casefoldedAccount) - unregisteredKey := fmt.Sprintf(keyAccountUnregistered, casefoldedAccount) - modesKey := fmt.Sprintf(keyAccountModes, casefoldedAccount) - realnameKey := fmt.Sprintf(keyAccountRealname, casefoldedAccount) - suspendedKey := fmt.Sprintf(keyAccountSuspended, casefoldedAccount) - pwResetKey := fmt.Sprintf(keyAccountPwReset, casefoldedAccount) - emailChangeKey := fmt.Sprintf(keyAccountEmailChange, casefoldedAccount) var clients []*Client - defer func() { - am.killClients(clients) - }() - - // on our way out, unregister all the account's channels and delete them from the db - defer func() { - for _, channelName := range am.server.channels.ChannelsForAccount(casefoldedAccount) { - err := am.server.channels.SetUnregistered(channelName, casefoldedAccount) - if err != nil { - am.server.logger.Error("internal", "couldn't unregister channel", channelName, err.Error()) - } - } - }() var credText string var rawNicks string @@ -1865,62 +588,33 @@ func (am *AccountManager) Unregister(account string, erase bool) error { am.serialCacheUpdateMutex.Lock() defer am.serialCacheUpdateMutex.Unlock() - var accountName string - keepProtections := false am.server.store.Update(func(tx *buntdb.Tx) error { - // get the unfolded account name; for an active account, this is - // stored under accountNameKey, for an unregistered account under unregisteredKey - accountName, _ = tx.Get(accountNameKey) - if accountName == "" { - accountName, _ = tx.Get(unregisteredKey) - } - if erase { - tx.Delete(unregisteredKey) - } else { - if _, err := tx.Get(verifiedKey); err == nil { - tx.Set(unregisteredKey, accountName, nil) - keepProtections = true - } - } tx.Delete(accountKey) tx.Delete(accountNameKey) tx.Delete(verifiedKey) tx.Delete(registeredTimeKey) + tx.Delete(callbackKey) tx.Delete(verificationCodeKey) - tx.Delete(settingsKey) rawNicks, _ = tx.Get(nicksKey) tx.Delete(nicksKey) credText, err = tx.Get(credentialsKey) tx.Delete(credentialsKey) - tx.Delete(vhostKey) - tx.Delete(joinedChannelsKey) - tx.Delete(lastSeenKey) - tx.Delete(readMarkersKey) - tx.Delete(modesKey) - tx.Delete(realnameKey) - tx.Delete(suspendedKey) - tx.Delete(pwResetKey) - tx.Delete(emailChangeKey) - return nil }) if err == nil { var creds AccountCredentials - if err := json.Unmarshal([]byte(credText), &creds); err == nil { - for _, cert := range creds.Certfps { - certFPKey := fmt.Sprintf(keyCertToAccount, cert) - am.server.store.Update(func(tx *buntdb.Tx) error { - if account, err := tx.Get(certFPKey); err == nil && account == casefoldedAccount { - tx.Delete(certFPKey) - } - return nil - }) - } + if err = json.Unmarshal([]byte(credText), &creds); err == nil && creds.Certificate != "" { + certFPKey := fmt.Sprintf(keyCertToAccount, creds.Certificate) + am.server.store.Update(func(tx *buntdb.Tx) error { + if account, err := tx.Get(certFPKey); err == nil && account == casefoldedAccount { + tx.Delete(certFPKey) + } + return nil + }) } } - skeleton, _ := Skeleton(accountName) additionalNicks := unmarshalReservedNicks(rawNicks) am.Lock() @@ -1928,80 +622,39 @@ func (am *AccountManager) Unregister(account string, erase bool) error { clients = am.accountToClients[casefoldedAccount] delete(am.accountToClients, casefoldedAccount) - // protect the account name itself where applicable, but not any grouped nicks - if !(keepProtections && config.Accounts.NickReservation.Method == NickEnforcementStrict) { - delete(am.nickToAccount, casefoldedAccount) - delete(am.skeletonToAccount, skeleton) - } + delete(am.nickToAccount, casefoldedAccount) for _, nick := range additionalNicks { delete(am.nickToAccount, nick) - additionalSkel, _ := Skeleton(nick) - delete(am.skeletonToAccount, additionalSkel) + } + for _, client := range clients { + am.logoutOfAccount(client) } - if err != nil && !erase { + if err != nil { return errAccountDoesNotExist } - return nil } -func unmarshalRegisteredChannels(channelsStr string) (result []string) { - if channelsStr != "" { - result = strings.Split(channelsStr, ",") - } - return -} - -func (am *AccountManager) AuthenticateByCertificate(client *Client, certfp string, peerCerts []*x509.Certificate, authzid string) (err error) { - if certfp == "" { +func (am *AccountManager) AuthenticateByCertFP(client *Client) error { + if client.certfp == "" { return errAccountInvalidCredentials } - var clientAccount ClientAccount - - defer func() { - if err != nil { - return - } else if !clientAccount.Verified { - err = errAccountUnverified - return - } else if clientAccount.Suspended != nil { - err = errAccountSuspended - return - } - // TODO(#1109) clean this check up? - if client.registered { - if clientAlready := am.server.clients.Get(clientAccount.Name); clientAlready != nil && clientAlready.AlwaysOn() { - err = errNickAccountMismatch - return - } - } - am.Login(client, clientAccount) - return - }() - - config := am.server.Config() - if config.Accounts.AuthScript.Enabled { - var output AuthScriptOutput - output, err = CheckAuthScript(am.server.semaphores.AuthScript, config.Accounts.AuthScript.ScriptConfig, - AuthScriptInput{Certfp: certfp, IP: client.IP().String(), peerCerts: peerCerts}) - if err != nil { - am.server.logger.Error("internal", "failed shell auth invocation", err.Error()) - } else if output.Success && output.AccountName != "" { - clientAccount, err = am.loadWithAutocreation(output.AccountName, config.Accounts.AuthScript.Autocreate) - return - } - } - var account string - certFPKey := fmt.Sprintf(keyCertToAccount, certfp) + var rawAccount rawClientAccount + certFPKey := fmt.Sprintf(keyCertToAccount, client.certfp) - err = am.server.store.View(func(tx *buntdb.Tx) error { + err := am.server.store.Update(func(tx *buntdb.Tx) error { + var err error account, _ = tx.Get(certFPKey) if account == "" { return errAccountInvalidCredentials } + rawAccount, err = am.loadRawAccount(tx, account) + if err != nil || !rawAccount.Verified { + return errAccountUnverified + } return nil }) @@ -2009,177 +662,18 @@ func (am *AccountManager) AuthenticateByCertificate(client *Client, certfp strin return err } - if authzid != "" { - if cfAuthzid, err := CasefoldName(authzid); err != nil || cfAuthzid != account { - return errAuthzidAuthcidMismatch - } - } - // ok, we found an account corresponding to their certificate - clientAccount, err = am.LoadAccount(account) - return err + + am.Login(client, rawAccount.Name) + return nil } -type settingsMunger func(input AccountSettings) (output AccountSettings, err error) - -func (am *AccountManager) ModifyAccountSettings(account string, munger settingsMunger) (newSettings AccountSettings, err error) { - casefoldedAccount, err := CasefoldName(account) - if err != nil { - return newSettings, errAccountDoesNotExist - } - // TODO implement this in general via a compare-and-swap API - accountData, err := am.LoadAccount(casefoldedAccount) - if err != nil { - return - } else if !accountData.Verified { - return newSettings, errAccountUnverified - } - newSettings, err = munger(accountData.Settings) - if err != nil { - return - } - text, err := json.Marshal(newSettings) - if err != nil { - return - } - key := fmt.Sprintf(keyAccountSettings, casefoldedAccount) - serializedValue := string(text) - err = am.server.store.Update(func(tx *buntdb.Tx) (err error) { - _, _, err = tx.Set(key, serializedValue, nil) - return - }) - if err != nil { - err = errAccountUpdateFailed - return - } - // success, push new settings into the client objects +func (am *AccountManager) Login(client *Client, account string) { am.Lock() defer am.Unlock() - for _, client := range am.accountToClients[casefoldedAccount] { - client.SetAccountSettings(newSettings) - } - return -} - -// represents someone's status in hostserv -type VHostInfo struct { - ApprovedVHost string - Enabled bool -} - -// callback type implementing the actual business logic of vhost operations -type vhostMunger func(input VHostInfo) (output VHostInfo, err error) - -func (am *AccountManager) VHostSet(account string, vhost string) (result VHostInfo, err error) { - munger := func(input VHostInfo) (output VHostInfo, err error) { - output = input - output.Enabled = true - output.ApprovedVHost = vhost - return - } - - return am.performVHostChange(account, munger) -} - -func (am *AccountManager) VHostSetEnabled(client *Client, enabled bool) (result VHostInfo, err error) { - munger := func(input VHostInfo) (output VHostInfo, err error) { - if input.ApprovedVHost == "" { - err = errNoVhost - return - } - output = input - output.Enabled = enabled - return - } - - return am.performVHostChange(client.Account(), munger) -} - -func (am *AccountManager) performVHostChange(account string, munger vhostMunger) (result VHostInfo, err error) { - account, err = CasefoldName(account) - if err != nil || account == "" { - err = errAccountDoesNotExist - return - } - - if am.server.Defcon() <= 3 { - err = errFeatureDisabled - return - } - - clientAccount, err := am.LoadAccount(account) - if err != nil { - err = errAccountDoesNotExist - return - } else if !clientAccount.Verified { - err = errAccountUnverified - return - } - - result, err = munger(clientAccount.VHost) - if err != nil { - return - } - - vhtext, err := json.Marshal(result) - if err != nil { - err = errAccountUpdateFailed - return - } - vhstr := string(vhtext) - - key := fmt.Sprintf(keyAccountVHost, account) - err = am.server.store.Update(func(tx *buntdb.Tx) error { - _, _, err := tx.Set(key, vhstr, nil) - return err - }) - - if err != nil { - err = errAccountUpdateFailed - return - } - - am.applyVhostToClients(account, result) - return result, nil -} - -func (am *AccountManager) applyVHostInfo(client *Client, info VHostInfo) { - // if hostserv is disabled in config, then don't grant vhosts - // that were previously approved while it was enabled - if !am.server.Config().Accounts.VHosts.Enabled { - return - } - - vhost := "" - if info.Enabled { - vhost = info.ApprovedVHost - } - oldNickmask := client.NickMaskString() - updated := client.SetVHost(vhost) - if updated && client.Registered() { - // TODO: doing I/O here is kind of a kludge - client.sendChghost(oldNickmask, client.Hostname()) - } -} - -func (am *AccountManager) applyVhostToClients(account string, result VHostInfo) { - am.RLock() - clients := am.accountToClients[account] - am.RUnlock() - - for _, client := range clients { - am.applyVHostInfo(client, result) - } -} - -func (am *AccountManager) Login(client *Client, account ClientAccount) { - client.Login(account) - - am.applyVHostInfo(client, account.VHost) + am.loginToAccount(client, account) casefoldedAccount := client.Account() - am.Lock() - defer am.Unlock() am.accountToClients[casefoldedAccount] = append(am.accountToClients[casefoldedAccount], client) } @@ -2192,7 +686,7 @@ func (am *AccountManager) Logout(client *Client) { return } - client.Logout() + am.logoutOfAccount(client) clients := am.accountToClients[casefoldedAccount] if len(clients) <= 1 { @@ -2208,219 +702,35 @@ func (am *AccountManager) Logout(client *Client) { } } am.accountToClients[casefoldedAccount] = remainingClients + return } var ( // EnabledSaslMechanisms contains the SASL mechanisms that exist and that we support. // This can be moved to some other data structure/place if we need to load/unload mechs later. - EnabledSaslMechanisms = map[string]func(*Server, *Client, *Session, []byte, *ResponseBuffer) bool{ - "PLAIN": authPlainHandler, - "EXTERNAL": authExternalHandler, - "SCRAM-SHA-256": authScramHandler, - "OAUTHBEARER": authOauthBearerHandler, - "IRCV3BEARER": authIRCv3BearerHandler, + EnabledSaslMechanisms = map[string]func(*Server, *Client, string, []byte, *ResponseBuffer) bool{ + "PLAIN": authPlainHandler, + "EXTERNAL": authExternalHandler, } ) -type CredentialsVersion int - -const ( - CredentialsLegacy CredentialsVersion = 0 - CredentialsSHA3Bcrypt CredentialsVersion = 1 - // negative numbers for migration - CredentialsAtheme = -1 - CredentialsAnope = -2 -) - -type SCRAMCreds struct { - Salt []byte - Iters int - StoredKey []byte - ServerKey []byte -} - // AccountCredentials stores the various methods for verifying accounts. type AccountCredentials struct { - Version CredentialsVersion + PassphraseSalt []byte PassphraseHash []byte - Certfps []string - SCRAMCreds -} - -func (ac *AccountCredentials) Empty() bool { - return len(ac.PassphraseHash) == 0 && len(ac.Certfps) == 0 -} - -// helper to assemble the serialized JSON for an account's credentials -func (ac *AccountCredentials) Serialize() (result string, err error) { - ac.Version = 1 - credText, err := json.Marshal(*ac) - if err != nil { - return "", err - } - return string(credText), nil -} - -func (ac *AccountCredentials) SetPassphrase(passphrase string, bcryptCost uint) (err error) { - if passphrase == "" { - ac.PassphraseHash = nil - ac.SCRAMCreds = SCRAMCreds{} - return nil - } - - if ValidatePassphrase(passphrase) != nil { - return errAccountBadPassphrase - } - - ac.PassphraseHash, err = passwd.GenerateFromPassword([]byte(passphrase), int(bcryptCost)) - if err != nil { - return errAccountBadPassphrase - } - - // we can pass an empty account name because it won't actually be incorporated - // into the credentials; it's just a quirk of the xdg-go/scram API that the way - // to produce server credentials is to call NewClient* and then GetStoredCredentials - scramClient, err := scram.SHA256.NewClientUnprepped("", passphrase, "") - if err != nil { - return errAccountBadPassphrase - } - salt := make([]byte, 16) - rand.Read(salt) - // xdg-go/scram says: "Clients have a default minimum PBKDF2 iteration count of 4096." - minIters := 4096 - scramCreds := scramClient.GetStoredCredentials(scram.KeyFactors{Salt: string(salt), Iters: minIters}) - ac.SCRAMCreds = SCRAMCreds{ - Salt: salt, - Iters: minIters, - StoredKey: scramCreds.StoredKey, - ServerKey: scramCreds.ServerKey, - } - - return nil -} - -func (am *AccountManager) NewScramConversation() *scram.ServerConversation { - server, _ := scram.SHA256.NewServer(am.lookupSCRAMCreds) - return server.NewConversation() -} - -func (am *AccountManager) lookupSCRAMCreds(accountName string) (creds scram.StoredCredentials, err error) { - // strip client ID if present: - if strudelIndex := strings.IndexByte(accountName, '@'); strudelIndex != -1 { - accountName = accountName[:strudelIndex] - } - - acct, err := am.LoadAccount(accountName) - if err != nil { - return - } - if acct.Credentials.SCRAMCreds.Iters == 0 { - err = errNoSCRAMCredentials - return - } - creds.Salt = string(acct.Credentials.SCRAMCreds.Salt) - creds.Iters = acct.Credentials.SCRAMCreds.Iters - creds.StoredKey = acct.Credentials.SCRAMCreds.StoredKey - creds.ServerKey = acct.Credentials.SCRAMCreds.ServerKey - return -} - -func (ac *AccountCredentials) AddCertfp(certfp string) (err error) { - // XXX we require that certfp is already normalized (rather than normalize here - // and pass back the normalized version as an additional return parameter); - // this is just a final sanity check: - if len(certfp) != 64 { - return utils.ErrInvalidCertfp - } - - for _, current := range ac.Certfps { - if certfp == current { - return errNoop - } - } - - if maxCertfpsPerAccount <= len(ac.Certfps) { - return errLimitExceeded - } - - ac.Certfps = append(ac.Certfps, certfp) - return nil -} - -func (ac *AccountCredentials) RemoveCertfp(certfp string) (err error) { - found := false - newList := make([]string, 0, len(ac.Certfps)) - for _, current := range ac.Certfps { - if current == certfp { - found = true - } else { - newList = append(newList, current) - } - } - if !found { - // this is important because it prevents you from deleting someone else's - // fingerprint record - return errNoop - } - ac.Certfps = newList - return nil -} - -type MulticlientAllowedSetting int - -const ( - MulticlientAllowedServerDefault MulticlientAllowedSetting = iota - MulticlientDisallowedByUser - MulticlientAllowedByUser -) - -// controls whether/when clients without event-playback support see fake -// PRIVMSGs for JOINs -type ReplayJoinsSetting uint - -const ( - ReplayJoinsCommandsOnly = iota // replay in HISTORY or CHATHISTORY output - ReplayJoinsAlways // replay in HISTORY, CHATHISTORY, or autoreplay -) - -func replayJoinsSettingFromString(str string) (result ReplayJoinsSetting, err error) { - switch strings.ToLower(str) { - case "commands-only": - result = ReplayJoinsCommandsOnly - case "always": - result = ReplayJoinsAlways - default: - err = errInvalidParams - } - return -} - -// XXX: AllowBouncer cannot be renamed AllowMulticlient because it is stored in -// persistent JSON blobs in the database -type AccountSettings struct { - AutoreplayLines *int - NickEnforcement NickEnforcementMethod - AllowBouncer MulticlientAllowedSetting - ReplayJoins ReplayJoinsSetting - AlwaysOn PersistentStatus - AutoreplayMissed bool - DMHistory HistoryStatus - AutoAway PersistentStatus - Email string + PassphraseIsV2 bool `json:"passphrase-is-v2"` + Certificate string // fingerprint } // ClientAccount represents a user account. type ClientAccount struct { // Name of the account. - Name string - NameCasefolded string + Name string + // RegisteredAt represents the time that the account was registered. RegisteredAt time.Time Credentials AccountCredentials Verified bool - Suspended *AccountSuspension AdditionalNicks []string - VHost VHostInfo - Settings AccountSettings } // convenience for passing around raw serialized account data @@ -2428,9 +738,34 @@ type rawClientAccount struct { Name string RegisteredAt string Credentials string + Callback string Verified bool AdditionalNicks string - VHost string - Settings string - Suspended string +} + +// loginToAccount logs the client into the given account. +func (am *AccountManager) loginToAccount(client *Client, account string) { + changed := client.SetAccountName(account) + if changed { + go client.nickTimer.Touch() + } +} + +// logoutOfAccount logs the client out of their current account. +func (am *AccountManager) logoutOfAccount(client *Client) { + if client.Account() == "" { + // already logged out + return + } + + client.SetAccountName("") + go client.nickTimer.Touch() + + // dispatch account-notify + // TODO: doing the I/O here is kind of a kludge, let's move this somewhere else + go func() { + for friend := range client.Friends(caps.AccountNotify) { + friend.Send(nil, client.NickMaskString(), "ACCOUNT", "*") + } + }() } diff --git a/irc/authscript.go b/irc/authscript.go deleted file mode 100644 index a63268dd..00000000 --- a/irc/authscript.go +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright (c) 2020 Shivaram Lingamneni -// released under the MIT license - -package irc - -import ( - "crypto/x509" - "encoding/json" - "encoding/pem" - "fmt" - "net" - - "github.com/ergochat/ergo/irc/oauth2" - "github.com/ergochat/ergo/irc/utils" -) - -// JSON-serializable input and output types for the script -type AuthScriptInput struct { - AccountName string `json:"accountName,omitempty"` - Passphrase string `json:"passphrase,omitempty"` - Certfp string `json:"certfp,omitempty"` - PeerCerts []string `json:"peerCerts,omitempty"` - peerCerts []*x509.Certificate - IP string `json:"ip,omitempty"` - OAuthBearer *oauth2.OAuthBearerOptions `json:"oauth2,omitempty"` -} - -type AuthScriptOutput struct { - AccountName string `json:"accountName"` - Success bool `json:"success"` - Error string `json:"error"` -} - -func CheckAuthScript(sem utils.Semaphore, config ScriptConfig, input AuthScriptInput) (output AuthScriptOutput, err error) { - if sem != nil { - sem.Acquire() - defer sem.Release() - } - - // PEM-encode the peer certificates before applying JSON - if len(input.peerCerts) != 0 { - input.PeerCerts = make([]string, len(input.peerCerts)) - for i, cert := range input.peerCerts { - input.PeerCerts[i] = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})) - } - } - - inputBytes, err := json.Marshal(input) - if err != nil { - return - } - outBytes, err := RunScript(config.Command, config.Args, inputBytes, config.Timeout, config.KillTimeout) - if err != nil { - return - } - err = json.Unmarshal(outBytes, &output) - if err != nil { - return - } - - if output.Error != "" { - err = fmt.Errorf("Authentication process reported error: %s", output.Error) - } - return -} - -type IPScriptResult uint - -const ( - IPNotChecked IPScriptResult = 0 - IPAccepted IPScriptResult = 1 - IPBanned IPScriptResult = 2 - IPRequireSASL IPScriptResult = 3 -) - -type IPScriptInput struct { - IP string `json:"ip"` -} - -type IPScriptOutput struct { - Result IPScriptResult `json:"result"` - BanMessage string `json:"banMessage"` - // for caching: the network to which this result is applicable, and a TTL in seconds: - CacheNet string `json:"cacheNet"` - CacheSeconds int `json:"cacheSeconds"` - Error string `json:"error"` -} - -func CheckIPBan(sem utils.Semaphore, config IPCheckScriptConfig, addr net.IP) (output IPScriptOutput, err error) { - if sem != nil { - sem.Acquire() - defer sem.Release() - } - - inputBytes, err := json.Marshal(IPScriptInput{IP: addr.String()}) - if err != nil { - return - } - outBytes, err := RunScript(config.Command, config.Args, inputBytes, config.Timeout, config.KillTimeout) - if err != nil { - return - } - err = json.Unmarshal(outBytes, &output) - if err != nil { - return - } - - if output.Error != "" { - err = fmt.Errorf("IP ban process reported error: %s", output.Error) - } else if !(IPAccepted <= output.Result && output.Result <= IPRequireSASL) { - err = fmt.Errorf("Invalid result from IP checking script: %d", output.Result) - } - - return -} diff --git a/irc/batch.go b/irc/batch.go new file mode 100644 index 00000000..0a602b22 --- /dev/null +++ b/irc/batch.go @@ -0,0 +1,78 @@ +// Copyright (c) 2017 Daniel Oaks +// released under the MIT license + +package irc + +import ( + "strconv" + "time" + + "github.com/goshuirc/irc-go/ircmsg" + "github.com/oragono/oragono/irc/caps" +) + +const ( + // maxBatchID is the maximum ID the batch counter can get to before it rotates. + // + // Batch IDs are made up of the current unix timestamp plus a rolling int ID that's + // incremented for every new batch. It's an alright solution and will work unless we get + // more than maxId batches per nanosecond. Later on when we have S2S linking, the batch + // ID will also contain the server ID to ensure they stay unique. + maxBatchID uint64 = 60000 +) + +// BatchManager helps generate new batches and new batch IDs. +type BatchManager struct { + idCounter uint64 +} + +// NewBatchManager returns a new Manager. +func NewBatchManager() *BatchManager { + return &BatchManager{} +} + +// NewID returns a new batch ID that should be unique. +func (bm *BatchManager) NewID() string { + bm.idCounter++ + if maxBatchID < bm.idCounter { + bm.idCounter = 0 + } + + return strconv.FormatInt(time.Now().UnixNano(), 36) + strconv.FormatUint(bm.idCounter, 36) +} + +// Batch represents an IRCv3 batch. +type Batch struct { + ID string + Type string + Params []string +} + +// New returns a new batch. +func (bm *BatchManager) New(batchType string, params ...string) *Batch { + newBatch := Batch{ + ID: bm.NewID(), + Type: batchType, + Params: params, + } + + return &newBatch +} + +// Start sends the batch start message to this client +func (b *Batch) Start(client *Client, tags *map[string]ircmsg.TagValue) { + if client.capabilities.Has(caps.Batch) { + params := []string{"+" + b.ID, b.Type} + for _, param := range b.Params { + params = append(params, param) + } + client.Send(tags, client.server.name, "BATCH", params...) + } +} + +// End sends the batch end message to this client +func (b *Batch) End(client *Client) { + if client.capabilities.Has(caps.Batch) { + client.Send(nil, client.server.name, "BATCH", "-"+b.ID) + } +} diff --git a/irc/bunt/bunt_datastore.go b/irc/bunt/bunt_datastore.go deleted file mode 100644 index 76c831e3..00000000 --- a/irc/bunt/bunt_datastore.go +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) 2022 Shivaram Lingamneni -// released under the MIT license - -package bunt - -import ( - "fmt" - "strings" - "time" - - "github.com/tidwall/buntdb" - - "github.com/ergochat/ergo/irc/datastore" - "github.com/ergochat/ergo/irc/logger" - "github.com/ergochat/ergo/irc/utils" -) - -// BuntKey yields a string key corresponding to a (table, UUID) pair. -// Ideally this would not be public, but some of the migration code -// needs it. -func BuntKey(table datastore.Table, uuid utils.UUID) string { - return fmt.Sprintf("%x %s", table, uuid.String()) -} - -// buntdbDatastore implements datastore.Datastore using a buntdb. -type buntdbDatastore struct { - db *buntdb.DB - logger *logger.Manager -} - -// NewBuntdbDatastore returns a datastore.Datastore backed by buntdb. -func NewBuntdbDatastore(db *buntdb.DB, logger *logger.Manager) datastore.Datastore { - return &buntdbDatastore{ - db: db, - logger: logger, - } -} - -func (b *buntdbDatastore) Backoff() time.Duration { - return 0 -} - -func (b *buntdbDatastore) GetAll(table datastore.Table) (result []datastore.KV, err error) { - tablePrefix := fmt.Sprintf("%x ", table) - err = b.db.View(func(tx *buntdb.Tx) error { - err := tx.AscendGreaterOrEqual("", tablePrefix, func(key, value string) bool { - if !strings.HasPrefix(key, tablePrefix) { - return false - } - uuid, err := utils.DecodeUUID(strings.TrimPrefix(key, tablePrefix)) - if err == nil { - result = append(result, datastore.KV{UUID: uuid, Value: []byte(value)}) - } else { - b.logger.Error("datastore", "invalid uuid", key) - } - return true - }) - return err - }) - return -} - -func (b *buntdbDatastore) Get(table datastore.Table, uuid utils.UUID) (value []byte, err error) { - buntKey := BuntKey(table, uuid) - var result string - err = b.db.View(func(tx *buntdb.Tx) error { - result, err = tx.Get(buntKey) - return err - }) - return []byte(result), err -} - -func (b *buntdbDatastore) Set(table datastore.Table, uuid utils.UUID, value []byte, expiration time.Time) (err error) { - buntKey := BuntKey(table, uuid) - var setOptions *buntdb.SetOptions - if !expiration.IsZero() { - ttl := time.Until(expiration) - if ttl > 0 { - setOptions = &buntdb.SetOptions{Expires: true, TTL: ttl} - } else { - return nil // it already expired, i guess? - } - } - strVal := string(value) - - err = b.db.Update(func(tx *buntdb.Tx) error { - _, _, err := tx.Set(buntKey, strVal, setOptions) - return err - }) - return -} - -func (b *buntdbDatastore) Delete(table datastore.Table, key utils.UUID) (err error) { - buntKey := BuntKey(table, key) - err = b.db.Update(func(tx *buntdb.Tx) error { - _, err := tx.Delete(buntKey) - return err - }) - // deleting a nonexistent key is not considered an error - switch err { - case buntdb.ErrNotFound: - return nil - default: - return err - } -} diff --git a/irc/caps/constants.go b/irc/caps/constants.go index 38497a2b..00e60a74 100644 --- a/irc/caps/constants.go +++ b/irc/caps/constants.go @@ -3,30 +3,58 @@ package caps -import "errors" - // Capability represents an optional feature that a client may request from the server. -type Capability uint +type Capability string -// actual capability definitions appear in defs.go +const ( + // LabelTagName is the tag name used for the labeled-response spec. + LabelTagName = "draft/label" -var ( - nameToCapability map[string]Capability - - NoSuchCap = errors.New("Unsupported capability name") + // AccountNotify is this IRCv3 capability: http://ircv3.net/specs/extensions/account-notify-3.1.html + AccountNotify Capability = "account-notify" + // AccountTag is this IRCv3 capability: http://ircv3.net/specs/extensions/account-tag-3.2.html + AccountTag Capability = "account-tag" + // AwayNotify is this IRCv3 capability: http://ircv3.net/specs/extensions/away-notify-3.1.html + AwayNotify Capability = "away-notify" + // Batch is this IRCv3 capability: http://ircv3.net/specs/extensions/batch-3.2.html + Batch Capability = "batch" + // CapNotify is this IRCv3 capability: http://ircv3.net/specs/extensions/cap-notify-3.2.html + CapNotify Capability = "cap-notify" + // ChgHost is this IRCv3 capability: http://ircv3.net/specs/extensions/chghost-3.2.html + ChgHost Capability = "chghost" + // EchoMessage is this IRCv3 capability: http://ircv3.net/specs/extensions/echo-message-3.2.html + EchoMessage Capability = "echo-message" + // ExtendedJoin is this IRCv3 capability: http://ircv3.net/specs/extensions/extended-join-3.1.html + ExtendedJoin Capability = "extended-join" + // InviteNotify is this IRCv3 capability: http://ircv3.net/specs/extensions/invite-notify-3.2.html + InviteNotify Capability = "invite-notify" + // LabeledResponse is this draft IRCv3 capability: http://ircv3.net/specs/extensions/labeled-response.html + LabeledResponse Capability = "draft/labeled-response" + // Languages is this proposed IRCv3 capability: https://gist.github.com/DanielOaks/8126122f74b26012a3de37db80e4e0c6 + Languages Capability = "draft/languages" + // MaxLine is this capability: https://oragono.io/maxline + MaxLine Capability = "oragono.io/maxline" + // MessageTags is this draft IRCv3 capability: http://ircv3.net/specs/core/message-tags-3.3.html + MessageTags Capability = "draft/message-tags-0.2" + // MultiPrefix is this IRCv3 capability: http://ircv3.net/specs/extensions/multi-prefix-3.1.html + MultiPrefix Capability = "multi-prefix" + // Rename is this proposed capability: https://github.com/SaberUK/ircv3-specifications/blob/rename/extensions/rename.md + Rename Capability = "draft/rename" + // Resume is this proposed capability: https://github.com/DanielOaks/ircv3-specifications/blob/master+resume/extensions/resume.md + Resume Capability = "draft/resume" + // SASL is this IRCv3 capability: http://ircv3.net/specs/extensions/sasl-3.2.html + SASL Capability = "sasl" + // ServerTime is this IRCv3 capability: http://ircv3.net/specs/extensions/server-time-3.2.html + ServerTime Capability = "server-time" + // STS is this IRCv3 capability: http://ircv3.net/specs/extensions/sts.html + STS Capability = "sts" + // UserhostInNames is this IRCv3 capability: http://ircv3.net/specs/extensions/userhost-in-names-3.2.html + UserhostInNames Capability = "userhost-in-names" ) // Name returns the name of the given capability. func (capability Capability) Name() string { - return capabilityNames[capability] -} - -func NameToCapability(name string) (result Capability, err error) { - result, found := nameToCapability[name] - if !found { - err = NoSuchCap - } - return + return string(capability) } // Version is used to select which max version of CAP the client supports. @@ -50,26 +78,3 @@ const ( // NegotiatedState means CAP negotiation has been successfully ended and reg should complete. NegotiatedState State = iota ) - -const ( - // LabelTagName is the tag name used for the labeled-response spec. - // https://ircv3.net/specs/extensions/labeled-response.html - LabelTagName = "label" - // More draft names associated with draft/multiline: - MultilineBatchType = "draft/multiline" - MultilineConcatTag = "draft/multiline-concat" - // draft/relaymsg: - RelaymsgTagName = "draft/relaymsg" - // BOT mode: https://ircv3.net/specs/extensions/bot-mode - BotTagName = "bot" - // https://ircv3.net/specs/extensions/chathistory - ChathistoryTargetsBatchType = "draft/chathistory-targets" - ExtendedISupportBatchType = "draft/extended-isupport" -) - -func init() { - nameToCapability = make(map[string]Capability, numCapabs) - for capab, name := range capabilityNames { - nameToCapability[name] = Capability(capab) - } -} diff --git a/irc/caps/defs.go b/irc/caps/defs.go deleted file mode 100644 index f02a5081..00000000 --- a/irc/caps/defs.go +++ /dev/null @@ -1,199 +0,0 @@ -package caps - -/* - WARNING: this file is autogenerated by `make capdefs` - DO NOT EDIT MANUALLY. -*/ - -const ( - // number of recognized capabilities: - numCapabs = 36 - // length of the uint32 array that represents the bitset: - bitsetLen = 2 -) - -const ( - // AccountNotify is the IRCv3 capability named "account-notify": - // https://ircv3.net/specs/extensions/account-notify-3.1.html - AccountNotify Capability = iota - - // AccountTag is the IRCv3 capability named "account-tag": - // https://ircv3.net/specs/extensions/account-tag-3.2.html - AccountTag Capability = iota - - // AwayNotify is the IRCv3 capability named "away-notify": - // https://ircv3.net/specs/extensions/away-notify-3.1.html - AwayNotify Capability = iota - - // Batch is the IRCv3 capability named "batch": - // https://ircv3.net/specs/extensions/batch-3.2.html - Batch Capability = iota - - // CapNotify is the IRCv3 capability named "cap-notify": - // https://ircv3.net/specs/extensions/cap-notify-3.2.html - CapNotify Capability = iota - - // ChgHost is the IRCv3 capability named "chghost": - // https://ircv3.net/specs/extensions/chghost-3.2.html - ChgHost Capability = iota - - // AccountRegistration is the draft IRCv3 capability named "draft/account-registration": - // https://github.com/ircv3/ircv3-specifications/pull/435 - AccountRegistration Capability = iota - - // ChannelRename is the draft IRCv3 capability named "draft/channel-rename": - // https://ircv3.net/specs/extensions/channel-rename - ChannelRename Capability = iota - - // Chathistory is the proposed IRCv3 capability named "draft/chathistory": - // https://github.com/ircv3/ircv3-specifications/pull/393 - Chathistory Capability = iota - - // EventPlayback is the proposed IRCv3 capability named "draft/event-playback": - // https://github.com/ircv3/ircv3-specifications/pull/362 - EventPlayback Capability = iota - - // ExtendedISupport is the proposed IRCv3 capability named "draft/extended-isupport": - // https://github.com/ircv3/ircv3-specifications/pull/543 - ExtendedISupport Capability = iota - - // Languages is the proposed IRCv3 capability named "draft/languages": - // https://gist.github.com/DanielOaks/8126122f74b26012a3de37db80e4e0c6 - Languages Capability = iota - - // MessageRedaction is the proposed IRCv3 capability named "draft/message-redaction": - // https://github.com/progval/ircv3-specifications/blob/redaction/extensions/message-redaction.md - MessageRedaction Capability = iota - - // Multiline is the proposed IRCv3 capability named "draft/multiline": - // https://github.com/ircv3/ircv3-specifications/pull/398 - Multiline Capability = iota - - // NoImplicitNames is the proposed IRCv3 capability named "draft/no-implicit-names": - // https://github.com/ircv3/ircv3-specifications/pull/527 - NoImplicitNames Capability = iota - - // Persistence is the proposed IRCv3 capability named "draft/persistence": - // https://github.com/ircv3/ircv3-specifications/pull/503 - Persistence Capability = iota - - // Preaway is the proposed IRCv3 capability named "draft/pre-away": - // https://github.com/ircv3/ircv3-specifications/pull/514 - Preaway Capability = iota - - // ReadMarker is the draft IRCv3 capability named "draft/read-marker": - // https://github.com/ircv3/ircv3-specifications/pull/489 - ReadMarker Capability = iota - - // Relaymsg is the proposed IRCv3 capability named "draft/relaymsg": - // https://github.com/ircv3/ircv3-specifications/pull/417 - Relaymsg Capability = iota - - // EchoMessage is the IRCv3 capability named "echo-message": - // https://ircv3.net/specs/extensions/echo-message-3.2.html - EchoMessage Capability = iota - - // Nope is the Ergo vendor capability named "ergo.chat/nope": - // https://ergo.chat/nope - Nope Capability = iota - - // ExtendedJoin is the IRCv3 capability named "extended-join": - // https://ircv3.net/specs/extensions/extended-join-3.1.html - ExtendedJoin Capability = iota - - // ExtendedMonitor is the IRCv3 capability named "extended-monitor": - // https://ircv3.net/specs/extensions/extended-monitor.html - ExtendedMonitor Capability = iota - - // InviteNotify is the IRCv3 capability named "invite-notify": - // https://ircv3.net/specs/extensions/invite-notify-3.2.html - InviteNotify Capability = iota - - // LabeledResponse is the IRCv3 capability named "labeled-response": - // https://ircv3.net/specs/extensions/labeled-response.html - LabeledResponse Capability = iota - - // MessageTags is the IRCv3 capability named "message-tags": - // https://ircv3.net/specs/extensions/message-tags.html - MessageTags Capability = iota - - // MultiPrefix is the IRCv3 capability named "multi-prefix": - // https://ircv3.net/specs/extensions/multi-prefix-3.1.html - MultiPrefix Capability = iota - - // SASL is the IRCv3 capability named "sasl": - // https://ircv3.net/specs/extensions/sasl-3.2.html - SASL Capability = iota - - // ServerTime is the IRCv3 capability named "server-time": - // https://ircv3.net/specs/extensions/server-time-3.2.html - ServerTime Capability = iota - - // SetName is the IRCv3 capability named "setname": - // https://ircv3.net/specs/extensions/setname.html - SetName Capability = iota - - // StandardReplies is the IRCv3 capability named "standard-replies": - // https://github.com/ircv3/ircv3-specifications/pull/506 - StandardReplies Capability = iota - - // STS is the IRCv3 capability named "sts": - // https://ircv3.net/specs/extensions/sts.html - STS Capability = iota - - // UserhostInNames is the IRCv3 capability named "userhost-in-names": - // https://ircv3.net/specs/extensions/userhost-in-names-3.2.html - UserhostInNames Capability = iota - - // ZNCPlayback is the ZNC vendor capability named "znc.in/playback": - // https://wiki.znc.in/Playback - ZNCPlayback Capability = iota - - // ZNCSelfMessage is the ZNC vendor capability named "znc.in/self-message": - // https://wiki.znc.in/Query_buffers - ZNCSelfMessage Capability = iota - - ExtendedNames Capability = iota -) - -// `capabilityNames[capab]` is the string name of the capability `capab` -var ( - capabilityNames = [numCapabs]string{ - "account-notify", - "account-tag", - "away-notify", - "batch", - "cap-notify", - "chghost", - "draft/account-registration", - "draft/channel-rename", - "draft/chathistory", - "draft/event-playback", - "draft/extended-isupport", - "draft/languages", - "draft/message-redaction", - "draft/multiline", - "draft/no-implicit-names", - "draft/persistence", - "draft/pre-away", - "draft/read-marker", - "draft/relaymsg", - "echo-message", - "ergo.chat/nope", - "extended-join", - "extended-monitor", - "invite-notify", - "labeled-response", - "message-tags", - "multi-prefix", - "sasl", - "server-time", - "setname", - "standard-replies", - "sts", - "userhost-in-names", - "znc.in/playback", - "znc.in/self-message", - "cef/extended-names", - } -) diff --git a/irc/caps/set.go b/irc/caps/set.go index 26251f2e..f18852bd 100644 --- a/irc/caps/set.go +++ b/irc/caps/set.go @@ -4,46 +4,45 @@ package caps import ( - "fmt" - "github.com/ergochat/ergo/irc/utils" + "sort" + "strings" + "sync" ) // Set holds a set of enabled capabilities. -type Set [bitsetLen]uint32 - -// Values holds capability values. -type Values map[Capability]string +type Set struct { + sync.RWMutex + // capabilities holds the capabilities this manager has. + capabilities map[Capability]bool +} // NewSet returns a new Set, with the given capabilities enabled. func NewSet(capabs ...Capability) *Set { - var newSet Set - newSet.Enable(capabs...) - return &newSet -} - -// NewCompleteSet returns a new Set, with all defined capabilities enabled. -func NewCompleteSet() *Set { - var newSet Set - asSlice := newSet[:] - for i := 0; i < numCapabs; i += 1 { - utils.BitsetSet(asSlice, uint(i), true) + newSet := Set{ + capabilities: make(map[Capability]bool), } + newSet.Enable(capabs...) + return &newSet } // Enable enables the given capabilities. func (s *Set) Enable(capabs ...Capability) { - asSlice := s[:] + s.Lock() + defer s.Unlock() + for _, capab := range capabs { - utils.BitsetSet(asSlice, uint(capab), true) + s.capabilities[capab] = true } } // Disable disables the given capabilities. func (s *Set) Disable(capabs ...Capability) { - asSlice := s[:] + s.Lock() + defer s.Unlock() + for _, capab := range capabs { - utils.BitsetSet(asSlice, uint(capab), false) + delete(s.capabilities, capab) } } @@ -59,85 +58,60 @@ func (s *Set) Remove(capabs ...Capability) { s.Disable(capabs...) } -// Has returns true if this set has the given capability. -func (s *Set) Has(capab Capability) bool { - return utils.BitsetGet(s[:], uint(capab)) -} +// Has returns true if this set has the given capabilities. +func (s *Set) Has(caps ...Capability) bool { + s.RLock() + defer s.RUnlock() -// HasAll returns true if the set has all the given capabilities. -func (s *Set) HasAll(capabs ...Capability) bool { - for _, capab := range capabs { - if !s.Has(capab) { + for _, cap := range caps { + if !s.capabilities[cap] { return false } } return true } -// Union adds all the capabilities of another set to this set. -func (s *Set) Union(other *Set) { - utils.BitsetUnion(s[:], other[:]) -} +// List return a list of our enabled capabilities. +func (s *Set) List() []Capability { + s.RLock() + defer s.RUnlock() -// Subtract removes all the capabilities of another set from this set. -func (s *Set) Subtract(other *Set) { - utils.BitsetSubtract(s[:], other[:]) -} - -// Empty returns whether the set is empty. -func (s *Set) Empty() bool { - return utils.BitsetEmpty(s[:]) -} - -const defaultMaxPayloadLength = 450 - -// Strings returns all of our enabled capabilities as a slice of strings. -func (s *Set) Strings(version Version, values Values, maxLen int) (result []string) { - if maxLen == 0 { - maxLen = defaultMaxPayloadLength + var allCaps []Capability + for capab := range s.capabilities { + allCaps = append(allCaps, capab) } - var t utils.TokenLineBuilder - t.Initialize(maxLen, " ") - var capab Capability - asSlice := s[:] - for capab = 0; capab < numCapabs; capab++ { - // XXX clients that only support CAP LS 301 cannot handle multiline - // responses. omit some CAPs in this case, forcing the response to fit on - // a single line. this is technically buggy for CAP LIST (as opposed to LS) - // but it shouldn't matter - if version < Cap302 && !isAllowed301(capab) { - continue - } - // skip any capabilities that are not enabled - if !utils.BitsetGet(asSlice, uint(capab)) { - continue - } - capString := capab.Name() - if version >= Cap302 { - val, exists := values[capab] + return allCaps +} + +// Count returns how many enabled caps this set has. +func (s *Set) Count() int { + s.RLock() + defer s.RUnlock() + + return len(s.capabilities) +} + +// String returns all of our enabled capabilities as a string. +func (s *Set) String(version Version, values *Values) string { + s.RLock() + defer s.RUnlock() + + var strs sort.StringSlice + + for capability := range s.capabilities { + capString := capability.Name() + if version == Cap302 { + val, exists := values.Get(capability) if exists { - capString = fmt.Sprintf("%s=%s", capString, val) + capString += "=" + val } } - t.Add(capString) + strs = append(strs, capString) } - result = t.Lines() - if result == nil { - result = []string{""} - } - return -} + // sort the cap string before we send it out + sort.Sort(strs) -// this is a fixed whitelist of caps that are eligible for display in CAP LS 301 -func isAllowed301(capab Capability) bool { - switch capab { - case AccountNotify, AccountTag, AwayNotify, Batch, ChgHost, Chathistory, EventPlayback, - Relaymsg, EchoMessage, Nope, ExtendedJoin, InviteNotify, LabeledResponse, MessageTags, - MultiPrefix, SASL, ServerTime, SetName, STS, UserhostInNames, ZNCSelfMessage, ZNCPlayback: - return true - default: - return false - } + return strings.Join(strs, " ") } diff --git a/irc/caps/set_test.go b/irc/caps/set_test.go index d807e5be..290c6e7f 100644 --- a/irc/caps/set_test.go +++ b/irc/caps/set_test.go @@ -3,23 +3,20 @@ package caps -import ( - "fmt" - "reflect" - "testing" -) +import "testing" +import "reflect" func TestSets(t *testing.T) { s1 := NewSet() s1.Enable(AccountTag, EchoMessage, UserhostInNames) - if !(s1.Has(AccountTag) && s1.Has(EchoMessage) && s1.Has(UserhostInNames)) { + if !s1.Has(AccountTag, EchoMessage, UserhostInNames) { t.Error("Did not have the tags we expected") } - if s1.Has(STS) { - t.Error("Has() returned true when we don't have the given capability") + if s1.Has(AccountTag, EchoMessage, STS, UserhostInNames) { + t.Error("Has() returned true when we don't have all the given capabilities") } s1.Disable(AccountTag) @@ -28,9 +25,14 @@ func TestSets(t *testing.T) { t.Error("Disable() did not correctly disable the given capability") } - enabledCaps := NewSet() - enabledCaps.Union(s1) - expectedCaps := NewSet(EchoMessage, UserhostInNames) + enabledCaps := make(map[Capability]bool) + for _, capab := range s1.List() { + enabledCaps[capab] = true + } + expectedCaps := map[Capability]bool{ + EchoMessage: true, + UserhostInNames: true, + } if !reflect.DeepEqual(enabledCaps, expectedCaps) { t.Errorf("Enabled and expected capability lists do not match: %v, %v", enabledCaps, expectedCaps) } @@ -38,72 +40,31 @@ func TestSets(t *testing.T) { // make sure re-enabling doesn't add to the count or something weird like that s1.Enable(EchoMessage) + if s1.Count() != 2 { + t.Error("Count() did not match expected capability count") + } + // make sure add and remove work fine s1.Add(InviteNotify) s1.Remove(EchoMessage) - if !s1.Has(InviteNotify) || s1.Has(EchoMessage) { - t.Error("Add/Remove don't work") + if s1.Count() != 2 { + t.Error("Count() did not match expected capability count") } - // test Strings() - values := make(Values) - values[InviteNotify] = "invitemepls" + // test String() + values := NewValues() + values.Set(InviteNotify, "invitemepls") - actualCap301ValuesString := s1.Strings(Cap301, values, 0) - expectedCap301ValuesString := []string{"invite-notify userhost-in-names"} - if !reflect.DeepEqual(actualCap301ValuesString, expectedCap301ValuesString) { - t.Errorf("Generated Cap301 values string [%v] did not match expected values string [%v]", actualCap301ValuesString, expectedCap301ValuesString) + actualCap301ValuesString := s1.String(Cap301, values) + expectedCap301ValuesString := "invite-notify userhost-in-names" + if actualCap301ValuesString != expectedCap301ValuesString { + t.Errorf("Generated Cap301 values string [%s] did not match expected values string [%s]", actualCap301ValuesString, expectedCap301ValuesString) } - actualCap302ValuesString := s1.Strings(Cap302, values, 0) - expectedCap302ValuesString := []string{"invite-notify=invitemepls userhost-in-names"} - if !reflect.DeepEqual(actualCap302ValuesString, expectedCap302ValuesString) { + actualCap302ValuesString := s1.String(Cap302, values) + expectedCap302ValuesString := "invite-notify=invitemepls userhost-in-names" + if actualCap302ValuesString != expectedCap302ValuesString { t.Errorf("Generated Cap302 values string [%s] did not match expected values string [%s]", actualCap302ValuesString, expectedCap302ValuesString) } } - -func assertEqual(found, expected interface{}) { - if !reflect.DeepEqual(found, expected) { - panic(fmt.Sprintf("found %#v, expected %#v", found, expected)) - } -} - -func Test301WhitelistNotRespectedFor302(t *testing.T) { - s1 := NewSet() - s1.Enable(AccountTag, EchoMessage, StandardReplies) - assertEqual(s1.Strings(Cap301, nil, 0), []string{"account-tag echo-message"}) - assertEqual(s1.Strings(Cap302, nil, 0), []string{"account-tag echo-message standard-replies"}) -} - -func TestSubtract(t *testing.T) { - s1 := NewSet(AccountTag, EchoMessage, UserhostInNames, ServerTime) - - toRemove := NewSet(UserhostInNames, EchoMessage) - s1.Subtract(toRemove) - - if !reflect.DeepEqual(s1, NewSet(AccountTag, ServerTime)) { - t.Errorf("subtract doesn't work") - } -} - -func BenchmarkSetReads(b *testing.B) { - set := NewSet(UserhostInNames, EchoMessage) - b.ResetTimer() - for i := 0; i < b.N; i++ { - set.Has(UserhostInNames) - set.Has(LabeledResponse) - set.Has(EchoMessage) - set.Has(Nope) - } -} - -func BenchmarkSetWrites(b *testing.B) { - for i := 0; i < b.N; i++ { - set := NewSet(UserhostInNames, EchoMessage) - set.Add(Nope) - set.Add(ExtendedJoin) - set.Remove(UserhostInNames) - set.Remove(LabeledResponse) - } -} diff --git a/irc/caps/values.go b/irc/caps/values.go new file mode 100644 index 00000000..b274f35f --- /dev/null +++ b/irc/caps/values.go @@ -0,0 +1,45 @@ +// Copyright (c) 2017 Daniel Oaks +// released under the MIT license + +package caps + +import "sync" + +// Values holds capability values. +type Values struct { + sync.RWMutex + // values holds our actual capability values. + values map[Capability]string +} + +// NewValues returns a new Values. +func NewValues() *Values { + return &Values{ + values: make(map[Capability]string), + } +} + +// Set sets the value for the given capability. +func (v *Values) Set(capab Capability, value string) { + v.Lock() + defer v.Unlock() + + v.values[capab] = value +} + +// Unset removes the value for the given capability, if it exists. +func (v *Values) Unset(capab Capability) { + v.Lock() + defer v.Unlock() + + delete(v.values, capab) +} + +// Get returns the value of the given capability, and whether one exists. +func (v *Values) Get(capab Capability) (string, bool) { + v.RLock() + defer v.RUnlock() + + value, exists := v.values[capab] + return value, exists +} diff --git a/irc/cef.go b/irc/cef.go deleted file mode 100644 index fa9c5a0c..00000000 --- a/irc/cef.go +++ /dev/null @@ -1,194 +0,0 @@ -package irc - -import ( - "context" - "crypto/sha256" - "encoding/json" - "fmt" - "github.com/cshum/imagor/imagorpath" - "github.com/ergochat/ergo/irc/caps" - "github.com/ergochat/irc-go/ircmsg" - "net/http" - "regexp" - "strings" - "time" -) - -var ctx = context.Background() - -func (channel *Channel) RedisBroadcast(message ...string) { - if channel.server.redis == nil { - return - } - err := channel.server.redis.Publish(ctx, "channel."+channel.NameCasefolded(), strings.Join(message, " ")).Err() - if err != nil { - fmt.Printf("Channel broadcast error: %v", err) - } -} - -func (client *Client) RedisBroadcast(message ...string) { - err := client.server.redis.Publish(ctx, "user."+client.NickCasefolded(), strings.Join(message, " ")).Err() - if err != nil { - fmt.Printf("User broadcast error: %v", err) - } -} - -func (channel *Channel) Broadcast(command string, params ...string) { - channel.BroadcastFrom(channel.server.name, command, params...) -} - -func (channel *Channel) BroadcastFrom(prefix string, command string, params ...string) { - for _, member := range channel.Members() { - for _, session := range member.Sessions() { - session.Send(nil, prefix, command, params...) - } - } -} - -// ChannelSub Actions and info centering around channels -func (server *Server) ChannelSub() { - pubsub := server.redis.PSubscribe(ctx, "channel.*") - defer pubsub.Close() - - ch := pubsub.Channel() - - for msg := range ch { - server.logger.Info("RedisMessage", msg.Channel, msg.Payload) - line := strings.Split(msg.Payload, " ") - channelName := strings.SplitN(msg.Channel, ".", 2)[1] - channel := server.channels.Get(channelName) - if len(line) == 0 { - println("Empty string dumped into ", msg.Channel, " channel") - } - if channel == nil { - server.logger.Warning("RedisMessage", "Unknown channel") - continue - } - - switch line[0] { - case "VOICEPART": - channel.Broadcast("VOICEPART", channelName, line[1]) - case "VOICESTATE": - channel.Broadcast("VOICESTATE", channelName, line[1], line[2], line[3]) - case "BROADCASTTO": - for _, person := range channel.Members() { - person.Send(nil, server.name, line[1], line[2:]...) - } - } - } -} - -// UserSub Handles things pertaining to users -func (server *Server) UserSub() { - pubsub := server.redis.PSubscribe(ctx, "user.*") - defer pubsub.Close() - - ch := pubsub.Channel() - - for msg := range ch { - server.logger.Info("RedisMessage", msg.Channel, msg.Payload) - line := strings.Split(msg.Payload, " ") - userName := strings.SplitN(msg.Channel, ".", 2)[1] - user := server.clients.Get(userName) - if len(line) == 0 { - println("Empty string dumped into ", msg.Channel, " channel") - } - - switch line[0] { - case "FULLYREMOVE": - if user != nil { - user.destroy(nil) - err := server.accounts.Unregister(user.Account(), true) - if err != nil { - return - } - } - break - case "BROADCASTAS": - if user != nil { - // I'm not too sure what the capability bit is, I think it's just ones that match - for friend := range user.Friends(caps.ExtendedNames) { - friend.Send(nil, user.NickMaskString(), line[1], line[2:]...) - } - } - break - } - } -} - -func startRedis(server *Server) { - go server.ChannelSub() - go server.UserSub() -} - -func (server *Server) GenerateImagorSignaturesFromMessage(message *ircmsg.Message) string { - line, err := message.Line() - if err == nil { - return server.GenerateImagorSignatures(line) - } - return "" -} - -func (server *Server) GetUrlMime(url string) string { - config := server.Config() - // hacky, should fix - if !strings.Contains(url, "?") { - url += "?" - } - params := imagorpath.Params{ - Image: url, - Meta: true, - } - metaPath := imagorpath.Generate(params, imagorpath.NewHMACSigner(sha256.New, 0, config.Cef.Imagor.Secret)) - client := http.Client{ - Timeout: 5 * time.Second, - } - resp, err := client.Get(config.Cef.Imagor.Url + metaPath) - if err != nil { - println("Failed on the initial get") - println(err.Error()) - return "" - } - defer resp.Body.Close() - var meta map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&meta) - if err != nil { - println("Failed on the JSON decode") - return "" - } - contentType, valid := meta["format"].(string) - if !valid { - println("No content type") - return "" - } - return contentType -} - -var urlRegex = regexp.MustCompile("https?:\\/\\/[\\w-]+(\\.[\\w-]+)+([\\w.,@?^=%&:/~+#-]*[\\w@?^=%&/~+#-])?") - -// Process a message to add Imagor signatures -func (server *Server) GenerateImagorSignatures(str string) string { - urls := urlRegex.FindAllString(str, -1) - var sigs []string - for _, url := range urls { - params := imagorpath.Params{ - Image: url, - FitIn: true, - Width: 600, - Height: 600, - } - path := imagorpath.Generate(params, imagorpath.NewHMACSigner(sha256.New, 0, server.Config().Cef.Imagor.Secret)) - signature := path[:strings.IndexByte(path, '/')] - contentType := server.GetUrlMime(url) - if contentType != "" { - sigs = append(sigs, signature+"|"+strings.ReplaceAll(contentType, "/", "_")) - } else { - sigs = append(sigs, signature) - } - - } - if len(sigs) > 0 { - return strings.Join(sigs, ",") - } - return "" -} diff --git a/irc/channel.go b/irc/channel.go index 780c1246..b082926c 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -7,113 +7,75 @@ package irc import ( "fmt" - "maps" "strconv" - "strings" "time" "sync" - "github.com/ergochat/irc-go/ircmsg" - - "github.com/ergochat/ergo/irc/caps" - "github.com/ergochat/ergo/irc/datastore" - "github.com/ergochat/ergo/irc/history" - "github.com/ergochat/ergo/irc/modes" - "github.com/ergochat/ergo/irc/utils" + "github.com/goshuirc/irc-go/ircmsg" + "github.com/oragono/oragono/irc/caps" + "github.com/oragono/oragono/irc/modes" ) -type ChannelSettings struct { - History HistoryStatus - QueryCutoff HistoryCutoff -} - // Channel represents a channel that clients can join. type Channel struct { flags modes.ModeSet lists map[modes.Mode]*UserMaskSet key string - forward string members MemberSet + membersCache []*Client // allow iteration over channel members without holding the lock + membersCacheMutex sync.Mutex // tier 2; see `regenerateMembersCache` name string nameCasefolded string server *Server createdTime time.Time registeredFounder string registeredTime time.Time - transferPendingTo string + stateMutex sync.RWMutex // tier 1 topic string topicSetBy string topicSetTime time.Time - userLimit int - accountToUMode map[string]modes.Mode - history history.Buffer - stateMutex sync.RWMutex // tier 1 - writebackLock sync.Mutex // tier 1.5 - joinPartMutex sync.Mutex // tier 3 - dirtyBits uint - settings ChannelSettings - uuid utils.UUID - // these caches are paired to allow iteration over channel members without holding the lock - membersCache []*Client - memberDataCache []*memberData + userLimit uint64 } // NewChannel creates a new channel from a `Server` and a `name` // string, which must be unique on the server. -func NewChannel(s *Server, name, casefoldedName string, registered bool, regInfo RegisteredChannel) *Channel { - config := s.Config() +func NewChannel(s *Server, name string, addDefaultModes bool, regInfo *RegisteredChannel) *Channel { + casefoldedName, err := CasefoldChannel(name) + if err != nil { + s.logger.Error("internal", fmt.Sprintf("Bad channel name %s: %v", name, err)) + return nil + } channel := &Channel{ - createdTime: time.Now().UTC(), // may be overwritten by applyRegInfo + createdTime: time.Now(), // may be overwritten by applyRegInfo + flags: make(modes.ModeSet), + lists: map[modes.Mode]*UserMaskSet{ + modes.BanMask: NewUserMaskSet(), + modes.ExceptMask: NewUserMaskSet(), + modes.InviteMask: NewUserMaskSet(), + }, members: make(MemberSet), name: name, nameCasefolded: casefoldedName, server: s, } - channel.initializeLists() - channel.history.Initialize(0, 0) - - if registered { - channel.applyRegInfo(regInfo) - } else { - channel.resizeHistory(config) - for _, mode := range config.Channels.defaultModes { - channel.flags.SetMode(mode, true) + if addDefaultModes { + for _, mode := range s.DefaultChannelModes() { + channel.flags[mode] = true } - channel.uuid = utils.GenerateUUIDv4() + } + + if regInfo != nil { + channel.applyRegInfo(regInfo) } return channel } -func (channel *Channel) initializeLists() { - channel.lists = map[modes.Mode]*UserMaskSet{ - modes.BanMask: NewUserMaskSet(), - modes.ExceptMask: NewUserMaskSet(), - modes.InviteMask: NewUserMaskSet(), - } - channel.accountToUMode = make(map[string]modes.Mode) -} - -func (channel *Channel) resizeHistory(config *Config) { - status, _, _ := channel.historyStatus(config) - if status == HistoryEphemeral { - channel.history.Resize(config.History.ChannelLength, time.Duration(config.History.AutoresizeWindow)) - } else { - channel.history.Resize(0, 0) - } -} - // read in channel state that was persisted in the DB -func (channel *Channel) applyRegInfo(chanReg RegisteredChannel) { - defer channel.resizeHistory(channel.server.Config()) - - channel.stateMutex.Lock() - defer channel.stateMutex.Unlock() - - channel.uuid = chanReg.UUID +func (channel *Channel) applyRegInfo(chanReg *RegisteredChannel) { channel.registeredFounder = chanReg.Founder channel.registeredTime = chanReg.RegisteredAt channel.topic = chanReg.Topic @@ -121,183 +83,41 @@ func (channel *Channel) applyRegInfo(chanReg RegisteredChannel) { channel.topicSetTime = chanReg.TopicSetTime channel.name = chanReg.Name channel.createdTime = chanReg.RegisteredAt - channel.key = chanReg.Key - channel.userLimit = chanReg.UserLimit - channel.settings = chanReg.Settings - channel.forward = chanReg.Forward - - for _, mode := range chanReg.Modes { - channel.flags.SetMode(mode, true) + for _, mask := range chanReg.Banlist { + channel.lists[modes.BanMask].Add(mask) } - for account, mode := range chanReg.AccountToUMode { - channel.accountToUMode[account] = mode + for _, mask := range chanReg.Exceptlist { + channel.lists[modes.ExceptMask].Add(mask) + } + for _, mask := range chanReg.Invitelist { + channel.lists[modes.InviteMask].Add(mask) } - channel.lists[modes.BanMask].SetMasks(chanReg.Bans) - channel.lists[modes.InviteMask].SetMasks(chanReg.Invites) - channel.lists[modes.ExceptMask].SetMasks(chanReg.Excepts) } // obtain a consistent snapshot of the channel state that can be persisted to the DB -func (channel *Channel) ExportRegistration() (info RegisteredChannel) { +func (channel *Channel) ExportRegistration(includeLists bool) (info RegisteredChannel) { channel.stateMutex.RLock() defer channel.stateMutex.RUnlock() info.Name = channel.name - info.UUID = channel.uuid - info.Founder = channel.registeredFounder - info.RegisteredAt = channel.registeredTime - info.Topic = channel.topic info.TopicSetBy = channel.topicSetBy info.TopicSetTime = channel.topicSetTime - - info.Key = channel.key - info.Forward = channel.forward - info.Modes = channel.flags.AllModes() - info.UserLimit = channel.userLimit - - info.Bans = channel.lists[modes.BanMask].Masks() - info.Invites = channel.lists[modes.InviteMask].Masks() - info.Excepts = channel.lists[modes.ExceptMask].Masks() - info.AccountToUMode = maps.Clone(channel.accountToUMode) - - info.Settings = channel.settings - - return -} - -func (channel *Channel) exportSummary() (info RegisteredChannel) { - channel.stateMutex.RLock() - defer channel.stateMutex.RUnlock() - - info.Name = channel.name info.Founder = channel.registeredFounder info.RegisteredAt = channel.registeredTime - return -} - -// begin: asynchronous database writeback implementation, modeled on irc/socket.go - -// MarkDirty marks part (or all) of a channel's data as needing to be written back -// to the database, then starts a writer goroutine if necessary. -// This is the equivalent of Socket.Write(). -func (channel *Channel) MarkDirty(dirtyBits uint) { - channel.stateMutex.Lock() - isRegistered := channel.registeredFounder != "" - channel.dirtyBits = channel.dirtyBits | dirtyBits - channel.stateMutex.Unlock() - if !isRegistered { - return - } - - channel.wakeWriter() -} - -// IsClean returns whether a channel can be safely removed from the server. -// To avoid the obvious TOCTOU race condition, it must be called while holding -// ChannelManager's lock (that way, no one can join and make the channel dirty again -// between this method exiting and the actual deletion). -func (channel *Channel) IsClean() bool { - if !channel.writebackLock.TryLock() { - // a database write (which may fail) is in progress, the channel cannot be cleaned up - return false - } - defer channel.writebackLock.Unlock() - - channel.stateMutex.RLock() - defer channel.stateMutex.RUnlock() - if len(channel.members) != 0 { - return false - } - // see #1507 and #704 among others; registered channels should never be removed - return channel.registeredFounder == "" -} - -func (channel *Channel) wakeWriter() { - if channel.writebackLock.TryLock() { - go channel.writeLoop() - } -} - -// equivalent of Socket.send() -func (channel *Channel) writeLoop() { - defer channel.server.HandlePanic() - - for { - // TODO(#357) check the error value of this and implement timed backoff - channel.performWrite(0) - channel.writebackLock.Unlock() - - channel.stateMutex.RLock() - isDirty := channel.dirtyBits != 0 - isEmpty := len(channel.members) == 0 - channel.stateMutex.RUnlock() - - if !isDirty { - if isEmpty { - channel.server.channels.Cleanup(channel) - } - return // nothing to do - } // else: isDirty, so we need to write again - - if !channel.writebackLock.TryLock() { - return + if includeLists { + for mask := range channel.lists[modes.BanMask].masks { + info.Banlist = append(info.Banlist, mask) + } + for mask := range channel.lists[modes.ExceptMask].masks { + info.Exceptlist = append(info.Exceptlist, mask) + } + for mask := range channel.lists[modes.InviteMask].masks { + info.Invitelist = append(info.Invitelist, mask) } } -} -// Store writes part (or all) of the channel's data back to the database, -// blocking until the write is complete. This is the equivalent of -// Socket.BlockingWrite. -func (channel *Channel) Store(dirtyBits uint) (err error) { - defer func() { - channel.stateMutex.Lock() - isDirty := channel.dirtyBits != 0 - isEmpty := len(channel.members) == 0 - channel.stateMutex.Unlock() - - if isDirty { - channel.wakeWriter() - } else if isEmpty { - channel.server.channels.Cleanup(channel) - } - }() - - channel.writebackLock.Lock() - defer channel.writebackLock.Unlock() - return channel.performWrite(dirtyBits) -} - -// do an individual write; equivalent of Socket.send() -func (channel *Channel) performWrite(additionalDirtyBits uint) (err error) { - channel.stateMutex.Lock() - dirtyBits := channel.dirtyBits | additionalDirtyBits - channel.dirtyBits = 0 - isRegistered := channel.registeredFounder != "" - channel.stateMutex.Unlock() - - if !isRegistered || dirtyBits == 0 { - return - } - - var success bool - info := channel.ExportRegistration() - if b, err := info.Serialize(); err == nil { - if err := channel.server.dstore.Set(datastore.TableChannels, info.UUID, b, time.Time{}); err == nil { - success = true - } else { - channel.server.logger.Error("internal", "couldn't persist channel", info.Name, err.Error()) - } - } else { - channel.server.logger.Error("internal", "couldn't serialize channel", info.Name, err.Error()) - } - - if !success { - channel.stateMutex.Lock() - channel.dirtyBits = channel.dirtyBits | dirtyBits - channel.stateMutex.Unlock() - } return } @@ -310,41 +130,10 @@ func (channel *Channel) SetRegistered(founder string) error { return errChannelAlreadyRegistered } channel.registeredFounder = founder - channel.registeredTime = time.Now().UTC() - channel.accountToUMode[founder] = modes.ChannelFounder + channel.registeredTime = time.Now() return nil } -// SetUnregistered deletes the channel's registration information. -func (channel *Channel) SetUnregistered(expectedFounder string) { - uuid := utils.GenerateUUIDv4() - channel.stateMutex.Lock() - defer channel.stateMutex.Unlock() - - if channel.registeredFounder != expectedFounder { - return - } - channel.registeredFounder = "" - var zeroTime time.Time - channel.registeredTime = zeroTime - channel.accountToUMode = make(map[string]modes.Mode) - // reset the UUID so that any re-registration will persist under - // a separate key: - channel.uuid = uuid -} - -// implements `CHANSERV CLEAR #chan ACCESS` (resets bans, invites, excepts, and amodes) -func (channel *Channel) resetAccess() { - defer channel.MarkDirty(IncludeLists) - - channel.stateMutex.Lock() - defer channel.stateMutex.Unlock() - channel.initializeLists() - if channel.registeredFounder != "" { - channel.accountToUMode[channel.registeredFounder] = modes.ChannelFounder - } -} - // IsRegistered returns whether the channel is registered. func (channel *Channel) IsRegistered() bool { channel.stateMutex.RLock() @@ -352,301 +141,181 @@ func (channel *Channel) IsRegistered() bool { return channel.registeredFounder != "" } -type channelTransferStatus uint - -const ( - channelTransferComplete channelTransferStatus = iota - channelTransferPending - channelTransferCancelled - channelTransferFailed -) - -// Transfer transfers ownership of a registered channel to a different account -func (channel *Channel) Transfer(client *Client, target string, hasPrivs bool) (status channelTransferStatus, err error) { - status = channelTransferFailed - defer func() { - if status == channelTransferComplete && err == nil { - channel.Store(IncludeAllAttrs) - } - }() - - cftarget, err := CasefoldName(target) - if err != nil { - err = errAccountDoesNotExist - return +func (channel *Channel) regenerateMembersCache(noLocksNeeded bool) { + // this is eventually consistent even without holding stateMutex.Lock() + // throughout the update; all updates to `members` while holding Lock() + // have a serial order, so the call to `regenerateMembersCache` that + // happens-after the last one will see *all* the updates. then, + // `membersCacheMutex` ensures that this final read is correctly paired + // with the final write to `membersCache`. + if !noLocksNeeded { + channel.membersCacheMutex.Lock() + defer channel.membersCacheMutex.Unlock() + channel.stateMutex.RLock() } - channel.stateMutex.Lock() - defer channel.stateMutex.Unlock() - if channel.registeredFounder == "" { - err = errChannelNotOwnedByAccount - return - } - if hasPrivs { - channel.transferOwnership(cftarget) - return channelTransferComplete, nil - } else { - if channel.registeredFounder == cftarget { - // transferring back to yourself cancels a pending transfer - channel.transferPendingTo = "" - return channelTransferCancelled, nil - } else { - channel.transferPendingTo = cftarget - return channelTransferPending, nil - } - } -} -func (channel *Channel) transferOwnership(newOwner string) { - delete(channel.accountToUMode, channel.registeredFounder) - channel.registeredFounder = newOwner - channel.accountToUMode[channel.registeredFounder] = modes.ChannelFounder - channel.transferPendingTo = "" -} - -// AcceptTransfer implements `CS TRANSFER #chan ACCEPT` -func (channel *Channel) AcceptTransfer(client *Client) (err error) { - defer func() { - if err == nil { - channel.Store(IncludeAllAttrs) - } - }() - - account := client.Account() - if account == "" { - return errAccountNotLoggedIn - } - channel.stateMutex.Lock() - defer channel.stateMutex.Unlock() - if account != channel.transferPendingTo { - return errChannelTransferNotOffered - } - channel.transferOwnership(account) - return nil -} - -func (channel *Channel) regenerateMembersCache() { - channel.stateMutex.RLock() - membersCache := make([]*Client, len(channel.members)) - dataCache := make([]*memberData, len(channel.members)) + result := make([]*Client, len(channel.members)) i := 0 - for client, info := range channel.members { - membersCache[i] = client - dataCache[i] = info + for client := range channel.members { + result[i] = client i++ } - channel.stateMutex.RUnlock() - - channel.stateMutex.Lock() - channel.membersCache = membersCache - channel.memberDataCache = dataCache - channel.stateMutex.Unlock() + if !noLocksNeeded { + channel.stateMutex.RUnlock() + channel.stateMutex.Lock() + } + channel.membersCache = result + if !noLocksNeeded { + channel.stateMutex.Unlock() + } } // Names sends the list of users joined to the channel to the given client. func (channel *Channel) Names(client *Client, rb *ResponseBuffer) { - channel.stateMutex.RLock() - clientData, isJoined := channel.members[client] - chname := channel.name - membersCache, memberDataCache := channel.membersCache, channel.memberDataCache - channel.stateMutex.RUnlock() - symbol := "=" // https://modern.ircdocs.horse/#rplnamreply-353 - if channel.flags.HasMode(modes.Secret) { - symbol = "@" - } - isOper := client.HasRoleCapabs("sajoin") - respectAuditorium := channel.flags.HasMode(modes.Auditorium) && !isOper && - (!isJoined || clientData.modes.HighestChannelUserMode() == modes.Mode(0)) - isMultiPrefix := rb.session.capabilities.Has(caps.MultiPrefix) - isUserhostInNames := rb.session.capabilities.Has(caps.UserhostInNames) - - maxNamLen := 480 - len(client.server.name) - len(client.Nick()) - len(chname) - var tl utils.TokenLineBuilder - tl.Initialize(maxNamLen, " ") - if isJoined || !channel.flags.HasMode(modes.Secret) || isOper { - for i, target := range membersCache { - if !isJoined && target.HasMode(modes.Invisible) && !isOper { - continue - } - var nick string - if isUserhostInNames { - nick = target.NickMaskString() - } else { - nick = target.Nick() - } - memberData := memberDataCache[i] - if respectAuditorium && memberData.modes.HighestChannelUserMode() == modes.Mode(0) { - continue - } - if rb.session.capabilities.Has(caps.ExtendedNames) { - away, _ := target.Away() - if away { - nick = nick + "*" - } - } - tl.AddParts(memberData.modes.Prefixes(isMultiPrefix), nick) + currentNicks := channel.nicks(client) + // assemble and send replies + maxNamLen := 480 - len(client.server.name) - len(client.nick) + var buffer string + for _, nick := range currentNicks { + if buffer == "" { + buffer += nick + continue } + + if len(buffer)+1+len(nick) > maxNamLen { + rb.Add(nil, client.server.name, RPL_NAMREPLY, client.nick, "=", channel.name, buffer) + buffer = nick + continue + } + + buffer += " " + buffer += nick } - for _, line := range tl.Lines() { - rb.Add(nil, client.server.name, RPL_NAMREPLY, client.nick, symbol, chname, line) - } - rb.Add(nil, client.server.name, RPL_ENDOFNAMES, client.nick, chname, client.t("End of NAMES list")) -} - -// does `clientMode` give you privileges to grant/remove `targetMode` to/from people, -// or to kick them? -func channelUserModeHasPrivsOver(clientMode modes.Mode, targetMode modes.Mode) bool { - switch clientMode { - case modes.ChannelFounder: - return true - case modes.ChannelAdmin, modes.ChannelOperator: - // admins cannot kick other admins, operators *can* kick other operators - return targetMode != modes.ChannelFounder && targetMode != modes.ChannelAdmin - case modes.Halfop: - // halfops cannot kick other halfops - return targetMode == modes.Voice || targetMode == modes.Mode(0) - default: - // voice and unprivileged cannot kick anyone - return false - } + rb.Add(nil, client.server.name, RPL_NAMREPLY, client.nick, "=", channel.name, buffer) + rb.Add(nil, client.server.name, RPL_ENDOFNAMES, client.nick, channel.name, client.t("End of NAMES list")) } // ClientIsAtLeast returns whether the client has at least the given channel privilege. func (channel *Channel) ClientIsAtLeast(client *Client, permission modes.Mode) bool { channel.stateMutex.RLock() - memberData, present := channel.members[client] - founder := channel.registeredFounder - channel.stateMutex.RUnlock() + defer channel.stateMutex.RUnlock() - if founder != "" && founder == client.Account() { + // get voice, since it's not a part of ChannelPrivModes + if channel.members.HasMode(client, permission) { return true } - if !present { - return false - } - - for _, mode := range modes.ChannelUserModes { - if memberData.modes.HasMode(mode) { + // check regular modes + for _, mode := range modes.ChannelPrivModes { + if channel.members.HasMode(client, mode) { return true } + if mode == permission { break } } + return false } func (channel *Channel) ClientPrefixes(client *Client, isMultiPrefix bool) string { channel.stateMutex.RLock() defer channel.stateMutex.RUnlock() - memberData, present := channel.members[client] + modes, present := channel.members[client] if !present { return "" } else { - return memberData.modes.Prefixes(isMultiPrefix) - } -} - -func (channel *Channel) ClientStatus(client *Client) (present bool, joinTimeSecs int64, cModes modes.Modes) { - channel.stateMutex.RLock() - defer channel.stateMutex.RUnlock() - memberData, present := channel.members[client] - return present, time.Unix(0, memberData.joinTime).Unix(), memberData.modes.AllModes() -} - -// helper for persisting channel-user modes for always-on clients; -// return the channel name and all channel-user modes for a client -func (channel *Channel) alwaysOnStatus(client *Client) (ok bool, chname string, status alwaysOnChannelStatus) { - channel.stateMutex.RLock() - defer channel.stateMutex.RUnlock() - chname = channel.name - data, ok := channel.members[client] - if !ok { - return - } - status.Modes = data.modes.String() - status.JoinTime = data.joinTime - return -} - -// overwrite any existing channel-user modes with the stored ones -func (channel *Channel) setMemberStatus(client *Client, status alwaysOnChannelStatus) { - newModes := modes.NewModeSet() - for _, mode := range status.Modes { - newModes.SetMode(modes.Mode(mode), true) - } - channel.stateMutex.Lock() - defer channel.stateMutex.Unlock() - if mData, ok := channel.members[client]; ok { - mData.modes.Clear() - for _, mode := range status.Modes { - mData.modes.SetMode(modes.Mode(mode), true) - } - mData.joinTime = status.JoinTime + return modes.Prefixes(isMultiPrefix) } } func (channel *Channel) ClientHasPrivsOver(client *Client, target *Client) bool { channel.stateMutex.RLock() - founder := channel.registeredFounder - clientData, clientOK := channel.members[client] - targetData, targetOK := channel.members[target] - channel.stateMutex.RUnlock() + defer channel.stateMutex.RUnlock() - if founder != "" { - if founder == client.Account() { - return true // #950: founder can take any privileged action without actually having +q - } else if founder == target.Account() { - return false // conversely, only the founder can kick the founder + clientModes := channel.members[client] + targetModes := channel.members[target] + result := false + for _, mode := range modes.ChannelPrivModes { + if clientModes[mode] { + result = true + // admins cannot kick other admins + if mode == modes.ChannelAdmin && targetModes[modes.ChannelAdmin] { + result = false + } + break + } else if channel.members[target][mode] { + break } } + return result +} - return clientOK && targetOK && - channelUserModeHasPrivsOver( - clientData.modes.HighestChannelUserMode(), - targetData.modes.HighestChannelUserMode(), - ) +func (channel *Channel) nicks(target *Client) []string { + isMultiPrefix := (target != nil) && target.capabilities.Has(caps.MultiPrefix) + isUserhostInNames := (target != nil) && target.capabilities.Has(caps.UserhostInNames) + + // slightly cumbersome: get the mutex and copy both the client pointers and + // the mode prefixes + channel.stateMutex.RLock() + length := len(channel.members) + clients := make([]*Client, length) + result := make([]string, length) + i := 0 + for client, modes := range channel.members { + clients[i] = client + result[i] = modes.Prefixes(isMultiPrefix) + i++ + } + channel.stateMutex.RUnlock() + + i = 0 + for i < length { + if isUserhostInNames { + result[i] += clients[i].NickMaskString() + } else { + result[i] += clients[i].Nick() + } + i++ + } + + return result } func (channel *Channel) hasClient(client *Client) bool { channel.stateMutex.RLock() + defer channel.stateMutex.RUnlock() _, present := channel.members[client] - channel.stateMutex.RUnlock() return present } // func (channel *Channel) modeStrings(client *Client) (result []string) { - hasPrivs := client.HasRoleCapabs("sajoin") + isMember := client.HasMode(modes.Operator) || channel.hasClient(client) + showKey := isMember && (channel.key != "") + showUserLimit := channel.userLimit > 0 + + mods := "+" + + // flags with args + if showKey { + mods += modes.Key.String() + } + if showUserLimit { + mods += modes.UserLimit.String() + } channel.stateMutex.RLock() defer channel.stateMutex.RUnlock() - isMember := hasPrivs || channel.members.Has(client) - showKey := isMember && (channel.key != "") - showUserLimit := channel.userLimit > 0 - showForward := channel.forward != "" - - var mods strings.Builder - mods.WriteRune('+') - - // flags with args - if showKey { - mods.WriteRune(rune(modes.Key)) - } - if showUserLimit { - mods.WriteRune(rune(modes.UserLimit)) - } - if showForward { - mods.WriteRune(rune(modes.Forward)) + // flags + for mode := range channel.flags { + mods += mode.String() } - for _, m := range channel.flags.AllModes() { - mods.WriteRune(rune(m)) - } - - result = []string{mods.String()} + result = []string{mods} // args for flags with args: The order must match above to keep // positional arguments in place. @@ -654,552 +323,159 @@ func (channel *Channel) modeStrings(client *Client) (result []string) { result = append(result, channel.key) } if showUserLimit { - result = append(result, strconv.Itoa(channel.userLimit)) - } - if showForward { - result = append(result, channel.forward) + result = append(result, strconv.FormatUint(channel.userLimit, 10)) } return } +// IsFull returns true if this channel is at its' members limit. +func (channel *Channel) IsFull() bool { + channel.stateMutex.RLock() + defer channel.stateMutex.RUnlock() + return (channel.userLimit > 0) && (uint64(len(channel.members)) >= channel.userLimit) +} + +// CheckKey returns true if the key is not set or matches the given key. +func (channel *Channel) CheckKey(key string) bool { + return (channel.key == "") || (channel.key == key) +} + func (channel *Channel) IsEmpty() bool { channel.stateMutex.RLock() defer channel.stateMutex.RUnlock() return len(channel.members) == 0 } -// figure out where history is being stored: persistent, ephemeral, or neither -// target is only needed if we're doing persistent history -func (channel *Channel) historyStatus(config *Config) (status HistoryStatus, target string, restrictions HistoryCutoff) { - if !config.History.Enabled { - return HistoryDisabled, "", HistoryCutoffNone - } - - channel.stateMutex.RLock() - target = channel.nameCasefolded - settings := channel.settings - registered := channel.registeredFounder != "" - channel.stateMutex.RUnlock() - - restrictions = settings.QueryCutoff - if restrictions == HistoryCutoffDefault { - restrictions = config.History.Restrictions.queryCutoff - } - - return channelHistoryStatus(config, registered, settings.History), target, restrictions -} - -func (channel *Channel) joinTimeCutoff(client *Client) (present bool, cutoff time.Time) { - account := client.Account() - - channel.stateMutex.RLock() - defer channel.stateMutex.RUnlock() - if data, ok := channel.members[client]; ok { - present = true - // report a cutoff of zero, i.e., no restriction, if the user is privileged - if !((account != "" && account == channel.registeredFounder) || data.modes.HasMode(modes.ChannelFounder) || data.modes.HasMode(modes.ChannelAdmin) || data.modes.HasMode(modes.ChannelOperator)) { - cutoff = time.Unix(0, data.joinTime) - } - } - return -} - -func channelHistoryStatus(config *Config, registered bool, storedStatus HistoryStatus) (result HistoryStatus) { - if !config.History.Enabled { - return HistoryDisabled - } - - // ephemeral history: either the channel owner explicitly set the ephemeral preference, - // or persistent history is disabled for unregistered channels - if registered { - return historyEnabled(config.History.Persistent.RegisteredChannels, storedStatus) - } else { - if config.History.Persistent.UnregisteredChannels { - return HistoryPersistent - } else { - return HistoryEphemeral - } - } -} - -func (channel *Channel) AddHistoryItem(item history.Item, account string) (err error) { - if !itemIsStorable(&item, channel.server.Config()) { - return - } - if item.Target == "" { - item.Target = channel.nameCasefolded - } - - status, target, _ := channel.historyStatus(channel.server.Config()) - if status == HistoryPersistent { - err = channel.server.historyDB.AddChannelItem(target, item, account) - } else if status == HistoryEphemeral { - channel.history.Add(item) - } - return -} - // Join joins the given client to this channel (if they can be joined). -func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *ResponseBuffer) (joinErr error, forward string) { - details := client.Details() - isBot := client.HasMode(modes.Bot) - - channel.stateMutex.RLock() - chname := channel.name - chcfname := channel.nameCasefolded - founder := channel.registeredFounder - createdAt := channel.createdTime - chkey := channel.key - limit := channel.userLimit - chcount := len(channel.members) - _, alreadyJoined := channel.members[client] - persistentMode := channel.accountToUMode[details.account] - forward = channel.forward - channel.stateMutex.RUnlock() - - if alreadyJoined { - // no message needs to be sent - return nil, "" - } - - // 0. SAJOIN always succeeds - // 1. the founder can always join (even if they disabled auto +q on join) - // 2. anyone who automatically receives halfop or higher can always join - // 3. people invited with INVITE can join - hasPrivs := isSajoin || (founder != "" && founder == details.account) || - (persistentMode != 0 && persistentMode != modes.Voice) || - client.CheckInvited(chcfname, createdAt) - if !hasPrivs { - if limit != 0 && chcount >= limit { - return errLimitExceeded, forward - } - - if chkey != "" && !utils.SecretTokensMatch(chkey, key) { - return errWrongChannelKey, forward - } - - // #1901: +h and up exempt from all restrictions, but +v additionally exempts from +i: - if channel.flags.HasMode(modes.InviteOnly) && persistentMode == 0 && - !channel.lists[modes.InviteMask].Match(details.nickMaskCasefolded) { - return errInviteOnly, forward - } - - if channel.lists[modes.BanMask].Match(details.nickMaskCasefolded) && - !channel.lists[modes.ExceptMask].Match(details.nickMaskCasefolded) && - !channel.lists[modes.InviteMask].Match(details.nickMaskCasefolded) { - // do not forward people who are banned: - return errBanned, "" - } - - if details.account == "" && - (channel.flags.HasMode(modes.RegisteredOnly) || channel.server.Defcon() <= 2) && - !channel.lists[modes.InviteMask].Match(details.nickMaskCasefolded) { - return errRegisteredOnly, forward - } - } - - if joinErr := client.addChannel(channel, rb == nil); joinErr != nil { - return joinErr, "" - } - - client.server.logger.Debug("channels", fmt.Sprintf("%s joined channel %s", details.nick, chname)) - // I think this is assured to always be a good join point - channel.RedisBroadcast("VOICEPOLL") - - givenMode := func() (givenMode modes.Mode) { - channel.joinPartMutex.Lock() - defer channel.joinPartMutex.Unlock() - - func() { - channel.stateMutex.Lock() - defer channel.stateMutex.Unlock() - - channel.members.Add(client) - firstJoin := len(channel.members) == 1 - newChannel := firstJoin && channel.registeredFounder == "" - if newChannel { - givenMode = modes.ChannelOperator - } else { - givenMode = persistentMode - } - if givenMode != 0 { - channel.members[client].modes.SetMode(givenMode, true) - } - }() - - channel.regenerateMembersCache() - +//TODO(dan): /SAJOIN and maybe a ForceJoin function? +func (channel *Channel) Join(client *Client, key string, rb *ResponseBuffer) { + if channel.hasClient(client) { + // already joined, no message needs to be sent return - }() - - var message utils.SplitMessage - respectAuditorium := givenMode == modes.Mode(0) && channel.flags.HasMode(modes.Auditorium) - message = utils.MakeMessage("") - // no history item for fake persistent joins - if rb != nil && !respectAuditorium { - histItem := history.Item{ - Type: history.Join, - Nick: details.nickMask, - Account: details.account, - Message: message, - Target: channel.NameCasefolded(), - IsBot: isBot, - } - histItem.Params[0] = details.realname - channel.AddHistoryItem(histItem, details.account) } - if rb == nil { - return nil, "" + if channel.IsFull() { + rb.Add(nil, client.server.name, ERR_CHANNELISFULL, channel.name, fmt.Sprintf(client.t("Cannot join channel (+%s)"), "l")) + return } - var modestr string - if givenMode != 0 { - modestr = fmt.Sprintf("+%v", givenMode) + if !channel.CheckKey(key) { + rb.Add(nil, client.server.name, ERR_BADCHANNELKEY, channel.name, fmt.Sprintf(client.t("Cannot join channel (+%s)"), "k")) + return } - // cache the most common case (JOIN without extended-join) - var cache MessageCache - cache.Initialize(channel.server, message.Time, message.Msgid, details.nickMask, details.accountName, isBot, nil, "JOIN", chname) - isAway, awayMessage := client.Away() + isInvited := channel.lists[modes.InviteMask].Match(client.nickMaskCasefolded) + if channel.flags[modes.InviteOnly] && !isInvited { + rb.Add(nil, client.server.name, ERR_INVITEONLYCHAN, channel.name, fmt.Sprintf(client.t("Cannot join channel (+%s)"), "i")) + return + } + + if channel.lists[modes.BanMask].Match(client.nickMaskCasefolded) && + !isInvited && + !channel.lists[modes.ExceptMask].Match(client.nickMaskCasefolded) { + rb.Add(nil, client.server.name, ERR_BANNEDFROMCHAN, channel.name, fmt.Sprintf(client.t("Cannot join channel (+%s)"), "b")) + return + } + + client.server.logger.Debug("join", fmt.Sprintf("%s joined channel %s", client.nick, channel.name)) + for _, member := range channel.Members() { - if respectAuditorium { - channel.stateMutex.RLock() - memberData, ok := channel.members[member] - channel.stateMutex.RUnlock() - if !ok || memberData.modes.HighestChannelUserMode() == modes.Mode(0) { - continue - } - } - for _, session := range member.Sessions() { - if session == rb.session { - continue - } else if client == session.client { - channel.playJoinForSession(session) - continue - } - if session.capabilities.Has(caps.ExtendedJoin) { - session.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, isBot, nil, "JOIN", chname, details.accountName, details.realname) + if member == client { + if member.capabilities.Has(caps.ExtendedJoin) { + rb.Add(nil, client.nickMaskString, "JOIN", channel.name, client.AccountName(), client.realname) } else { - cache.Send(session) - } - if givenMode != 0 { - session.Send(nil, client.server.name, "MODE", chname, modestr, details.nick) - } - if isAway && session.capabilities.Has(caps.AwayNotify) { - session.sendFromClientInternal(false, time.Time{}, "", details.nickMask, details.accountName, isBot, nil, "AWAY", awayMessage) - } - } - } - - if rb.session.capabilities.Has(caps.ExtendedJoin) { - rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, isBot, nil, "JOIN", chname, details.accountName, details.realname) - } else { - rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, isBot, nil, "JOIN", chname) - } - - if rb.session.capabilities.Has(caps.ReadMarker) { - rb.Add(nil, client.server.name, "MARKREAD", chname, client.GetReadMarker(chcfname)) - } - - if rb.session.client == client { - // don't send topic and names for a SAJOIN of a different client - channel.SendTopic(client, rb, false) - if !rb.session.capabilities.Has(caps.NoImplicitNames) { - channel.Names(client, rb) - } - } else { - // ensure that SAJOIN sends a MODE line to the originating client, if applicable - if givenMode != 0 { - rb.Add(nil, client.server.name, "MODE", chname, modestr, details.nick) - } - } - - // TODO #259 can be implemented as Flush(false) (i.e., nonblocking) while holding joinPartMutex - rb.Flush(true) - - channel.autoReplayHistory(client, rb, message.Msgid) - return nil, "" -} - -func (channel *Channel) autoReplayHistory(client *Client, rb *ResponseBuffer, skipMsgid string) { - // autoreplay any messages as necessary - var items []history.Item - - hasAutoreplayTimestamps := false - var start, end time.Time - if rb.session.zncPlaybackTimes.ValidFor(channel.NameCasefolded()) { - hasAutoreplayTimestamps = true - start, end = rb.session.zncPlaybackTimes.start, rb.session.zncPlaybackTimes.end - } else if !rb.session.autoreplayMissedSince.IsZero() { - // we already checked for history caps in `playReattachMessages` - hasAutoreplayTimestamps = true - start = time.Now().UTC() - end = rb.session.autoreplayMissedSince - } - - if hasAutoreplayTimestamps { - _, seq, _ := channel.server.GetHistorySequence(channel, client, "") - if seq != nil { - zncMax := channel.server.Config().History.ZNCMax - items, _ = seq.Between(history.Selector{Time: start}, history.Selector{Time: end}, zncMax) - } - } else if !rb.session.HasHistoryCaps() { - var replayLimit int - customReplayLimit := client.AccountSettings().AutoreplayLines - if customReplayLimit != nil { - replayLimit = *customReplayLimit - maxLimit := channel.server.Config().History.ChathistoryMax - if maxLimit < replayLimit { - replayLimit = maxLimit + rb.Add(nil, client.nickMaskString, "JOIN", channel.name) } } else { - replayLimit = channel.server.Config().History.AutoreplayOnJoin - } - if 0 < replayLimit { - _, seq, _ := channel.server.GetHistorySequence(channel, client, "") - if seq != nil { - items, _ = seq.Between(history.Selector{}, history.Selector{}, replayLimit) + if member.capabilities.Has(caps.ExtendedJoin) { + member.Send(nil, client.nickMaskString, "JOIN", channel.name, client.AccountName(), client.realname) + } else { + member.Send(nil, client.nickMaskString, "JOIN", channel.name) } } } - // remove the client's own JOIN line from the replay - numItems := len(items) - for i := len(items) - 1; 0 <= i; i-- { - if items[i].Message.Msgid == skipMsgid { - // zero'ed items will not be replayed because their `Type` field is not recognized - items[i] = history.Item{} - numItems-- - break + + channel.stateMutex.Lock() + channel.members.Add(client) + firstJoin := len(channel.members) == 1 + channel.stateMutex.Unlock() + channel.regenerateMembersCache(false) + + client.addChannel(channel) + + // give channel mode if necessary + newChannel := firstJoin && !channel.IsRegistered() + var givenMode *modes.Mode + account := client.Account() + cffounder, _ := CasefoldName(channel.registeredFounder) + if account != "" && account == cffounder { + givenMode = &modes.ChannelFounder + } else if newChannel { + givenMode = &modes.ChannelOperator + } + if givenMode != nil { + channel.stateMutex.Lock() + channel.members[client][*givenMode] = true + channel.stateMutex.Unlock() + } + + if client.capabilities.Has(caps.ExtendedJoin) { + rb.Add(nil, client.nickMaskString, "JOIN", channel.name, client.AccountName(), client.realname) + } else { + rb.Add(nil, client.nickMaskString, "JOIN", channel.name) + } + // don't send topic when it's an entirely new channel + if !newChannel { + channel.SendTopic(client, rb) + } + channel.Names(client, rb) + if givenMode != nil { + for _, member := range channel.Members() { + if member == client { + rb.Add(nil, client.server.name, "MODE", channel.name, fmt.Sprintf("+%v", *givenMode), client.nick) + } else { + member.Send(nil, client.server.name, "MODE", channel.name, fmt.Sprintf("+%v", *givenMode), client.nick) + } } } - if 0 < numItems { - channel.replayHistoryItems(rb, items, false, "", "", numItems) - rb.Flush(true) - } -} - -// plays channel join messages (the JOIN line, topic, and names) to a session. -// this is used when attaching a new session to an existing client that already has -// channels, and also when one session of a client initiates a JOIN and the other -// sessions need to receive the state change -func (channel *Channel) playJoinForSession(session *Session) { - client := session.client - sessionRb := NewResponseBuffer(session) - details := client.Details() - chname := channel.Name() - if session.capabilities.Has(caps.ExtendedJoin) { - sessionRb.Add(nil, details.nickMask, "JOIN", chname, details.accountName, details.realname) - } else { - sessionRb.Add(nil, details.nickMask, "JOIN", chname) - } - if session.capabilities.Has(caps.ReadMarker) { - chcfname := channel.NameCasefolded() - sessionRb.Add(nil, client.server.name, "MARKREAD", chname, client.GetReadMarker(chcfname)) - } - channel.SendTopic(client, sessionRb, false) - if !session.capabilities.Has(caps.NoImplicitNames) { - channel.Names(client, sessionRb) - } - sessionRb.Send(false) - channel.RedisBroadcast("VOICEPOLL") } // Part parts the given client from this channel, with the given message. func (channel *Channel) Part(client *Client, message string, rb *ResponseBuffer) { - channel.stateMutex.RLock() - chname := channel.name - clientData, ok := channel.members[client] - channel.stateMutex.RUnlock() - - if !ok { - rb.Add(nil, client.server.name, ERR_NOTONCHANNEL, client.Nick(), chname, client.t("You're not on that channel")) + if !channel.hasClient(client) { + rb.Add(nil, client.server.name, ERR_NOTONCHANNEL, channel.name, client.t("You're not on that channel")) return } + for _, member := range channel.Members() { + if member == client { + rb.Add(nil, client.nickMaskString, "PART", channel.name, message) + } else { + member.Send(nil, client.nickMaskString, "PART", channel.name, message) + } + } channel.Quit(client) - splitMessage := utils.MakeMessage(message) - - details := client.Details() - isBot := client.HasMode(modes.Bot) - params := make([]string, 1, 2) - params[0] = chname - if message != "" { - params = append(params, message) - } - respectAuditorium := channel.flags.HasMode(modes.Auditorium) && - clientData.modes.HighestChannelUserMode() == modes.Mode(0) - var cache MessageCache - cache.Initialize(channel.server, splitMessage.Time, splitMessage.Msgid, details.nickMask, details.accountName, isBot, nil, "PART", params...) - for _, member := range channel.Members() { - if respectAuditorium { - channel.stateMutex.RLock() - memberData, ok := channel.members[member] - channel.stateMutex.RUnlock() - if !ok || memberData.modes.HighestChannelUserMode() == modes.Mode(0) { - continue - } - } - for _, session := range member.Sessions() { - cache.Send(session) - } - } - rb.AddFromClient(splitMessage.Time, splitMessage.Msgid, details.nickMask, details.accountName, isBot, nil, "PART", params...) - for _, session := range client.Sessions() { - if session != rb.session { - session.sendFromClientInternal(false, splitMessage.Time, splitMessage.Msgid, details.nickMask, details.accountName, isBot, nil, "PART", params...) - } - } - - if !respectAuditorium { - channel.AddHistoryItem(history.Item{ - Type: history.Part, - Nick: details.nickMask, - Account: details.account, - Message: splitMessage, - Target: channel.NameCasefolded(), - IsBot: isBot, - }, details.account) - } - - client.server.logger.Debug("channels", fmt.Sprintf("%s left channel %s", details.nick, chname)) -} - -func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.Item, chathistoryCommand bool, identifier string, preposition string, limit int) { - // send an empty batch if necessary, as per the CHATHISTORY spec - chname := channel.Name() - client := rb.target - eventPlayback := rb.session.capabilities.Has(caps.EventPlayback) - extendedJoin := rb.session.capabilities.Has(caps.ExtendedJoin) - var playJoinsAsPrivmsg bool - if !eventPlayback { - if chathistoryCommand { - playJoinsAsPrivmsg = true - } else { - switch client.AccountSettings().ReplayJoins { - case ReplayJoinsCommandsOnly: - playJoinsAsPrivmsg = false - case ReplayJoinsAlways: - playJoinsAsPrivmsg = true - } - } - } - - batchID := rb.StartNestedBatch("chathistory", chname, identifier, preposition, strconv.Itoa(limit)) - defer rb.EndNestedBatch(batchID) - - for _, item := range items { - nick := NUHToNick(item.Nick) - switch item.Type { - case history.Privmsg: - rb.AddSplitMessageFromClientWithReactions(item.Nick, item.Account, item.IsBot, item.Tags, "PRIVMSG", chname, item.Message, item.Reactions) - case history.Notice: - rb.AddSplitMessageFromClientWithReactions(item.Nick, item.Account, item.IsBot, item.Tags, "NOTICE", chname, item.Message, item.Reactions) - case history.Tagmsg: - if eventPlayback { - rb.AddSplitMessageFromClient(item.Nick, item.Account, item.IsBot, item.Tags, "TAGMSG", chname, item.Message) - } else if chathistoryCommand { - // #1676, we have to send something here or else it breaks pagination - rb.AddFromClient(item.Message.Time, history.HistservMungeMsgid(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, fmt.Sprintf(client.t("%s sent a TAGMSG"), nick)) - } - case history.Join: - if eventPlayback { - if extendedJoin { - rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.Account, item.IsBot, nil, "JOIN", chname, item.Account, item.Params[0]) - } else { - rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.Account, item.IsBot, nil, "JOIN", chname) - } - } else { - if !playJoinsAsPrivmsg { - continue // #474 - } - var message string - if item.Account == "*" { - message = fmt.Sprintf(client.t("%s joined the channel"), nick) - } else { - message = fmt.Sprintf(client.t("%[1]s [account: %[2]s] joined the channel"), nick, item.Account) - } - rb.AddFromClient(item.Message.Time, history.HistservMungeMsgid(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, message) - } - case history.Part: - if eventPlayback { - rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.Account, item.IsBot, nil, "PART", chname, item.Message.Message) - } else { - if !playJoinsAsPrivmsg { - continue // #474 - } - message := fmt.Sprintf(client.t("%[1]s left the channel (%[2]s)"), nick, item.Message.Message) - rb.AddFromClient(item.Message.Time, history.HistservMungeMsgid(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, message) - } - case history.Kick: - if eventPlayback { - rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.Account, item.IsBot, nil, "KICK", chname, item.Params[0], item.Message.Message) - } else { - message := fmt.Sprintf(client.t("%[1]s kicked %[2]s (%[3]s)"), nick, item.Params[0], item.Message.Message) - rb.AddFromClient(item.Message.Time, history.HistservMungeMsgid(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, message) - } - case history.Quit: - if eventPlayback { - rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.Account, item.IsBot, nil, "QUIT", item.Message.Message) - } else { - if !playJoinsAsPrivmsg { - continue // #474 - } - message := fmt.Sprintf(client.t("%[1]s quit (%[2]s)"), nick, item.Message.Message) - rb.AddFromClient(item.Message.Time, history.HistservMungeMsgid(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, message) - } - case history.Nick: - if eventPlayback { - rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.Account, item.IsBot, nil, "NICK", item.Params[0]) - } else { - message := fmt.Sprintf(client.t("%[1]s changed nick to %[2]s"), nick, item.Params[0]) - rb.AddFromClient(item.Message.Time, history.HistservMungeMsgid(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, message) - } - case history.Topic: - if eventPlayback { - rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.Account, item.IsBot, nil, "TOPIC", chname, item.Message.Message) - } else { - message := fmt.Sprintf(client.t("%[1]s set the channel topic to: %[2]s"), nick, item.Message.Message) - rb.AddFromClient(item.Message.Time, history.HistservMungeMsgid(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, message) - } - case history.Mode: - params := make([]string, len(item.Message.Split)+1) - params[0] = chname - for i, pair := range item.Message.Split { - params[i+1] = pair.Message - } - if eventPlayback { - rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.Account, item.IsBot, nil, "MODE", params...) - } else { - message := fmt.Sprintf(client.t("%[1]s set channel modes: %[2]s"), nick, strings.Join(params[1:], " ")) - rb.AddFromClient(item.Message.Time, history.HistservMungeMsgid(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, message) - } - } - } + client.server.logger.Debug("part", fmt.Sprintf("%s left channel %s", client.nick, channel.name)) } // SendTopic sends the channel topic to the given client. -// `sendNoTopic` controls whether RPL_NOTOPIC is sent when the topic is unset -func (channel *Channel) SendTopic(client *Client, rb *ResponseBuffer, sendNoTopic bool) { +func (channel *Channel) SendTopic(client *Client, rb *ResponseBuffer) { + if !channel.hasClient(client) { + rb.Add(nil, client.server.name, ERR_NOTONCHANNEL, client.nick, channel.name, client.t("You're not on that channel")) + return + } + channel.stateMutex.RLock() name := channel.name topic := channel.topic topicSetBy := channel.topicSetBy topicSetTime := channel.topicSetTime - _, hasClient := channel.members[client] channel.stateMutex.RUnlock() - if !hasClient { - rb.Add(nil, client.server.name, ERR_NOTONCHANNEL, client.Nick(), channel.name, client.t("You're not on that channel")) - return - } - if topic == "" { - if sendNoTopic { - rb.Add(nil, client.server.name, RPL_NOTOPIC, client.nick, name, client.t("No topic is set")) - } + rb.Add(nil, client.server.name, RPL_NOTOPIC, client.nick, name, client.t("No topic is set")) return } @@ -1209,219 +485,209 @@ func (channel *Channel) SendTopic(client *Client, rb *ResponseBuffer, sendNoTopi // SetTopic sets the topic of this channel, if the client is allowed to do so. func (channel *Channel) SetTopic(client *Client, topic string, rb *ResponseBuffer) { - if !channel.hasClient(client) { - rb.Add(nil, client.server.name, ERR_NOTONCHANNEL, client.Nick(), channel.Name(), client.t("You're not on that channel")) + if !(client.flags[modes.Operator] || channel.hasClient(client)) { + rb.Add(nil, client.server.name, ERR_NOTONCHANNEL, channel.name, client.t("You're not on that channel")) return } - if channel.flags.HasMode(modes.OpOnlyTopic) && !(channel.ClientIsAtLeast(client, modes.Halfop) || client.HasRoleCapabs("samode")) { - rb.Add(nil, client.server.name, ERR_CHANOPRIVSNEEDED, client.Nick(), channel.Name(), client.t("You're not a channel operator")) + if channel.HasMode(modes.OpOnlyTopic) && !channel.ClientIsAtLeast(client, modes.ChannelOperator) { + rb.Add(nil, client.server.name, ERR_CHANOPRIVSNEEDED, channel.name, client.t("You're not a channel operator")) return } - topic = ircmsg.TruncateUTF8Safe(topic, client.server.Config().Limits.TopicLen) + if len(topic) > client.server.limits.TopicLen { + topic = topic[:client.server.limits.TopicLen] + } channel.stateMutex.Lock() - chname := channel.name channel.topic = topic channel.topicSetBy = client.nickMaskString - channel.topicSetTime = time.Now().UTC() + channel.topicSetTime = time.Now() channel.stateMutex.Unlock() - details := client.Details() - isBot := client.HasMode(modes.Bot) - message := utils.MakeMessage(topic) - rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, isBot, nil, "TOPIC", chname, topic) for _, member := range channel.Members() { - for _, session := range member.Sessions() { - if session != rb.session { - session.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, isBot, nil, "TOPIC", chname, topic) - } + if member == client { + rb.Add(nil, client.nickMaskString, "TOPIC", channel.name, topic) + } else { + member.Send(nil, client.nickMaskString, "TOPIC", channel.name, topic) } } - channel.AddHistoryItem(history.Item{ - Type: history.Topic, - Nick: details.nickMask, - Account: details.account, - Message: message, - IsBot: isBot, - Target: channel.NameCasefolded(), - }, details.account) - - channel.MarkDirty(IncludeTopic) + go channel.server.channelRegistry.StoreChannel(channel, false) } -// CanSpeak returns true if the client can speak on this channel, otherwise it returns false along with the channel mode preventing the client from speaking. -func (channel *Channel) CanSpeak(client *Client) (bool, modes.Mode) { +// CanSpeak returns true if the client can speak on this channel. +func (channel *Channel) CanSpeak(client *Client) bool { channel.stateMutex.RLock() - memberData, hasClient := channel.members[client] - channel.stateMutex.RUnlock() + defer channel.stateMutex.RUnlock() - highestMode := func() modes.Mode { - if !hasClient { - return modes.Mode(0) - } - return memberData.modes.HighestChannelUserMode() - } - - if !hasClient && channel.flags.HasMode(modes.NoOutside) { - // TODO: enforce regular +b bans on -n channels? - return false, modes.NoOutside - } - if channel.isMuted(client) && highestMode() == modes.Mode(0) { - return false, modes.BanMask - } - if channel.flags.HasMode(modes.Moderated) && highestMode() == modes.Mode(0) { - return false, modes.Moderated - } - if channel.flags.HasMode(modes.RegisteredOnlySpeak) && client.Account() == "" && - highestMode() == modes.Mode(0) { - return false, modes.RegisteredOnlySpeak - } - return true, modes.Mode('?') -} - -func (channel *Channel) isMuted(client *Client) bool { - muteRe := channel.lists[modes.BanMask].MuteRegexp() - if muteRe == nil { + _, hasClient := channel.members[client] + if channel.flags[modes.NoOutside] && !hasClient { return false } - nuh := client.NickMaskCasefolded() - return muteRe.MatchString(nuh) && !channel.lists[modes.ExceptMask].MatchMute(nuh) -} - -func (channel *Channel) relayNickMuted(relayNick string) bool { - relayNUH := fmt.Sprintf("%s!*@*", relayNick) - return channel.lists[modes.BanMask].MatchMute(relayNUH) && - !channel.lists[modes.ExceptMask].MatchMute(relayNUH) -} - -func msgCommandToHistType(command string) (history.ItemType, error) { - switch command { - case "PRIVMSG": - return history.Privmsg, nil - case "NOTICE": - return history.Notice, nil - case "TAGMSG": - return history.Tagmsg, nil - default: - return history.ItemType(0), errInvalidParams + if channel.flags[modes.Moderated] && !channel.ClientIsAtLeast(client, modes.Voice) { + return false } + if channel.flags[modes.RegisteredOnly] && client.Account() == "" { + return false + } + return true } -func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mode, clientOnlyTags map[string]string, client *Client, message utils.SplitMessage, rb *ResponseBuffer) { - histType, err := msgCommandToHistType(command) - if err != nil { +// TagMsg sends a tag message to everyone in this channel who can accept them. +func (channel *Channel) TagMsg(msgid string, minPrefix *modes.Mode, clientOnlyTags *map[string]ircmsg.TagValue, client *Client, rb *ResponseBuffer) { + channel.sendMessage(msgid, "TAGMSG", []caps.Capability{caps.MessageTags}, minPrefix, clientOnlyTags, client, nil, rb) +} + +// sendMessage sends a given message to everyone on this channel. +func (channel *Channel) sendMessage(msgid, cmd string, requiredCaps []caps.Capability, minPrefix *modes.Mode, clientOnlyTags *map[string]ircmsg.TagValue, client *Client, message *string, rb *ResponseBuffer) { + if !channel.CanSpeak(client) { + rb.Add(nil, client.server.name, ERR_CANNOTSENDTOCHAN, channel.name, client.t("Cannot send to channel")) return } - if canSpeak, mode := channel.CanSpeak(client); !canSpeak { - if histType != history.Notice { - rb.Add(nil, client.server.name, ERR_CANNOTSENDTOCHAN, client.Nick(), channel.Name(), fmt.Sprintf(client.t("Cannot send to channel (+%s)"), mode)) - } - return + // for STATUSMSG + var minPrefixMode modes.Mode + if minPrefix != nil { + minPrefixMode = *minPrefix } - - isCTCP := message.IsRestrictedCTCPMessage() - if isCTCP && channel.flags.HasMode(modes.NoCTCP) { - if histType != history.Notice { - rb.Add(nil, client.server.name, ERR_CANNOTSENDTOCHAN, client.Nick(), channel.Name(), fmt.Sprintf(client.t("Cannot send to channel (+%s)"), "C")) - } - return - } - - details := client.Details() - isBot := client.HasMode(modes.Bot) - chname := channel.Name() - - // STATUSMSG targets are prefixed with the supplied min-prefix, e.g., @#channel - if minPrefixMode != modes.Mode(0) { - chname = fmt.Sprintf("%s%s", modes.ChannelModePrefixes[minPrefixMode], chname) - } - - if !client.server.Config().Server.Compatibility.allowTruncation { - if !validateSplitMessageLen(histType, details.nickMask, chname, message) { - rb.Add(nil, client.server.name, ERR_INPUTTOOLONG, details.nick, client.t("Line too long to be relayed without truncation")) - return - } - } - - if channel.flags.HasMode(modes.OpModerated) { - channel.stateMutex.RLock() - cuData, ok := channel.members[client] - channel.stateMutex.RUnlock() - if !ok || cuData.modes.HighestChannelUserMode() == modes.Mode(0) { - // max(statusmsg_minmode, halfop) - if minPrefixMode == modes.Mode(0) || minPrefixMode == modes.Voice { - minPrefixMode = modes.Halfop - } - } - } - // send echo-message - rb.addEchoMessage(clientOnlyTags, details.nickMask, details.accountName, command, chname, message) + if client.capabilities.Has(caps.EchoMessage) { + var messageTagsToUse *map[string]ircmsg.TagValue + if client.capabilities.Has(caps.MessageTags) { + messageTagsToUse = clientOnlyTags + } - var cache MessageCache - cache.InitializeSplitMessage(channel.server, details.nickMask, details.accountName, isBot, clientOnlyTags, command, chname, message) + if message == nil { + rb.AddFromClient(msgid, client, messageTagsToUse, cmd, channel.name) + } else { + rb.AddFromClient(msgid, client, messageTagsToUse, cmd, channel.name, *message) + } + } for _, member := range channel.Members() { - if minPrefixMode != modes.Mode(0) && !channel.ClientIsAtLeast(member, minPrefixMode) { - // STATUSMSG or OpModerated + if minPrefix != nil && !channel.ClientIsAtLeast(member, minPrefixMode) { + // STATUSMSG + continue + } + // echo-message is handled above, so skip sending the msg to the user themselves as well + if member == client { continue } - for _, session := range member.Sessions() { - if session == rb.session { - continue // we already sent echo-message, if applicable + canReceive := true + for _, capName := range requiredCaps { + if !member.capabilities.Has(capName) { + canReceive = false } - - if isCTCP && session.isTor { - continue // #753 - } - - cache.Send(session) } - } + if !canReceive { + continue + } - // #959: don't save STATUSMSG (or OpModerated) - if minPrefixMode == modes.Mode(0) { - channel.AddHistoryItem(history.Item{ - Type: histType, - Message: message, - Nick: details.nickMask, - Account: details.accountName, - Tags: clientOnlyTags, - IsBot: isBot, - Target: channel.NameCasefolded(), - }, details.account) + var messageTagsToUse *map[string]ircmsg.TagValue + if member.capabilities.Has(caps.MessageTags) { + messageTagsToUse = clientOnlyTags + } + + if message == nil { + member.SendFromClient(msgid, client, messageTagsToUse, cmd, channel.name) + } else { + member.SendFromClient(msgid, client, messageTagsToUse, cmd, channel.name, *message) + } } } -func (channel *Channel) applyModeToMember(client *Client, change modes.ModeChange, rb *ResponseBuffer) (applied bool, result modes.ModeChange) { - target := channel.server.clients.Get(change.Arg) - if target == nil { - rb.Add(nil, client.server.name, ERR_NOSUCHNICK, client.Nick(), utils.SafeErrorParam(change.Arg), client.t("No such nick")) +// SplitPrivMsg sends a private message to everyone in this channel. +func (channel *Channel) SplitPrivMsg(msgid string, minPrefix *modes.Mode, clientOnlyTags *map[string]ircmsg.TagValue, client *Client, message SplitMessage, rb *ResponseBuffer) { + channel.sendSplitMessage(msgid, "PRIVMSG", minPrefix, clientOnlyTags, client, &message, rb) +} + +// SplitNotice sends a private message to everyone in this channel. +func (channel *Channel) SplitNotice(msgid string, minPrefix *modes.Mode, clientOnlyTags *map[string]ircmsg.TagValue, client *Client, message SplitMessage, rb *ResponseBuffer) { + channel.sendSplitMessage(msgid, "NOTICE", minPrefix, clientOnlyTags, client, &message, rb) +} + +func (channel *Channel) sendSplitMessage(msgid, cmd string, minPrefix *modes.Mode, clientOnlyTags *map[string]ircmsg.TagValue, client *Client, message *SplitMessage, rb *ResponseBuffer) { + if !channel.CanSpeak(client) { + rb.Add(nil, client.server.name, ERR_CANNOTSENDTOCHAN, channel.name, client.t("Cannot send to channel")) return } - change.Arg = target.Nick() + + // for STATUSMSG + var minPrefixMode modes.Mode + if minPrefix != nil { + minPrefixMode = *minPrefix + } + // send echo-message + if client.capabilities.Has(caps.EchoMessage) { + var tagsToUse *map[string]ircmsg.TagValue + if client.capabilities.Has(caps.MessageTags) { + tagsToUse = clientOnlyTags + } + if message == nil { + rb.AddFromClient(msgid, client, tagsToUse, cmd, channel.name) + } else { + rb.AddSplitMessageFromClient(msgid, client, tagsToUse, cmd, channel.name, *message) + } + } + for _, member := range channel.Members() { + if minPrefix != nil && !channel.ClientIsAtLeast(member, minPrefixMode) { + // STATUSMSG + continue + } + // echo-message is handled above, so skip sending the msg to the user themselves as well + if member == client { + continue + } + var tagsToUse *map[string]ircmsg.TagValue + if member.capabilities.Has(caps.MessageTags) { + tagsToUse = clientOnlyTags + } + + if message == nil { + member.SendFromClient(msgid, client, tagsToUse, cmd, channel.name) + } else { + member.SendSplitMsgFromClient(msgid, client, tagsToUse, cmd, channel.name, *message) + } + } +} + +func (channel *Channel) applyModeMemberNoMutex(client *Client, mode modes.Mode, op modes.ModeOp, nick string, rb *ResponseBuffer) *modes.ModeChange { + if nick == "" { + //TODO(dan): shouldn't this be handled before it reaches this function? + rb.Add(nil, client.server.name, ERR_NEEDMOREPARAMS, "MODE", client.t("Not enough parameters")) + return nil + } + + casefoldedName, err := CasefoldName(nick) + target := channel.server.clients.Get(casefoldedName) + if err != nil || target == nil { + rb.Add(nil, client.server.name, ERR_NOSUCHNICK, client.nick, nick, client.t("No such nick")) + return nil + } channel.stateMutex.Lock() - memberData, exists := channel.members[target] + modeset, exists := channel.members[target] + var already bool if exists { - if memberData.modes.SetMode(change.Mode, change.Op == modes.Add) { - applied = true - result = change - } + enable := op == modes.Add + already = modeset[mode] == enable + modeset[mode] = enable } channel.stateMutex.Unlock() if !exists { - rb.Add(nil, client.server.name, ERR_USERNOTINCHANNEL, client.Nick(), channel.Name(), client.t("They aren't on that channel")) + rb.Add(nil, client.server.name, ERR_USERNOTINCHANNEL, client.nick, channel.name, client.t("They aren't on that channel")) + return nil + } else if already { + return nil + } else { + return &modes.ModeChange{ + Op: op, + Mode: mode, + Arg: nick, + } } - if applied { - target.markDirty(IncludeChannels) - } - return } // ShowMaskList shows the given list to the client. @@ -1440,235 +706,121 @@ func (channel *Channel) ShowMaskList(client *Client, mode modes.Mode, rb *Respon } nick := client.Nick() - chname := channel.Name() - for mask, info := range channel.lists[mode].Masks() { - rb.Add(nil, client.server.name, rpllist, nick, chname, mask, info.CreatorNickmask, strconv.FormatInt(info.TimeCreated.Unix(), 10)) + channel.stateMutex.RLock() + // XXX don't acquire any new locks in this section, besides Socket.Write + for mask := range channel.lists[mode].masks { + rb.Add(nil, client.server.name, rpllist, nick, channel.name, mask) + } + channel.stateMutex.RUnlock() + + rb.Add(nil, client.server.name, rplendoflist, nick, channel.name, client.t("End of list")) +} + +func (channel *Channel) applyModeMask(client *Client, mode modes.Mode, op modes.ModeOp, mask string, rb *ResponseBuffer) bool { + list := channel.lists[mode] + if list == nil { + // This should never happen, but better safe than panicky. + return false } - rb.Add(nil, client.server.name, rplendoflist, nick, chname, client.t("End of list")) + if (op == modes.List) || (mask == "") { + channel.ShowMaskList(client, mode, rb) + return false + } + + if !channel.ClientIsAtLeast(client, modes.ChannelOperator) { + rb.Add(nil, client.server.name, ERR_CHANOPRIVSNEEDED, channel.name, client.t("You're not a channel operator")) + return false + } + + if op == modes.Add { + return list.Add(mask) + } + + if op == modes.Remove { + return list.Remove(mask) + } + + return false } // Quit removes the given client from the channel func (channel *Channel) Quit(client *Client) { - channelEmpty := func() bool { - channel.joinPartMutex.Lock() - defer channel.joinPartMutex.Unlock() + channel.stateMutex.Lock() + channel.members.Remove(client) + empty := len(channel.members) == 0 + channel.stateMutex.Unlock() + channel.regenerateMembersCache(false) - channel.stateMutex.Lock() - channel.members.Remove(client) - channelEmpty := len(channel.members) == 0 - channel.stateMutex.Unlock() - channel.regenerateMembersCache() - return channelEmpty - }() + client.removeChannel(channel) - if channelEmpty { + if empty { client.server.channels.Cleanup(channel) } - client.removeChannel(channel) - channel.Broadcast("KICK", client.NickCasefolded()) } -func (channel *Channel) Kick(client *Client, target *Client, comment string, rb *ResponseBuffer, hasPrivs bool) { - if !hasPrivs { - if !channel.ClientHasPrivsOver(client, target) { - rb.Add(nil, client.server.name, ERR_CHANOPRIVSNEEDED, client.Nick(), channel.Name(), client.t("You don't have enough channel privileges")) - return - } +func (channel *Channel) Kick(client *Client, target *Client, comment string, rb *ResponseBuffer) { + if !(client.flags[modes.Operator] || channel.hasClient(client)) { + rb.Add(nil, client.server.name, ERR_NOTONCHANNEL, channel.name, client.t("You're not on that channel")) + return + } + if !channel.ClientIsAtLeast(client, modes.ChannelOperator) { + rb.Add(nil, client.server.name, ERR_CANNOTSENDTOCHAN, channel.name, client.t("Cannot send to channel")) + return } if !channel.hasClient(target) { - rb.Add(nil, client.server.name, ERR_USERNOTINCHANNEL, client.Nick(), channel.Name(), client.t("They aren't on that channel")) + rb.Add(nil, client.server.name, ERR_USERNOTINCHANNEL, client.nick, channel.name, client.t("They aren't on that channel")) + return + } + if !channel.ClientHasPrivsOver(client, target) { + rb.Add(nil, client.server.name, ERR_CHANOPRIVSNEEDED, channel.name, client.t("You're not a channel operator")) return } - comment = ircmsg.TruncateUTF8Safe(comment, channel.server.Config().Limits.KickLen) - - message := utils.MakeMessage(comment) - details := client.Details() - isBot := client.HasMode(modes.Bot) + kicklimit := client.server.Limits().KickLen + if len(comment) > kicklimit { + comment = comment[:kicklimit] + } + clientMask := client.NickMaskString() targetNick := target.Nick() - chname := channel.Name() for _, member := range channel.Members() { - for _, session := range member.Sessions() { - if session != rb.session { - session.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, isBot, nil, "KICK", chname, targetNick, comment) - } - } + member.Send(nil, clientMask, "KICK", channel.name, targetNick, comment) } - rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, isBot, nil, "KICK", chname, targetNick, comment) - - histItem := history.Item{ - Type: history.Kick, - Nick: details.nickMask, - Account: details.account, - Message: message, - IsBot: isBot, - Target: channel.NameCasefolded(), - } - histItem.Params[0] = targetNick - channel.AddHistoryItem(histItem, details.account) channel.Quit(target) } -// handle a purge: kick everyone off the channel, clean up all the pointers between -// *Channel and *Client -func (channel *Channel) Purge(source string) { - if source == "" { - source = channel.server.name - } - - channel.stateMutex.Lock() - chname := channel.name - members := channel.membersCache - channel.membersCache = nil - channel.memberDataCache = nil - channel.members = make(MemberSet) - // TODO try to prevent Purge racing against (pending) Join? - channel.stateMutex.Unlock() - - now := time.Now().UTC() - for _, member := range members { - tnick := member.Nick() - msgid := utils.GenerateMessageIdStr() - for _, session := range member.Sessions() { - session.sendFromClientInternal(false, now, msgid, source, "*", false, nil, "KICK", chname, tnick, member.t("This channel has been purged by the server administrators and cannot be used")) - } - member.removeChannel(channel) - } -} - // Invite invites the given client to the channel, if the inviter can do so. func (channel *Channel) Invite(invitee *Client, inviter *Client, rb *ResponseBuffer) { - channel.stateMutex.RLock() - chname := channel.name - chcfname := channel.nameCasefolded - createdAt := channel.createdTime - _, inviterPresent := channel.members[inviter] - _, inviteePresent := channel.members[invitee] - channel.stateMutex.RUnlock() - - if !inviterPresent { - rb.Add(nil, inviter.server.name, ERR_NOTONCHANNEL, inviter.Nick(), chname, inviter.t("You're not on that channel")) + if channel.flags[modes.InviteOnly] && !channel.ClientIsAtLeast(inviter, modes.ChannelOperator) { + rb.Add(nil, inviter.server.name, ERR_CHANOPRIVSNEEDED, channel.name, inviter.t("You're not a channel operator")) return } - inviteOnly := channel.flags.HasMode(modes.InviteOnly) - hasPrivs := channel.ClientIsAtLeast(inviter, modes.ChannelOperator) - if inviteOnly && !hasPrivs { - rb.Add(nil, inviter.server.name, ERR_CHANOPRIVSNEEDED, inviter.Nick(), chname, inviter.t("You're not a channel operator")) + if !channel.hasClient(inviter) { + rb.Add(nil, inviter.server.name, ERR_NOTONCHANNEL, channel.name, inviter.t("You're not on that channel")) return } - if inviteePresent { - rb.Add(nil, inviter.server.name, ERR_USERONCHANNEL, inviter.Nick(), invitee.Nick(), chname, inviter.t("User is already on that channel")) - return - } - - // #1876: INVITE should override all join restrictions, including +b and +l, - // not just +i. so we need to record it on a per-client basis iff the inviter - // is privileged: - if hasPrivs { - invitee.Invite(chcfname, createdAt) - } - - details := inviter.Details() - isBot := inviter.HasMode(modes.Bot) - tDetails := invitee.Details() - tnick := invitee.Nick() - message := utils.MakeMessage(chname) - item := history.Item{ - Type: history.Invite, - Message: message, - Account: inviter.Account(), - Target: invitee.Account(), + //TODO(dan): handle this more nicely, keep a list of last X invited channels on invitee rather than explicitly modifying the invite list? + if channel.flags[modes.InviteOnly] { + nmc := invitee.NickCasefolded() + channel.stateMutex.Lock() + channel.lists[modes.InviteMask].Add(nmc) + channel.stateMutex.Unlock() } for _, member := range channel.Members() { - if member == inviter || member == invitee || !channel.ClientIsAtLeast(member, modes.Halfop) { - continue - } - for _, session := range member.Sessions() { - if session.capabilities.Has(caps.InviteNotify) { - session.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, isBot, nil, "INVITE", tnick, chname) - } + if member.capabilities.Has(caps.InviteNotify) && member != inviter && member != invitee && channel.ClientIsAtLeast(member, modes.Halfop) { + member.Send(nil, inviter.NickMaskString(), "INVITE", invitee.Nick(), channel.name) } } - rb.Add(nil, inviter.server.name, RPL_INVITING, details.nick, tnick, chname) - for _, iSession := range invitee.Sessions() { - iSession.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, isBot, nil, "INVITE", tnick, chname) + //TODO(dan): should inviter.server.name here be inviter.nickMaskString ? + rb.Add(nil, inviter.server.name, RPL_INVITING, invitee.nick, channel.name) + invitee.Send(nil, inviter.nickMaskString, "INVITE", invitee.nick, channel.name) + if invitee.flags[modes.Away] { + rb.Add(nil, inviter.server.name, RPL_AWAY, invitee.nick, invitee.awayMessage) } - if away, awayMessage := invitee.Away(); away { - rb.Add(nil, inviter.server.name, RPL_AWAY, details.nick, tnick, awayMessage) - } - inviter.addHistoryItem(invitee, item, &details, &tDetails, channel.server.Config()) -} - -// Uninvite rescinds a channel invitation, if the inviter can do so. -func (channel *Channel) Uninvite(invitee *Client, inviter *Client, rb *ResponseBuffer) { - if !channel.flags.HasMode(modes.InviteOnly) { - rb.Add(nil, channel.server.name, "FAIL", "UNINVITE", "NOT_INVITE_ONLY", channel.Name(), inviter.t("Channel is not invite-only")) - return - } - - if !channel.ClientIsAtLeast(inviter, modes.ChannelOperator) { - rb.Add(nil, channel.server.name, "FAIL", "UNINVITE", "PRIVS_NEEDED", channel.Name(), inviter.t("You're not a channel operator")) - return - } - - invitee.Uninvite(channel.NameCasefolded()) - rb.Add(nil, channel.server.name, "UNINVITE", invitee.Nick(), channel.Name()) -} - -// returns who the client can "see" in the channel, respecting the auditorium mode -func (channel *Channel) auditoriumFriends(client *Client) (friends []*Client) { - channel.stateMutex.RLock() - defer channel.stateMutex.RUnlock() - - clientData, found := channel.members[client] - if !found { - return // non-members have no friends - } - if !channel.flags.HasMode(modes.Auditorium) { - return channel.membersCache // default behavior for members - } - if clientData.modes.HighestChannelUserMode() != modes.Mode(0) { - return channel.membersCache // +v and up can see everyone in the auditorium - } - // without +v, your friends are those with +v and up - for member, memberData := range channel.members { - if memberData.modes.HighestChannelUserMode() != modes.Mode(0) { - friends = append(friends, member) - } - } - return -} - -// returns whether the client is visible to unprivileged users in the channel -// (i.e., respecting auditorium mode). note that this assumes that the client -// is a member; if the client is not, it may return true anyway -func (channel *Channel) memberIsVisible(client *Client) bool { - // fast path, we assume they're a member so if this isn't an auditorium, - // they're visible: - if !channel.flags.HasMode(modes.Auditorium) { - return true - } - - channel.stateMutex.RLock() - defer channel.stateMutex.RUnlock() - - clientData, found := channel.members[client] - if !found { - return false - } - return clientData.modes.HighestChannelUserMode() != modes.Mode(0) -} - -// data for RPL_LIST -func (channel *Channel) listData() (memberCount int, name, topic string) { - channel.stateMutex.RLock() - defer channel.stateMutex.RUnlock() - return len(channel.members), channel.name, channel.topic } diff --git a/irc/channelmanager.go b/irc/channelmanager.go index 5934ab43..8d2fa737 100644 --- a/irc/channelmanager.go +++ b/irc/channelmanager.go @@ -4,12 +4,7 @@ package irc import ( - "sort" "sync" - "time" - - "github.com/ergochat/ergo/irc/datastore" - "github.com/ergochat/ergo/irc/utils" ) type channelManagerEntry struct { @@ -18,7 +13,6 @@ type channelManagerEntry struct { // think the channel is empty (without holding a lock across the entire Channel.Join() // call) pendingJoins int - skeleton string } // ChannelManager keeps track of all the channels on the server, @@ -26,162 +20,96 @@ type channelManagerEntry struct { // cleanup of empty channels on last part, and renames. type ChannelManager struct { sync.RWMutex // tier 2 - // chans is the main data structure, mapping casefolded name -> *Channel - chans map[string]*channelManagerEntry - chansSkeletons utils.HashSet[string] - purgedChannels map[string]ChannelPurgeRecord // casefolded name to purge record - server *Server + chans map[string]*channelManagerEntry } // NewChannelManager returns a new ChannelManager. -func (cm *ChannelManager) Initialize(server *Server, config *Config) (err error) { - cm.chans = make(map[string]*channelManagerEntry) - cm.chansSkeletons = make(utils.HashSet[string]) - cm.server = server - return cm.loadRegisteredChannels(config) -} - -func (cm *ChannelManager) loadRegisteredChannels(config *Config) (err error) { - allChannels, err := FetchAndDeserializeAll[RegisteredChannel](datastore.TableChannels, cm.server.dstore, cm.server.logger) - if err != nil { - return +func NewChannelManager() *ChannelManager { + return &ChannelManager{ + chans: make(map[string]*channelManagerEntry), } - allPurgeRecords, err := FetchAndDeserializeAll[ChannelPurgeRecord](datastore.TableChannelPurges, cm.server.dstore, cm.server.logger) - if err != nil { - return - } - - cm.Lock() - defer cm.Unlock() - - cm.purgedChannels = make(map[string]ChannelPurgeRecord, len(allPurgeRecords)) - for _, purge := range allPurgeRecords { - cm.purgedChannels[purge.NameCasefolded] = purge - } - - for _, regInfo := range allChannels { - cfname, err := CasefoldChannel(regInfo.Name) - if err != nil { - cm.server.logger.Error("channels", "couldn't casefold registered channel, skipping", regInfo.Name, err.Error()) - continue - } else { - cm.server.logger.Debug("channels", "initializing registered channel", regInfo.Name) - } - skeleton, err := Skeleton(regInfo.Name) - if err == nil { - cm.chansSkeletons.Add(skeleton) - } - - if _, ok := cm.purgedChannels[cfname]; !ok { - ch := NewChannel(cm.server, regInfo.Name, cfname, true, regInfo) - cm.chans[cfname] = &channelManagerEntry{ - channel: ch, - pendingJoins: 0, - skeleton: skeleton, - } - } - } - - return nil } // Get returns an existing channel with name equivalent to `name`, or nil -func (cm *ChannelManager) Get(name string) (channel *Channel) { +func (cm *ChannelManager) Get(name string) *Channel { name, err := CasefoldChannel(name) - if err != nil { - return nil - } - cm.RLock() - defer cm.RUnlock() - entry := cm.chans[name] - if entry != nil { - return entry.channel + if err == nil { + cm.RLock() + defer cm.RUnlock() + entry := cm.chans[name] + if entry != nil { + return entry.channel + } } return nil } // Join causes `client` to join the channel named `name`, creating it if necessary. -func (cm *ChannelManager) Join(client *Client, name string, key string, isSajoin bool, rb *ResponseBuffer) (err error, forward string) { +func (cm *ChannelManager) Join(client *Client, name string, key string, rb *ResponseBuffer) error { server := client.server casefoldedName, err := CasefoldChannel(name) - skeleton, skerr := Skeleton(name) - if err != nil || skerr != nil || len(casefoldedName) > server.Config().Limits.ChannelLen { - return errNoSuchChannel, "" + if err != nil || len(casefoldedName) > server.Limits().ChannelLen { + return errNoSuchChannel } - channel, err, newChannel := func() (*Channel, error, bool) { - var newChannel bool + cm.Lock() + entry := cm.chans[casefoldedName] + if entry == nil { + // XXX give up the lock to check for a registration, then check again + // to see if we need to create the channel. we could solve this by doing LoadChannel + // outside the lock initially on every join, so this is best thought of as an + // optimization to avoid that. + cm.Unlock() + info := client.server.channelRegistry.LoadChannel(casefoldedName) cm.Lock() - defer cm.Unlock() - - // check purges first; a registered purged channel will still be present in `chans` - if _, ok := cm.purgedChannels[casefoldedName]; ok { - return nil, errChannelPurged, false - } - entry := cm.chans[casefoldedName] + entry = cm.chans[casefoldedName] if entry == nil { - if server.Config().Channels.OpOnlyCreation && - !(isSajoin || client.HasRoleCapabs("chanreg")) { - return nil, errInsufficientPrivs, false - } - // enforce confusables - if cm.chansSkeletons.Has(skeleton) { - return nil, errConfusableIdentifier, false - } entry = &channelManagerEntry{ - channel: NewChannel(server, name, casefoldedName, false, RegisteredChannel{}), + channel: NewChannel(server, name, true, info), pendingJoins: 0, } - cm.chansSkeletons.Add(skeleton) - entry.skeleton = skeleton cm.chans[casefoldedName] = entry - newChannel = true } - entry.pendingJoins += 1 - return entry.channel, nil, newChannel - }() - - if err != nil { - return err, "" } + entry.pendingJoins += 1 + cm.Unlock() - err, forward = channel.Join(client, key, isSajoin || newChannel, rb) + entry.channel.Join(client, key, rb) - cm.maybeCleanup(channel, true) + cm.maybeCleanup(entry.channel, true) - return + return nil } func (cm *ChannelManager) maybeCleanup(channel *Channel, afterJoin bool) { cm.Lock() defer cm.Unlock() - cfname := channel.NameCasefolded() - - entry := cm.chans[cfname] + entry := cm.chans[channel.NameCasefolded()] if entry == nil || entry.channel != channel { return } - cm.maybeCleanupInternal(cfname, entry, afterJoin) -} - -func (cm *ChannelManager) maybeCleanupInternal(cfname string, entry *channelManagerEntry, afterJoin bool) { if afterJoin { entry.pendingJoins -= 1 } - if entry.pendingJoins == 0 && entry.channel.IsClean() { - delete(cm.chans, cfname) - if entry.skeleton != "" { - delete(cm.chansSkeletons, entry.skeleton) - } + // TODO(slingamn) right now, registered channels cannot be cleaned up. + // this is because once ChannelManager becomes the source of truth about a channel, + // we can't move the source of truth back to the database unless we do an ACID + // store while holding the ChannelManager's Lock(). This is pending more decisions + // about where the database transaction lock fits into the overall lock model. + if !entry.channel.IsRegistered() && entry.channel.IsEmpty() && entry.pendingJoins == 0 { + // reread the name, handling the case where the channel was renamed + casefoldedName := entry.channel.NameCasefolded() + delete(cm.chans, casefoldedName) + // invalidate the entry (otherwise, a subsequent cleanup attempt could delete + // a valid, distinct entry under casefoldedName): + entry.channel = nil } } // Part parts `client` from the channel named `name`, deleting it if it's empty. func (cm *ChannelManager) Part(client *Client, name string, message string, rb *ResponseBuffer) error { - var channel *Channel - casefoldedName, err := CasefoldChannel(name) if err != nil { return errNoSuchChannel @@ -189,15 +117,12 @@ func (cm *ChannelManager) Part(client *Client, name string, message string, rb * cm.RLock() entry := cm.chans[casefoldedName] - if entry != nil { - channel = entry.channel - } cm.RUnlock() - if channel == nil { + if entry == nil { return errNoSuchChannel } - channel.Part(client, message, rb) + entry.channel.Part(client, message, rb) return nil } @@ -205,137 +130,34 @@ func (cm *ChannelManager) Cleanup(channel *Channel) { cm.maybeCleanup(channel, false) } -func (cm *ChannelManager) SetRegistered(channelName string, account string) (err error) { - if cm.server.Defcon() <= 4 { - return errFeatureDisabled - } - - var channel *Channel - cfname, err := CasefoldChannel(channelName) - if err != nil { - return err - } - - var entry *channelManagerEntry - - defer func() { - if err == nil && channel != nil { - // registration was successful: make the database reflect it - err = channel.Store(IncludeAllAttrs) - } - }() - - cm.Lock() - defer cm.Unlock() - entry = cm.chans[cfname] - if entry == nil { - return errNoSuchChannel - } - channel = entry.channel - err = channel.SetRegistered(account) - if err != nil { - return err - } - return nil -} - -func (cm *ChannelManager) SetUnregistered(channelName string, account string) (err error) { - cfname, err := CasefoldChannel(channelName) - if err != nil { - return err - } - - var uuid utils.UUID - - defer func() { - if err == nil { - if delErr := cm.server.dstore.Delete(datastore.TableChannels, uuid); delErr != nil { - cm.server.logger.Error("datastore", "couldn't delete channel registration", cfname, delErr.Error()) - } - } - }() - - cm.Lock() - defer cm.Unlock() - entry := cm.chans[cfname] - if entry != nil { - if entry.channel.Founder() != account { - return errChannelNotOwnedByAccount - } - uuid = entry.channel.UUID() - entry.channel.SetUnregistered(account) // changes the UUID - // #1619: if the channel has 0 members and was only being retained - // because it was registered, clean it up: - cm.maybeCleanupInternal(cfname, entry, false) - } - return nil -} - // Rename renames a channel (but does not notify the members) -func (cm *ChannelManager) Rename(name string, newName string) (err error) { - oldCfname, err := CasefoldChannel(name) +func (cm *ChannelManager) Rename(name string, newname string) error { + cfname, err := CasefoldChannel(name) if err != nil { return errNoSuchChannel } - newCfname, err := CasefoldChannel(newName) + cfnewname, err := CasefoldChannel(newname) if err != nil { return errInvalidChannelName } - newSkeleton, err := Skeleton(newName) - if err != nil { - return errInvalidChannelName - } - - var channel *Channel - var info RegisteredChannel - defer func() { - if channel != nil && info.Founder != "" { - channel.MarkDirty(IncludeAllAttrs) - } - // always-on clients need to update their saved channel memberships - for _, member := range channel.Members() { - member.markDirty(IncludeChannels) - } - }() cm.Lock() defer cm.Unlock() - entry := cm.chans[oldCfname] + if cm.chans[cfnewname] != nil { + return errChannelNameInUse + } + entry := cm.chans[cfname] if entry == nil { return errNoSuchChannel } - channel = entry.channel - info = channel.ExportRegistration() - registered := info.Founder != "" - - oldSkeleton, err := Skeleton(info.Name) - if err != nil { - return errNoSuchChannel // ugh - } - - if newCfname != oldCfname { - if cm.chans[newCfname] != nil { - return errChannelNameInUse - } - } - - if oldSkeleton != newSkeleton { - if cm.chansSkeletons.Has(newSkeleton) { - return errConfusableIdentifier - } - } - - delete(cm.chans, oldCfname) - if !registered { - entry.skeleton = newSkeleton - } - cm.chans[newCfname] = entry - delete(cm.chansSkeletons, oldSkeleton) - cm.chansSkeletons.Add(newSkeleton) - entry.channel.Rename(newName, newCfname) + delete(cm.chans, cfname) + cm.chans[cfnewname] = entry + entry.channel.setName(newname) + entry.channel.setNameCasefolded(cfnewname) return nil + } // Len returns the number of channels @@ -349,163 +171,8 @@ func (cm *ChannelManager) Len() int { func (cm *ChannelManager) Channels() (result []*Channel) { cm.RLock() defer cm.RUnlock() - result = make([]*Channel, 0, len(cm.chans)) for _, entry := range cm.chans { result = append(result, entry.channel) } return } - -// ListableChannels returns a slice of all non-purged channels. -func (cm *ChannelManager) ListableChannels() (result []*Channel) { - cm.RLock() - defer cm.RUnlock() - result = make([]*Channel, 0, len(cm.chans)) - for cfname, entry := range cm.chans { - if _, ok := cm.purgedChannels[cfname]; !ok { - result = append(result, entry.channel) - } - } - return -} - -// Purge marks a channel as purged. -func (cm *ChannelManager) Purge(chname string, record ChannelPurgeRecord) (err error) { - chname, err = CasefoldChannel(chname) - if err != nil { - return errInvalidChannelName - } - - record.NameCasefolded = chname - record.UUID = utils.GenerateUUIDv4() - - channel, err := func() (channel *Channel, err error) { - cm.Lock() - defer cm.Unlock() - - if _, ok := cm.purgedChannels[chname]; ok { - return nil, errChannelPurgedAlready - } - - entry := cm.chans[chname] - // atomically prevent anyone from rejoining - cm.purgedChannels[chname] = record - if entry != nil { - channel = entry.channel - } - return - }() - - if err != nil { - return err - } - - if channel != nil { - // actually kick everyone off the channel - channel.Purge("") - } - - var purgeBytes []byte - if purgeBytes, err = record.Serialize(); err != nil { - cm.server.logger.Error("internal", "couldn't serialize purge record", channel.Name(), err.Error()) - } - // TODO we need a better story about error handling for later - if err = cm.server.dstore.Set(datastore.TableChannelPurges, record.UUID, purgeBytes, time.Time{}); err != nil { - cm.server.logger.Error("datastore", "couldn't store purge record", chname, err.Error()) - } - - return -} - -// IsPurged queries whether a channel is purged. -func (cm *ChannelManager) IsPurged(chname string) (result bool) { - chname, err := CasefoldChannel(chname) - if err != nil { - return false - } - - cm.RLock() - _, result = cm.purgedChannels[chname] - cm.RUnlock() - return -} - -// Unpurge deletes a channel's purged status. -func (cm *ChannelManager) Unpurge(chname string) (err error) { - chname, err = CasefoldChannel(chname) - if err != nil { - return errNoSuchChannel - } - - cm.Lock() - record, found := cm.purgedChannels[chname] - delete(cm.purgedChannels, chname) - cm.Unlock() - - if !found { - return errNoSuchChannel - } - if err := cm.server.dstore.Delete(datastore.TableChannelPurges, record.UUID); err != nil { - cm.server.logger.Error("datastore", "couldn't delete purge record", chname, err.Error()) - } - return nil -} - -func (cm *ChannelManager) ListPurged() (result []string) { - cm.RLock() - result = make([]string, 0, len(cm.purgedChannels)) - for c := range cm.purgedChannels { - result = append(result, c) - } - cm.RUnlock() - sort.Strings(result) - return -} - -func (cm *ChannelManager) UnfoldName(cfname string) (result string) { - cm.RLock() - entry := cm.chans[cfname] - cm.RUnlock() - if entry != nil { - return entry.channel.Name() - } - return cfname -} - -func (cm *ChannelManager) LoadPurgeRecord(cfchname string) (record ChannelPurgeRecord, err error) { - cm.RLock() - defer cm.RUnlock() - - if record, ok := cm.purgedChannels[cfchname]; ok { - return record, nil - } else { - return record, errNoSuchChannel - } -} - -func (cm *ChannelManager) ChannelsForAccount(account string) (channels []string) { - cm.RLock() - defer cm.RUnlock() - - for cfname, entry := range cm.chans { - if entry.channel.Founder() == account { - channels = append(channels, cfname) - } - } - - return -} - -// AllChannels returns the uncasefolded names of all registered channels. -func (cm *ChannelManager) AllRegisteredChannels() (result []string) { - cm.RLock() - defer cm.RUnlock() - - for cfname, entry := range cm.chans { - if entry.channel.Founder() != "" { - result = append(result, cfname) - } - } - - return -} diff --git a/irc/channelreg.go b/irc/channelreg.go index 1978b4ef..1e4d669f 100644 --- a/irc/channelreg.go +++ b/irc/channelreg.go @@ -4,37 +4,51 @@ package irc import ( - "encoding/json" + "fmt" + "strconv" + "sync" "time" - "github.com/ergochat/ergo/irc/modes" - "github.com/ergochat/ergo/irc/utils" + "encoding/json" + + "github.com/tidwall/buntdb" ) // this is exclusively the *persistence* layer for channel registration; // channel creation/tracking/destruction is in channelmanager.go -// these are bit flags indicating what part of the channel status is "dirty" -// and needs to be read from memory and written to the db const ( - IncludeInitial uint = 1 << iota - IncludeTopic - IncludeModes - IncludeLists - IncludeSettings + keyChannelExists = "channel.exists %s" + keyChannelName = "channel.name %s" // stores the 'preferred name' of the channel, not casemapped + keyChannelRegTime = "channel.registered.time %s" + keyChannelFounder = "channel.founder %s" + keyChannelTopic = "channel.topic %s" + keyChannelTopicSetBy = "channel.topic.setby %s" + keyChannelTopicSetTime = "channel.topic.settime %s" + keyChannelBanlist = "channel.banlist %s" + keyChannelExceptlist = "channel.exceptlist %s" + keyChannelInvitelist = "channel.invitelist %s" ) -// this is an OR of all possible flags -const ( - IncludeAllAttrs = ^uint(0) +var ( + channelKeyStrings = []string{ + keyChannelExists, + keyChannelName, + keyChannelRegTime, + keyChannelFounder, + keyChannelTopic, + keyChannelTopicSetBy, + keyChannelTopicSetTime, + keyChannelBanlist, + keyChannelExceptlist, + keyChannelInvitelist, + } ) // RegisteredChannel holds details about a given registered channel. type RegisteredChannel struct { // Name of the channel. Name string - // UUID for the datastore. - UUID utils.UUID // RegisteredAt represents the time that the channel was registered. RegisteredAt time.Time // Founder indicates the founder of the channel. @@ -45,46 +59,166 @@ type RegisteredChannel struct { TopicSetBy string // TopicSetTime represents the time the topic was set. TopicSetTime time.Time - // Modes represents the channel modes - Modes []modes.Mode - // Key represents the channel key / password - Key string - // Forward is the forwarding/overflow (+f) channel - Forward string - // UserLimit is the user limit (0 for no limit) - UserLimit int - // AccountToUMode maps user accounts to their persistent channel modes (e.g., +q, +h) - AccountToUMode map[string]modes.Mode - // Bans represents the bans set on the channel. - Bans map[string]MaskInfo - // Excepts represents the exceptions set on the channel. - Excepts map[string]MaskInfo - // Invites represents the invite exceptions set on the channel. - Invites map[string]MaskInfo - // Settings are the chanserv-modifiable settings - Settings ChannelSettings + // Banlist represents the bans set on the channel. + Banlist []string + // Exceptlist represents the exceptions set on the channel. + Exceptlist []string + // Invitelist represents the invite exceptions set on the channel. + Invitelist []string } -func (r *RegisteredChannel) Serialize() ([]byte, error) { - return json.Marshal(r) +// ChannelRegistry manages registered channels. +type ChannelRegistry struct { + // This serializes operations of the form (read channel state, synchronously persist it); + // this is enough to guarantee eventual consistency of the database with the + // ChannelManager and Channel objects, which are the source of truth. + // + // We could use the buntdb RW transaction lock for this purpose but we share + // that with all the other modules, so let's not. + sync.Mutex // tier 2 + server *Server } -func (r *RegisteredChannel) Deserialize(b []byte) (err error) { - return json.Unmarshal(b, r) +// NewChannelRegistry returns a new ChannelRegistry. +func NewChannelRegistry(server *Server) *ChannelRegistry { + return &ChannelRegistry{ + server: server, + } } -type ChannelPurgeRecord struct { - NameCasefolded string `json:"Name"` - UUID utils.UUID - Oper string - PurgedAt time.Time - Reason string +// StoreChannel obtains a consistent view of a channel, then persists it to the store. +func (reg *ChannelRegistry) StoreChannel(channel *Channel, includeLists bool) { + if !reg.server.ChannelRegistrationEnabled() { + return + } + + reg.Lock() + defer reg.Unlock() + + key := channel.NameCasefolded() + info := channel.ExportRegistration(includeLists) + if info.Founder == "" { + // sanity check, don't try to store an unregistered channel + return + } + + reg.server.store.Update(func(tx *buntdb.Tx) error { + reg.saveChannel(tx, key, info, includeLists) + return nil + }) } -func (c *ChannelPurgeRecord) Serialize() ([]byte, error) { - return json.Marshal(c) +// LoadChannel loads a channel from the store. +func (reg *ChannelRegistry) LoadChannel(nameCasefolded string) (info *RegisteredChannel) { + if !reg.server.ChannelRegistrationEnabled() { + return nil + } + + channelKey := nameCasefolded + // nice to have: do all JSON (de)serialization outside of the buntdb transaction + reg.server.store.View(func(tx *buntdb.Tx) error { + _, err := tx.Get(fmt.Sprintf(keyChannelExists, channelKey)) + if err == buntdb.ErrNotFound { + // chan does not already exist, return + return nil + } + + // channel exists, load it + name, _ := tx.Get(fmt.Sprintf(keyChannelName, channelKey)) + regTime, _ := tx.Get(fmt.Sprintf(keyChannelRegTime, channelKey)) + regTimeInt, _ := strconv.ParseInt(regTime, 10, 64) + founder, _ := tx.Get(fmt.Sprintf(keyChannelFounder, channelKey)) + topic, _ := tx.Get(fmt.Sprintf(keyChannelTopic, channelKey)) + topicSetBy, _ := tx.Get(fmt.Sprintf(keyChannelTopicSetBy, channelKey)) + topicSetTime, _ := tx.Get(fmt.Sprintf(keyChannelTopicSetTime, channelKey)) + topicSetTimeInt, _ := strconv.ParseInt(topicSetTime, 10, 64) + banlistString, _ := tx.Get(fmt.Sprintf(keyChannelBanlist, channelKey)) + exceptlistString, _ := tx.Get(fmt.Sprintf(keyChannelExceptlist, channelKey)) + invitelistString, _ := tx.Get(fmt.Sprintf(keyChannelInvitelist, channelKey)) + + var banlist []string + _ = json.Unmarshal([]byte(banlistString), &banlist) + var exceptlist []string + _ = json.Unmarshal([]byte(exceptlistString), &exceptlist) + var invitelist []string + _ = json.Unmarshal([]byte(invitelistString), &invitelist) + + info = &RegisteredChannel{ + Name: name, + RegisteredAt: time.Unix(regTimeInt, 0), + Founder: founder, + Topic: topic, + TopicSetBy: topicSetBy, + TopicSetTime: time.Unix(topicSetTimeInt, 0), + Banlist: banlist, + Exceptlist: exceptlist, + Invitelist: invitelist, + } + return nil + }) + + return info } -func (c *ChannelPurgeRecord) Deserialize(b []byte) error { - return json.Unmarshal(b, c) +// Rename handles the persistence part of a channel rename: the channel is +// persisted under its new name, and the old name is cleaned up if necessary. +func (reg *ChannelRegistry) Rename(channel *Channel, casefoldedOldName string) { + if !reg.server.ChannelRegistrationEnabled() { + return + } + + reg.Lock() + defer reg.Unlock() + + includeLists := true + oldKey := casefoldedOldName + key := channel.NameCasefolded() + info := channel.ExportRegistration(includeLists) + if info.Founder == "" { + return + } + + reg.server.store.Update(func(tx *buntdb.Tx) error { + reg.deleteChannel(tx, oldKey, info) + reg.saveChannel(tx, key, info, includeLists) + return nil + }) +} + +// delete a channel, unless it was overwritten by another registration of the same channel +func (reg *ChannelRegistry) deleteChannel(tx *buntdb.Tx, key string, info RegisteredChannel) { + _, err := tx.Get(fmt.Sprintf(keyChannelExists, key)) + if err == nil { + regTime, _ := tx.Get(fmt.Sprintf(keyChannelRegTime, key)) + regTimeInt, _ := strconv.ParseInt(regTime, 10, 64) + registeredAt := time.Unix(regTimeInt, 0) + founder, _ := tx.Get(fmt.Sprintf(keyChannelFounder, key)) + + // to see if we're deleting the right channel, confirm the founder and the registration time + if founder == info.Founder && registeredAt == info.RegisteredAt { + for _, keyFmt := range channelKeyStrings { + tx.Delete(fmt.Sprintf(keyFmt, key)) + } + } + } +} + +// saveChannel saves a channel to the store. +func (reg *ChannelRegistry) saveChannel(tx *buntdb.Tx, channelKey string, channelInfo RegisteredChannel, includeLists bool) { + tx.Set(fmt.Sprintf(keyChannelExists, channelKey), "1", nil) + tx.Set(fmt.Sprintf(keyChannelName, channelKey), channelInfo.Name, nil) + tx.Set(fmt.Sprintf(keyChannelRegTime, channelKey), strconv.FormatInt(channelInfo.RegisteredAt.Unix(), 10), nil) + tx.Set(fmt.Sprintf(keyChannelFounder, channelKey), channelInfo.Founder, nil) + tx.Set(fmt.Sprintf(keyChannelTopic, channelKey), channelInfo.Topic, nil) + tx.Set(fmt.Sprintf(keyChannelTopicSetBy, channelKey), channelInfo.TopicSetBy, nil) + tx.Set(fmt.Sprintf(keyChannelTopicSetTime, channelKey), strconv.FormatInt(channelInfo.TopicSetTime.Unix(), 10), nil) + + if includeLists { + banlistString, _ := json.Marshal(channelInfo.Banlist) + tx.Set(fmt.Sprintf(keyChannelBanlist, channelKey), string(banlistString), nil) + exceptlistString, _ := json.Marshal(channelInfo.Exceptlist) + tx.Set(fmt.Sprintf(keyChannelExceptlist, channelKey), string(exceptlistString), nil) + invitelistString, _ := json.Marshal(channelInfo.Invitelist) + tx.Set(fmt.Sprintf(keyChannelInvitelist, channelKey), string(invitelistString), nil) + } } diff --git a/irc/chanserv.go b/irc/chanserv.go index 4ffd20a8..d5ccaead 100644 --- a/irc/chanserv.go +++ b/irc/chanserv.go @@ -5,46 +5,46 @@ package irc import ( "fmt" - "regexp" - "slices" "sort" "strings" - "time" - "github.com/ergochat/ergo/irc/modes" - "github.com/ergochat/ergo/irc/sno" - "github.com/ergochat/ergo/irc/utils" - "github.com/ergochat/irc-go/ircfmt" + "github.com/goshuirc/irc-go/ircfmt" + "github.com/oragono/oragono/irc/modes" + "github.com/oragono/oragono/irc/sno" + "github.com/oragono/oragono/irc/utils" ) -const chanservHelp = `ChanServ lets you register and manage channels.` +const chanservHelp = `ChanServ lets you register and manage channels. -func chanregEnabled(config *Config) bool { - return config.Channels.Registration.Enabled +To see in-depth help for a specific ChanServ command, try: + $b/CS HELP $b + +Here are the commands you can use: +%s` + +type csCommand struct { + capabs []string // oper capabs the given user has to have to access this command + handler func(server *Server, client *Client, command, params string, rb *ResponseBuffer) + help string + helpShort string + oper bool // true if the user has to be an oper to use this command } var ( - chanservCommands = map[string]*serviceCommand{ + chanservCommands = map[string]*csCommand{ + "help": { + help: `Syntax: $bHELP [command]$b + +HELP returns information on the given command.`, + helpShort: `$bHELP$b shows in-depth information about commands.`, + }, "op": { handler: csOpHandler, help: `Syntax: $bOP #channel [nickname]$b OP makes the given nickname, or yourself, a channel admin. You can only use -this command if you're a founder or in the AMODEs of the channel.`, - helpShort: `$bOP$b makes the given user (or yourself) a channel admin.`, - authRequired: true, - enabled: chanregEnabled, - minParams: 1, - }, - "deop": { - handler: csDeopHandler, - help: `Syntax: $bDEOP #channel [nickname]$b - -DEOP removes the given nickname, or yourself, the channel admin. You can only use this command if you're the founder of the channel.`, - helpShort: `$bDEOP$b removes the given user (or yourself) from a channel admin.`, - enabled: chanregEnabled, - minParams: 1, + helpShort: `$bOP$b makes the given user (or yourself) a channel admin.`, }, "register": { handler: csRegisterHandler, @@ -53,911 +53,220 @@ this command if you're the founder of the channel.`, REGISTER lets you own the given channel. If you rejoin this channel, you'll be given admin privs on it. Modes set on the channel and the topic will also be remembered.`, - helpShort: `$bREGISTER$b lets you own a given channel.`, - authRequired: true, - enabled: chanregEnabled, - minParams: 1, - }, - "unregister": { - handler: csUnregisterHandler, - help: `Syntax: $bUNREGISTER #channel [code]$b - -UNREGISTER deletes a channel registration, allowing someone else to claim it. -To prevent accidental unregistrations, a verification code is required; -invoking the command without a code will display the necessary code.`, - helpShort: `$bUNREGISTER$b deletes a channel registration.`, - enabled: chanregEnabled, - minParams: 1, - }, - "drop": { - aliasOf: "unregister", - }, - "amode": { - handler: csAmodeHandler, - help: `Syntax: $bAMODE #channel [mode change] [account]$b - -AMODE lists or modifies persistent mode settings that affect channel members. -For example, $bAMODE #channel +o dan$b grants the holder of the "dan" -account the +o operator mode every time they join #channel. To list current -accounts and modes, use $bAMODE #channel$b. Note that users are always -referenced by their registered account names, not their nicknames. -The permissions hierarchy for adding and removing modes is the same as in -the ordinary /MODE command.`, - helpShort: `$bAMODE$b modifies persistent mode settings for channel members.`, - enabled: chanregEnabled, - minParams: 1, - }, - "clear": { - handler: csClearHandler, - help: `Syntax: $bCLEAR #channel target$b - -CLEAR removes users or settings from a channel. Specifically: - -$bCLEAR #channel users$b kicks all users except for you. -$bCLEAR #channel access$b resets all stored bans, invites, ban exceptions, -and persistent user-mode grants made with CS AMODE.`, - helpShort: `$bCLEAR$b removes users or settings from a channel.`, - enabled: chanregEnabled, - minParams: 2, - }, - "transfer": { - handler: csTransferHandler, - help: `Syntax: $bTRANSFER [accept] #channel user [code]$b - -TRANSFER transfers ownership of a channel from one user to another. -To prevent accidental transfers, a verification code is required. For -example, $bTRANSFER #channel alice$b displays the required confirmation -code, then $bTRANSFER #channel alice 2930242125$b initiates the transfer. -Unless you are an IRC operator with the correct permissions, alice must -then accept the transfer, which she can do with $bTRANSFER accept #channel$b. -To cancel a pending transfer, transfer the channel to yourself.`, - helpShort: `$bTRANSFER$b transfers ownership of a channel to another user.`, - enabled: chanregEnabled, - minParams: 2, - }, - "purge": { - handler: csPurgeHandler, - help: `Syntax: $bPURGE #channel [code] [reason]$b - -PURGE ADD blacklists a channel from the server, making it impossible to join -or otherwise interact with the channel. If the channel currently has members, -they will be kicked from it. PURGE may also be applied preemptively to -channels that do not currently have members. A purge can be undone with -PURGE DEL. To list purged channels, use PURGE LIST.`, - helpShort: `$bPURGE$b blacklists a channel from the server.`, - capabs: []string{"chanreg"}, - minParams: 1, - maxParams: 3, - unsplitFinalParam: true, - }, - "list": { - handler: csListHandler, - help: `Syntax: $bLIST [regex]$b - -LIST returns the list of registered channels, which match the given regex. -If no regex is provided, all registered channels are returned.`, - helpShort: `$bLIST$b searches the list of registered channels.`, - capabs: []string{"chanreg"}, - minParams: 0, - }, - "info": { - handler: csInfoHandler, - help: `Syntax: $INFO #channel$b - -INFO displays info about a registered channel.`, - helpShort: `$bINFO$b displays info about a registered channel.`, - enabled: chanregEnabled, - }, - "get": { - handler: csGetHandler, - help: `Syntax: $bGET #channel $b - -GET queries the current values of the channel settings. For more information -on the settings and their possible values, see HELP SET.`, - helpShort: `$bGET$b queries the current values of a channel's settings`, - enabled: chanregEnabled, - minParams: 2, - }, - "set": { - handler: csSetHandler, - helpShort: `$bSET$b modifies a channel's settings`, - // these are broken out as separate strings so they can be translated separately - helpStrings: []string{ - `Syntax $bSET #channel $b - -SET modifies a channel's settings. The following settings are available:`, - - `$bHISTORY$b -'history' lets you control how channel history is stored. Your options are: -1. 'off' [no history] -2. 'ephemeral' [a limited amount of temporary history, not stored on disk] -3. 'on' [history stored in a permanent database, if available] -4. 'default' [use the server default]`, - `$bQUERY-CUTOFF$b -'query-cutoff' lets you restrict how much channel history can be retrieved -by unprivileged users. Your options are: -1. 'none' [no restrictions] -2. 'registration-time' [users can view history from after their account was - registered, plus a grace period] -3. 'join-time' [users can view history from after they joined the - channel; note that history will be effectively - unavailable to clients that are not always-on] -4. 'default' [use the server default]`, - }, - enabled: chanregEnabled, - minParams: 3, - }, - "howtoban": { - handler: csHowToBanHandler, - helpShort: `$bHOWTOBAN$b suggests the best available way of banning a user`, - help: `Syntax: $bHOWTOBAN #channel - -The best way to ban a user from a channel will depend on how they are -connected to the server. $bHOWTOBAN$b suggests a ban command that will -(ideally) prevent the user from returning to the channel.`, - enabled: chanregEnabled, - minParams: 2, + helpShort: `$bREGISTER$b lets you own a given channel.`, }, } ) -func csAmodeHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { - channelName := params[0] - - channel := server.channels.Get(channelName) - if channel == nil { - service.Notice(rb, client.t("Channel does not exist")) - return - } else if channel.Founder() == "" { - service.Notice(rb, client.t("Channel is not registered")) - return - } - - modeChanges, unknown := modes.ParseChannelModeChanges(params[1:]...) - invalid := len(unknown) != 0 - // #2002: +f takes an argument but is not a channel-user mode, - // check for anything valid as a channel mode change that is not valid - // as an AMODE change - for _, modeChange := range modeChanges { - if !slices.Contains(modes.ChannelUserModes, modeChange.Mode) { - invalid = true - } - } - var change modes.ModeChange - if len(modeChanges) > 1 || invalid { - service.Notice(rb, client.t("Invalid mode change")) - return - } else if len(modeChanges) == 1 { - change = modeChanges[0] - } else { - change = modes.ModeChange{Op: modes.List} - } - - // normalize and validate the account argument - accountIsValid := false - change.Arg, _ = CasefoldName(change.Arg) - switch change.Op { - case modes.List: - accountIsValid = true - case modes.Add: - // if we're adding a mode, the account must exist - if change.Arg != "" { - _, err := server.accounts.LoadAccount(change.Arg) - accountIsValid = (err == nil) - } - case modes.Remove: - // allow removal of accounts that may have been deleted - accountIsValid = (change.Arg != "") - } - if !accountIsValid { - service.Notice(rb, client.t("Account does not exist")) - return - } - - affectedModes, err := channel.ProcessAccountToUmodeChange(client, change) - - if err == errInsufficientPrivs { - service.Notice(rb, client.t("Insufficient privileges")) - return - } else if err != nil { - service.Notice(rb, client.t("Internal error")) - return - } - - switch change.Op { - case modes.List: - // sort the persistent modes in descending order of priority - sort.Slice(affectedModes, func(i, j int) bool { - return umodeGreaterThan(affectedModes[i].Mode, affectedModes[j].Mode) - }) - service.Notice(rb, fmt.Sprintf(client.t("Channel %[1]s has %[2]d persistent modes set"), channelName, len(affectedModes))) - for _, modeChange := range affectedModes { - service.Notice(rb, fmt.Sprintf(client.t("Account %[1]s receives mode +%[2]s"), modeChange.Arg, string(modeChange.Mode))) - } - case modes.Add, modes.Remove: - if len(affectedModes) > 0 { - service.Notice(rb, fmt.Sprintf(client.t("Successfully set persistent mode %[1]s on %[2]s"), strings.Join([]string{string(change.Op), string(change.Mode)}, ""), change.Arg)) - // #729: apply change to current membership - for _, member := range channel.Members() { - if member.Account() == change.Arg { - // applyModeToMember takes the nickname, not the account name, - // so translate: - modeChange := change - modeChange.Arg = member.Nick() - applied, modeChange := channel.applyModeToMember(client, modeChange, rb) - if applied { - announceCmodeChanges(channel, modes.ModeChanges{modeChange}, server.name, "*", "", false, rb) - } - } - } - } else { - service.Notice(rb, client.t("No changes were made")) - } - } +// csNotice sends the client a notice from ChanServ +func csNotice(rb *ResponseBuffer, text string) { + rb.Add(nil, "ChanServ", "NOTICE", rb.target.Nick(), text) } -func csOpHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { - channelInfo := server.channels.Get(params[0]) - if channelInfo == nil { - service.Notice(rb, client.t("Channel does not exist")) +// chanservReceiveNotice handles NOTICEs that ChanServ receives. +func (server *Server) chanservNoticeHandler(client *Client, message string, rb *ResponseBuffer) { + // do nothing +} + +// chanservReceiveNotice handles NOTICEs that ChanServ receives. +func (server *Server) chanservPrivmsgHandler(client *Client, message string, rb *ResponseBuffer) { + commandName, params := utils.ExtractParam(message) + commandName = strings.ToLower(commandName) + + commandInfo := chanservCommands[commandName] + if commandInfo == nil { + csNotice(rb, client.t("Unknown command. To see available commands, run /CS HELP")) + return + } + + if commandInfo.oper && !client.HasMode(modes.Operator) { + csNotice(rb, client.t("Command restricted")) + return + } + + if 0 < len(commandInfo.capabs) && !client.HasRoleCapabs(commandInfo.capabs...) { + csNotice(rb, client.t("Command restricted")) + return + } + + // custom help handling here to prevent recursive init loop + if commandName == "help" { + csHelpHandler(server, client, commandName, params, rb) + return + } + + if commandInfo.handler == nil { + csNotice(rb, client.t("Command error. Please report this to the developers")) + return + } + + server.logger.Debug("chanserv", fmt.Sprintf("Client %s ran command %s", client.Nick(), commandName)) + + commandInfo.handler(server, client, commandName, params, rb) +} + +func csHelpHandler(server *Server, client *Client, command, params string, rb *ResponseBuffer) { + csNotice(rb, ircfmt.Unescape(client.t("*** $bChanServ HELP$b ***"))) + + if params == "" { + // show general help + var shownHelpLines sort.StringSlice + for _, commandInfo := range chanservCommands { + // skip commands user can't access + if commandInfo.oper && !client.HasMode(modes.Operator) { + continue + } + if 0 < len(commandInfo.capabs) && !client.HasRoleCapabs(commandInfo.capabs...) { + continue + } + + shownHelpLines = append(shownHelpLines, " "+client.t(commandInfo.helpShort)) + } + + // sort help lines + sort.Sort(shownHelpLines) + + // assemble help text + assembledHelpLines := strings.Join(shownHelpLines, "\n") + fullHelp := ircfmt.Unescape(fmt.Sprintf(client.t(chanservHelp), assembledHelpLines)) + + // push out help text + for _, line := range strings.Split(fullHelp, "\n") { + csNotice(rb, line) + } + } else { + commandInfo := chanservCommands[strings.ToLower(strings.TrimSpace(params))] + if commandInfo == nil { + csNotice(rb, client.t("Unknown command. To see available commands, run /cs HELP")) + } else { + for _, line := range strings.Split(ircfmt.Unescape(client.t(commandInfo.help)), "\n") { + csNotice(rb, line) + } + } + } + + csNotice(rb, ircfmt.Unescape(client.t("*** $bEnd of ChanServ HELP$b ***"))) +} + +func csOpHandler(server *Server, client *Client, command, params string, rb *ResponseBuffer) { + channelName, clientToOp := utils.ExtractParam(params) + + if channelName == "" { + csNotice(rb, ircfmt.Unescape(client.t("Syntax: $bOP #channel [nickname]$b"))) + return + } + + clientToOp = strings.TrimSpace(clientToOp) + + channelKey, err := CasefoldChannel(channelName) + if err != nil { + csNotice(rb, client.t("Channel name is not valid")) + return + } + + channelInfo := server.channels.Get(channelKey) + if channelInfo == nil { + csNotice(rb, client.t("Channel does not exist")) return } - channelName := channelInfo.Name() - founder := channelInfo.Founder() clientAccount := client.Account() + if clientAccount == "" { - service.Notice(rb, client.t("You're not logged into an account")) + csNotice(rb, client.t("You must be logged in to op on a channel")) + return + } + + if clientAccount != channelInfo.Founder() { + csNotice(rb, client.t("You must be the channel founder to op")) return } var target *Client - if len(params) > 1 { - target = server.clients.Get(params[1]) - if target == nil { - service.Notice(rb, client.t("Could not find given client")) + if clientToOp != "" { + casefoldedNickname, err := CasefoldName(clientToOp) + target = server.clients.Get(casefoldedNickname) + if err != nil || target == nil { + csNotice(rb, client.t("Could not find given client")) return } } else { target = client } - var givenMode modes.Mode - if target == client { - if clientAccount == founder { - givenMode = modes.ChannelFounder - } else { - givenMode = channelInfo.getAmode(clientAccount) - if givenMode == modes.Mode(0) { - service.Notice(rb, client.t("You don't have any stored privileges on that channel")) - return - } - } - } else { - if clientAccount == founder { - givenMode = modes.ChannelOperator - } else { - service.Notice(rb, client.t("Only the channel founder can do this")) - return + // give them privs + givenMode := modes.ChannelOperator + if client == target { + givenMode = modes.ChannelFounder + } + change := channelInfo.applyModeMemberNoMutex(target, givenMode, modes.Add, client.NickCasefolded(), rb) + if change != nil { + //TODO(dan): we should change the name of String and make it return a slice here + //TODO(dan): unify this code with code in modes.go + args := append([]string{channelName}, strings.Split(change.String(), " ")...) + for _, member := range channelInfo.Members() { + member.Send(nil, fmt.Sprintf("ChanServ!services@%s", client.server.name), "MODE", args...) } } - applied, change := channelInfo.applyModeToMember(client, - modes.ModeChange{Mode: givenMode, - Op: modes.Add, - Arg: target.NickCasefolded(), - }, - rb) - if applied { - announceCmodeChanges(channelInfo, modes.ModeChanges{change}, server.name, "*", "", false, rb) - } + csNotice(rb, fmt.Sprintf(client.t("Successfully op'd in channel %s"), channelName)) - service.Notice(rb, client.t("Successfully granted operator privileges")) - - tnick := target.Nick() - server.logger.Info("services", fmt.Sprintf("Client %s op'd [%s] in channel %s", client.Nick(), tnick, channelName)) - server.snomasks.Send(sno.LocalChannels, fmt.Sprintf(ircfmt.Unescape("Client $c[grey][$r%s$c[grey]] CS OP'd $c[grey][$r%s$c[grey]] in channel $c[grey][$r%s$c[grey]]"), client.NickMaskString(), tnick, channelName)) + server.logger.Info("chanserv", fmt.Sprintf("Client %s op'd [%s] in channel %s", client.nick, clientToOp, channelName)) + server.snomasks.Send(sno.LocalChannels, fmt.Sprintf(ircfmt.Unescape("Client $c[grey][$r%s$c[grey]] CS OP'd $c[grey][$r%s$c[grey]] in channel $c[grey][$r%s$c[grey]]"), client.nickMaskString, clientToOp, channelName)) } -func csDeopHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { - channel := server.channels.Get(params[0]) - if channel == nil { - service.Notice(rb, client.t("Channel does not exist")) - return - } - if !channel.hasClient(client) { - service.Notice(rb, client.t("You're not on that channel")) +func csRegisterHandler(server *Server, client *Client, command, params string, rb *ResponseBuffer) { + if !server.channelRegistrationEnabled { + csNotice(rb, client.t("Channel registration is not enabled")) return } - var target *Client - if len(params) > 1 { - target = server.clients.Get(params[1]) - if target == nil { - service.Notice(rb, client.t("Could not find given client")) - return - } - } else { - target = client - } - - present, _, cumodes := channel.ClientStatus(target) - if !present || len(cumodes) == 0 { - service.Notice(rb, client.t("Target has no privileges to remove")) + channelName := strings.TrimSpace(params) + if channelName == "" { + csNotice(rb, ircfmt.Unescape(client.t("Syntax: $bREGISTER #channel$b"))) return } - tnick := target.Nick() - modeChanges := make(modes.ModeChanges, len(cumodes)) - for i, mode := range cumodes { - modeChanges[i] = modes.ModeChange{ - Mode: mode, - Op: modes.Remove, - Arg: tnick, - } - } - - // use the user's own permissions for the check, then announce - // the changes as coming from chanserv - applied := channel.ApplyChannelModeChanges(client, false, modeChanges, rb) - details := client.Details() - isBot := client.HasMode(modes.Bot) - announceCmodeChanges(channel, applied, details.nickMask, details.accountName, details.account, isBot, rb) - - if len(applied) == 0 { + channelKey, err := CasefoldChannel(channelName) + if err != nil { + csNotice(rb, client.t("Channel name is not valid")) return } - service.Notice(rb, client.t("Successfully removed operator privileges")) -} - -func csRegisterHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { - if server.Config().Channels.Registration.OperatorOnly && !client.HasRoleCapabs("chanreg") { - service.Notice(rb, client.t("Channel registration is restricted to server operators")) - return - } - channelName := params[0] - channelInfo := server.channels.Get(channelName) - if channelInfo == nil { - service.Notice(rb, client.t("No such channel")) - return - } - if !channelInfo.ClientIsAtLeast(client, modes.ChannelOperator) { - service.Notice(rb, client.t("You must be an oper on the channel to register it")) + channelInfo := server.channels.Get(channelKey) + if channelInfo == nil || !channelInfo.ClientIsAtLeast(client, modes.ChannelOperator) { + csNotice(rb, client.t("You must be an oper on the channel to register it")) return } - account := client.Account() - if !checkChanLimit(service, client, rb) { + if client.Account() == "" { + csNotice(rb, client.t("You must be logged in to register a channel")) return } // this provides the synchronization that allows exactly one registration of the channel: - err := server.channels.SetRegistered(channelName, account) + err = channelInfo.SetRegistered(client.Account()) if err != nil { - service.Notice(rb, err.Error()) + csNotice(rb, err.Error()) return } - service.Notice(rb, fmt.Sprintf(client.t("Channel %s successfully registered"), channelName)) + // registration was successful: make the database reflect it + go server.channelRegistry.StoreChannel(channelInfo, true) - server.logger.Info("services", fmt.Sprintf("Client %s registered channel %s", client.Nick(), channelName)) + csNotice(rb, fmt.Sprintf(client.t("Channel %s successfully registered"), channelName)) + + server.logger.Info("chanserv", fmt.Sprintf("Client %s registered channel %s", client.nick, channelName)) server.snomasks.Send(sno.LocalChannels, fmt.Sprintf(ircfmt.Unescape("Channel registered $c[grey][$r%s$c[grey]] by $c[grey][$r%s$c[grey]]"), channelName, client.nickMaskString)) // give them founder privs - applied, change := channelInfo.applyModeToMember(client, - modes.ModeChange{ - Mode: modes.ChannelFounder, - Op: modes.Add, - Arg: client.NickCasefolded(), - }, - rb) - if applied { - announceCmodeChanges(channelInfo, modes.ModeChanges{change}, service.prefix, "*", "", false, rb) - } -} - -// check whether a client has already registered too many channels -func checkChanLimit(service *ircService, client *Client, rb *ResponseBuffer) (ok bool) { - account := client.Account() - channelsAlreadyRegistered := client.server.channels.ChannelsForAccount(account) - ok = len(channelsAlreadyRegistered) < client.server.Config().Channels.Registration.MaxChannelsPerAccount || client.HasRoleCapabs("chanreg") - if !ok { - service.Notice(rb, client.t("You have already registered the maximum number of channels; try dropping some with /CS UNREGISTER")) - } - return -} - -func csPrivsCheck(service *ircService, channel RegisteredChannel, client *Client, rb *ResponseBuffer) (success bool) { - founder := channel.Founder - if founder == "" { - service.Notice(rb, client.t("That channel is not registered")) - return false - } - if client.HasRoleCapabs("chanreg") { - return true - } - if founder != client.Account() { - service.Notice(rb, client.t("Insufficient privileges")) - return false - } - return true -} - -func csUnregisterHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { - channelName := params[0] - var verificationCode string - if len(params) > 1 { - verificationCode = params[1] - } - - channel := server.channels.Get(channelName) - if channel == nil { - service.Notice(rb, client.t("No such channel")) - return - } - - info := channel.exportSummary() - channelKey := channel.NameCasefolded() - if !csPrivsCheck(service, info, client, rb) { - return - } - - expectedCode := utils.ConfirmationCode(info.Name, info.RegisteredAt) - if expectedCode != verificationCode { - service.Notice(rb, ircfmt.Unescape(client.t("$bWarning: unregistering this channel will remove all stored channel attributes.$b"))) - service.Notice(rb, fmt.Sprintf(client.t("To confirm, run this command: %s"), fmt.Sprintf("/CS UNREGISTER %s %s", channelKey, expectedCode))) - return - } - - server.channels.SetUnregistered(channelKey, info.Founder) - service.Notice(rb, fmt.Sprintf(client.t("Channel %s is now unregistered"), channelKey)) -} - -func csClearHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { - channel := server.channels.Get(params[0]) - if channel == nil { - service.Notice(rb, client.t("Channel does not exist")) - return - } - if !csPrivsCheck(service, channel.exportSummary(), client, rb) { - return - } - - switch strings.ToLower(params[1]) { - case "access": - channel.resetAccess() - service.Notice(rb, client.t("Successfully reset channel access")) - case "users": - for _, target := range channel.Members() { - if target != client { - channel.Kick(client, target, "Cleared by ChanServ", rb, true) - } - } - default: - service.Notice(rb, client.t("Invalid parameters")) - } - -} - -func csTransferHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { - if strings.ToLower(params[0]) == "accept" { - processTransferAccept(service, client, params[1], rb) - return - } - chname := params[0] - channel := server.channels.Get(chname) - if channel == nil { - service.Notice(rb, client.t("Channel does not exist")) - return - } - regInfo := channel.exportSummary() - chname = regInfo.Name - account := client.Account() - isFounder := account != "" && account == regInfo.Founder - oper := client.Oper() - hasPrivs := oper.HasRoleCapab("chanreg") - if !isFounder && !hasPrivs { - service.Notice(rb, client.t("Insufficient privileges")) - return - } - target := params[1] - targetAccount, err := server.accounts.LoadAccount(params[1]) - if err != nil { - service.Notice(rb, client.t("Account does not exist")) - return - } - if targetAccount.NameCasefolded != account { - expectedCode := utils.ConfirmationCode(regInfo.Name, regInfo.RegisteredAt) - codeValidated := 2 < len(params) && params[2] == expectedCode - if !codeValidated { - service.Notice(rb, ircfmt.Unescape(client.t("$bWarning: you are about to transfer control of your channel to another user.$b"))) - service.Notice(rb, fmt.Sprintf(client.t("To confirm your channel transfer, type: /CS TRANSFER %[1]s %[2]s %[3]s"), chname, target, expectedCode)) - return - } - } - if !isFounder { - message := fmt.Sprintf("Operator %s ran CS TRANSFER on %s to account %s", oper.Name, chname, target) - server.snomasks.Send(sno.LocalOpers, message) - server.logger.Info("opers", message) - } - status, err := channel.Transfer(client, target, hasPrivs) - if err == nil { - switch status { - case channelTransferComplete: - service.Notice(rb, fmt.Sprintf(client.t("Successfully transferred channel %[1]s to account %[2]s"), chname, target)) - case channelTransferPending: - sendTransferPendingNotice(service, server, target, chname) - service.Notice(rb, fmt.Sprintf(client.t("Transfer of channel %[1]s to account %[2]s succeeded, pending acceptance"), chname, target)) - case channelTransferCancelled: - service.Notice(rb, fmt.Sprintf(client.t("Cancelled pending transfer of channel %s"), chname)) - } - } else { - switch err { - case errChannelNotOwnedByAccount: - service.Notice(rb, client.t("You don't own that channel")) - default: - service.Notice(rb, client.t("Could not transfer channel")) - } - } -} - -func sendTransferPendingNotice(service *ircService, server *Server, account, chname string) { - clients := server.accounts.AccountToClients(account) - if len(clients) == 0 { - return - } - var client *Client - for _, candidate := range clients { - client = candidate - if candidate.NickCasefolded() == candidate.Account() { - break // prefer the login where the nick is the account - } - } - client.Send(nil, service.prefix, "NOTICE", client.Nick(), fmt.Sprintf(client.t("You have been offered ownership of channel %[1]s. To accept, /CS TRANSFER ACCEPT %[1]s"), chname)) -} - -func processTransferAccept(service *ircService, client *Client, chname string, rb *ResponseBuffer) { - channel := client.server.channels.Get(chname) - if channel == nil { - service.Notice(rb, client.t("Channel does not exist")) - return - } - if !checkChanLimit(service, client, rb) { - return - } - switch channel.AcceptTransfer(client) { - case nil: - service.Notice(rb, fmt.Sprintf(client.t("Successfully accepted ownership of channel %s"), channel.Name())) - case errChannelTransferNotOffered: - service.Notice(rb, fmt.Sprintf(client.t("You weren't offered ownership of channel %s"), channel.Name())) - default: - service.Notice(rb, fmt.Sprintf(client.t("Could not accept ownership of channel %s"), channel.Name())) - } -} - -func csPurgeHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { - oper := client.Oper() - if oper == nil { - return // should be impossible because you need oper capabs for this - } - - switch strings.ToLower(params[0]) { - case "add": - csPurgeAddHandler(service, client, params[1:], oper.Name, rb) - case "del", "remove": - csPurgeDelHandler(service, client, params[1:], oper.Name, rb) - case "list": - csPurgeListHandler(service, client, rb) - default: - service.Notice(rb, client.t("Invalid parameters")) - } -} - -func csPurgeAddHandler(service *ircService, client *Client, params []string, operName string, rb *ResponseBuffer) { - if len(params) == 0 { - service.Notice(rb, client.t("Invalid parameters")) - return - } - - chname := params[0] - params = params[1:] - channel := client.server.channels.Get(chname) // possibly nil - var ctime time.Time - if channel != nil { - chname = channel.Name() - ctime = channel.Ctime() - } - code := utils.ConfirmationCode(chname, ctime) - - if len(params) == 0 || params[0] != code { - service.Notice(rb, ircfmt.Unescape(client.t("$bWarning: you are about to empty this channel and remove it from the server.$b"))) - service.Notice(rb, fmt.Sprintf(client.t("To confirm, run this command: %s"), fmt.Sprintf("/CS PURGE ADD %s %s", chname, code))) - return - } - params = params[1:] - - var reason string - if 1 < len(params) { - reason = params[1] - } - - purgeRecord := ChannelPurgeRecord{ - Oper: operName, - PurgedAt: time.Now().UTC(), - Reason: reason, - } - switch client.server.channels.Purge(chname, purgeRecord) { - case nil: - if channel != nil { // channel need not exist to be purged - for _, target := range channel.Members() { - channel.Kick(client, target, "Cleared by ChanServ", rb, true) - } - } - service.Notice(rb, fmt.Sprintf(client.t("Successfully purged channel %s from the server"), chname)) - client.server.snomasks.Send(sno.LocalChannels, fmt.Sprintf("Operator %s purged channel %s [reason: %s]", operName, chname, reason)) - case errInvalidChannelName: - service.Notice(rb, fmt.Sprintf(client.t("Can't purge invalid channel %s"), chname)) - default: - service.Notice(rb, client.t("An error occurred")) - } -} - -func csPurgeDelHandler(service *ircService, client *Client, params []string, operName string, rb *ResponseBuffer) { - if len(params) == 0 { - service.Notice(rb, client.t("Invalid parameters")) - return - } - - chname := params[0] - switch client.server.channels.Unpurge(chname) { - case nil: - service.Notice(rb, fmt.Sprintf(client.t("Successfully unpurged channel %s from the server"), chname)) - client.server.snomasks.Send(sno.LocalChannels, fmt.Sprintf("Operator %s removed purge of channel %s", operName, chname)) - case errNoSuchChannel: - service.Notice(rb, fmt.Sprintf(client.t("Channel %s wasn't previously purged from the server"), chname)) - default: - service.Notice(rb, client.t("An error occurred")) - } -} - -func csPurgeListHandler(service *ircService, client *Client, rb *ResponseBuffer) { - l := client.server.channels.ListPurged() - service.Notice(rb, fmt.Sprintf(client.t("There are %d purged channel(s)."), len(l))) - for i, c := range l { - service.Notice(rb, fmt.Sprintf("%d: %s", i+1, c)) - } -} - -func csListHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { - var searchRegex *regexp.Regexp - if len(params) > 0 { - var err error - searchRegex, err = regexp.Compile(params[0]) - if err != nil { - service.Notice(rb, client.t("Invalid regex")) - return - } - } - - service.Notice(rb, ircfmt.Unescape(client.t("*** $bChanServ LIST$b ***"))) - - channels := server.channels.AllRegisteredChannels() - for _, channel := range channels { - if searchRegex == nil || searchRegex.MatchString(channel) { - service.Notice(rb, fmt.Sprintf(" %s", channel)) - } - } - - service.Notice(rb, ircfmt.Unescape(client.t("*** $bEnd of ChanServ LIST$b ***"))) -} - -func csInfoHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { - - if len(params) == 0 { - // #765 - listRegisteredChannels(service, client.Account(), rb) - return - } - - chname, err := CasefoldChannel(params[0]) - if err != nil { - service.Notice(rb, client.t("Invalid channel name")) - return - } - - var chinfo RegisteredChannel - channel := server.channels.Get(params[0]) - if channel != nil { - chinfo = channel.exportSummary() - } - - tags := map[string]string{ - "target": chinfo.Name, - } - - // purge status - if client.HasRoleCapabs("chanreg") { - purgeRecord, err := server.channels.LoadPurgeRecord(chname) - if err == nil { - service.TaggedNotice(rb, fmt.Sprintf(client.t("Channel %s was purged by the server operators and cannot be used"), chname), tags) - service.TaggedNotice(rb, fmt.Sprintf(client.t("Purged by operator: %s"), purgeRecord.Oper), tags) - service.TaggedNotice(rb, fmt.Sprintf(client.t("Purged at: %s"), purgeRecord.PurgedAt.Format(time.RFC1123)), tags) - if purgeRecord.Reason != "" { - service.TaggedNotice(rb, fmt.Sprintf(client.t("Purge reason: %s"), purgeRecord.Reason), tags) - } - } - } else { - if server.channels.IsPurged(chname) { - service.TaggedNotice(rb, fmt.Sprintf(client.t("Channel %s was purged by the server operators and cannot be used"), chname), tags) - } - } - - // channel exists but is unregistered, or doesn't exist: - if chinfo.Founder == "" { - service.TaggedNotice(rb, fmt.Sprintf(client.t("Channel %s is not registered"), chname), tags) - return - } - service.TaggedNotice(rb, fmt.Sprintf(client.t("Channel %s is registered"), chinfo.Name), tags) - service.TaggedNotice(rb, fmt.Sprintf(client.t("Founder: %s"), chinfo.Founder), tags) - service.TaggedNotice(rb, fmt.Sprintf(client.t("Registered at: %s"), chinfo.RegisteredAt.Format(time.RFC1123)), tags) -} - -func displayChannelSetting(service *ircService, settingName string, settings ChannelSettings, client *Client, rb *ResponseBuffer) { - config := client.server.Config() - - switch strings.ToLower(settingName) { - case "history": - effectiveValue := historyEnabled(config.History.Persistent.RegisteredChannels, settings.History) - service.Notice(rb, fmt.Sprintf(client.t("The stored channel history setting is: %s"), historyStatusToString(settings.History))) - service.Notice(rb, fmt.Sprintf(client.t("Given current server settings, the channel history setting is: %s"), historyStatusToString(effectiveValue))) - case "query-cutoff": - effectiveValue := settings.QueryCutoff - if effectiveValue == HistoryCutoffDefault { - effectiveValue = config.History.Restrictions.queryCutoff - } - service.Notice(rb, fmt.Sprintf(client.t("The stored channel history query cutoff setting is: %s"), historyCutoffToString(settings.QueryCutoff))) - service.Notice(rb, fmt.Sprintf(client.t("Given current server settings, the channel history query cutoff setting is: %s"), historyCutoffToString(effectiveValue))) - default: - service.Notice(rb, client.t("Invalid params")) - } -} - -func csGetHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { - chname, setting := params[0], params[1] - channel := server.channels.Get(chname) - if channel == nil { - service.Notice(rb, client.t("No such channel")) - return - } - info := channel.exportSummary() - if !csPrivsCheck(service, info, client, rb) { - return - } - - displayChannelSetting(service, setting, channel.Settings(), client, rb) -} - -func csSetHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { - chname, setting, value := params[0], params[1], params[2] - channel := server.channels.Get(chname) - if channel == nil { - service.Notice(rb, client.t("No such channel")) - return - } - info := channel.exportSummary() - if !csPrivsCheck(service, info, client, rb) { - return - } - - settings := channel.Settings() - var err error - switch strings.ToLower(setting) { - case "history": - settings.History, err = historyStatusFromString(value) - if err != nil { - err = errInvalidParams - break - } - channel.SetSettings(settings) - channel.resizeHistory(server.Config()) - case "query-cutoff": - settings.QueryCutoff, err = historyCutoffFromString(value) - if err != nil { - err = errInvalidParams - break - } - channel.SetSettings(settings) - } - - switch err { - case nil: - service.Notice(rb, client.t("Successfully changed the channel settings")) - displayChannelSetting(service, setting, settings, client, rb) - case errInvalidParams: - service.Notice(rb, client.t("Invalid parameters")) - default: - server.logger.Error("internal", "CS SET error:", err.Error()) - service.Notice(rb, client.t("An error occurred")) - } -} - -func csHowToBanHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { - success := false - defer func() { - if success { - service.Notice(rb, client.t("Note that if the user is currently in the channel, you must /KICK them after you ban them")) - } - }() - - chname, nick := params[0], params[1] - channel := server.channels.Get(chname) - if channel == nil { - service.Notice(rb, client.t("No such channel")) - return - } - - if !(channel.ClientIsAtLeast(client, modes.ChannelOperator) || client.HasRoleCapabs("samode")) { - service.Notice(rb, client.t("Insufficient privileges")) - return - } - - var details WhoWas - target := server.clients.Get(nick) - if target == nil { - whowasList := server.whoWas.Find(nick, 1) - if len(whowasList) == 0 { - service.Notice(rb, client.t("No such nick")) - return - } - service.Notice(rb, fmt.Sprintf(client.t("Warning: %s is not currently connected to the server. Using WHOWAS data, which may be inaccurate:"), nick)) - details = whowasList[0] - } else { - details = target.Details().WhoWas - } - - if details.account != "" { - if channel.getAmode(details.account) != modes.Mode(0) { - service.Notice(rb, fmt.Sprintf(client.t("Warning: account %s currently has a persistent channel privilege granted with CS AMODE. If this mode is not removed, bans will not be respected"), details.accountName)) - return - } else if details.account == channel.Founder() { - service.Notice(rb, fmt.Sprintf(client.t("Warning: account %s is the channel founder and cannot be banned"), details.accountName)) - return - } - } - - config := server.Config() - if !config.Server.Cloaks.EnabledForAlwaysOn { - service.Notice(rb, client.t("Warning: server.ip-cloaking.enabled-for-always-on is disabled. This reduces the precision of channel bans.")) - } - - if details.account != "" { - if config.Accounts.NickReservation.ForceNickEqualsAccount || target.AlwaysOn() { - service.Notice(rb, fmt.Sprintf(client.t("User %[1]s is authenticated and can be banned by nickname: /MODE %[2]s +b %[3]s!*@*"), details.nick, channel.Name(), details.nick)) - success = true - return - } - } - - ban := fmt.Sprintf("*!*@%s", strings.ToLower(details.hostname)) - banRe, err := utils.CompileGlob(ban, false) - if err != nil { - server.logger.Error("internal", "couldn't compile ban regex", ban, err.Error()) - service.Notice(rb, "An error occurred") - return - } - var collateralDamage []string - for _, mcl := range channel.Members() { - if mcl != target && banRe.MatchString(mcl.NickMaskCasefolded()) { - collateralDamage = append(collateralDamage, mcl.Nick()) - } - } - service.Notice(rb, fmt.Sprintf(client.t("User %[1]s can be banned by hostname: /MODE %[2]s +b %[3]s"), details.nick, channel.Name(), ban)) - success = true - if len(collateralDamage) != 0 { - service.Notice(rb, fmt.Sprintf(client.t("Warning: this ban will affect %d other users:"), len(collateralDamage))) - for _, line := range utils.BuildTokenLines(maxLastArgLength, collateralDamage, " ") { - service.Notice(rb, line) + change := channelInfo.applyModeMemberNoMutex(client, modes.ChannelFounder, modes.Add, client.NickCasefolded(), rb) + if change != nil { + //TODO(dan): we should change the name of String and make it return a slice here + //TODO(dan): unify this code with code in modes.go + args := append([]string{channelName}, strings.Split(change.String(), " ")...) + for _, member := range channelInfo.Members() { + member.Send(nil, fmt.Sprintf("ChanServ!services@%s", client.server.name), "MODE", args...) } } } diff --git a/irc/client.go b/irc/client.go index 763144db..d309102a 100644 --- a/irc/client.go +++ b/irc/client.go @@ -6,9 +6,8 @@ package irc import ( - "crypto/x509" "fmt" - "maps" + "log" "net" "runtime/debug" "strconv" @@ -17,936 +16,452 @@ import ( "sync/atomic" "time" - ident "github.com/ergochat/go-ident" - "github.com/ergochat/irc-go/ircfmt" - "github.com/ergochat/irc-go/ircmsg" - "github.com/ergochat/irc-go/ircreader" - "github.com/ergochat/irc-go/ircutils" - "github.com/xdg-go/scram" - - "github.com/ergochat/ergo/irc/caps" - "github.com/ergochat/ergo/irc/connection_limits" - "github.com/ergochat/ergo/irc/flatip" - "github.com/ergochat/ergo/irc/history" - "github.com/ergochat/ergo/irc/modes" - "github.com/ergochat/ergo/irc/oauth2" - "github.com/ergochat/ergo/irc/sno" - "github.com/ergochat/ergo/irc/utils" + "github.com/goshuirc/irc-go/ircfmt" + "github.com/goshuirc/irc-go/ircmsg" + ident "github.com/oragono/go-ident" + "github.com/oragono/oragono/irc/caps" + "github.com/oragono/oragono/irc/modes" + "github.com/oragono/oragono/irc/sno" + "github.com/oragono/oragono/irc/utils" ) const ( - // Set to 4096 because CEF doesn't care about compatibility - DefaultMaxLineLen = 4096 - - // IdentTimeout is how long before our ident (username) check times out. - IdentTimeout = time.Second + 500*time.Millisecond - IRCv3TimestampFormat = utils.IRCv3TimestampFormat - // limit the number of device IDs a client can use, as a DoS mitigation - maxDeviceIDsPerClient = 64 - // maximum total read markers that can be stored - // (writeback of read markers is controlled by lastSeen logic) - maxReadMarkers = 256 -) - -const ( - // RegisterTimeout is how long clients have to register before we disconnect them - RegisterTimeout = time.Minute - // DefaultIdleTimeout is how long without traffic before we send the client a PING - DefaultIdleTimeout = time.Minute + 30*time.Second - // For Tor clients, we send a PING at least every 30 seconds, as a workaround for this bug - // (single-onion circuits will close unless the client sends data once every 60 seconds): - // https://bugs.torproject.org/29665 - TorIdleTimeout = time.Second * 30 - // This is how long a client gets without sending any message, including the PONG to our - // PING, before we disconnect them: - DefaultTotalTimeout = 2*time.Minute + 30*time.Second - - // round off the ping interval by this much, see below: - PingCoalesceThreshold = time.Second + // IdentTimeoutSeconds is how many seconds before our ident (username) check times out. + IdentTimeoutSeconds = 1.5 ) var ( - MaxLineLen = DefaultMaxLineLen + LoopbackIP = net.ParseIP("127.0.0.1") ) // Client is an IRC client. type Client struct { account string - accountName string // display name of the account: uncasefolded, '*' if not logged in - accountRegDate time.Time - accountSettings AccountSettings + accountName string + atime time.Time + authorized bool awayMessage string + capabilities *caps.Set + capState caps.State + capVersion caps.Version + certfp string channels ChannelSet + class *OperClass ctime time.Time - destroyed bool - modes modes.ModeSet + exitedSnomaskSent bool + fakelag *Fakelag + flags map[modes.Mode]bool + hasQuit bool + hops int hostname string - invitedTo map[string]channelInvite - isSTSOnly bool - isKlined bool // #1941: k-line kills are special-cased to suppress some triggered notices/events + idletimer *IdleTimer + isDestroyed bool + isQuitting bool languages []string - lastActive time.Time // last time they sent a command that wasn't PONG or similar - lastSeen map[string]time.Time // maps device ID (including "") to time of last received command - readMarkers map[string]time.Time // maps casefolded target to time of last read marker - loginThrottle connection_limits.GenericThrottle - nextSessionID int64 // Incremented when a new session is established + maxlenTags uint32 + maxlenRest uint32 nick string nickCasefolded string nickMaskCasefolded string nickMaskString string // cache for nickmask string since it's used with lots of replies - oper *Oper + nickTimer *NickTimer + operName string preregNick string proxiedIP net.IP // actual remote IP if using the PROXY protocol + quitMessage string rawHostname string - cloakedHostname string realname string - realIP net.IP - requireSASLMessage string - requireSASL bool registered bool - registerCmdSent bool // already sent the draft/register command, can't send it again - dirtyTimestamps bool // lastSeen or readMarkers is dirty - registrationTimer *time.Timer + resumeDetails *ResumeDetails + saslInProgress bool + saslMechanism string + saslValue string server *Server - skeleton string - sessions []*Session + socket *Socket stateMutex sync.RWMutex // tier 1 - alwaysOn bool username string vhost string - history history.Buffer - dirtyBits uint - writebackLock sync.Mutex // tier 1.5 + whoisLine string } -type saslStatus struct { - mechanism string - value ircutils.SASLBuffer - scramConv *scram.ServerConversation - oauthConv *oauth2.OAuthBearerServer -} - -func (s *saslStatus) Initialize() { - s.value.Initialize(saslMaxResponseLength) -} - -func (s *saslStatus) Clear() { - s.mechanism = "" - s.value.Clear() - s.scramConv = nil - s.oauthConv = nil -} - -// what stage the client is at w.r.t. the PASS command: -type serverPassStatus uint - -const ( - serverPassUnsent serverPassStatus = iota - serverPassSuccessful - serverPassFailed -) - -// Session is an individual client connection to the server (TCP connection -// and associated per-connection data, such as capabilities). There is a -// many-one relationship between sessions and clients. -type Session struct { - client *Client - - deviceID string - - ctime time.Time - lastActive time.Time // last non-CTCP PRIVMSG sent; updates publicly visible idle time - lastTouch time.Time // last line sent; updates timer for idle timeouts - idleTimer *time.Timer - pingSent bool // we sent PING to a putatively idle connection and we're waiting for PONG - - sessionID int64 - socket *Socket - realIP net.IP - proxiedIP net.IP - rawHostname string - hostnameFinalized bool - isTor bool - hideSTS bool - - fakelag Fakelag - deferredFakelagCount int - - certfp string - peerCerts []*x509.Certificate - sasl saslStatus - passStatus serverPassStatus - - batchCounter atomic.Uint32 - - isupportSentPrereg bool - - quitMessage string - - awayMessage string - awayAt time.Time - - capabilities caps.Set - capState caps.State - capVersion caps.Version - - registrationMessages int - - zncPlaybackTimes *zncPlaybackTimes - autoreplayMissedSince time.Time - - batch MultilineBatch -} - -// MultilineBatch tracks the state of a client-to-server multiline batch. -type MultilineBatch struct { - label string // this is the first param to BATCH (the "reference tag") - command string - target string - responseLabel string // this is the value of the labeled-response tag sent with BATCH - message utils.SplitMessage - lenBytes int - tags map[string]string -} - -// Starts a multiline batch, failing if there's one already open -func (s *Session) StartMultilineBatch(label, target, responseLabel string, tags map[string]string) (err error) { - if s.batch.label != "" { - return errInvalidMultilineBatch - } - - s.batch.label, s.batch.target, s.batch.responseLabel, s.batch.tags = label, target, responseLabel, tags - s.fakelag.Suspend() - return -} - -// Closes a multiline batch unconditionally; returns the batch and whether -// it was validly terminated (pass "" as the label if you don't care about the batch) -func (s *Session) EndMultilineBatch(label string) (batch MultilineBatch, err error) { - batch = s.batch - s.batch = MultilineBatch{} - s.fakelag.Unsuspend() - - // heuristics to estimate how much data they used while fakelag was suspended - fakelagBill := (batch.lenBytes / MaxLineLen) + 1 - fakelagBillLines := (batch.message.LenLines() * 60) / MaxLineLen - if fakelagBill < fakelagBillLines { - fakelagBill = fakelagBillLines - } - s.deferredFakelagCount = fakelagBill - - if batch.label == "" || batch.label != label || !batch.message.ValidMultiline() { - err = errInvalidMultilineBatch - return - } - - batch.message.SetTime() - - return -} - -// sets the session quit message, if there isn't one already -func (sd *Session) setQuitMessage(message string) (set bool) { - if message == "" { - message = "Connection closed" - } - if sd.quitMessage == "" { - sd.quitMessage = message - return true - } else { - return false - } -} - -func (s *Session) IP() net.IP { - if s.proxiedIP != nil { - return s.proxiedIP - } - return s.realIP -} - -// returns whether the client supports a smart history replay cap, -// and therefore autoreplay-on-join and similar should be suppressed -func (session *Session) HasHistoryCaps() bool { - return session.capabilities.Has(caps.Chathistory) || session.capabilities.Has(caps.ZNCPlayback) -} - -// generates a batch ID. the uniqueness requirements for this are fairly weak: -// any two batch IDs that are active concurrently (either through interleaving -// or nesting) on an individual session connection need to be unique. -// this allows ~4 billion such batches which should be fine. -func (session *Session) generateBatchID() string { - id := session.batchCounter.Add(1) - return strconv.FormatInt(int64(id), 32) -} - -// WhoWas is the subset of client details needed to answer a WHOWAS query -type WhoWas struct { - nick string - nickCasefolded string - username string - hostname string - realname string - ip net.IP - // technically not required for WHOWAS: - account string - accountName string -} - -// ClientDetails is a standard set of details about a client -type ClientDetails struct { - WhoWas - - nickMask string - nickMaskCasefolded string -} - -// RunClient sets up a new client and runs its goroutine. -func (server *Server) RunClient(conn IRCConn) { - config := server.Config() - wConn := conn.UnderlyingConn() - var isBanned, requireSASL bool - var banMsg string - realIP := utils.AddrToIP(wConn.RemoteAddr()) - var proxiedIP net.IP - if wConn.Tor { - // cover up details of the tor proxying infrastructure (not a user privacy concern, - // but a hardening measure): - proxiedIP = utils.IPv4LoopbackAddress - isBanned, banMsg = server.checkTorLimits() - } else { - ipToCheck := realIP - if wConn.ProxiedIP != nil { - proxiedIP = wConn.ProxiedIP - ipToCheck = proxiedIP - } - // XXX only run the check script now if the IP cannot be replaced by PROXY or WEBIRC, - // otherwise we'll do it in ApplyProxiedIP. - checkScripts := proxiedIP != nil || !utils.IPInNets(realIP, config.Server.proxyAllowedFromNets) - isBanned, requireSASL, banMsg = server.checkBans(config, ipToCheck, checkScripts) - } - - if isBanned { - // this might not show up properly on some clients, - // but our objective here is just to close the connection out before it has a load impact on us - conn.WriteLine([]byte(fmt.Sprintf(errorMsg, banMsg))) - conn.Close() - return - } - - server.logger.Info("connect-ip", fmt.Sprintf("Client connecting: real IP %v, proxied IP %v", realIP, proxiedIP)) - - now := time.Now().UTC() - // give them 1k of grace over the limit: - socket := NewSocket(conn, config.Server.MaxSendQBytes) +// NewClient returns a client with all the appropriate info setup. +func NewClient(server *Server, conn net.Conn, isTLS bool) *Client { + now := time.Now() + limits := server.Limits() + fullLineLenLimit := limits.LineLen.Tags + limits.LineLen.Rest + socket := NewSocket(conn, fullLineLenLimit*2, server.MaxSendQBytes()) + go socket.RunSocketWriter() client := &Client{ - lastActive: now, - channels: make(ChannelSet), - ctime: now, - isSTSOnly: wConn.STSOnly, - languages: server.Languages().Default(), - loginThrottle: connection_limits.GenericThrottle{ - Duration: config.Accounts.LoginThrottling.Duration, - Limit: config.Accounts.LoginThrottling.MaxAttempts, - }, + atime: now, + authorized: server.Password() == nil, + capabilities: caps.NewSet(), + capState: caps.NoneState, + capVersion: caps.Cap301, + channels: make(ChannelSet), + ctime: now, + flags: make(map[modes.Mode]bool), server: server, - accountName: "*", + socket: &socket, nick: "*", // * is used until actual nick is given nickCasefolded: "*", nickMaskString: "*", // * is used until actual nick is given - realIP: realIP, - proxiedIP: proxiedIP, - requireSASL: requireSASL, - nextSessionID: 1, } - if requireSASL { - client.requireSASLMessage = banMsg - } - client.history.Initialize(config.History.ClientLength, time.Duration(config.History.AutoresizeWindow)) - session := &Session{ - client: client, - socket: socket, - capVersion: caps.Cap301, - capState: caps.NoneState, - ctime: now, - lastActive: now, - realIP: realIP, - proxiedIP: proxiedIP, - isTor: wConn.Tor, - hideSTS: wConn.Tor || wConn.HideSTS, - } - session.sasl.Initialize() - client.sessions = []*Session{session} + client.languages = server.languages.Default() - session.resetFakelag() + client.recomputeMaxlens() + if isTLS { + client.flags[modes.TLS] = true - if wConn.Secure { - client.SetMode(modes.TLS, true) - } - - if wConn.TLS { // error is not useful to us here anyways so we can ignore it - session.certfp, session.peerCerts, _ = utils.GetCertFP(wConn.Conn, RegisterTimeout) + client.certfp, _ = client.socket.CertFP() } - - if session.isTor { - session.rawHostname = config.Server.TorListeners.Vhost - client.rawHostname = session.rawHostname - } else { - if config.Server.CheckIdent { - client.doIdentLookup(wConn.Conn) + if server.checkIdent && !utils.AddrIsUnix(conn.RemoteAddr()) { + _, serverPortString, err := net.SplitHostPort(conn.LocalAddr().String()) + serverPort, _ := strconv.Atoi(serverPortString) + if err != nil { + log.Fatal(err) } - } - - client.registrationTimer = time.AfterFunc(RegisterTimeout, client.handleRegisterTimeout) - server.stats.Add() - client.run(session) -} - -func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToStatus map[string]alwaysOnChannelStatus, lastSeen, readMarkers map[string]time.Time, uModes modes.Modes, realname string) { - now := time.Now().UTC() - config := server.Config() - if lastSeen == nil && account.Settings.AutoreplayMissed { - lastSeen = map[string]time.Time{"": now} - } - - rawHostname, cloakedHostname := server.name, "" - if config.Server.Cloaks.EnabledForAlwaysOn { - cloakedHostname = config.Server.Cloaks.ComputeAccountCloak(account.Name) - } - - username := "~u" - if config.Server.CoerceIdent != "" { - username = config.Server.CoerceIdent - } - - client := &Client{ - lastSeen: lastSeen, - readMarkers: readMarkers, - lastActive: now, - channels: make(ChannelSet), - ctime: now, - languages: server.Languages().Default(), - server: server, - - username: username, - cloakedHostname: cloakedHostname, - rawHostname: rawHostname, - realIP: utils.IPv4LoopbackAddress, - - alwaysOn: true, - realname: realname, - - nextSessionID: 1, - } - - if client.checkAlwaysOnExpirationNoMutex(config, true) { - server.logger.Debug("accounts", "always-on client not created due to expiration", account.Name) - return - } - - client.SetMode(modes.TLS, true) - for _, m := range uModes { - client.SetMode(m, true) - } - client.history.Initialize(0, 0) - - server.accounts.Login(client, account) - - client.resizeHistory(config) - - _, err, _ := server.clients.SetNick(client, nil, account.Name, false) - if err != nil { - server.logger.Error("internal", "could not establish always-on client", account.Name, err.Error()) - return - } else { - server.logger.Debug("accounts", "established always-on client", account.Name) - } - - // XXX set this last to avoid confusing SetNick: - client.registered = true - - for chname, status := range channelToStatus { - // XXX we're using isSajoin=true, to make these joins succeed even without channel key - // this is *probably* ok as long as the persisted memberships are accurate - server.channels.Join(client, chname, "", true, nil) - if channel := server.channels.Get(chname); channel != nil { - channel.setMemberStatus(client, status) - } else { - server.logger.Error("internal", "could not create channel", chname) + clientHost, clientPortString, err := net.SplitHostPort(conn.RemoteAddr().String()) + clientPort, _ := strconv.Atoi(clientPortString) + if err != nil { + log.Fatal(err) } - } - if persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway) { - client.setAutoAwayNoMutex(config) - } -} - -func (client *Client) resizeHistory(config *Config) { - status, _ := client.historyStatus(config) - if status == HistoryEphemeral { - client.history.Resize(config.History.ClientLength, time.Duration(config.History.AutoresizeWindow)) - } else { - client.history.Resize(0, 0) - } -} - -// once we have the final IP address (from the connection itself or from proxy data), -// compute the various possibilities for the hostname: -// * In the default/recommended configuration, via the cloak algorithm -// * If hostname lookup is enabled, via (forward-confirmed) reverse DNS -// * If WEBIRC was used, possibly via the hostname passed on the WEBIRC line -func (client *Client) finalizeHostname(session *Session) { - // only allow this once, since registration can fail (e.g. if the nickname is in use) - if session.hostnameFinalized { - return - } - session.hostnameFinalized = true - - if session.isTor { - return - } - - config := client.server.Config() - ip := session.realIP - if session.proxiedIP != nil { - ip = session.proxiedIP - } - - // even if cloaking is enabled, we may want to look up the real hostname to show to operators: - if session.rawHostname == "" { - var hostname string - lookupSuccessful := false - if config.Server.lookupHostnames { - session.Notice("*** Looking up your hostname...") - hostname, lookupSuccessful = utils.LookupHostname(ip, config.Server.ForwardConfirmHostnames) - if lookupSuccessful { - session.Notice("*** Found your hostname") + client.Notice(client.t("*** Looking up your username")) + resp, err := ident.Query(clientHost, serverPort, clientPort, IdentTimeoutSeconds) + if err == nil { + username := resp.Identifier + _, err := CasefoldName(username) // ensure it's a valid username + if err == nil { + client.Notice(client.t("*** Found your username")) + client.username = username + // we don't need to updateNickMask here since nickMask is not used for anything yet } else { - session.Notice("*** Couldn't look up your hostname") + client.Notice(client.t("*** Got a malformed username, ignoring")) } } else { - hostname = utils.IPStringToHostname(ip.String()) + client.Notice(client.t("*** Could not find your username")) } - session.rawHostname = hostname } + go client.run() - // these will be discarded if this is actually a reattach: - client.rawHostname = session.rawHostname - client.cloakedHostname = config.Server.Cloaks.ComputeCloak(ip) + return client } -func (client *Client) doIdentLookup(conn net.Conn) { - localTCPAddr, ok := conn.LocalAddr().(*net.TCPAddr) - if !ok { - return - } - serverPort := localTCPAddr.Port - remoteTCPAddr, ok := conn.RemoteAddr().(*net.TCPAddr) - if !ok { - return - } - clientPort := remoteTCPAddr.Port - - client.Notice(client.t("*** Looking up your username")) - resp, err := ident.Query(remoteTCPAddr.IP.String(), serverPort, clientPort, IdentTimeout) - if err == nil { - err := client.SetNames(resp.Identifier, "", true) - if err == nil { - client.Notice(client.t("*** Found your username")) - // we don't need to updateNickMask here since nickMask is not used for anything yet - } else { - client.Notice(client.t("*** Got a malformed username, ignoring")) +func (client *Client) resetFakelag() { + fakelag := func() *Fakelag { + if client.HasRoleCapabs("nofakelag") { + return nil } - } else { - client.Notice(client.t("*** Could not find your username")) - } -} -type AuthOutcome uint + flc := client.server.FakelagConfig() -const ( - authSuccess AuthOutcome = iota - authFailPass - authFailTorSaslRequired - authFailSaslRequired -) + if !flc.Enabled { + return nil + } -func (client *Client) isAuthorized(server *Server, config *Config, session *Session, forceRequireSASL bool) AuthOutcome { - saslSent := client.account != "" - // PASS requirement - if (config.Server.passwordBytes != nil) && session.passStatus != serverPassSuccessful && !(config.Accounts.SkipServerPassword && saslSent) { - return authFailPass - } - // Tor connections may be required to authenticate with SASL - if session.isTor && !saslSent && (config.Server.TorListeners.RequireSasl || server.Defcon() <= 4) { - return authFailTorSaslRequired - } - // finally, enforce require-sasl - if !saslSent && (forceRequireSASL || config.Accounts.RequireSasl.Enabled || server.Defcon() <= 2) && - !utils.IPInNets(session.IP(), config.Accounts.RequireSasl.exemptedNets) { - return authFailSaslRequired - } - return authSuccess -} + return NewFakelag(flc.Window, flc.BurstLimit, flc.MessagesPerWindow, flc.Cooldown) + }() -func (session *Session) resetFakelag() { - var flc FakelagConfig = session.client.server.Config().Fakelag - flc.Enabled = flc.Enabled && !session.client.HasRoleCapabs("nofakelag") - session.fakelag.Initialize(flc) + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + client.fakelag = fakelag } // IP returns the IP address of this client. func (client *Client) IP() net.IP { - client.stateMutex.RLock() - defer client.stateMutex.RUnlock() - - return client.getIPNoMutex() -} - -func (client *Client) getIPNoMutex() net.IP { if client.proxiedIP != nil { return client.proxiedIP } - return client.realIP + if ip := utils.AddrToIP(client.socket.conn.RemoteAddr()); ip != nil { + return ip + } + // unix domain socket that hasn't issued PROXY/WEBIRC yet. YOLO + return LoopbackIP } // IPString returns the IP address of this client as a string. func (client *Client) IPString() string { - return utils.IPStringToHostname(client.IP().String()) -} - -// t returns the translated version of the given string, based on the languages configured by the client. -func (client *Client) t(originalString string) string { - languageManager := client.server.Config().languageManager - if !languageManager.Enabled() { - return originalString + ip := client.IP().String() + if 0 < len(ip) && ip[0] == ':' { + ip = "0" + ip } - return languageManager.Translate(client.Languages(), originalString) + return ip } -// main client goroutine: read lines and execute the corresponding commands -// `proxyLine` is the PROXY-before-TLS line, if there was one -func (client *Client) run(session *Session) { +// +// command goroutine +// + +func (client *Client) recomputeMaxlens() (int, int) { + maxlenTags := 512 + maxlenRest := 512 + if client.capabilities.Has(caps.MessageTags) { + maxlenTags = 4096 + } + if client.capabilities.Has(caps.MaxLine) { + limits := client.server.Limits() + if limits.LineLen.Tags > maxlenTags { + maxlenTags = limits.LineLen.Tags + } + maxlenRest = limits.LineLen.Rest + } + + atomic.StoreUint32(&client.maxlenTags, uint32(maxlenTags)) + atomic.StoreUint32(&client.maxlenRest, uint32(maxlenRest)) + + return maxlenTags, maxlenRest +} + +// allow these negotiated length limits to be read without locks; this is a convenience +// so that Client.Send doesn't have to acquire any Client locks +func (client *Client) maxlens() (int, int) { + return int(atomic.LoadUint32(&client.maxlenTags)), int(atomic.LoadUint32(&client.maxlenRest)) +} + +func (client *Client) run() { + var err error + var isExiting bool + var line string + var msg ircmsg.IrcMessage defer func() { if r := recover(); r != nil { client.server.logger.Error("internal", fmt.Sprintf("Client caused panic: %v\n%s", r, debug.Stack())) - if client.server.Config().Debug.recoverFromErrors { + if client.server.RecoverFromErrors() { client.server.logger.Error("internal", "Disconnecting client and attempting to recover") } else { panic(r) } } // ensure client connection gets closed - client.destroy(session) + client.destroy(false) }() - isReattach := client.Registered() - if isReattach { - client.Touch(session) - client.playReattachMessages(session) - } + client.idletimer = NewIdleTimer(client) + client.idletimer.Start() - firstLine := !isReattach + client.nickTimer = NewNickTimer(client) - correspondents, _ := client.server.historyDB.GetPMs(client.Account()) - // For safety, let's keep this within the 4096 character barrier - var lineBuilder utils.TokenLineBuilder - lineBuilder.Initialize(MaxLineLen, ",") - for username, timestamp := range correspondents { - lineBuilder.Add(fmt.Sprintf("%s %d", client.server.getCurrentNick(username), timestamp)) - } - for _, message := range lineBuilder.Lines() { - session.Send(nil, client.server.name, "PMS", message) - } + client.resetFakelag() + + // Set the hostname for this client + // (may be overridden by a later PROXY command from stunnel) + client.rawHostname = utils.AddrLookupHostname(client.socket.conn.RemoteAddr()) for { - var invalidUtf8 bool - line, err := session.socket.Read() - if err == errInvalidUtf8 { - invalidUtf8 = true // handle as normal, including labeling - } else if err != nil { - client.server.logger.Debug("connect-ip", "read error from client", err.Error()) - var quitMessage string - switch err { - case ircreader.ErrReadQ: - quitMessage = err.Error() - default: - quitMessage = "connection closed" + maxlenTags, maxlenRest := client.recomputeMaxlens() + + line, err = client.socket.Read() + if err != nil { + quitMessage := "connection closed" + if err == errReadQ { + quitMessage = "readQ exceeded" } - client.Quit(quitMessage, session) + client.Quit(quitMessage) break } - if client.server.logger.IsLoggingRawIO() { - client.server.logger.Debug("userinput", client.nick, "<- ", line) - } - - // special-cased handling of PROXY protocol, see `handleProxyCommand` for details: - if firstLine { - firstLine = false - if strings.HasPrefix(line, "PROXY") { - err = handleProxyCommand(client.server, client, session, line) - if err != nil { - break - } else { - continue - } - } - } - - msg, err := ircmsg.ParseLineStrict(line, true, MaxLineLen) - // XXX defer processing of command error parsing until after fakelag - - if client.registered { - // apply deferred fakelag - for i := 0; i < session.deferredFakelagCount; i++ { - session.fakelag.Touch("") - } - session.deferredFakelagCount = 0 - // touch for the current command - var command string - if err == nil { - command = msg.Command - } - session.fakelag.Touch(command) - } else { - // DoS hardening, #505 - session.registrationMessages++ - if client.server.Config().Limits.RegistrationMessages < session.registrationMessages { - client.Send(nil, client.server.name, ERR_UNKNOWNERROR, "*", client.t("You have sent too many registration messages")) - break - } - } + client.server.logger.Debug("userinput ", client.nick, "<- ", line) + msg, err = ircmsg.ParseLineMaxLen(line, maxlenTags, maxlenRest) if err == ircmsg.ErrorLineIsEmpty { continue - } else if err == ircmsg.ErrorTagsTooLong { - session.Send(nil, client.server.name, ERR_INPUTTOOLONG, client.Nick(), client.t("Input line contained excess tag data")) - continue - } else if err == ircmsg.ErrorBodyTooLong { - if !client.server.Config().Server.Compatibility.allowTruncation { - session.Send(nil, client.server.name, ERR_INPUTTOOLONG, client.Nick(), client.t("Input line too long")) - continue - } // else: proceed with the truncated line } else if err != nil { - client.Quit(client.t("Received malformed line"), session) + client.Quit(client.t("Received malformed line")) break } cmd, exists := Commands[msg.Command] if !exists { - cmd = unknownCommand - } else if invalidUtf8 { - cmd = invalidUtf8Command - } - - isExiting := cmd.Run(client.server, client, session, msg) - if isExiting { - break - } else if session.client != client { - // bouncer reattach - go session.client.run(session) - break - } - } -} - -func (client *Client) playReattachMessages(session *Session) { - client.server.playRegistrationBurst(session) - hasHistoryCaps := session.HasHistoryCaps() - for _, channel := range session.client.Channels() { - channel.playJoinForSession(session) - // clients should receive autoreplay-on-join lines, if applicable: - if hasHistoryCaps { + if len(msg.Command) > 0 { + client.Send(nil, client.server.name, ERR_UNKNOWNCOMMAND, client.nick, msg.Command, client.t("Unknown command")) + } else { + client.Send(nil, client.server.name, ERR_UNKNOWNCOMMAND, client.nick, "lastcmd", client.t("No command given")) + } continue } - // if they negotiated znc.in/playback or chathistory, they will receive nothing, - // because those caps disable autoreplay-on-join and they haven't sent the relevant - // *playback PRIVMSG or CHATHISTORY command yet - rb := NewResponseBuffer(session) - channel.autoReplayHistory(client, rb, "") - rb.Send(true) + + isExiting = cmd.Run(client.server, client, msg) + if isExiting || client.isQuitting { + break + } } - if !session.autoreplayMissedSince.IsZero() && !hasHistoryCaps { - rb := NewResponseBuffer(session) - zncPlayPrivmsgsFromAll(client, rb, time.Now().UTC(), session.autoreplayMissedSince) - rb.Send(true) - } - session.autoreplayMissedSince = time.Time{} } // // idle, quit, timers and timeouts // -// Touch indicates that we received a line from the client (so the connection is healthy -// at this time, modulo network latency and fakelag). -func (client *Client) Touch(session *Session) { - now := time.Now().UTC() +// Active updates when the client was last 'active' (i.e. the user should be sitting in front of their client). +func (client *Client) Active() { client.stateMutex.Lock() - if client.registered { - client.updateIdleTimer(session, now) - if client.alwaysOn { - client.setLastSeen(now, session.deviceID) - client.dirtyTimestamps = true - } - } - client.stateMutex.Unlock() + defer client.stateMutex.Unlock() + client.atime = time.Now() } -func (client *Client) setLastSeen(now time.Time, deviceID string) { - if client.lastSeen == nil { - client.lastSeen = make(map[string]time.Time) - } - updateLRUMap(client.lastSeen, deviceID, now, maxDeviceIDsPerClient) -} - -func (client *Client) updateIdleTimer(session *Session, now time.Time) { - session.lastTouch = now - session.pingSent = false - - if session.idleTimer == nil { - pingTimeout := DefaultIdleTimeout - if session.isTor { - pingTimeout = TorIdleTimeout - } - session.idleTimer = time.AfterFunc(pingTimeout, session.handleIdleTimeout) - } -} - -func (session *Session) handleIdleTimeout() { - totalTimeout := DefaultTotalTimeout - pingTimeout := DefaultIdleTimeout - if session.isTor { - pingTimeout = TorIdleTimeout - } - - session.client.stateMutex.Lock() - now := time.Now() - timeUntilDestroy := session.lastTouch.Add(totalTimeout).Sub(now) - timeUntilPing := session.lastTouch.Add(pingTimeout).Sub(now) - shouldDestroy := session.pingSent && timeUntilDestroy <= 0 - // XXX this should really be time <= 0, but let's do some hacky timer coalescing: - // a typical idling client will do nothing other than respond immediately to our pings, - // so we'll PING at t=0, they'll respond at t=0.05, then we'll wake up at t=90 and find - // that we need to PING again at t=90.05. Rather than wake up again, just send it now: - shouldSendPing := !session.pingSent && timeUntilPing <= PingCoalesceThreshold - if !shouldDestroy { - if shouldSendPing { - session.pingSent = true - } - // check in again at the minimum of these 3 possible intervals: - // 1. the ping timeout (assuming we PING and they reply immediately with PONG) - // 2. the next time we would send PING (if they don't send any more lines) - // 3. the next time we would destroy (if they don't send any more lines) - nextTimeout := pingTimeout - if PingCoalesceThreshold < timeUntilPing && timeUntilPing < nextTimeout { - nextTimeout = timeUntilPing - } - if 0 < timeUntilDestroy && timeUntilDestroy < nextTimeout { - nextTimeout = timeUntilDestroy - } - session.idleTimer.Stop() - session.idleTimer.Reset(nextTimeout) - } - session.client.stateMutex.Unlock() - - if shouldDestroy { - session.client.Quit(fmt.Sprintf("Ping timeout: %v", totalTimeout), session) - session.client.destroy(session) - } else if shouldSendPing { - session.Ping() - } -} - -func (session *Session) stopIdleTimer() { - session.client.stateMutex.Lock() - defer session.client.stateMutex.Unlock() - if session.idleTimer != nil { - session.idleTimer.Stop() - } +// Touch marks the client as alive (as it it has a connection to us and we +// can receive messages from it). +func (client *Client) Touch() { + client.idletimer.Touch() } // Ping sends the client a PING message. -func (session *Session) Ping() { - session.Send(nil, "", "PING", session.client.Nick()) +func (client *Client) Ping() { + client.Send(nil, "", "PING", client.nick) + } -func (client *Client) replayPrivmsgHistory(rb *ResponseBuffer, items []history.Item, target string, chathistoryCommand bool, identifier string, preposition string, limit int) { - var batchID string - details := client.Details() - nick := details.nick - if target == "" { - target = nick - } - batchID = rb.StartNestedBatch("chathistory", target, identifier, preposition, strconv.Itoa(limit)) +// +// server goroutine +// - isSelfMessage := func(item *history.Item) bool { - // XXX: Params[0] is the message target. if the source of this message is an in-memory - // buffer, then it's "" for an incoming message and the recipient's nick for an outgoing - // message. if the source of the message is mysql, then mysql only sees one copy of the - // message, and it's the version with the recipient's nick filled in. so this is an - // incoming message if Params[0] (the recipient's nick) equals the client's nick: - return item.Params[0] != "" && item.Params[0] != nick +// Register sets the client details as appropriate when entering the network. +func (client *Client) Register() { + client.stateMutex.Lock() + alreadyRegistered := client.registered + client.registered = true + client.stateMutex.Unlock() + + if alreadyRegistered { + return } - hasEventPlayback := rb.session.capabilities.Has(caps.EventPlayback) - hasTags := rb.session.capabilities.Has(caps.MessageTags) - for _, item := range items { - var command string - switch item.Type { - case history.Invite: - if isSelfMessage(&item) { - continue - } - if hasEventPlayback { - rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.Account, item.IsBot, nil, "INVITE", nick, item.Message.Message) + // apply resume details if we're able to. + client.TryResume() + + // finish registration + client.updateNickMask("") + client.server.monitorManager.AlertAbout(client, true) +} + +// TryResume tries to resume if the client asked us to. +func (client *Client) TryResume() { + if client.resumeDetails == nil { + return + } + + server := client.server + + // just grab these mutexes for safety. later we can work out whether we can grab+release them earlier + server.clients.Lock() + defer server.clients.Unlock() + server.channels.Lock() + defer server.channels.Unlock() + + oldnick := client.resumeDetails.OldNick + timestamp := client.resumeDetails.Timestamp + var timestampString string + if timestamp != nil { + timestampString = timestamp.UTC().Format("2006-01-02T15:04:05.999Z") + } + + // can't use server.clients.Get since we hold server.clients' tier 1 mutex + casefoldedName, err := CasefoldName(oldnick) + if err != nil { + client.Send(nil, server.name, ERR_CANNOT_RESUME, oldnick, client.t("Cannot resume connection, old client not found")) + return + } + + oldClient := server.clients.byNick[casefoldedName] + if oldClient == nil { + client.Send(nil, server.name, ERR_CANNOT_RESUME, oldnick, client.t("Cannot resume connection, old client not found")) + return + } + + oldAccountName := oldClient.Account() + newAccountName := client.Account() + + if oldAccountName == "" || newAccountName == "" || oldAccountName != newAccountName { + client.Send(nil, server.name, ERR_CANNOT_RESUME, oldnick, client.t("Cannot resume connection, old and new clients must be logged into the same account")) + return + } + + if !oldClient.HasMode(modes.TLS) || !client.HasMode(modes.TLS) { + client.Send(nil, server.name, ERR_CANNOT_RESUME, oldnick, client.t("Cannot resume connection, old and new clients must have TLS")) + return + } + + // unmark the new client's nick as being occupied + server.clients.removeInternal(client) + + // send RESUMED to the reconnecting client + if timestamp == nil { + client.Send(nil, oldClient.NickMaskString(), "RESUMED", oldClient.nick, client.username, client.Hostname()) + } else { + client.Send(nil, oldClient.NickMaskString(), "RESUMED", oldClient.nick, client.username, client.Hostname(), timestampString) + } + + // send QUIT/RESUMED to friends + for friend := range oldClient.Friends() { + if friend.capabilities.Has(caps.Resume) { + if timestamp == nil { + friend.Send(nil, oldClient.NickMaskString(), "RESUMED", oldClient.nick, client.username, client.Hostname()) } else { - rb.AddFromClient(item.Message.Time, history.HistservMungeMsgid(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", fmt.Sprintf(client.t("%[1]s invited you to channel %[2]s"), NUHToNick(item.Nick), item.Message.Message)) + friend.Send(nil, oldClient.NickMaskString(), "RESUMED", oldClient.nick, client.username, client.Hostname(), timestampString) } - continue - case history.Privmsg: - command = "PRIVMSG" - case history.Notice: - command = "NOTICE" - case history.Tagmsg: - if hasEventPlayback && hasTags { - command = "TAGMSG" - } else if chathistoryCommand { - // #1676: send something for TAGMSG; we can't discard it entirely - // because it'll break pagination - rb.AddFromClient(item.Message.Time, history.HistservMungeMsgid(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", fmt.Sprintf(client.t("%[1]s sent you a TAGMSG"), NUHToNick(item.Nick))) - } else { - continue - } - default: - // see #1676, this shouldn't happen - continue - } - var tags map[string]string - if hasTags { - tags = item.Tags - } - if !isSelfMessage(&item) { - rb.AddSplitMessageFromClientWithReactions(item.Nick, item.Account, item.IsBot, tags, command, nick, item.Message, item.Reactions) } else { - // this message was sent *from* the client to another nick; the target is item.Params[0] - // substitute client's current nickmask in case client changed nick - rb.AddSplitMessageFromClientWithReactions(details.nickMask, item.Account, item.IsBot, tags, command, item.Params[0], item.Message, item.Reactions) + friend.Send(nil, oldClient.NickMaskString(), "QUIT", friend.t("Client reconnected")) } } - rb.EndNestedBatch(batchID) + // apply old client's details to new client + client.nick = oldClient.nick + client.updateNickMaskNoMutex() + + for channel := range oldClient.channels { + channel.stateMutex.Lock() + + client.channels[channel] = true + client.resumeDetails.SendFakeJoinsFor = append(client.resumeDetails.SendFakeJoinsFor, channel.name) + + oldModeSet := channel.members[oldClient] + channel.members.Remove(oldClient) + channel.members[client] = oldModeSet + channel.regenerateMembersCache(true) + + // construct fake modestring if necessary + oldModes := oldModeSet.String() + var params []string + if 0 < len(oldModes) { + params = []string{channel.name, "+" + oldModes} + for range oldModes { + params = append(params, client.nick) + } + } + + // send join for old clients + for member := range channel.members { + if member.capabilities.Has(caps.Resume) { + continue + } + + if member.capabilities.Has(caps.ExtendedJoin) { + member.Send(nil, client.nickMaskString, "JOIN", channel.name, client.AccountName(), client.realname) + } else { + member.Send(nil, client.nickMaskString, "JOIN", channel.name) + } + + // send fake modestring if necessary + if 0 < len(oldModes) { + member.Send(nil, server.name, "MODE", params...) + } + } + + channel.stateMutex.Unlock() + } + + server.clients.byNick[oldnick] = client + + oldClient.destroy(true) } // IdleTime returns how long this client's been idle. func (client *Client) IdleTime() time.Duration { client.stateMutex.RLock() defer client.stateMutex.RUnlock() - return time.Since(client.lastActive) + return time.Since(client.atime) } // SignonTime returns this client's signon time as a unix timestamp. @@ -959,50 +474,28 @@ func (client *Client) IdleSeconds() uint64 { return uint64(client.IdleTime().Seconds()) } -// SetNames sets the client's ident and realname. -func (client *Client) SetNames(username, realname string, fromIdent bool) error { - config := client.server.Config() - limit := config.Limits.IdentLen - if !fromIdent { - limit -= 1 // leave room for the prepended ~ - } - if limit < len(username) { - username = username[:limit] - } +// HasNick returns true if the client's nickname is set (used in registration). +func (client *Client) HasNick() bool { + client.stateMutex.RLock() + defer client.stateMutex.RUnlock() + return client.nick != "" && client.nick != "*" +} - if !isIdent(username) { - return errInvalidUsername - } - - if config.Server.CoerceIdent != "" { - username = config.Server.CoerceIdent - } else if !fromIdent { - username = "~" + username - } - - client.stateMutex.Lock() - defer client.stateMutex.Unlock() - - if client.username == "" { - client.username = username - } - - if client.realname == "" { - client.realname = realname - } - - return nil +// HasUsername returns true if the client's username is set (used in registration). +func (client *Client) HasUsername() bool { + client.stateMutex.RLock() + defer client.stateMutex.RUnlock() + return client.username != "" && client.username != "*" } // HasRoleCapabs returns true if client has the given (role) capabilities. func (client *Client) HasRoleCapabs(capabs ...string) bool { - oper := client.Oper() - if oper == nil { + if client.class == nil { return false } for _, capab := range capabs { - if !oper.Class.Capabilities.Has(capab) { + if !client.class.Capabilities[capab] { return false } } @@ -1012,156 +505,118 @@ func (client *Client) HasRoleCapabs(capabs ...string) bool { // ModeString returns the mode string for this client. func (client *Client) ModeString() (str string) { - return "+" + client.modes.String() + str = "+" + + for flag := range client.flags { + str += flag.String() + } + + return } // Friends refers to clients that share a channel with this client. -func (client *Client) Friends(capabs ...caps.Capability) (result utils.HashSet[*Session]) { - result = make(utils.HashSet[*Session]) +func (client *Client) Friends(capabs ...caps.Capability) ClientSet { + friends := make(ClientSet) - // look at the client's own sessions - addFriendsToSet(result, client, capabs...) + // make sure that I have the right caps + hasCaps := true + for _, capab := range capabs { + if !client.capabilities.Has(capab) { + hasCaps = false + break + } + } + if hasCaps { + friends.Add(client) + } for _, channel := range client.Channels() { - for _, member := range channel.auditoriumFriends(client) { - addFriendsToSet(result, member, capabs...) + for _, member := range channel.Members() { + // make sure they have all the required caps + hasCaps = true + for _, capab := range capabs { + if !member.capabilities.Has(capab) { + hasCaps = false + break + } + } + if hasCaps { + friends.Add(member) + } } } - - return + return friends } -// Friends refers to clients that share a channel or extended-monitor this client. -func (client *Client) FriendsMonitors(capabs ...caps.Capability) (result utils.HashSet[*Session]) { - result = client.Friends(capabs...) - client.server.monitorManager.AddMonitors(result, client.nickCasefolded, capabs...) - return -} - -// helper for Friends -func addFriendsToSet(set utils.HashSet[*Session], client *Client, capabs ...caps.Capability) { - client.stateMutex.RLock() - defer client.stateMutex.RUnlock() - for _, session := range client.sessions { - if session.capabilities.HasAll(capabs...) { - set.Add(session) - } +// updateNick updates `nick` and `nickCasefolded`. +func (client *Client) updateNick(nick string) { + casefoldedName, err := CasefoldName(nick) + if err != nil { + log.Println(fmt.Sprintf("ERROR: Nick [%s] couldn't be casefolded... this should never happen. Printing stacktrace.", client.nick)) + debug.PrintStack() } -} - -func (client *Client) SetOper(oper *Oper) { client.stateMutex.Lock() - defer client.stateMutex.Unlock() - client.oper = oper - // operators typically get a vhost, update the nickmask - client.updateNickMaskNoMutex() -} - -// XXX: CHGHOST requires prefix nickmask to have original hostname, -// this is annoying to do correctly -func (client *Client) sendChghost(oldNickMask string, vhost string) { - details := client.Details() - isBot := client.HasMode(modes.Bot) - for fClient := range client.FriendsMonitors(caps.ChgHost) { - fClient.sendFromClientInternal(false, time.Time{}, "", oldNickMask, details.accountName, isBot, nil, "CHGHOST", details.username, vhost) - } -} - -// choose the correct vhost to display -func (client *Client) getVHostNoMutex() string { - // hostserv vhost OR operclass vhost OR nothing (i.e., normal rdns hostmask) - if client.vhost != "" { - return client.vhost - } else if client.oper != nil && !client.oper.Hidden { - return client.oper.Vhost - } else { - return "" - } -} - -// SetVHost updates the client's hostserv-based vhost -func (client *Client) SetVHost(vhost string) (updated bool) { - client.stateMutex.Lock() - defer client.stateMutex.Unlock() - updated = (client.vhost != vhost) - client.vhost = vhost - if updated { - client.updateNickMaskNoMutex() - } - return -} - -// SetNick gives the client a nickname and marks it as registered, if necessary -func (client *Client) SetNick(nick, nickCasefolded, skeleton string) (success bool) { - client.stateMutex.Lock() - defer client.stateMutex.Unlock() - if client.destroyed { - return false - } else if !client.registered { - // XXX test this before setting it to avoid annoying the race detector - client.registered = true - if client.registrationTimer != nil { - client.registrationTimer.Stop() - client.registrationTimer = nil - } - } client.nick = nick - client.nickCasefolded = nickCasefolded - client.skeleton = skeleton - client.updateNickMaskNoMutex() - return true + client.nickCasefolded = casefoldedName + client.stateMutex.Unlock() } -// updateNickMaskNoMutex updates the casefolded nickname and nickmask, not acquiring any mutexes. +// updateNickMask updates the casefolded nickname and nickmask. +func (client *Client) updateNickMask(nick string) { + // on "", just regenerate the nickmask etc. + // otherwise, update the actual nick + if nick != "" { + client.updateNick(nick) + } + + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + client.updateNickMaskNoMutex() +} + +// updateNickMask updates the casefolded nickname and nickmask, not holding any mutexes. func (client *Client) updateNickMaskNoMutex() { - if client.nick == "*" { - return // pre-registration, don't bother generating the hostname + if len(client.vhost) > 0 { + client.hostname = client.vhost + } else { + client.hostname = client.rawHostname } - client.hostname = client.getVHostNoMutex() - if client.hostname == "" { - client.hostname = client.cloakedHostname - if client.hostname == "" { - client.hostname = client.rawHostname - } + nickMaskString := fmt.Sprintf("%s!%s@%s", client.nick, client.username, client.hostname) + nickMaskCasefolded, err := Casefold(nickMaskString) + if err != nil { + log.Println(fmt.Sprintf("ERROR: Nickmask [%s] couldn't be casefolded... this should never happen. Printing stacktrace.", client.nickMaskString)) + debug.PrintStack() } - cfhostname := strings.ToLower(client.hostname) - client.nickMaskString = fmt.Sprintf("%s!%s@%s", client.nick, client.username, client.hostname) - client.nickMaskCasefolded = fmt.Sprintf("%s!%s@%s", client.nickCasefolded, strings.ToLower(client.username), cfhostname) + client.nickMaskString = nickMaskString + client.nickMaskCasefolded = nickMaskCasefolded } // AllNickmasks returns all the possible nickmasks for the client. -func (client *Client) AllNickmasks() (masks []string) { - client.stateMutex.RLock() - nick := client.nickCasefolded - username := client.username - rawHostname := client.rawHostname - cloakedHostname := client.cloakedHostname - vhost := client.getVHostNoMutex() - client.stateMutex.RUnlock() - username = strings.ToLower(username) +func (client *Client) AllNickmasks() []string { + var masks []string + var mask string + var err error - if len(vhost) > 0 { - cfvhost := strings.ToLower(vhost) - masks = append(masks, fmt.Sprintf("%s!%s@%s", nick, username, cfvhost)) + if len(client.vhost) > 0 { + mask, err = Casefold(fmt.Sprintf("%s!%s@%s", client.nick, client.username, client.vhost)) + if err == nil { + masks = append(masks, mask) + } } - var rawhostmask string - cfrawhost := strings.ToLower(rawHostname) - rawhostmask = fmt.Sprintf("%s!%s@%s", nick, username, cfrawhost) - masks = append(masks, rawhostmask) - - if cloakedHostname != "" { - masks = append(masks, fmt.Sprintf("%s!%s@%s", nick, username, cloakedHostname)) + mask, err = Casefold(fmt.Sprintf("%s!%s@%s", client.nick, client.username, client.rawHostname)) + if err == nil { + masks = append(masks, mask) } - ipmask := fmt.Sprintf("%s!%s@%s", nick, username, client.IPString()) - if ipmask != rawhostmask { - masks = append(masks, ipmask) + mask2, err := Casefold(fmt.Sprintf("%s!%s@%s", client.nick, client.username, client.IPString())) + if err == nil && mask2 != mask { + masks = append(masks, mask2) } - return + return masks } // LoggedIntoAccount returns true if this client is logged into an account. @@ -1169,722 +624,253 @@ func (client *Client) LoggedIntoAccount() bool { return client.Account() != "" } -// Quit sets the given quit message for the client. -// (You must ensure separately that destroy() is called, e.g., by returning `true` from -// the command handler or calling it yourself.) -func (client *Client) Quit(message string, session *Session) { - setFinalData := func(sess *Session) { - message := sess.quitMessage - var finalData []byte - // #364: don't send QUIT lines to unregistered clients - if client.registered { - quitMsg := ircmsg.MakeMessage(nil, client.nickMaskString, "QUIT", message) - finalData, _ = quitMsg.LineBytesStrict(false, MaxLineLen) - } - - errorMsg := ircmsg.MakeMessage(nil, "", "ERROR", message) - errorMsgBytes, _ := errorMsg.LineBytesStrict(false, MaxLineLen) - finalData = append(finalData, errorMsgBytes...) - - sess.socket.SetFinalData(finalData) - } - - client.stateMutex.Lock() - defer client.stateMutex.Unlock() - - var sessions []*Session - if session != nil { - sessions = []*Session{session} - } else { - sessions = client.sessions - } - - for _, session := range sessions { - if session.setQuitMessage(message) { - setFinalData(session) - } +// RplISupport outputs our ISUPPORT lines to the client. This is used on connection and in VERSION responses. +func (client *Client) RplISupport(rb *ResponseBuffer) { + translatedISupport := client.t("are supported by this server") + for _, tokenline := range client.server.ISupport().CachedReply { + // ugly trickery ahead + tokenline = append(tokenline, translatedISupport) + rb.Add(nil, client.server.name, RPL_ISUPPORT, append([]string{client.nick}, tokenline...)...) } } -// destroy gets rid of a client, removes them from server lists etc. -// if `session` is nil, destroys the client unconditionally, removing all sessions; -// otherwise, destroys one specific session, only destroying the client if it -// has no more sessions. -func (client *Client) destroy(session *Session) { - config := client.server.Config() - var sessionsToDestroy []*Session - var quitMessage string - +// Quit sets the given quit message for the client and tells the client to quit out. +func (client *Client) Quit(message string) { client.stateMutex.Lock() - - details := client.detailsNoMutex() - sessionRemoved := false - registered := client.registered - isKlined := client.isKlined - // XXX a temporary (reattaching) client can be marked alwaysOn when it logs in, - // but then the session attaches to another client and we need to clean it up here - alwaysOn := registered && client.alwaysOn - // if we hit always-on-expiration, confirm the expiration and then proceed as though - // always-on is disabled: - if alwaysOn && session == nil && client.checkAlwaysOnExpirationNoMutex(config, false) { - quitMessage = "Timed out due to inactivity" - alwaysOn = false - client.alwaysOn = false + alreadyQuit := client.isQuitting + if !alreadyQuit { + client.isQuitting = true + client.quitMessage = message } - - var remainingSessions int - if session == nil { - sessionsToDestroy = client.sessions - client.sessions = nil - remainingSessions = 0 - } else { - sessionRemoved, remainingSessions = client.removeSession(session) - if sessionRemoved { - sessionsToDestroy = []*Session{session} - } - } - - // should we destroy the whole client this time? - shouldDestroy := !client.destroyed && remainingSessions == 0 && !alwaysOn - // decrement stats on a true destroy, or for the removal of the last connected session - // of an always-on client - shouldDecrement := shouldDestroy || (alwaysOn && len(sessionsToDestroy) != 0 && len(client.sessions) == 0) - if shouldDestroy { - // if it's our job to destroy it, don't let anyone else try - client.destroyed = true - } - - wasAway := client.awayMessage - if client.autoAwayEnabledNoMutex(config) { - client.setAutoAwayNoMutex(config) - } - nowAway := client.awayMessage - - if client.registrationTimer != nil { - // unconditionally stop; if the client is still unregistered it must be destroyed - client.registrationTimer.Stop() - } - client.stateMutex.Unlock() - // destroy all applicable sessions: - for _, session := range sessionsToDestroy { - if session.client != client { - // session has been attached to a new client; do not destroy it - continue - } - session.stopIdleTimer() - // send quit/error message to client if they haven't been sent already - client.Quit("", session) - quitMessage = session.quitMessage // doesn't need synch, we already detached - session.socket.Close() - - // clean up monitor state - client.server.monitorManager.RemoveAll(session) - - // remove from connection limits - var source string - if session.isTor { - client.server.torLimiter.RemoveClient() - source = "tor" - } else { - ip := session.realIP - if session.proxiedIP != nil { - ip = session.proxiedIP - } - client.server.connectionLimiter.RemoveClient(flatip.FromNetIP(ip)) - source = ip.String() - } - if !shouldDestroy { - client.server.snomasks.Send(sno.LocalDisconnects, fmt.Sprintf(ircfmt.Unescape("Client session disconnected for [a:%s] [h:%s] [ip:%s]"), details.accountName, session.rawHostname, source)) - } - client.server.logger.Info("connect-ip", fmt.Sprintf("disconnecting session of %s from %s", details.nick, source)) - } - - // decrement stats if we have no more sessions, even if the client will not be destroyed - if shouldDecrement { - invisible := client.HasMode(modes.Invisible) - operator := client.HasMode(modes.Operator) - client.server.stats.Remove(registered, invisible, operator) - } - - if !shouldDestroy && wasAway != nowAway { - dispatchAwayNotify(client, nowAway) - } - - if !shouldDestroy { + if alreadyQuit { return } - var quitItem history.Item - var quitHistoryChannels []*Channel - // use a defer here to avoid writing to mysql while holding the destroy semaphore: - defer func() { - for _, channel := range quitHistoryChannels { - channel.AddHistoryItem(quitItem, details.account) - } - }() + quitMsg := ircmsg.MakeMessage(nil, client.nickMaskString, "QUIT", message) + quitLine, _ := quitMsg.Line() - // see #235: deduplicating the list of PART recipients uses (comparatively speaking) - // a lot of RAM, so limit concurrency to avoid thrashing - client.server.semaphores.ClientDestroy.Acquire() - defer client.server.semaphores.ClientDestroy.Release() + errorMsg := ircmsg.MakeMessage(nil, "", "ERROR", message) + errorLine, _ := errorMsg.Line() - if registered { - client.server.whoWas.Append(client.WhoWas()) + client.socket.SetFinalData(quitLine + errorLine) +} + +// destroy gets rid of a client, removes them from server lists etc. +func (client *Client) destroy(beingResumed bool) { + // allow destroy() to execute at most once + if !beingResumed { + client.stateMutex.Lock() + } + isDestroyed := client.isDestroyed + client.isDestroyed = true + if !beingResumed { + client.stateMutex.Unlock() + } + if isDestroyed { + return + } + + if beingResumed { + client.server.logger.Debug("quit", fmt.Sprintf("%s is being resumed", client.nick)) + } else { + client.server.logger.Debug("quit", fmt.Sprintf("%s is no longer on the server", client.nick)) + } + + // send quit/error message to client if they haven't been sent already + client.Quit("Connection closed") + + friends := client.Friends() + friends.Remove(client) + if !beingResumed { + client.server.whoWas.Append(client) + } + + // remove from connection limits + ipaddr := client.IP() + // this check shouldn't be required but eh + if ipaddr != nil { + client.server.connectionLimiter.RemoveClient(ipaddr) } // alert monitors - if registered { - client.server.monitorManager.AlertAbout(details.nick, details.nickCasefolded, false) - } + client.server.monitorManager.AlertAbout(client, false) + // clean up monitor state + client.server.monitorManager.RemoveAll(client) // clean up channels - // (note that if this is a reattach, client has no channels and therefore no friends) - friends := make(ClientSet) - channels := client.Channels() - for _, channel := range channels { - if channel.memberIsVisible(client) { - quitHistoryChannels = append(quitHistoryChannels, channel) + for _, channel := range client.Channels() { + if !beingResumed { + channel.Quit(client) } - for _, member := range channel.auditoriumFriends(client) { + for _, member := range channel.Members() { friends.Add(member) } - channel.Quit(client) } - friends.Remove(client) // clean up server - client.server.clients.Remove(client) - client.server.accepts.Remove(client) + if !beingResumed { + client.server.clients.Remove(client) + } + + // clean up self + client.idletimer.Stop() + client.nickTimer.Stop() + client.server.accounts.Logout(client) - if quitMessage == "" { - quitMessage = "Exited" - } - splitQuitMessage := utils.MakeMessage(quitMessage) - isBot := client.HasMode(modes.Bot) - quitItem = history.Item{ - Type: history.Quit, - Nick: details.nickMask, - Account: details.accountName, - Message: splitQuitMessage, - IsBot: isBot, - } - var cache MessageCache - cache.Initialize(client.server, splitQuitMessage.Time, splitQuitMessage.Msgid, details.nickMask, details.accountName, isBot, nil, "QUIT", quitMessage) - for friend := range friends { - for _, session := range friend.Sessions() { - cache.Send(session) + client.socket.Close() + + // send quit messages to friends + if !beingResumed { + for friend := range friends { + if client.quitMessage == "" { + client.quitMessage = "Exited" + } + friend.Send(nil, client.nickMaskString, "QUIT", client.quitMessage) } } - - if registered { - if !isKlined { - client.server.snomasks.Send(sno.LocalQuits, fmt.Sprintf(ircfmt.Unescape("%s$r exited the network"), details.nick)) - client.server.logger.Info("quit", fmt.Sprintf("%s is no longer on the server", details.nick)) + if !client.exitedSnomaskSent { + if beingResumed { + client.server.snomasks.Send(sno.LocalQuits, fmt.Sprintf(ircfmt.Unescape("%s$r is resuming their connection, old client has been destroyed"), client.nick)) + } else { + client.server.snomasks.Send(sno.LocalQuits, fmt.Sprintf(ircfmt.Unescape("%s$r exited the network"), client.nick)) } } } // SendSplitMsgFromClient sends an IRC PRIVMSG/NOTICE coming from a specific client. // Adds account-tag to the line as well. -func (session *Session) sendSplitMsgFromClientInternal(blocking bool, nickmask, accountName string, isBot bool, tags map[string]string, command, target string, message utils.SplitMessage) { - if message.Is512() { - session.sendFromClientInternal(blocking, message.Time, message.Msgid, nickmask, accountName, isBot, tags, command, target, message.Message) +func (client *Client) SendSplitMsgFromClient(msgid string, from *Client, tags *map[string]ircmsg.TagValue, command, target string, message SplitMessage) { + if client.capabilities.Has(caps.MaxLine) { + client.SendFromClient(msgid, from, tags, command, target, message.ForMaxLine) } else { - if session.capabilities.Has(caps.Multiline) { - for _, msg := range composeMultilineBatch(session.generateBatchID(), nickmask, accountName, isBot, tags, command, target, message) { - session.SendRawMessage(msg, blocking) - } - } else { - msgidSent := false // send msgid on the first nonblank line - for _, messagePair := range message.Split { - if len(messagePair.Message) == 0 { - continue - } - var msgid string - if !msgidSent { - msgidSent = true - msgid = message.Msgid - } - session.sendFromClientInternal(blocking, message.Time, msgid, nickmask, accountName, isBot, tags, command, target, messagePair.Message) - } + for _, str := range message.For512 { + client.SendFromClient(msgid, from, tags, command, target, str) } } } -func (session *Session) sendFromClientInternal(blocking bool, serverTime time.Time, msgid string, nickmask, accountName string, isBot bool, tags map[string]string, command string, params ...string) (err error) { - msg := ircmsg.MakeMessage(tags, nickmask, command, params...) +// SendFromClient sends an IRC line coming from a specific client. +// Adds account-tag to the line as well. +func (client *Client) SendFromClient(msgid string, from *Client, tags *map[string]ircmsg.TagValue, command string, params ...string) error { // attach account-tag - if session.capabilities.Has(caps.AccountTag) && accountName != "*" { - msg.SetTag("account", accountName) + if client.capabilities.Has(caps.AccountTag) && from.LoggedIntoAccount() { + if tags == nil { + tags = ircmsg.MakeTags("account", from.AccountName()) + } else { + (*tags)["account"] = ircmsg.MakeTagValue(from.AccountName()) + } } // attach message-id - if msgid != "" && session.capabilities.Has(caps.MessageTags) { - msg.SetTag("msgid", msgid) - } - // attach server-time - session.setTimeTag(&msg, serverTime) - // attach bot tag - if isBot && session.capabilities.Has(caps.MessageTags) { - msg.SetTag(caps.BotTagName, "") - } - - return session.SendRawMessage(msg, blocking) -} - -func composeMultilineBatch(batchID, fromNickMask, fromAccount string, isBot bool, tags map[string]string, command, target string, message utils.SplitMessage) (result []ircmsg.Message) { - batchStart := ircmsg.MakeMessage(tags, fromNickMask, "BATCH", "+"+batchID, caps.MultilineBatchType, target) - batchStart.SetTag("time", message.Time.Format(IRCv3TimestampFormat)) - batchStart.SetTag("msgid", message.Msgid) - if fromAccount != "*" { - batchStart.SetTag("account", fromAccount) - } - if isBot { - batchStart.SetTag(caps.BotTagName, "") - } - result = append(result, batchStart) - - for _, msg := range message.Split { - message := ircmsg.MakeMessage(nil, fromNickMask, command, target, msg.Message) - message.SetTag("batch", batchID) - for k, v := range msg.Tags { - message.SetTag(k, v) + if len(msgid) > 0 && client.capabilities.Has(caps.MessageTags) { + if tags == nil { + tags = ircmsg.MakeTags("draft/msgid", msgid) + } else { + (*tags)["draft/msgid"] = ircmsg.MakeTagValue(msgid) } - - if msg.Concat { - message.SetTag(caps.MultilineConcatTag, "") - } - result = append(result, message) } - result = append(result, ircmsg.MakeMessage(nil, fromNickMask, "BATCH", "-"+batchID)) - return + return client.Send(tags, from.nickMaskString, command, params...) } var ( - // in practice, many clients require that the final parameter be a trailing - // (prefixed with `:`) even when this is not syntactically necessary. - // by default, force the following commands to use a trailing: - commandsThatMustUseTrailing = utils.SetLiteral( - "PRIVMSG", - "NOTICE", - RPL_WHOISCHANNELS, - RPL_USERHOST, - // mirc's handling of RPL_NAMREPLY is broken: - // https://forums.mirc.com/ubbthreads.php/topics/266939/re-nick-list - RPL_NAMREPLY, - ) + // these are all the output commands that MUST have their last param be a trailing. + // this is needed because dumb clients like to treat trailing params separately from the + // other params in messages. + commandsThatMustUseTrailing = map[string]bool{ + "PRIVMSG": true, + "NOTICE": true, + + RPL_WHOISCHANNELS: true, + RPL_USERHOST: true, + } ) -func forceTrailing(config *Config, command string) bool { - return config.Server.Compatibility.forceTrailing && commandsThatMustUseTrailing.Has(command) -} - // SendRawMessage sends a raw message to the client. -func (session *Session) SendRawMessage(message ircmsg.Message, blocking bool) error { - if forceTrailing(session.client.server.Config(), message.Command) { - message.ForceTrailing() +func (client *Client) SendRawMessage(message ircmsg.IrcMessage) error { + // use dumb hack to force the last param to be a trailing param if required + var usedTrailingHack bool + if commandsThatMustUseTrailing[strings.ToUpper(message.Command)] && len(message.Params) > 0 { + lastParam := message.Params[len(message.Params)-1] + // to force trailing, we ensure the final param contains a space + if !strings.Contains(lastParam, " ") { + message.Params[len(message.Params)-1] = lastParam + " " + usedTrailingHack = true + } } // assemble message - line, err := message.LineBytesStrict(false, MaxLineLen) - if !(err == nil || err == ircmsg.ErrorBodyTooLong) { - errorParams := []string{"Error assembling message for sending", err.Error(), message.Command} - errorParams = append(errorParams, message.Params...) - session.client.server.logger.Error("internal", errorParams...) + maxlenTags, maxlenRest := client.maxlens() + line, err := message.LineMaxLen(maxlenTags, maxlenRest) + if err != nil { + logline := fmt.Sprintf("Error assembling message for sending: %v\n%s", err, debug.Stack()) + client.server.logger.Error("internal", logline) - message = ircmsg.MakeMessage(nil, session.client.server.name, ERR_UNKNOWNERROR, "*", "Error assembling message for sending") - line, _ := message.LineBytesStrict(false, 0) + message = ircmsg.MakeMessage(nil, client.server.name, ERR_UNKNOWNERROR, "*", "Error assembling message for sending") + line, _ := message.Line() - if blocking { - session.socket.BlockingWrite(line) - } else { - session.socket.Write(line) + // if we used the trailing hack, we need to strip the final space we appended earlier on + if usedTrailingHack { + line = line[:len(line)-3] + "\r\n" } + + client.socket.Write(line) return err } - return session.sendBytes(line, blocking) -} + client.server.logger.Debug("useroutput", client.nick, " ->", strings.TrimRight(line, "\r\n")) -func (session *Session) sendBytes(line []byte, blocking bool) (err error) { - if session.client.server.logger.IsLoggingRawIO() { - logline := string(line[:len(line)-2]) // strip "\r\n" - session.client.server.logger.Debug("useroutput", session.client.Nick(), " ->", logline) - } + client.socket.Write(line) - if blocking { - err = session.socket.BlockingWrite(line) - } else { - err = session.socket.Write(line) - } - if err != nil { - session.client.server.logger.Info("quit", "send error to client", fmt.Sprintf("%s [%d]", session.client.Nick(), session.sessionID), err.Error()) - } - return err + return nil } // Send sends an IRC line to the client. -func (client *Client) Send(tags map[string]string, prefix string, command string, params ...string) (err error) { - for _, session := range client.Sessions() { - err_ := session.Send(tags, prefix, command, params...) - if err_ != nil { - err = err_ +func (client *Client) Send(tags *map[string]ircmsg.TagValue, prefix string, command string, params ...string) error { + // attach server-time + if client.capabilities.Has(caps.ServerTime) { + t := time.Now().UTC().Format("2006-01-02T15:04:05.999Z") + if tags == nil { + tags = ircmsg.MakeTags("time", t) + } else { + (*tags)["time"] = ircmsg.MakeTagValue(t) } } - return -} -func (session *Session) Send(tags map[string]string, prefix string, command string, params ...string) (err error) { - msg := ircmsg.MakeMessage(tags, prefix, command, params...) - session.setTimeTag(&msg, time.Time{}) - return session.SendRawMessage(msg, false) -} - -func (session *Session) setTimeTag(msg *ircmsg.Message, serverTime time.Time) { - if session.capabilities.Has(caps.ServerTime) && !msg.HasTag("time") { - if serverTime.IsZero() { - serverTime = time.Now() - } - msg.SetTag("time", serverTime.UTC().Format(IRCv3TimestampFormat)) - } + // send out the message + message := ircmsg.MakeMessage(tags, prefix, command, params...) + client.SendRawMessage(message) + return nil } // Notice sends the client a notice from the server. func (client *Client) Notice(text string) { - client.Send(nil, client.server.name, "NOTICE", client.Nick(), text) + limit := 400 + if client.capabilities.Has(caps.MaxLine) { + limit = client.server.Limits().LineLen.Rest - 110 + } + lines := wordWrap(text, limit) + + // force blank lines to be sent if we receive them + if len(lines) == 0 { + lines = []string{""} + } + + for _, line := range lines { + client.Send(nil, client.server.name, "NOTICE", client.nick, line) + } } -func (session *Session) Notice(text string) { - session.Send(nil, session.client.server.name, "NOTICE", session.client.Nick(), text) -} - -// `simulated` is for the fake join of an always-on client -// (we just read the channel name from the database, there's no need to write it back) -func (client *Client) addChannel(channel *Channel, simulated bool) (err error) { - config := client.server.Config() - +func (client *Client) addChannel(channel *Channel) { client.stateMutex.Lock() - alwaysOn := client.alwaysOn - if client.destroyed { - err = errClientDestroyed - } else if client.oper == nil && len(client.channels) >= config.Channels.MaxChannelsPerClient { - err = errTooManyChannels - } else { - client.channels.Add(channel) // success - } + client.channels[channel] = true client.stateMutex.Unlock() - - if err == nil && alwaysOn && !simulated { - client.markDirty(IncludeChannels) - } - return } func (client *Client) removeChannel(channel *Channel) { client.stateMutex.Lock() delete(client.channels, channel) - alwaysOn := client.alwaysOn client.stateMutex.Unlock() - - if alwaysOn { - client.markDirty(IncludeChannels) - } -} - -type channelInvite struct { - channelCreatedAt time.Time - invitedAt time.Time -} - -// Records that the client has been invited to join an invite-only channel -func (client *Client) Invite(casefoldedChannel string, channelCreatedAt time.Time) { - now := time.Now().UTC() - client.stateMutex.Lock() - defer client.stateMutex.Unlock() - - if client.invitedTo == nil { - client.invitedTo = make(map[string]channelInvite) - } - - client.invitedTo[casefoldedChannel] = channelInvite{ - channelCreatedAt: channelCreatedAt, - invitedAt: now, - } - - return -} - -func (client *Client) Uninvite(casefoldedChannel string) { - client.stateMutex.Lock() - defer client.stateMutex.Unlock() - delete(client.invitedTo, casefoldedChannel) -} - -// Checks that the client was invited to join a given channel -func (client *Client) CheckInvited(casefoldedChannel string, createdTime time.Time) (invited bool) { - config := client.server.Config() - expTime := time.Duration(config.Channels.InviteExpiration) - now := time.Now().UTC() - - client.stateMutex.Lock() - defer client.stateMutex.Unlock() - - curInvite, ok := client.invitedTo[casefoldedChannel] - if ok { - // joining an invited channel "uses up" your invite, so you can't rejoin on kick - delete(client.invitedTo, casefoldedChannel) - } - invited = ok && (expTime == time.Duration(0) || now.Sub(curInvite.invitedAt) < expTime) && - createdTime.Equal(curInvite.channelCreatedAt) - return -} - -// Implements auto-oper by certfp (scans for an auto-eligible operator block that matches -// the client's cert, then applies it). -func (client *Client) attemptAutoOper(session *Session) { - if session.certfp == "" || client.HasMode(modes.Operator) { - return - } - for _, oper := range client.server.Config().operators { - if oper.Auto && oper.Pass == nil && oper.Certfp != "" && oper.Certfp == session.certfp { - rb := NewResponseBuffer(session) - applyOper(client, oper, rb) - rb.Send(true) - return - } - } -} - -func (client *Client) checkLoginThrottle() (throttled bool, remainingTime time.Duration) { - client.stateMutex.Lock() - defer client.stateMutex.Unlock() - return client.loginThrottle.Touch() -} - -func (client *Client) historyStatus(config *Config) (status HistoryStatus, target string) { - if !config.History.Enabled { - return HistoryDisabled, "" - } - - client.stateMutex.RLock() - target = client.account - historyStatus := client.accountSettings.DMHistory - client.stateMutex.RUnlock() - - if target == "" { - return HistoryEphemeral, "" - } - status = historyEnabled(config.History.Persistent.DirectMessages, historyStatus) - if status != HistoryPersistent { - target = "" - } - return -} - -func (client *Client) addHistoryItem(target *Client, item history.Item, details, tDetails *ClientDetails, config *Config) (err error) { - if !itemIsStorable(&item, config) { - return - } - - item.Nick = details.nickMask - item.Account = details.account - targetedItem := item - targetedItem.Params[0] = tDetails.nick - - cStatus, _ := client.historyStatus(config) - tStatus, _ := target.historyStatus(config) - // add to ephemeral history - if cStatus == HistoryEphemeral { - targetedItem.Target = tDetails.account - client.history.Add(targetedItem) - } - if tStatus == HistoryEphemeral && client != target { - item.Target = target.Account() - target.history.Add(item) - } - if cStatus == HistoryPersistent || tStatus == HistoryPersistent { - targetedItem.Target = target.account - client.server.historyDB.AddDirectMessage(details.nickCasefolded, details.account, tDetails.nickCasefolded, tDetails.account, targetedItem) - } - return nil -} - -func (client *Client) listTargets(start, end history.Selector, limit int) (results []history.TargetListing, err error) { - var base, extras []history.TargetListing - var chcfnames []string - for _, channel := range client.Channels() { - _, seq, err := client.server.GetHistorySequence(channel, client, "") - if seq == nil || err != nil { - continue - } - if seq.Ephemeral() { - items, err := seq.Between(history.Selector{}, history.Selector{}, 1) - if err == nil && len(items) != 0 { - extras = append(extras, history.TargetListing{ - Time: items[0].Message.Time, - CfName: channel.NameCasefolded(), - }) - } - } else { - chcfnames = append(chcfnames, channel.NameCasefolded()) - } - } - persistentExtras, err := client.server.historyDB.ListChannels(chcfnames) - if err == nil && len(persistentExtras) != 0 { - extras = append(extras, persistentExtras...) - } - - _, cSeq, err := client.server.GetHistorySequence(nil, client, "") - if err == nil && cSeq != nil { - correspondents, err := cSeq.ListCorrespondents(start, end, limit) - if err == nil { - base = correspondents - } - } - - results = history.MergeTargets(base, extras, start.Time, end.Time, limit) - return results, nil -} - -// latest PRIVMSG from all DM targets -func (client *Client) privmsgsBetween(startTime, endTime time.Time, targetLimit, messageLimit int) (results []history.Item, err error) { - start := history.Selector{Time: startTime} - end := history.Selector{Time: endTime} - targets, err := client.listTargets(start, end, targetLimit) - if err != nil { - return - } - for _, target := range targets { - if strings.HasPrefix(target.CfName, "#") { - continue - } - _, seq, err := client.server.GetHistorySequence(nil, client, target.CfName) - if err == nil && seq != nil { - items, err := seq.Between(start, end, messageLimit) - if err == nil { - results = append(results, items...) - } else { - client.server.logger.Error("internal", "error querying privmsg history", client.Nick(), target.CfName, err.Error()) - } - } - } - return -} - -func (client *Client) handleRegisterTimeout() { - client.Quit(fmt.Sprintf("Registration timeout: %v", RegisterTimeout), nil) - client.destroy(nil) -} - -func (client *Client) copyLastSeen() (result map[string]time.Time) { - client.stateMutex.RLock() - defer client.stateMutex.RUnlock() - return maps.Clone(client.lastSeen) -} - -// these are bit flags indicating what part of the client status is "dirty" -// and needs to be read from memory and written to the db -const ( - IncludeChannels uint = 1 << iota - IncludeUserModes - IncludeRealname -) - -func (client *Client) markDirty(dirtyBits uint) { - client.stateMutex.Lock() - alwaysOn := client.alwaysOn - client.dirtyBits = client.dirtyBits | dirtyBits - client.stateMutex.Unlock() - - if alwaysOn { - client.wakeWriter() - } -} - -func (client *Client) wakeWriter() { - if client.writebackLock.TryLock() { - go client.writeLoop() - } -} - -func (client *Client) writeLoop() { - defer client.server.HandlePanic() - - for { - client.performWrite(0) - client.writebackLock.Unlock() - - client.stateMutex.RLock() - isDirty := client.dirtyBits != 0 - client.stateMutex.RUnlock() - - if !isDirty || !client.writebackLock.TryLock() { - return - } - } -} - -func (client *Client) performWrite(additionalDirtyBits uint) { - client.stateMutex.Lock() - dirtyBits := client.dirtyBits | additionalDirtyBits - client.dirtyBits = 0 - account := client.account - client.stateMutex.Unlock() - - if account == "" { - client.server.logger.Error("internal", "attempting to persist logged-out client", client.Nick()) - return - } - - if (dirtyBits & IncludeChannels) != 0 { - channels := client.Channels() - channelToModes := make(map[string]alwaysOnChannelStatus, len(channels)) - for _, channel := range channels { - ok, chname, status := channel.alwaysOnStatus(client) - if !ok { - client.server.logger.Error("internal", "client and channel membership out of sync", chname, client.Nick()) - continue - } - channelToModes[chname] = status - } - client.server.accounts.saveChannels(account, channelToModes) - } - if (dirtyBits & IncludeUserModes) != 0 { - uModes := make(modes.Modes, 0, len(modes.SupportedUserModes)) - for _, m := range modes.SupportedUserModes { - switch m { - case modes.Operator, modes.ServerNotice: - // these can't be persisted because they depend on the operator block - default: - if client.HasMode(m) { - uModes = append(uModes, m) - } - } - } - client.server.accounts.saveModes(account, uModes) - } - if (dirtyBits & IncludeRealname) != 0 { - client.server.accounts.saveRealname(account, client.realname) - } -} - -// Blocking store; see Channel.Store and Socket.BlockingWrite -func (client *Client) Store(dirtyBits uint) (err error) { - defer func() { - client.stateMutex.Lock() - isDirty := client.dirtyBits != 0 - client.stateMutex.Unlock() - - if isDirty { - client.wakeWriter() - } - }() - - client.writebackLock.Lock() - defer client.writebackLock.Unlock() - client.performWrite(dirtyBits) - return nil } diff --git a/irc/client_lookup_set.go b/irc/client_lookup_set.go index ea9c88b7..953fb591 100644 --- a/irc/client_lookup_set.go +++ b/irc/client_lookup_set.go @@ -5,25 +5,50 @@ package irc import ( + "fmt" + "log" + "regexp" "strings" - "sync" - "github.com/ergochat/ergo/irc/caps" - "github.com/ergochat/ergo/irc/modes" - "github.com/ergochat/ergo/irc/utils" + "github.com/goshuirc/irc-go/ircmatch" + "github.com/oragono/oragono/irc/caps" + + "sync" ) +// ExpandUserHost takes a userhost, and returns an expanded version. +func ExpandUserHost(userhost string) (expanded string) { + expanded = userhost + // fill in missing wildcards for nicks + //TODO(dan): this would fail with dan@lol, fix that. + if !strings.Contains(expanded, "!") { + expanded += "!*" + } + if !strings.Contains(expanded, "@") { + expanded += "@*" + } + return +} + // ClientManager keeps track of clients by nick, enforcing uniqueness of casefolded nicks type ClientManager struct { sync.RWMutex // tier 2 byNick map[string]*Client - bySkeleton map[string]*Client } -// Initialize initializes a ClientManager. -func (clients *ClientManager) Initialize() { - clients.byNick = make(map[string]*Client) - clients.bySkeleton = make(map[string]*Client) +// NewClientManager returns a new ClientManager. +func NewClientManager() *ClientManager { + return &ClientManager{ + byNick: make(map[string]*Client), + } +} + +// Count returns how many clients are in the manager. +func (clients *ClientManager) Count() int { + clients.RLock() + defer clients.RUnlock() + count := len(clients.byNick) + return count } // Get retrieves a client from the manager, if they exist. @@ -38,37 +63,19 @@ func (clients *ClientManager) Get(nick string) *Client { return nil } -func (clients *ClientManager) removeInternal(client *Client, oldcfnick, oldskeleton string) (err error) { +func (clients *ClientManager) removeInternal(client *Client) (removed bool) { // requires holding the writable Lock() - if oldcfnick == "*" || oldcfnick == "" { - return errNickMissing - } - + oldcfnick := client.NickCasefolded() currentEntry, present := clients.byNick[oldcfnick] if present { if currentEntry == client { delete(clients.byNick, oldcfnick) + removed = true } else { // this shouldn't happen, but we can ignore it - client.server.logger.Warning("internal", "clients for nick out of sync", oldcfnick) - err = errNickMissing + client.server.logger.Warning("internal", fmt.Sprintf("clients for nick %s out of sync", oldcfnick)) } - } else { - err = errNickMissing } - - currentEntry, present = clients.bySkeleton[oldskeleton] - if present { - if currentEntry == client { - delete(clients.bySkeleton, oldskeleton) - } else { - client.server.logger.Warning("internal", "clients for skeleton out of sync", oldskeleton) - err = errNickMissing - } - } else { - err = errNickMissing - } - return } @@ -77,168 +84,42 @@ func (clients *ClientManager) Remove(client *Client) error { clients.Lock() defer clients.Unlock() - oldcfnick, oldskeleton := client.uniqueIdentifiers() - return clients.removeInternal(client, oldcfnick, oldskeleton) + if !client.HasNick() { + return errNickMissing + } + clients.removeInternal(client) + return nil } // SetNick sets a client's nickname, validating it against nicknames in use -// XXX: dryRun validates a client's ability to claim a nick, without -// actually claiming it -func (clients *ClientManager) SetNick(client *Client, session *Session, newNick string, dryRun bool) (setNick string, err error, awayChanged bool) { - config := client.server.Config() - - var newCfNick, newSkeleton string - - client.stateMutex.RLock() - account := client.account - accountName := client.accountName - settings := client.accountSettings - registered := client.registered - realname := client.realname - client.stateMutex.RUnlock() - - // these restrictions have grandfather exceptions for nicknames registered - // on previous versions of Ergo: - if newNick != accountName { - // can't contain "disfavored" characters like <, or start with a $ because - // it collides with the massmessage mask syntax. '0' conflicts with the use of 0 - // as a placeholder in WHOX (#1896): - if strings.ContainsAny(newNick, disfavoredNameCharacters) || strings.HasPrefix(newNick, "$") || - newNick == "0" { - return "", errNicknameInvalid, false - } +func (clients *ClientManager) SetNick(client *Client, newNick string) error { + newcfnick, err := CasefoldName(newNick) + if err != nil { + return err } - // recompute always-on status, because client.alwaysOn is not set for unregistered clients - var alwaysOn, useAccountName bool - if account != "" { - alwaysOn = persistenceEnabled(config.Accounts.Multiclient.AlwaysOn, settings.AlwaysOn) - useAccountName = alwaysOn || config.Accounts.NickReservation.ForceNickEqualsAccount - } - - nickIsReserved := false - - if useAccountName { - if registered && newNick != accountName { - return "", errNickAccountMismatch, false - } - newNick = accountName - newCfNick = account - newSkeleton, err = Skeleton(newNick) - if err != nil { - return "", errNicknameInvalid, false - } - } else { - newNick = strings.TrimSpace(newNick) - if len(newNick) == 0 { - return "", errNickMissing, false - } - - if account == "" && config.Accounts.NickReservation.ForceGuestFormat && !dryRun { - newCfNick, err = CasefoldName(newNick) - if err != nil { - return "", errNicknameInvalid, false - } - if !config.Accounts.NickReservation.guestRegexpFolded.MatchString(newCfNick) { - newNick = strings.Replace(config.Accounts.NickReservation.GuestFormat, "*", newNick, 1) - newCfNick = "" // re-fold it below - } - } - - if newCfNick == "" { - newCfNick, err = CasefoldName(newNick) - } - if err != nil { - return "", errNicknameInvalid, false - } - if len(newNick) > config.Limits.NickLen || len(newCfNick) > config.Limits.NickLen { - return "", errNicknameInvalid, false - } - newSkeleton, err = Skeleton(newNick) - if err != nil { - return "", errNicknameInvalid, false - } - - if config.isRelaymsgIdentifier(newNick) { - return "", errNicknameInvalid, false - } - - if restrictedCasefoldedNicks.Has(newCfNick) || restrictedSkeletons.Has(newSkeleton) { - return "", errNicknameInvalid, false - } - - reservedAccount, method := client.server.accounts.EnforcementStatus(newCfNick, newSkeleton) - if method == NickEnforcementStrict && reservedAccount != "" && reservedAccount != account { - // see #2135: we want to enter the critical section, see if the nick is actually in use, - // and return errNicknameInUse in that case - nickIsReserved = true - } - } - - var bouncerAllowed bool - if config.Accounts.Multiclient.Enabled { - if useAccountName { - bouncerAllowed = true - } else { - if config.Accounts.Multiclient.AllowedByDefault && settings.AllowBouncer != MulticlientDisallowedByUser { - bouncerAllowed = true - } else if settings.AllowBouncer == MulticlientAllowedByUser { - bouncerAllowed = true - } - } + var reservedAccount string + var method NickReservationMethod + if client.server.AccountConfig().NickReservation.Enabled { + reservedAccount = client.server.accounts.NickToAccount(newcfnick) + method = client.server.AccountConfig().NickReservation.Method } clients.Lock() defer clients.Unlock() - currentClient := clients.byNick[newCfNick] + clients.removeInternal(client) + currentNewEntry := clients.byNick[newcfnick] // the client may just be changing case - if currentClient != nil && currentClient != client { - // these conditions forbid reattaching to an existing session: - if registered || !bouncerAllowed || account == "" || account != currentClient.Account() || - dryRun || session == nil { - return "", errNicknameInUse, false - } - reattachSuccessful, numSessions, lastSeen, wasAway, nowAway := currentClient.AddSession(session) - if !reattachSuccessful { - return "", errNicknameInUse, false - } - if numSessions == 1 { - invisible := currentClient.HasMode(modes.Invisible) - operator := currentClient.HasMode(modes.Operator) - client.server.stats.AddRegistered(invisible, operator) - } - session.autoreplayMissedSince = lastSeen - // TODO: transition mechanism for #1065, clean this up eventually: - if currentClient.Realname() == "" { - currentClient.SetRealname(realname) - } - // successful reattach! - return newNick, nil, wasAway != nowAway - } else if currentClient == client && currentClient.Nick() == newNick { - return "", errNoop, false + if currentNewEntry != nil && currentNewEntry != client { + return errNicknameInUse } - // analogous checks for skeletons - skeletonHolder := clients.bySkeleton[newSkeleton] - if skeletonHolder != nil && skeletonHolder != client { - return "", errNicknameInUse, false + if method == NickReservationStrict && reservedAccount != client.Account() { + return errNicknameReserved } - if nickIsReserved { - return "", errNicknameReserved, false - } - - if dryRun { - return "", nil, false - } - - formercfnick, formerskeleton := client.uniqueIdentifiers() - if changeSuccess := client.SetNick(newNick, newCfNick, newSkeleton); !changeSuccess { - return "", errClientDestroyed, false - } - clients.removeInternal(client, formercfnick, formerskeleton) - clients.byNick[newCfNick] = client - clients.bySkeleton[newSkeleton] = client - return newNick, nil, false + clients.byNick[newcfnick] = client + client.updateNickMask(newNick) + return nil } func (clients *ClientManager) AllClients() (result []*Client) { @@ -253,41 +134,41 @@ func (clients *ClientManager) AllClients() (result []*Client) { return } -// AllWithCapsNotify returns all clients with the given capabilities, and that support cap-notify. -func (clients *ClientManager) AllWithCapsNotify(capabs ...caps.Capability) (sessions []*Session) { - capabs = append(capabs, caps.CapNotify) +// AllWithCaps returns all clients with the given capabilities. +func (clients *ClientManager) AllWithCaps(capabs ...caps.Capability) (set ClientSet) { + set = make(ClientSet) + clients.RLock() defer clients.RUnlock() - for _, client := range clients.byNick { - for _, session := range client.Sessions() { - // cap-notify is implicit in cap version 302 and above - if session.capabilities.HasAll(capabs...) || 302 <= session.capVersion { - sessions = append(sessions, session) + var client *Client + for _, client = range clients.byNick { + // make sure they have all the required caps + for _, capab := range capabs { + if !client.capabilities.Has(capab) { + continue } } + + set.Add(client) } - return + return set } // FindAll returns all clients that match the given userhost mask. func (clients *ClientManager) FindAll(userhost string) (set ClientSet) { set = make(ClientSet) - userhost, err := CanonicalizeMaskWildcard(userhost) + userhost, err := Casefold(ExpandUserHost(userhost)) if err != nil { return set } - matcher, err := utils.CompileGlob(userhost, false) - if err != nil { - // not much we can do here - return - } + matcher := ircmatch.MakeMatch(userhost) clients.RLock() defer clients.RUnlock() for _, client := range clients.byNick { - if matcher.MatchString(client.NickMaskCasefolded()) { + if matcher.Match(client.nickMaskCasefolded) { set.Add(client) } } @@ -295,15 +176,166 @@ func (clients *ClientManager) FindAll(userhost string) (set ClientSet) { return set } -// Determine the canonical / unfolded form of a nick, if a client matching it -// is present (or always-on). -func (clients *ClientManager) UnfoldNick(cfnick string) (nick string) { +// Find returns the first client that matches the given userhost mask. +func (clients *ClientManager) Find(userhost string) *Client { + userhost, err := Casefold(ExpandUserHost(userhost)) + if err != nil { + return nil + } + matcher := ircmatch.MakeMatch(userhost) + var matchedClient *Client + clients.RLock() - c := clients.byNick[cfnick] - clients.RUnlock() - if c != nil { - return c.Nick() - } else { - return cfnick + defer clients.RUnlock() + for _, client := range clients.byNick { + if matcher.Match(client.nickMaskCasefolded) { + matchedClient = client + break + } + } + + return matchedClient +} + +// +// usermask to regexp +// + +//TODO(dan): move this over to generally using glob syntax instead? +// kinda more expected in normal ban/etc masks, though regex is useful (probably as an extban?) + +// UserMaskSet holds a set of client masks and lets you match hostnames to them. +type UserMaskSet struct { + sync.RWMutex + masks map[string]bool + regexp *regexp.Regexp +} + +// NewUserMaskSet returns a new UserMaskSet. +func NewUserMaskSet() *UserMaskSet { + return &UserMaskSet{ + masks: make(map[string]bool), } } + +// Add adds the given mask to this set. +func (set *UserMaskSet) Add(mask string) (added bool) { + casefoldedMask, err := Casefold(mask) + if err != nil { + log.Println(fmt.Sprintf("ERROR: Could not add mask to usermaskset: [%s]", mask)) + return false + } + + set.Lock() + added = !set.masks[casefoldedMask] + if added { + set.masks[casefoldedMask] = true + } + set.Unlock() + + if added { + set.setRegexp() + } + return +} + +// AddAll adds the given masks to this set. +func (set *UserMaskSet) AddAll(masks []string) (added bool) { + set.Lock() + defer set.Unlock() + + for _, mask := range masks { + if !added && !set.masks[mask] { + added = true + } + set.masks[mask] = true + } + if added { + set.setRegexp() + } + return +} + +// Remove removes the given mask from this set. +func (set *UserMaskSet) Remove(mask string) (removed bool) { + set.Lock() + removed = set.masks[mask] + if removed { + delete(set.masks, mask) + } + set.Unlock() + + if removed { + set.setRegexp() + } + return +} + +// Match matches the given n!u@h. +func (set *UserMaskSet) Match(userhost string) bool { + set.RLock() + regexp := set.regexp + set.RUnlock() + + if regexp == nil { + return false + } + return regexp.MatchString(userhost) +} + +// String returns the masks in this set. +func (set *UserMaskSet) String() string { + set.RLock() + masks := make([]string, len(set.masks)) + index := 0 + for mask := range set.masks { + masks[index] = mask + index++ + } + set.RUnlock() + return strings.Join(masks, " ") +} + +func (set *UserMaskSet) Length() int { + set.RLock() + defer set.RUnlock() + return len(set.masks) +} + +// setRegexp generates a regular expression from the set of user mask +// strings. Masks are split at the two types of wildcards, `*` and +// `?`. All the pieces are meta-escaped. `*` is replaced with `.*`, +// the regexp equivalent. Likewise, `?` is replaced with `.`. The +// parts are re-joined and finally all masks are joined into a big +// or-expression. +func (set *UserMaskSet) setRegexp() { + var re *regexp.Regexp + + set.RLock() + maskExprs := make([]string, len(set.masks)) + index := 0 + for mask := range set.masks { + manyParts := strings.Split(mask, "*") + manyExprs := make([]string, len(manyParts)) + for mindex, manyPart := range manyParts { + oneParts := strings.Split(manyPart, "?") + oneExprs := make([]string, len(oneParts)) + for oindex, onePart := range oneParts { + oneExprs[oindex] = regexp.QuoteMeta(onePart) + } + manyExprs[mindex] = strings.Join(oneExprs, ".") + } + maskExprs[index] = strings.Join(manyExprs, ".*") + index++ + } + set.RUnlock() + + if index > 0 { + expr := "^" + strings.Join(maskExprs, "|") + "$" + re, _ = regexp.Compile(expr) + } + + set.Lock() + set.regexp = re + set.Unlock() +} diff --git a/irc/client_test.go b/irc/client_test.go deleted file mode 100644 index ca4c2619..00000000 --- a/irc/client_test.go +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright (c) 2019 Shivaram Lingamneni -// released under the MIT license - -package irc - -import ( - "fmt" - "testing" - - "github.com/ergochat/ergo/irc/languages" - "github.com/ergochat/ergo/irc/utils" -) - -func TestGenerateBatchID(t *testing.T) { - var session Session - s := make(utils.HashSet[string]) - - count := 100000 - for i := 0; i < count; i++ { - s.Add(session.generateBatchID()) - } - - if len(s) != count { - t.Error("duplicate batch ID detected") - } -} - -func BenchmarkGenerateBatchID(b *testing.B) { - var session Session - for i := 0; i < b.N; i++ { - session.generateBatchID() - } -} - -func BenchmarkNames(b *testing.B) { - channelSize := 1024 - server := &Server{ - name: "ergo.test", - } - lm, err := languages.NewManager(false, "", "") - if err != nil { - b.Fatal(err) - } - server.config.Store(&Config{ - languageManager: lm, - }) - for i := 0; i < b.N; i++ { - channel := &Channel{ - name: "#test", - nameCasefolded: "#test", - server: server, - members: make(MemberSet), - } - for j := 0; j < channelSize; j++ { - nick := fmt.Sprintf("client_%d", j) - client := &Client{ - server: server, - nick: nick, - nickCasefolded: nick, - } - channel.members.Add(client) - channel.regenerateMembersCache() - session := &Session{ - client: client, - } - rb := NewResponseBuffer(session) - channel.Names(client, rb) - if len(rb.messages) < 2 { - b.Fatalf("not enough messages: %d", len(rb.messages)) - } - // to inspect the messages: line, _ := rb.messages[0].Line() - } - } -} - -func TestUserMasks(t *testing.T) { - var um UserMaskSet - - if um.Match("horse_!user@tor-network.onion") { - t.Error("bad match") - } - - um.Add("_!*@*", "x", "x") - if !um.Match("_!user@tor-network.onion") { - t.Error("failure to match") - } - if um.Match("horse_!user@tor-network.onion") { - t.Error("bad match") - } - - um.Add("beer*!*@*", "x", "x") - if !um.Match("beergarden!user@tor-network.onion") { - t.Error("failure to match") - } - if um.Match("horse_!user@tor-network.onion") { - t.Error("bad match") - } - - um.Add("horse*!user@*", "x", "x") - if !um.Match("horse_!user@tor-network.onion") { - t.Error("failure to match") - } -} - -func TestWhoFields(t *testing.T) { - var w whoxFields - - if w.Has('a') { - t.Error("zero value of whoxFields must be empty") - } - w = w.Add('a') - if !w.Has('a') { - t.Error("failed to set and get") - } - if w.Has('A') { - t.Error("false positive") - } - if w.Has('o') { - t.Error("false positive") - } - w = w.Add('🐬') - if w.Has('🐬') { - t.Error("should not be able to set invalid who field") - } - w = w.Add('o') - if !w.Has('o') { - t.Error("failed to set and get") - } - w = w.Add('z') - if !w.Has('z') { - t.Error("failed to set and get") - } -} diff --git a/irc/cloaks/cloak_test.go b/irc/cloaks/cloak_test.go deleted file mode 100644 index d4464b9a..00000000 --- a/irc/cloaks/cloak_test.go +++ /dev/null @@ -1,136 +0,0 @@ -// Copyright (c) 2019 Shivaram Lingamneni -// released under the MIT license - -package cloaks - -import ( - "net" - "reflect" - "testing" -) - -func assertEqual(supplied, expected interface{}, t *testing.T) { - if !reflect.DeepEqual(supplied, expected) { - t.Errorf("expected %v but got %v", expected, supplied) - } -} - -func easyParseIP(ipstr string) (result net.IP) { - result = net.ParseIP(ipstr) - if result == nil { - panic(ipstr) - } - return -} - -func cloakConfForTesting() CloakConfig { - config := CloakConfig{ - Enabled: true, - Netname: "oragono", - secret: "_BdVPWB5sray7McbFmeuJL996yaLgG4l9tEyficGXKg", - CidrLenIPv4: 32, - CidrLenIPv6: 64, - NumBits: 80, - } - config.Initialize() - return config -} - -func TestCloakDeterminism(t *testing.T) { - config := cloakConfForTesting() - - v4ip := easyParseIP("8.8.8.8").To4() - assertEqual(config.ComputeCloak(v4ip), "d2z5guriqhzwazyr.oragono", t) - // use of the 4-in-6 mapping should not affect the cloak - v6mappedIP := v4ip.To16() - assertEqual(config.ComputeCloak(v6mappedIP), "d2z5guriqhzwazyr.oragono", t) - - v6ip := easyParseIP("2001:0db8::1") - assertEqual(config.ComputeCloak(v6ip), "w7ren6nxii6f3i3d.oragono", t) - // same CIDR, so same cloak: - v6ipsamecidr := easyParseIP("2001:0db8::2") - assertEqual(config.ComputeCloak(v6ipsamecidr), "w7ren6nxii6f3i3d.oragono", t) - v6ipdifferentcidr := easyParseIP("2001:0db9::1") - // different CIDR, different cloak: - assertEqual(config.ComputeCloak(v6ipdifferentcidr), "ccmptyrjwsxv4f4d.oragono", t) - - // cloak values must be sensitive to changes in the secret key - config.SetSecret("HJcXK4lLawxBE4-9SIdPji_21YiL3N5r5f5-SPNrGVY") - assertEqual(config.ComputeCloak(v4ip), "4khy3usk8mfu42pe.oragono", t) - assertEqual(config.ComputeCloak(v6mappedIP), "4khy3usk8mfu42pe.oragono", t) - assertEqual(config.ComputeCloak(v6ip), "mxpk3c83vdxkek9j.oragono", t) - assertEqual(config.ComputeCloak(v6ipsamecidr), "mxpk3c83vdxkek9j.oragono", t) -} - -func TestCloakShortv4Cidr(t *testing.T) { - config := CloakConfig{ - Enabled: true, - Netname: "oragono", - secret: "_BdVPWB5sray7McbFmeuJL996yaLgG4l9tEyficGXKg", - CidrLenIPv4: 24, - CidrLenIPv6: 64, - NumBits: 60, - } - config.Initialize() - - v4ip := easyParseIP("8.8.8.8") - assertEqual(config.ComputeCloak(v4ip), "3cay3zc72tnui.oragono", t) - v4ipsamecidr := easyParseIP("8.8.8.9") - assertEqual(config.ComputeCloak(v4ipsamecidr), "3cay3zc72tnui.oragono", t) -} - -func TestCloakZeroBits(t *testing.T) { - config := cloakConfForTesting() - config.NumBits = 0 - config.Netname = "example.com" - config.Initialize() - - v4ip := easyParseIP("8.8.8.8").To4() - assertEqual(config.ComputeCloak(v4ip), "example.com", t) -} - -func TestCloakDisabled(t *testing.T) { - config := cloakConfForTesting() - config.Enabled = false - v4ip := easyParseIP("8.8.8.8").To4() - assertEqual(config.ComputeCloak(v4ip), "", t) -} - -func BenchmarkCloaks(b *testing.B) { - config := cloakConfForTesting() - v6ip := easyParseIP("2001:0db8::1") - b.ResetTimer() - for i := 0; i < b.N; i++ { - config.ComputeCloak(v6ip) - } -} - -func TestAccountCloak(t *testing.T) { - config := cloakConfForTesting() - - // just assert that we get all distinct values - assertEqual(config.ComputeAccountCloak("shivaram"), "8yu8kunudb45ztxm.oragono", t) - assertEqual(config.ComputeAccountCloak("dolph🐬n"), "hhgeqsvzeagv3wjw.oragono", t) - assertEqual(config.ComputeAccountCloak("SHIVARAM"), "bgx32x4r7qzih4uh.oragono", t) - assertEqual(config.ComputeAccountCloak("ed"), "j5autmgxtdjdyzf4.oragono", t) -} - -func TestAccountCloakCollisions(t *testing.T) { - config := cloakConfForTesting() - - v4ip := easyParseIP("97.97.97.97") - v4cloak := config.ComputeCloak(v4ip) - // "aaaa" is the same bytestring as 97.97.97.97 - aaaacloak := config.ComputeAccountCloak("aaaa") - if v4cloak == aaaacloak { - t.Errorf("cloak collision between 97.97.97.97 and aaaa: %s", v4cloak) - } -} - -func BenchmarkAccountCloaks(b *testing.B) { - config := cloakConfForTesting() - b.ResetTimer() - for i := 0; i < b.N; i++ { - config.ComputeAccountCloak("shivaram") - } -} diff --git a/irc/cloaks/cloaks.go b/irc/cloaks/cloaks.go deleted file mode 100644 index a46b5208..00000000 --- a/irc/cloaks/cloaks.go +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (c) 2019 Shivaram Lingamneni - -package cloaks - -import ( - "fmt" - "net" - - "golang.org/x/crypto/sha3" - - "github.com/ergochat/ergo/irc/utils" -) - -type CloakConfig struct { - Enabled bool - EnabledForAlwaysOn bool `yaml:"enabled-for-always-on"` - Netname string - CidrLenIPv4 int `yaml:"cidr-len-ipv4"` - CidrLenIPv6 int `yaml:"cidr-len-ipv6"` - NumBits int `yaml:"num-bits"` - LegacySecretValue string `yaml:"secret"` - - secret string - numBytes int - ipv4Mask net.IPMask - ipv6Mask net.IPMask -} - -func (cloakConfig *CloakConfig) Initialize() { - // sanity checks: - numBits := cloakConfig.NumBits - if 0 == numBits { - numBits = 64 - } else if 256 < numBits { - numBits = 256 - } - - // derived values: - cloakConfig.numBytes = numBits / 8 - // round up to the nearest byte - if numBits%8 != 0 { - cloakConfig.numBytes += 1 - } - cloakConfig.ipv4Mask = net.CIDRMask(cloakConfig.CidrLenIPv4, 32) - cloakConfig.ipv6Mask = net.CIDRMask(cloakConfig.CidrLenIPv6, 128) -} - -func (cloakConfig *CloakConfig) SetSecret(secret string) { - cloakConfig.secret = secret -} - -// simple cloaking algorithm: normalize the IP to its CIDR, -// then hash the resulting bytes with a secret key, -// then truncate to the desired length, b32encode, and append the fake TLD. -func (config *CloakConfig) ComputeCloak(ip net.IP) string { - if !config.Enabled { - return "" - } else if config.NumBits == 0 || config.secret == "" { - return config.Netname - } - - var masked net.IP - v4ip := ip.To4() - if v4ip != nil { - masked = v4ip.Mask(config.ipv4Mask) - } else { - masked = ip.Mask(config.ipv6Mask) - } - return config.macAndCompose(masked) -} - -func (config *CloakConfig) macAndCompose(b []byte) string { - // SHA3(K || M): - // https://crypto.stackexchange.com/questions/17735/is-hmac-needed-for-a-sha-3-based-mac - input := make([]byte, len(config.secret)+len(b)) - copy(input, config.secret[:]) - copy(input[len(config.secret):], b) - digest := sha3.Sum512(input) - b32digest := utils.B32Encoder.EncodeToString(digest[:config.numBytes]) - return fmt.Sprintf("%s.%s", b32digest, config.Netname) -} - -func (config *CloakConfig) ComputeAccountCloak(accountName string) string { - // XXX don't bother checking EnabledForAlwaysOn, since if it's disabled, - // we need to use the server name which we don't have - if config.NumBits == 0 || config.secret == "" { - return config.Netname - } - - // pad with 16 initial bytes of zeroes, avoiding any possibility of collision - // with a masked IP that could be an input to ComputeCloak: - paddedAccountName := make([]byte, 16+len(accountName)) - copy(paddedAccountName[16:], accountName[:]) - return config.macAndCompose(paddedAccountName) -} diff --git a/irc/commands.go b/irc/commands.go index 6757da5e..9970af68 100644 --- a/irc/commands.go +++ b/irc/commands.go @@ -6,78 +6,73 @@ package irc import ( - "github.com/ergochat/irc-go/ircmsg" + "github.com/goshuirc/irc-go/ircmsg" + "github.com/oragono/oragono/irc/modes" ) // Command represents a command accepted from a client. type Command struct { - handler func(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool - usablePreReg bool - allowedInBatch bool // allowed in client-to-server batches - minParams int - capabs []string + handler func(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool + oper bool + usablePreReg bool + leaveClientActive bool // if true, leaves the client active time alone. reversed because we can't default a struct element to True + leaveClientIdle bool + minParams int + capabs []string } // Run runs this command with the given client/message. -func (cmd *Command) Run(server *Server, client *Client, session *Session, msg ircmsg.Message) (exiting bool) { - rb := NewResponseBuffer(session) - rb.Label = GetLabel(msg) - - exiting = func() bool { - defer rb.Send(true) - - if !client.registered && !cmd.usablePreReg { - rb.Add(nil, server.name, ERR_NOTREGISTERED, "*", client.t("You need to register before you can use that command")) - return false - } - if len(cmd.capabs) > 0 && !client.HasRoleCapabs(cmd.capabs...) { - rb.Add(nil, server.name, ERR_NOPRIVILEGES, client.Nick(), client.t("Permission Denied")) - return false - } - if len(msg.Params) < cmd.minParams { - rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.Nick(), msg.Command, rb.target.t("Not enough parameters")) - return false - } - if session.batch.label != "" && !cmd.allowedInBatch { - rb.Add(nil, server.name, "FAIL", "BATCH", "MULTILINE_INVALID", client.t("Command not allowed during a multiline batch")) - session.EndMultilineBatch("") - return false - } - - return cmd.handler(server, client, msg, rb) - }() - - // after each command, see if we can send registration to the client - if !exiting && !client.registered { - exiting = server.tryRegister(client, session) +func (cmd *Command) Run(server *Server, client *Client, msg ircmsg.IrcMessage) bool { + if !client.registered && !cmd.usablePreReg { + client.Send(nil, server.name, ERR_NOTREGISTERED, client.nick, client.t("You need to register before you can use that command")) + return false + } + if cmd.oper && !client.HasMode(modes.Operator) { + client.Send(nil, server.name, ERR_NOPRIVILEGES, client.nick, client.t("Permission Denied - You're not an IRC operator")) + return false + } + if len(cmd.capabs) > 0 && !client.HasRoleCapabs(cmd.capabs...) { + client.Send(nil, server.name, ERR_NOPRIVILEGES, client.nick, client.t("Permission Denied")) + return false + } + if len(msg.Params) < cmd.minParams { + client.Send(nil, server.name, ERR_NEEDMOREPARAMS, client.nick, msg.Command, client.t("Not enough parameters")) + return false } if client.registered { - client.Touch(session) // even if `exiting`, we bump the lastSeen timestamp + client.fakelag.Touch() + } + + rb := NewResponseBuffer(client) + rb.Label = GetLabel(msg) + exiting := cmd.handler(server, client, msg, rb) + rb.Send() + + // after each command, see if we can send registration to the client + if !client.registered { + server.tryRegister(client) + } + + if !cmd.leaveClientIdle { + client.Touch() + } + + if !cmd.leaveClientActive { + client.Active() } return exiting } -// fake handler for unknown commands (see #994: this ensures the response tags are correct) -var unknownCommand = Command{ - handler: unknownCommandHandler, - usablePreReg: true, -} - -var invalidUtf8Command = Command{ - handler: invalidUtf8Handler, - usablePreReg: true, -} - // Commands holds all commands executable by a client connected to us. var Commands map[string]Command func init() { Commands = map[string]Command{ - "ACCEPT": { - handler: acceptHandler, - minParams: 1, + "ACC": { + handler: accHandler, + minParams: 3, }, "AMBIANCE": { handler: sceneHandler, @@ -89,45 +84,31 @@ func init() { minParams: 1, }, "AWAY": { - handler: awayHandler, - usablePreReg: true, - minParams: 0, - }, - "BATCH": { - handler: batchHandler, - minParams: 1, - allowedInBatch: true, + handler: awayHandler, + minParams: 0, }, "CAP": { handler: capHandler, usablePreReg: true, minParams: 1, }, - "CHATHISTORY": { - handler: chathistoryHandler, - minParams: 4, + "CHANSERV": { + handler: csHandler, + minParams: 1, + }, + "CS": { + handler: csHandler, + minParams: 1, }, "DEBUG": { handler: debugHandler, minParams: 1, - capabs: []string{"rehash"}, - }, - "DEFCON": { - handler: defconHandler, - capabs: []string{"defcon"}, - }, - "DEOPER": { - handler: deoperHandler, - minParams: 0, + oper: true, }, "DLINE": { handler: dlineHandler, minParams: 1, - capabs: []string{"ban"}, - }, - "EXTJWT": { - handler: extjwtHandler, - minParams: 1, + oper: true, }, "HELP": { handler: helpHandler, @@ -137,10 +118,6 @@ func init() { handler: helpHandler, minParams: 0, }, - "HISTORY": { - handler: historyHandler, - minParams: 1, - }, "INFO": { handler: infoHandler, }, @@ -152,10 +129,6 @@ func init() { handler: isonHandler, minParams: 1, }, - "ISUPPORT": { - handler: isupportHandler, - usablePreReg: true, - }, "JOIN": { handler: joinHandler, minParams: 1, @@ -167,12 +140,13 @@ func init() { "KILL": { handler: killHandler, minParams: 1, - capabs: []string{"kill"}, + oper: true, + capabs: []string{"oper:local_kill"}, //TODO(dan): when we have S2S, this will be checked in the command handler itself }, "KLINE": { handler: klineHandler, minParams: 1, - capabs: []string{"ban"}, + oper: true, }, "LANGUAGE": { handler: languageHandler, @@ -187,10 +161,6 @@ func init() { handler: lusersHandler, minParams: 0, }, - "MARKREAD": { - handler: markReadHandler, - minParams: 0, // send FAIL instead of ERR_NEEDMOREPARAMS - }, "MODE": { handler: modeHandler, minParams: 1, @@ -212,10 +182,13 @@ func init() { usablePreReg: true, minParams: 1, }, + "NICKSERV": { + handler: nsHandler, + minParams: 1, + }, "NOTICE": { - handler: messageHandler, - minParams: 2, - allowedInBatch: true, + handler: noticeHandler, + minParams: 2, }, "NPC": { handler: npcHandler, @@ -225,9 +198,13 @@ func init() { handler: npcaHandler, minParams: 3, }, + "NS": { + handler: nsHandler, + minParams: 1, + }, "OPER": { handler: operHandler, - minParams: 1, + minParams: 2, }, "PART": { handler: partHandler, @@ -238,47 +215,40 @@ func init() { usablePreReg: true, minParams: 1, }, - "PERSISTENCE": { - handler: persistenceHandler, - minParams: 1, - }, "PING": { - handler: pingHandler, - usablePreReg: true, - minParams: 1, + handler: pingHandler, + usablePreReg: true, + minParams: 1, + leaveClientActive: true, }, "PONG": { - handler: pongHandler, - usablePreReg: true, - minParams: 1, + handler: pongHandler, + usablePreReg: true, + minParams: 1, + leaveClientActive: true, }, "PRIVMSG": { - handler: messageHandler, - minParams: 2, - allowedInBatch: true, + handler: privmsgHandler, + minParams: 2, }, - "RELAYMSG": { - handler: relaymsgHandler, - minParams: 3, - }, - "REGISTER": { - handler: registerHandler, - minParams: 3, + "PROXY": { + handler: proxyHandler, usablePreReg: true, + minParams: 5, }, "RENAME": { handler: renameHandler, minParams: 2, }, - "SAJOIN": { - handler: sajoinHandler, - minParams: 1, - capabs: []string{"sajoin"}, + "RESUME": { + handler: resumeHandler, + usablePreReg: true, + minParams: 1, }, "SANICK": { handler: sanickHandler, minParams: 2, - capabs: []string{"samode"}, + oper: true, }, "SAMODE": { handler: modeHandler, @@ -289,15 +259,8 @@ func init() { handler: sceneHandler, minParams: 2, }, - "SETNAME": { - handler: setnameHandler, - minParams: 1, - }, - "SUMMON": { - handler: summonHandler, - }, "TAGMSG": { - handler: messageHandler, + handler: tagmsgHandler, minParams: 1, }, "QUIT": { @@ -305,14 +268,11 @@ func init() { usablePreReg: true, minParams: 0, }, - "REDACT": { - handler: redactHandler, - minParams: 2, - }, "REHASH": { handler: rehashHandler, minParams: 0, - capabs: []string{"rehash"}, + oper: true, + capabs: []string{"oper:rehash"}, }, "TIME": { handler: timeHandler, @@ -322,24 +282,15 @@ func init() { handler: topicHandler, minParams: 1, }, - "UBAN": { - handler: ubanHandler, - minParams: 1, - capabs: []string{"ban"}, - }, "UNDLINE": { handler: unDLineHandler, minParams: 1, - capabs: []string{"ban"}, - }, - "UNINVITE": { - handler: inviteHandler, - minParams: 2, + oper: true, }, "UNKLINE": { handler: unKLineHandler, minParams: 1, - capabs: []string{"ban"}, + oper: true, }, "USER": { handler: userHandler, @@ -350,14 +301,6 @@ func init() { handler: userhostHandler, minParams: 1, }, - "USERS": { - handler: usersHandler, - }, - "VERIFY": { - handler: verifyHandler, - usablePreReg: true, - minParams: 2, - }, "VERSION": { handler: versionHandler, minParams: 0, @@ -379,16 +322,5 @@ func init() { handler: whowasHandler, minParams: 1, }, - "ZNC": { - handler: zncHandler, - minParams: 1, - }, - // CEF custom commands - "REACT": { - handler: reactHandler, - minParams: 2, - }, } - - initializeServices() } diff --git a/irc/config.go b/irc/config.go index 9cf3bee9..ada0a578 100644 --- a/irc/config.go +++ b/irc/config.go @@ -6,464 +6,129 @@ package irc import ( - "bytes" "crypto/tls" - "crypto/x509" + "encoding/json" "errors" "fmt" - "io" + "io/ioutil" "log" - "net" - "os" "path/filepath" - "reflect" - "regexp" - "runtime" - "strconv" "strings" "time" "code.cloudfoundry.org/bytefmt" - "github.com/ergochat/irc-go/ircfmt" + "github.com/oragono/oragono/irc/connection_limits" + "github.com/oragono/oragono/irc/custime" + "github.com/oragono/oragono/irc/languages" + "github.com/oragono/oragono/irc/logger" + "github.com/oragono/oragono/irc/passwd" + "github.com/oragono/oragono/irc/utils" "gopkg.in/yaml.v2" - - "github.com/ergochat/ergo/irc/caps" - "github.com/ergochat/ergo/irc/cloaks" - "github.com/ergochat/ergo/irc/connection_limits" - "github.com/ergochat/ergo/irc/custime" - "github.com/ergochat/ergo/irc/email" - "github.com/ergochat/ergo/irc/isupport" - "github.com/ergochat/ergo/irc/jwt" - "github.com/ergochat/ergo/irc/languages" - "github.com/ergochat/ergo/irc/logger" - "github.com/ergochat/ergo/irc/modes" - "github.com/ergochat/ergo/irc/mysql" - "github.com/ergochat/ergo/irc/oauth2" - "github.com/ergochat/ergo/irc/passwd" - "github.com/ergochat/ergo/irc/utils" ) -// here's how this works: exported (capitalized) members of the config structs -// are defined in the YAML file and deserialized directly from there. They may -// be postprocessed and overwritten by LoadConfig. Unexported (lowercase) members -// are derived from the exported members in LoadConfig. +// PassConfig holds the connection password. +type PassConfig struct { + Password string +} // TLSListenConfig defines configuration options for listening on TLS. type TLSListenConfig struct { - Cert string - Key string - Proxy bool // XXX: legacy key: it's preferred to specify this directly in listenerConfigBlock + Cert string + Key string } -// This is the YAML-deserializable type of the value of the `Server.Listeners` map -type listenerConfigBlock struct { - // normal TLS configuration, with a single certificate: - TLS TLSListenConfig - // SNI configuration, with multiple certificates: - TLSCertificates []TLSListenConfig `yaml:"tls-certificates"` - MinTLSVersion string `yaml:"min-tls-version"` - Proxy bool - Tor bool - STSOnly bool `yaml:"sts-only"` - WebSocket bool - HideSTS bool `yaml:"hide-sts"` -} - -type HistoryCutoff uint - -const ( - HistoryCutoffDefault HistoryCutoff = iota - HistoryCutoffNone - HistoryCutoffRegistrationTime - HistoryCutoffJoinTime -) - -func historyCutoffToString(restriction HistoryCutoff) string { - switch restriction { - case HistoryCutoffDefault: - return "default" - case HistoryCutoffNone: - return "none" - case HistoryCutoffRegistrationTime: - return "registration-time" - case HistoryCutoffJoinTime: - return "join-time" - default: - return "" +// Config returns the TLS contiguration assicated with this TLSListenConfig. +func (conf *TLSListenConfig) Config() (*tls.Config, error) { + cert, err := tls.LoadX509KeyPair(conf.Cert, conf.Key) + if err != nil { + return nil, ErrInvalidCertKeyPair } + + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + }, err } -func historyCutoffFromString(str string) (result HistoryCutoff, err error) { - switch strings.ToLower(str) { - case "default": - return HistoryCutoffDefault, nil - case "none", "disabled", "off", "false": - return HistoryCutoffNone, nil - case "registration-time": - return HistoryCutoffRegistrationTime, nil - case "join-time": - return HistoryCutoffJoinTime, nil - default: - return HistoryCutoffDefault, errInvalidParams +// PasswordBytes returns the bytes represented by the password hash. +func (conf *PassConfig) PasswordBytes() []byte { + bytes, err := passwd.DecodePasswordHash(conf.Password) + if err != nil { + log.Fatal("decode password error: ", err) } -} - -type PersistentStatus uint - -const ( - PersistentUnspecified PersistentStatus = iota - PersistentDisabled - PersistentOptIn - PersistentOptOut - PersistentMandatory -) - -func persistentStatusToString(status PersistentStatus) string { - switch status { - case PersistentUnspecified: - return "default" - case PersistentDisabled: - return "disabled" - case PersistentOptIn: - return "opt-in" - case PersistentOptOut: - return "opt-out" - case PersistentMandatory: - return "mandatory" - default: - return "" - } -} - -func persistentStatusFromString(status string) (PersistentStatus, error) { - switch strings.ToLower(status) { - case "default": - return PersistentUnspecified, nil - case "": - return PersistentDisabled, nil - case "opt-in": - return PersistentOptIn, nil - case "opt-out": - return PersistentOptOut, nil - case "mandatory": - return PersistentMandatory, nil - default: - b, err := utils.StringToBool(status) - if b { - return PersistentMandatory, err - } else { - return PersistentDisabled, err - } - } -} - -func (ps *PersistentStatus) UnmarshalYAML(unmarshal func(interface{}) error) error { - var orig string - var err error - if err = unmarshal(&orig); err != nil { - return err - } - result, err := persistentStatusFromString(orig) - if err == nil { - if result == PersistentUnspecified { - result = PersistentDisabled - } - *ps = result - } else { - err = fmt.Errorf("invalid value `%s` for server persistence status: %w", orig, err) - } - return err -} - -func persistenceEnabled(serverSetting, clientSetting PersistentStatus) (enabled bool) { - if serverSetting == PersistentDisabled { - return false - } else if serverSetting == PersistentMandatory { - return true - } else if clientSetting == PersistentDisabled { - return false - } else if clientSetting == PersistentMandatory { - return true - } else if serverSetting == PersistentOptOut { - return true - } else { - return false - } -} - -type HistoryStatus uint - -const ( - HistoryDefault HistoryStatus = iota - HistoryDisabled - HistoryEphemeral - HistoryPersistent -) - -func historyStatusFromString(str string) (status HistoryStatus, err error) { - switch strings.ToLower(str) { - case "default": - return HistoryDefault, nil - case "ephemeral": - return HistoryEphemeral, nil - case "persistent": - return HistoryPersistent, nil - default: - b, err := utils.StringToBool(str) - if b { - return HistoryPersistent, err - } else { - return HistoryDisabled, err - } - } -} - -func historyStatusToString(status HistoryStatus) string { - switch status { - case HistoryDefault: - return "default" - case HistoryDisabled: - return "disabled" - case HistoryEphemeral: - return "ephemeral" - case HistoryPersistent: - return "persistent" - default: - return "" - } -} - -// XXX you must have already checked History.Enabled before calling this -func historyEnabled(serverSetting PersistentStatus, localSetting HistoryStatus) (result HistoryStatus) { - switch serverSetting { - case PersistentMandatory: - return HistoryPersistent - case PersistentOptOut: - if localSetting == HistoryDefault { - return HistoryPersistent - } else { - return localSetting - } - case PersistentOptIn: - switch localSetting { - case HistoryPersistent: - return HistoryPersistent - case HistoryEphemeral, HistoryDefault: - return HistoryEphemeral - default: - return HistoryDisabled - } - case PersistentDisabled: - if localSetting == HistoryDisabled { - return HistoryDisabled - } else { - return HistoryEphemeral - } - default: - // PersistentUnspecified: shouldn't happen because the deserializer converts it - // to PersistentDisabled - if localSetting == HistoryDefault { - return HistoryEphemeral - } else { - return localSetting - } - } -} - -type MulticlientConfig struct { - Enabled bool - AllowedByDefault bool `yaml:"allowed-by-default"` - AlwaysOn PersistentStatus `yaml:"always-on"` - AutoAway PersistentStatus `yaml:"auto-away"` - AlwaysOnExpiration custime.Duration `yaml:"always-on-expiration"` -} - -type throttleConfig struct { - Enabled bool - Duration time.Duration - MaxAttempts int `yaml:"max-attempts"` -} - -type ThrottleConfig struct { - throttleConfig -} - -func (t *ThrottleConfig) UnmarshalYAML(unmarshal func(interface{}) error) (err error) { - // note that this technique only works if the zero value of the struct - // doesn't need any postprocessing (because if the field is omitted entirely - // from the YAML, then UnmarshalYAML won't be called at all) - if err = unmarshal(&t.throttleConfig); err != nil { - return - } - if !t.Enabled { - t.MaxAttempts = 0 // limit of 0 means disabled - } - return + return bytes } type AccountConfig struct { Registration AccountRegistrationConfig - AuthenticationEnabled bool `yaml:"authentication-enabled"` - AdvertiseSCRAM bool `yaml:"advertise-scram"` - RequireSasl struct { - Enabled bool - Exempted []string - exemptedNets []net.IPNet - } `yaml:"require-sasl"` - DefaultUserModes *string `yaml:"default-user-modes"` - defaultUserModes modes.Modes - LoginThrottling ThrottleConfig `yaml:"login-throttling"` - SkipServerPassword bool `yaml:"skip-server-password"` - LoginViaPassCommand bool `yaml:"login-via-pass-command"` - NickReservation struct { - Enabled bool - AdditionalNickLimit int `yaml:"additional-nick-limit"` - Method NickEnforcementMethod - AllowCustomEnforcement bool `yaml:"allow-custom-enforcement"` - // RenamePrefix is the legacy field, GuestFormat is the new version - RenamePrefix string `yaml:"rename-prefix"` - GuestFormat string `yaml:"guest-nickname-format"` - guestRegexp *regexp.Regexp - guestRegexpFolded *regexp.Regexp - ForceGuestFormat bool `yaml:"force-guest-format"` - ForceNickEqualsAccount bool `yaml:"force-nick-equals-account"` - ForbidAnonNickChanges bool `yaml:"forbid-anonymous-nick-changes"` - } `yaml:"nick-reservation"` - Multiclient MulticlientConfig - Bouncer *MulticlientConfig // # handle old name for 'multiclient' - VHosts VHostConfig - AuthScript AuthScriptConfig `yaml:"auth-script"` - OAuth2 oauth2.OAuth2BearerConfig `yaml:"oauth2"` - JWTAuth jwt.JWTAuthConfig `yaml:"jwt-auth"` -} - -type ScriptConfig struct { - Enabled bool - Command string - Args []string - Timeout time.Duration - KillTimeout time.Duration `yaml:"kill-timeout"` - MaxConcurrency uint `yaml:"max-concurrency"` -} - -type AuthScriptConfig struct { - ScriptConfig `yaml:",inline"` - Autocreate bool -} - -type IPCheckScriptConfig struct { - ScriptConfig `yaml:",inline"` - ExemptSASL bool `yaml:"exempt-sasl"` + AuthenticationEnabled bool `yaml:"authentication-enabled"` + SkipServerPassword bool `yaml:"skip-server-password"` + NickReservation NickReservationConfig `yaml:"nick-reservation"` } // AccountRegistrationConfig controls account registration. type AccountRegistrationConfig struct { - Enabled bool - AllowBeforeConnect bool `yaml:"allow-before-connect"` - Throttling ThrottleConfig - // new-style (v2.4 email verification config): - EmailVerification email.MailtoConfig `yaml:"email-verification"` - // old-style email verification config, with "callbacks": - LegacyEnabledCallbacks []string `yaml:"enabled-callbacks"` - LegacyCallbacks struct { - Mailto email.MailtoConfig - } `yaml:"callbacks"` - VerifyTimeout custime.Duration `yaml:"verify-timeout"` - BcryptCost uint `yaml:"bcrypt-cost"` + Enabled bool + EnabledCallbacks []string `yaml:"enabled-callbacks"` + EnabledCredentialTypes []string `yaml:"-"` + VerifyTimeout time.Duration `yaml:"verify-timeout"` + Callbacks struct { + Mailto struct { + Server string + Port int + TLS struct { + Enabled bool + InsecureSkipVerify bool `yaml:"insecure_skip_verify"` + ServerName string `yaml:"servername"` + } + Username string + Password string + Sender string + VerifyMessageSubject string `yaml:"verify-message-subject"` + VerifyMessage string `yaml:"verify-message"` + } + } + AllowMultiplePerConnection bool `yaml:"allow-multiple-per-connection"` } -type VHostConfig struct { - Enabled bool - MaxLength int `yaml:"max-length"` - ValidRegexpRaw string `yaml:"valid-regexp"` - validRegexp *regexp.Regexp -} - -type NickEnforcementMethod int +type NickReservationMethod int const ( - // NickEnforcementOptional is the zero value; it serializes to - // "optional" in the yaml config, and "default" as an arg to `NS ENFORCE`. - // in both cases, it means "defer to the other source of truth", i.e., - // in the config, defer to the user's custom setting, and as a custom setting, - // defer to the default in the config. if both are NickEnforcementOptional then - // there is no enforcement. - // XXX: these are serialized as numbers in the database, so beware of collisions - // when refactoring (any numbers currently in use must keep their meanings, or - // else be fixed up by a schema change) - NickEnforcementOptional NickEnforcementMethod = iota - NickEnforcementNone - NickEnforcementStrict + NickReservationWithTimeout NickReservationMethod = iota + NickReservationStrict ) -func nickReservationToString(method NickEnforcementMethod) string { - switch method { - case NickEnforcementOptional: - return "default" - case NickEnforcementNone: - return "none" - case NickEnforcementStrict: - return "strict" - default: - return "" - } -} - -func nickReservationFromString(method string) (NickEnforcementMethod, error) { - switch strings.ToLower(method) { - case "default": - return NickEnforcementOptional, nil - case "optional": - return NickEnforcementOptional, nil - case "none": - return NickEnforcementNone, nil - case "strict": - return NickEnforcementStrict, nil - default: - return NickEnforcementOptional, fmt.Errorf("invalid nick-reservation.method value: %s", method) - } -} - -func (nr *NickEnforcementMethod) UnmarshalYAML(unmarshal func(interface{}) error) error { - var orig string +func (nr *NickReservationMethod) UnmarshalYAML(unmarshal func(interface{}) error) error { + var orig, raw string var err error if err = unmarshal(&orig); err != nil { return err } - method, err := nickReservationFromString(orig) - if err == nil { - *nr = method - } else { - err = fmt.Errorf("invalid value `%s` for nick enforcement method: %w", orig, err) - } - return err -} - -func (cm *Casemapping) UnmarshalYAML(unmarshal func(interface{}) error) (err error) { - var orig string - if err = unmarshal(&orig); err != nil { + if raw, err = Casefold(orig); err != nil { return err } - - var result Casemapping - switch strings.ToLower(orig) { - case "ascii": - result = CasemappingASCII - case "precis", "rfc7613", "rfc8265": - result = CasemappingPRECIS - case "permissive", "fun": - result = CasemappingPermissive - case "rfc1459": - result = CasemappingRFC1459 - case "rfc1459-strict": - result = CasemappingRFC1459Strict - default: - return fmt.Errorf("invalid casemapping value: %s", orig) + if raw == "timeout" { + *nr = NickReservationWithTimeout + } else if raw == "strict" { + *nr = NickReservationStrict + } else { + return errors.New(fmt.Sprintf("invalid nick-reservation.method value: %s", orig)) } - *cm = result return nil } +type NickReservationConfig struct { + Enabled bool + AdditionalNickLimit int `yaml:"additional-nick-limit"` + Method NickReservationMethod + RenameTimeout time.Duration `yaml:"rename-timeout"` + RenamePrefix string `yaml:"rename-prefix"` +} + +// ChannelRegistrationConfig controls channel registration. +type ChannelRegistrationConfig struct { + Enabled bool +} + // OperClassConfig defines a specific operator class. type OperClassConfig struct { Title string @@ -474,49 +139,40 @@ type OperClassConfig struct { // OperConfig defines a specific operator's configuration. type OperConfig struct { - Class string - Vhost string - WhoisLine string `yaml:"whois-line"` - Password string - Fingerprint *string // legacy name for certfp, #1050 - Certfp string - Auto bool - Hidden bool - Modes string + Class string + Vhost string + WhoisLine string `yaml:"whois-line"` + Password string + Modes string } -// Various server-enforced limits on data size. -type Limits struct { - AwayLen int `yaml:"awaylen"` - ChanListModes int `yaml:"chan-list-modes"` - ChannelLen int `yaml:"channellen"` - IdentLen int `yaml:"identlen"` - RealnameLen int `yaml:"realnamelen"` - KickLen int `yaml:"kicklen"` - MonitorEntries int `yaml:"monitor-entries"` - NickLen int `yaml:"nicklen"` - TopicLen int `yaml:"topiclen"` - WhowasEntries int `yaml:"whowas-entries"` - RegistrationMessages int `yaml:"registration-messages"` - Multiline struct { - MaxBytes int `yaml:"max-bytes"` - MaxLines int `yaml:"max-lines"` +// PasswordBytes returns the bytes represented by the password hash. +func (conf *OperConfig) PasswordBytes() []byte { + bytes, err := passwd.DecodePasswordHash(conf.Password) + if err != nil { + log.Fatal("decode password error: ", err) } + return bytes +} + +// LineLenConfig controls line lengths. +type LineLenConfig struct { + Tags int + Rest int } // STSConfig controls the STS configuration/ type STSConfig struct { - Enabled bool - Duration custime.Duration - Port int - Preload bool - STSOnlyBanner string `yaml:"sts-only-banner"` - bannerLines []string + Enabled bool + Duration time.Duration `yaml:"duration-real"` + DurationString string `yaml:"duration"` + Port int + Preload bool } // Value returns the STS value to advertise in CAP func (sts *STSConfig) Value() string { - val := fmt.Sprintf("duration=%d", int(time.Duration(sts.Duration).Seconds())) + val := fmt.Sprintf("duration=%d", int(sts.Duration.Seconds())) if sts.Enabled && sts.Port > 0 { val += fmt.Sprintf(",port=%d", sts.Port) } @@ -526,219 +182,108 @@ func (sts *STSConfig) Value() string { return val } +// StackImpactConfig is the config used for StackImpact's profiling. +type StackImpactConfig struct { + Enabled bool + AgentKey string `yaml:"agent-key"` + AppName string `yaml:"app-name"` +} + type FakelagConfig struct { Enabled bool Window time.Duration BurstLimit uint `yaml:"burst-limit"` MessagesPerWindow uint `yaml:"messages-per-window"` Cooldown time.Duration - CommandBudgets map[string]int `yaml:"command-budgets"` -} - -type TorListenersConfig struct { - Listeners []string // legacy only - RequireSasl bool `yaml:"require-sasl"` - Vhost string - MaxConnections int `yaml:"max-connections"` - ThrottleDuration time.Duration `yaml:"throttle-duration"` - MaxConnectionsPerDuration int `yaml:"max-connections-per-duration"` } // Config defines the overall configuration. type Config struct { - AllowEnvironmentOverrides bool `yaml:"allow-environment-overrides"` - Network struct { Name string } Server struct { - Password string - passwordBytes []byte - Name string - nameCasefolded string - Listeners map[string]listenerConfigBlock - UnixBindMode os.FileMode `yaml:"unix-bind-mode"` - TorListeners TorListenersConfig `yaml:"tor-listeners"` - WebSockets struct { - AllowedOrigins []string `yaml:"allowed-origins"` - allowedOriginRegexps []*regexp.Regexp - } - // they get parsed into this internal representation: - trueListeners map[string]utils.ListenerConfig - STS STSConfig - LookupHostnames *bool `yaml:"lookup-hostnames"` - lookupHostnames bool - ForwardConfirmHostnames bool `yaml:"forward-confirm-hostnames"` - CheckIdent bool `yaml:"check-ident"` - CoerceIdent string `yaml:"coerce-ident"` - MOTD string - motdLines []string - MOTDFormatting bool `yaml:"motd-formatting"` - Relaymsg struct { - Enabled bool - Separators string - AvailableToChanops bool `yaml:"available-to-chanops"` - } - ProxyAllowedFrom []string `yaml:"proxy-allowed-from"` - proxyAllowedFromNets []net.IPNet - WebIRC []webircConfig `yaml:"webirc"` - MaxSendQString string `yaml:"max-sendq"` - MaxSendQBytes int - Compatibility struct { - ForceTrailing *bool `yaml:"force-trailing"` - forceTrailing bool - SendUnprefixedSasl bool `yaml:"send-unprefixed-sasl"` - AllowTruncation *bool `yaml:"allow-truncation"` - allowTruncation bool - } - isupport isupport.List - IPLimits connection_limits.LimiterConfig `yaml:"ip-limits"` - Cloaks cloaks.CloakConfig `yaml:"ip-cloaking"` - SecureNetDefs []string `yaml:"secure-nets"` - secureNets []net.IPNet - supportedCaps *caps.Set - supportedCapsWithoutSTS *caps.Set - capValues caps.Values - Casemapping Casemapping - EnforceUtf8 bool `yaml:"enforce-utf8"` - OutputPath string `yaml:"output-path"` - IPCheckScript IPCheckScriptConfig `yaml:"ip-check-script"` - OverrideServicesHostname string `yaml:"override-services-hostname"` - MaxLineLen int `yaml:"max-line-len"` - SuppressLusers bool `yaml:"suppress-lusers"` - } - - Roleplay struct { - Enabled bool - RequireChanops bool `yaml:"require-chanops"` - RequireOper bool `yaml:"require-oper"` - AddSuffix *bool `yaml:"add-suffix"` - addSuffix bool - } - - Extjwt struct { - Default jwt.JwtServiceConfig `yaml:",inline"` - Services map[string]jwt.JwtServiceConfig `yaml:"services"` + PassConfig + Password string + Name string + Listen []string + TLSListeners map[string]*TLSListenConfig `yaml:"tls-listeners"` + STS STSConfig + CheckIdent bool `yaml:"check-ident"` + MOTD string + MOTDFormatting bool `yaml:"motd-formatting"` + ProxyAllowedFrom []string `yaml:"proxy-allowed-from"` + WebIRC []webircConfig `yaml:"webirc"` + MaxSendQString string `yaml:"max-sendq"` + MaxSendQBytes int + ConnectionLimiter connection_limits.LimiterConfig `yaml:"connection-limits"` + ConnectionThrottler connection_limits.ThrottlerConfig `yaml:"connection-throttling"` } Languages struct { Enabled bool Path string Default string + Data map[string]languages.LangData } - languageManager *languages.Manager - - LockFile string `yaml:"lock-file"` - Datastore struct { - Path string - AutoUpgrade bool - MySQL mysql.Config + Path string } Accounts AccountConfig Channels struct { - DefaultModes *string `yaml:"default-modes"` - defaultModes modes.Modes - MaxChannelsPerClient int `yaml:"max-channels-per-client"` - OpOnlyCreation bool `yaml:"operator-only-creation"` - Registration struct { - Enabled bool - OperatorOnly bool `yaml:"operator-only"` - MaxChannelsPerAccount int `yaml:"max-channels-per-account"` - } - ListDelay time.Duration `yaml:"list-delay"` - InviteExpiration custime.Duration `yaml:"invite-expiration"` - AutoJoin []string `yaml:"auto-join"` + DefaultModes *string `yaml:"default-modes"` + Registration ChannelRegistrationConfig } OperClasses map[string]*OperClassConfig `yaml:"oper-classes"` Opers map[string]*OperConfig - // parsed operator definitions, unexported so they can't be defined - // directly in YAML: - operators map[string]*Oper - Logging []logger.LoggingConfig Debug struct { - RecoverFromErrors *bool `yaml:"recover-from-errors"` - recoverFromErrors bool - PprofListener string `yaml:"pprof-listener"` + RecoverFromErrors *bool `yaml:"recover-from-errors"` + PprofListener *string `yaml:"pprof-listener"` + StackImpact StackImpactConfig } - Limits Limits + Limits struct { + AwayLen uint `yaml:"awaylen"` + ChanListModes uint `yaml:"chan-list-modes"` + ChannelLen uint `yaml:"channellen"` + KickLen uint `yaml:"kicklen"` + MonitorEntries uint `yaml:"monitor-entries"` + NickLen uint `yaml:"nicklen"` + TopicLen uint `yaml:"topiclen"` + WhowasEntries uint `yaml:"whowas-entries"` + LineLen LineLenConfig `yaml:"linelen"` + } Fakelag FakelagConfig - History struct { - Enabled bool - ChannelLength int `yaml:"channel-length"` - ClientLength int `yaml:"client-length"` - AutoresizeWindow custime.Duration `yaml:"autoresize-window"` - AutoreplayOnJoin int `yaml:"autoreplay-on-join"` - ChathistoryMax int `yaml:"chathistory-maxmessages"` - ZNCMax int `yaml:"znc-maxmessages"` - Restrictions struct { - ExpireTime custime.Duration `yaml:"expire-time"` - // legacy key, superceded by QueryCutoff: - EnforceRegistrationDate_ bool `yaml:"enforce-registration-date"` - QueryCutoff string `yaml:"query-cutoff"` - queryCutoff HistoryCutoff - GracePeriod custime.Duration `yaml:"grace-period"` - } - Persistent struct { - Enabled bool - UnregisteredChannels bool `yaml:"unregistered-channels"` - RegisteredChannels PersistentStatus `yaml:"registered-channels"` - DirectMessages PersistentStatus `yaml:"direct-messages"` - } - Retention struct { - AllowIndividualDelete bool `yaml:"allow-individual-delete"` - EnableAccountIndexing bool `yaml:"enable-account-indexing"` - } - TagmsgStorage struct { - Default bool - Whitelist []string - Blacklist []string - } `yaml:"tagmsg-storage"` - } - Filename string - - Cef struct { - Imagor struct { - Url string - Secret string - } - Redis string - } } // OperClass defines an assembled operator class. type OperClass struct { Title string - WhoisLine string `yaml:"whois-line"` - Capabilities utils.HashSet[string] // map to make lookups much easier + WhoisLine string `yaml:"whois-line"` + Capabilities map[string]bool // map to make lookups much easier } // OperatorClasses returns a map of assembled operator classes from the given config. -func (conf *Config) OperatorClasses() (map[string]*OperClass, error) { - fixupCapability := func(capab string) string { - return strings.TrimPrefix(strings.TrimPrefix(capab, "oper:"), "local_") // #868, #1442 - } - - ocs := make(map[string]*OperClass) +func (conf *Config) OperatorClasses() (*map[string]OperClass, error) { + ocs := make(map[string]OperClass) // loop from no extends to most extended, breaking if we can't add any more lenOfLastOcs := -1 for { if lenOfLastOcs == len(ocs) { - return nil, errors.New("OperClasses contains a looping dependency, or a class extends from a class that doesn't exist") + return nil, ErrOperClassDependencies } lenOfLastOcs = len(ocs) @@ -761,24 +306,21 @@ func (conf *Config) OperatorClasses() (map[string]*OperClass, error) { // create new operclass var oc OperClass - oc.Capabilities = make(utils.HashSet[string]) + oc.Capabilities = make(map[string]bool) // get inhereted info from other operclasses if len(info.Extends) > 0 { - einfo := ocs[info.Extends] + einfo, _ := ocs[info.Extends] for capab := range einfo.Capabilities { - oc.Capabilities.Add(fixupCapability(capab)) + oc.Capabilities[capab] = true } } // add our own info oc.Title = info.Title - if oc.Title == "" { - oc.Title = "IRC operator" - } for _, capab := range info.Capabilities { - oc.Capabilities.Add(fixupCapability(capab)) + oc.Capabilities[capab] = true } if len(info.WhoisLine) > 0 { oc.WhoisLine = info.WhoisLine @@ -791,7 +333,7 @@ func (conf *Config) OperatorClasses() (map[string]*OperClass, error) { oc.WhoisLine += oc.Title } - ocs[name] = &oc + ocs[name] = oc } if !anyMissing { @@ -800,29 +342,21 @@ func (conf *Config) OperatorClasses() (map[string]*OperClass, error) { } } - return ocs, nil + return &ocs, nil } // Oper represents a single assembled operator's config. type Oper struct { - Name string Class *OperClass WhoisLine string Vhost string Pass []byte - Certfp string - Auto bool - Hidden bool - Modes []modes.ModeChange -} - -func (oper *Oper) HasRoleCapab(capab string) bool { - return oper != nil && oper.Class.Capabilities.Has(capab) + Modes string } // Operators returns a map of operator configs from the given OperClass and config. -func (conf *Config) Operators(oc map[string]*OperClass) (map[string]*Oper, error) { - operators := make(map[string]*Oper) +func (conf *Config) Operators(oc *map[string]OperClass) (map[string]Oper, error) { + operators := make(map[string]Oper) for name, opConf := range conf.Opers { var oper Oper @@ -831,184 +365,44 @@ func (conf *Config) Operators(oc map[string]*OperClass) (map[string]*Oper, error if err != nil { return nil, fmt.Errorf("Could not casefold oper name: %s", err.Error()) } - oper.Name = name - - if opConf.Password != "" { - oper.Pass, err = decodeLegacyPasswordHash(opConf.Password) - if err != nil { - return nil, fmt.Errorf("Oper %s has an invalid password hash: %s", oper.Name, err.Error()) - } - } - certfp := opConf.Certfp - if certfp == "" && opConf.Fingerprint != nil { - certfp = *opConf.Fingerprint - } - if certfp != "" { - oper.Certfp, err = utils.NormalizeCertfp(certfp) - if err != nil { - return nil, fmt.Errorf("Oper %s has an invalid fingerprint: %s", oper.Name, err.Error()) - } - } - oper.Auto = opConf.Auto - oper.Hidden = opConf.Hidden - - if oper.Pass == nil && oper.Certfp == "" { - return nil, fmt.Errorf("Oper %s has neither a password nor a fingerprint", name) - } + oper.Pass = opConf.PasswordBytes() oper.Vhost = opConf.Vhost - if oper.Vhost != "" && !conf.Accounts.VHosts.validRegexp.MatchString(oper.Vhost) { - return nil, fmt.Errorf("Oper %s has an invalid vhost: `%s`", name, oper.Vhost) - } - class, exists := oc[opConf.Class] + class, exists := (*oc)[opConf.Class] if !exists { return nil, fmt.Errorf("Could not load operator [%s] - they use operclass [%s] which does not exist", name, opConf.Class) } - oper.Class = class + oper.Class = &class if len(opConf.WhoisLine) > 0 { oper.WhoisLine = opConf.WhoisLine } else { oper.WhoisLine = class.WhoisLine } - modeStr := strings.TrimSpace(opConf.Modes) - modeChanges, unknownChanges := modes.ParseUserModeChanges(strings.Split(modeStr, " ")...) - if len(unknownChanges) > 0 { - return nil, fmt.Errorf("Could not load operator [%s] due to unknown modes %v", name, unknownChanges) - } - oper.Modes = modeChanges + oper.Modes = strings.TrimSpace(opConf.Modes) // successful, attach to list of opers - operators[name] = &oper + operators[name] = oper } return operators, nil } -func loadTlsConfig(config listenerConfigBlock) (tlsConfig *tls.Config, err error) { - var certificates []tls.Certificate - if len(config.TLSCertificates) != 0 { - // SNI configuration with multiple certificates - for _, certPairConf := range config.TLSCertificates { - cert, err := loadCertWithLeaf(certPairConf.Cert, certPairConf.Key) - if err != nil { - return nil, err - } - certificates = append(certificates, cert) - } - } else if config.TLS.Cert != "" { - // normal configuration with one certificate - cert, err := loadCertWithLeaf(config.TLS.Cert, config.TLS.Key) +// TLSListeners returns a list of TLS listeners and their configs. +func (conf *Config) TLSListeners() map[string]*tls.Config { + tlsListeners := make(map[string]*tls.Config) + for s, tlsListenersConf := range conf.Server.TLSListeners { + config, err := tlsListenersConf.Config() if err != nil { - return nil, err + log.Fatal(err) } - certificates = append(certificates, cert) - } else { - // plaintext! - return nil, nil + config.ClientAuth = tls.RequestClientCert + tlsListeners[s] = config } - clientAuth := tls.RequestClientCert - if config.WebSocket { - // if Chrome receives a server request for a client certificate - // on a websocket connection, it will immediately disconnect: - // https://bugs.chromium.org/p/chromium/issues/detail?id=329884 - // work around this behavior: - clientAuth = tls.NoClientCert - } - result := tls.Config{ - Certificates: certificates, - ClientAuth: clientAuth, - MinVersion: tlsMinVersionFromString(config.MinTLSVersion), - } - return &result, nil + return tlsListeners } -func tlsMinVersionFromString(version string) uint16 { - version = strings.ToLower(version) - version = strings.TrimPrefix(version, "v") - switch version { - case "1", "1.0": - return tls.VersionTLS10 - case "1.1": - return tls.VersionTLS11 - case "1.2": - return tls.VersionTLS12 - case "1.3": - return tls.VersionTLS13 - default: - // tls package will fill in a sane value, currently 1.0 - return 0 - } -} - -func loadCertWithLeaf(certFile, keyFile string) (cert tls.Certificate, err error) { - // LoadX509KeyPair: "On successful return, Certificate.Leaf will be nil because - // the parsed form of the certificate is not retained." tls.Config: - // "Note: if there are multiple Certificates, and they don't have the - // optional field Leaf set, certificate selection will incur a significant - // per-handshake performance cost." - cert, err = tls.LoadX509KeyPair(certFile, keyFile) - if err != nil { - return - } - cert.Leaf, err = x509.ParseCertificate(cert.Certificate[0]) - return -} - -// prepareListeners populates Config.Server.trueListeners -func (conf *Config) prepareListeners() (err error) { - if len(conf.Server.Listeners) == 0 { - return fmt.Errorf("No listeners were configured") - } - - conf.Server.trueListeners = make(map[string]utils.ListenerConfig) - for addr, block := range conf.Server.Listeners { - var lconf utils.ListenerConfig - lconf.ProxyDeadline = RegisterTimeout - lconf.Tor = block.Tor - lconf.STSOnly = block.STSOnly - if lconf.STSOnly && !conf.Server.STS.Enabled { - return fmt.Errorf("%s is configured as a STS-only listener, but STS is disabled", addr) - } - lconf.TLSConfig, err = loadTlsConfig(block) - if err != nil { - return &CertKeyError{Err: err} - } - lconf.RequireProxy = block.TLS.Proxy || block.Proxy - lconf.WebSocket = block.WebSocket - if lconf.WebSocket && !conf.Server.EnforceUtf8 { - return fmt.Errorf("enabling a websocket listener requires the use of server.enforce-utf8") - } - lconf.HideSTS = block.HideSTS - conf.Server.trueListeners[addr] = lconf - } - return nil -} - -func (config *Config) processExtjwt() (err error) { - // first process the default service, which may be disabled - err = config.Extjwt.Default.Postprocess() - if err != nil { - return - } - // now process the named services. it is an error if any is disabled - // also, normalize the service names to lowercase - services := make(map[string]jwt.JwtServiceConfig, len(config.Extjwt.Services)) - for service, sConf := range config.Extjwt.Services { - err := sConf.Postprocess() - if err != nil { - return err - } - if !sConf.Enabled() { - return fmt.Errorf("no keys enabled for extjwt service %s", service) - } - services[strings.ToLower(service)] = sConf - } - config.Extjwt.Services = services - return nil -} - -// LoadRawConfig loads the config without doing any consistency checks or postprocessing -func LoadRawConfig(filename string) (config *Config, err error) { - data, err := os.ReadFile(filename) +// LoadConfig loads the given YAML configuration file. +func LoadConfig(filename string) (config *Config, err error) { + data, err := ioutil.ReadFile(filename) if err != nil { return nil, err } @@ -1017,215 +411,51 @@ func LoadRawConfig(filename string) (config *Config, err error) { if err != nil { return nil, err } - return -} - -// convert, e.g., "ALLOWED_ORIGINS" to "allowed-origins" -func screamingSnakeToKebab(in string) (out string) { - var buf strings.Builder - for i := 0; i < len(in); i++ { - c := in[i] - switch { - case c == '_': - buf.WriteByte('-') - case 'A' <= c && c <= 'Z': - buf.WriteByte(c + ('a' - 'A')) - default: - buf.WriteByte(c) - } - } - return buf.String() -} - -func isExported(field reflect.StructField) bool { - return field.PkgPath == "" // https://golang.org/pkg/reflect/#StructField -} - -// errors caused by config overrides -type configPathError struct { - name string - desc string - fatalErr error -} - -func (ce *configPathError) Error() string { - if ce.fatalErr != nil { - return fmt.Sprintf("Couldn't apply config override `%s`: %s: %v", ce.name, ce.desc, ce.fatalErr) - } - return fmt.Sprintf("Couldn't apply config override `%s`: %s", ce.name, ce.desc) -} - -func mungeFromEnvironment(config *Config, envPair string) (applied bool, name string, err *configPathError) { - equalIdx := strings.IndexByte(envPair, '=') - name, value := envPair[:equalIdx], envPair[equalIdx+1:] - if strings.HasPrefix(name, "ERGO__") { - name = strings.TrimPrefix(name, "ERGO__") - } else if strings.HasPrefix(name, "ORAGONO__") { - name = strings.TrimPrefix(name, "ORAGONO__") - } else { - return false, "", nil - } - pathComponents := strings.Split(name, "__") - for i, pathComponent := range pathComponents { - pathComponents[i] = screamingSnakeToKebab(pathComponent) - } - - v := reflect.Indirect(reflect.ValueOf(config)) - t := v.Type() - for _, component := range pathComponents { - if component == "" { - return false, "", &configPathError{name, "invalid", nil} - } - if v.Kind() != reflect.Struct { - return false, "", &configPathError{name, "index into non-struct", nil} - } - var nextField reflect.StructField - success := false - n := t.NumField() - // preferentially get a field with an exact yaml tag match, - // then fall back to case-insensitive comparison of field names - for i := 0; i < n; i++ { - field := t.Field(i) - if isExported(field) && field.Tag.Get("yaml") == component { - nextField = field - success = true - break - } - } - if !success { - for i := 0; i < n; i++ { - field := t.Field(i) - if isExported(field) && strings.ToLower(field.Name) == component { - nextField = field - success = true - break - } - } - } - if !success { - return false, "", &configPathError{name, fmt.Sprintf("couldn't resolve path component: `%s`", component), nil} - } - v = v.FieldByName(nextField.Name) - // dereference pointer field if necessary, initialize new value if necessary - if v.Kind() == reflect.Ptr { - if v.IsNil() { - v.Set(reflect.New(v.Type().Elem())) - } - v = reflect.Indirect(v) - } - t = v.Type() - } - yamlErr := yaml.Unmarshal([]byte(value), v.Addr().Interface()) - if yamlErr != nil { - return false, "", &configPathError{name, "couldn't deserialize YAML", yamlErr} - } - return true, name, nil -} - -// LoadConfig loads the given YAML configuration file. -func LoadConfig(filename string) (config *Config, err error) { - config, err = LoadRawConfig(filename) - if err != nil { - return nil, err - } - - if config.AllowEnvironmentOverrides { - for _, envPair := range os.Environ() { - applied, name, envErr := mungeFromEnvironment(config, envPair) - if envErr != nil { - if envErr.fatalErr != nil { - return nil, envErr - } else { - log.Println(envErr.Error()) - } - } else if applied { - log.Printf("applied environment override: %s\n", name) - } - } - } config.Filename = filename + // we need this so PasswordBytes returns the correct info + if config.Server.Password != "" { + config.Server.PassConfig.Password = config.Server.Password + } + if config.Network.Name == "" { - return nil, errors.New("Network name missing") + return nil, ErrNetworkNameMissing } if config.Server.Name == "" { - return nil, errors.New("Server name missing") + return nil, ErrServerNameMissing } - if !utils.IsServerName(config.Server.Name) { - return nil, errors.New("Server name must match the format of a hostname") + if !utils.IsHostname(config.Server.Name) { + return nil, ErrServerNameNotHostname } - config.Server.nameCasefolded = strings.ToLower(config.Server.Name) if config.Datastore.Path == "" { - return nil, errors.New("Datastore path missing") + return nil, ErrDatastorePathMissing } - //dan: automagically fix identlen until a few releases in the future (from now, 0.12.0), being a newly-introduced limit - if config.Limits.IdentLen < 1 { - config.Limits.IdentLen = 20 + if len(config.Server.Listen) == 0 { + return nil, ErrNoListenersDefined } if config.Limits.NickLen < 1 || config.Limits.ChannelLen < 2 || config.Limits.AwayLen < 1 || config.Limits.KickLen < 1 || config.Limits.TopicLen < 1 { - return nil, errors.New("One or more limits values are too low") + return nil, ErrLimitsAreInsane } - if config.Limits.RegistrationMessages == 0 { - config.Limits.RegistrationMessages = 1024 - } - if config.Server.MaxLineLen < DefaultMaxLineLen { - config.Server.MaxLineLen = DefaultMaxLineLen - } - if config.Datastore.MySQL.Enabled { - if config.Limits.NickLen > mysql.MaxTargetLength || config.Limits.ChannelLen > mysql.MaxTargetLength { - return nil, fmt.Errorf("to use MySQL, nick and channel length limits must be %d or lower", mysql.MaxTargetLength) - } - } - - if config.Server.CoerceIdent != "" { - if config.Server.CheckIdent { - return nil, errors.New("Can't configure both check-ident and coerce-ident") - } - if config.Server.CoerceIdent[0] != '~' { - return nil, errors.New("coerce-ident value must start with a ~") - } - if !isIdent(config.Server.CoerceIdent[1:]) { - return nil, errors.New("coerce-ident must be valid as an IRC user/ident field") - } - } - - config.Server.supportedCaps = caps.NewCompleteSet() - config.Server.capValues = make(caps.Values) - - err = config.prepareListeners() - if err != nil { - return nil, fmt.Errorf("failed to prepare listeners: %v", err) - } - - for _, glob := range config.Server.WebSockets.AllowedOrigins { - globre, err := utils.CompileGlob(glob, false) - if err != nil { - return nil, fmt.Errorf("invalid websocket allowed-origin expression: %s", glob) - } - config.Server.WebSockets.allowedOriginRegexps = append(config.Server.WebSockets.allowedOriginRegexps, globre) - } - if config.Server.STS.Enabled { + config.Server.STS.Duration, err = custime.ParseDuration(config.Server.STS.DurationString) + if err != nil { + return nil, fmt.Errorf("Could not parse STS duration: %s", err.Error()) + } if config.Server.STS.Port < 0 || config.Server.STS.Port > 65535 { return nil, fmt.Errorf("STS port is incorrect, should be 0 if disabled: %d", config.Server.STS.Port) } - if config.Server.STS.STSOnlyBanner != "" { - for _, line := range strings.Split(config.Server.STS.STSOnlyBanner, "\n") { - config.Server.STS.bannerLines = append(config.Server.STS.bannerLines, strings.TrimSpace(line)) - } - } else { - config.Server.STS.bannerLines = []string{fmt.Sprintf("This server is only accessible over TLS. Please reconnect using TLS on port %d.", config.Server.STS.Port)} - } - } else { - config.Server.supportedCaps.Disable(caps.STS) - config.Server.STS.Duration = 0 } - // set this even if STS is disabled - config.Server.capValues[caps.STS] = config.Server.STS.Value() - - config.Server.lookupHostnames = utils.BoolDefaultTrue(config.Server.LookupHostnames) - + if config.Server.ConnectionThrottler.Enabled { + config.Server.ConnectionThrottler.Duration, err = time.ParseDuration(config.Server.ConnectionThrottler.DurationString) + if err != nil { + return nil, fmt.Errorf("Could not parse connection-throttle duration: %s", err.Error()) + } + config.Server.ConnectionThrottler.BanDuration, err = time.ParseDuration(config.Server.ConnectionThrottler.BanDurationString) + if err != nil { + return nil, fmt.Errorf("Could not parse connection-throttle ban-duration: %s", err.Error()) + } + } // process webirc blocks var newWebIRC []webircConfig for _, webirc := range config.Server.WebIRC { @@ -1241,51 +471,10 @@ func LoadConfig(filename string) (config *Config, err error) { newWebIRC = append(newWebIRC, webirc) } config.Server.WebIRC = newWebIRC - - if config.Limits.Multiline.MaxBytes <= 0 { - config.Server.supportedCaps.Disable(caps.Multiline) - } else { - var multilineCapValue string - if config.Limits.Multiline.MaxLines == 0 { - multilineCapValue = fmt.Sprintf("max-bytes=%d", config.Limits.Multiline.MaxBytes) - } else { - multilineCapValue = fmt.Sprintf("max-bytes=%d,max-lines=%d", config.Limits.Multiline.MaxBytes, config.Limits.Multiline.MaxLines) - } - config.Server.capValues[caps.Multiline] = multilineCapValue + // process limits + if config.Limits.LineLen.Tags < 512 || config.Limits.LineLen.Rest < 512 { + return nil, ErrLineLengthsTooSmall } - - // handle legacy name 'bouncer' for 'multiclient' section: - if config.Accounts.Bouncer != nil { - config.Accounts.Multiclient = *config.Accounts.Bouncer - } - - if !config.Accounts.Multiclient.Enabled { - config.Accounts.Multiclient.AlwaysOn = PersistentDisabled - } else if config.Accounts.Multiclient.AlwaysOn >= PersistentOptOut { - config.Accounts.Multiclient.AllowedByDefault = true - } - - if !config.Accounts.NickReservation.Enabled { - config.Accounts.NickReservation.ForceNickEqualsAccount = false - } - - if config.Accounts.NickReservation.ForceNickEqualsAccount && !config.Accounts.Multiclient.Enabled { - return nil, errors.New("force-nick-equals-account requires enabling multiclient as well") - } - - // handle guest format, including the legacy key rename-prefix - if config.Accounts.NickReservation.GuestFormat == "" { - renamePrefix := config.Accounts.NickReservation.RenamePrefix - if renamePrefix == "" { - renamePrefix = "Guest-" - } - config.Accounts.NickReservation.GuestFormat = renamePrefix + "*" - } - config.Accounts.NickReservation.guestRegexp, config.Accounts.NickReservation.guestRegexpFolded, err = compileGuestRegexp(config.Accounts.NickReservation.GuestFormat, config.Server.Casemapping) - if err != nil { - return nil, err - } - var newLogConfigs []logger.LoggingConfig for _, logConfig := range config.Logging { // methods @@ -1296,7 +485,7 @@ func LoadConfig(filename string) (config *Config, err error) { } } if methods["file"] && logConfig.Filename == "" { - return nil, errors.New("Logging configuration specifies 'file' method but 'filename' is empty") + return nil, ErrLoggerFilenameMissing } logConfig.MethodFile = methods["file"] logConfig.MethodStdout = methods["stdout"] @@ -1315,7 +504,7 @@ func LoadConfig(filename string) (config *Config, err error) { continue } if typeStr == "-" { - return nil, errors.New("Encountered logging type '-' with no type to exclude") + return nil, ErrLoggerExcludeEmpty } if typeStr[0] == '-' { typeStr = typeStr[1:] @@ -1325,130 +514,19 @@ func LoadConfig(filename string) (config *Config, err error) { } } if len(logConfig.Types) < 1 { - return nil, errors.New("Logger has no types to log") + return nil, ErrLoggerHasNoTypes } newLogConfigs = append(newLogConfigs, logConfig) } config.Logging = newLogConfigs - if config.Accounts.Registration.EmailVerification.Enabled { - err := config.Accounts.Registration.EmailVerification.Postprocess(config.Server.Name) - if err != nil { - return nil, err - } - } else { - // TODO: this processes the legacy "callback" config, clean this up in 2.5 or later - // TODO: also clean up the legacy "inline" MTA config format (from ee05a4324dfde) - mailtoEnabled := false - for _, name := range config.Accounts.Registration.LegacyEnabledCallbacks { - if name == "mailto" { - mailtoEnabled = true - break - } - } - if mailtoEnabled { - config.Accounts.Registration.EmailVerification = config.Accounts.Registration.LegacyCallbacks.Mailto - config.Accounts.Registration.EmailVerification.Enabled = true - err := config.Accounts.Registration.EmailVerification.Postprocess(config.Server.Name) - if err != nil { - return nil, err - } - } - } - - config.Accounts.defaultUserModes = ParseDefaultUserModes(config.Accounts.DefaultUserModes) - - if config.Server.Password != "" { - config.Server.passwordBytes, err = decodeLegacyPasswordHash(config.Server.Password) - if err != nil { - return nil, err - } - if config.Accounts.LoginViaPassCommand && !config.Accounts.SkipServerPassword { - return nil, errors.New("Using a server password and login-via-pass-command requires skip-server-password as well") - } - // #1634: accounts.registration.allow-before-connect is an auth bypass - // for configurations that start from default and then enable server.password - config.Accounts.Registration.AllowBeforeConnect = false - } - - if config.Accounts.RequireSasl.Enabled { - // minor gotcha: Tor listeners will typically be loopback and - // therefore exempted from require-sasl. if require-sasl is enabled - // for non-Tor (non-local) connections, enable it for Tor as well: - config.Server.TorListeners.RequireSasl = true - } - config.Accounts.RequireSasl.exemptedNets, err = utils.ParseNetList(config.Accounts.RequireSasl.Exempted) - if err != nil { - return nil, fmt.Errorf("Could not parse require-sasl exempted nets: %v", err.Error()) - } - - config.Server.proxyAllowedFromNets, err = utils.ParseNetList(config.Server.ProxyAllowedFrom) - if err != nil { - return nil, fmt.Errorf("Could not parse proxy-allowed-from nets: %v", err.Error()) - } - - config.Server.secureNets, err = utils.ParseNetList(config.Server.SecureNetDefs) - if err != nil { - return nil, fmt.Errorf("Could not parse secure-nets: %v\n", err.Error()) - } - - rawRegexp := config.Accounts.VHosts.ValidRegexpRaw - if rawRegexp != "" { - regexp, err := regexp.Compile(rawRegexp) - if err == nil { - config.Accounts.VHosts.validRegexp = regexp - } else { - log.Printf("invalid vhost regexp: %s\n", err.Error()) - } - } - if config.Accounts.VHosts.validRegexp == nil { - config.Accounts.VHosts.validRegexp = defaultValidVhostRegex - } - - if config.Accounts.AuthenticationEnabled { - saslCapValues := []string{"PLAIN", "EXTERNAL"} - if config.Accounts.AdvertiseSCRAM { - saslCapValues = append(saslCapValues, "SCRAM-SHA-256") - } - if config.Accounts.OAuth2.Enabled { - saslCapValues = append(saslCapValues, "OAUTHBEARER") - } - if config.Accounts.OAuth2.Enabled || config.Accounts.JWTAuth.Enabled { - saslCapValues = append(saslCapValues, "IRCV3BEARER") - } - config.Server.capValues[caps.SASL] = strings.Join(saslCapValues, ",") - } else { - config.Server.supportedCaps.Disable(caps.SASL) - } - - if err := config.Accounts.OAuth2.Postprocess(); err != nil { - return nil, err - } - - if err := config.Accounts.JWTAuth.Postprocess(); err != nil { - return nil, err - } - - if config.Accounts.OAuth2.Enabled && config.Accounts.OAuth2.AuthScript && !config.Accounts.AuthScript.Enabled { - return nil, fmt.Errorf("oauth2 is enabled with auth-script, but no auth-script is enabled") - } - - if !config.Accounts.Registration.Enabled { - config.Server.supportedCaps.Disable(caps.AccountRegistration) - } else { - var registerValues []string - if config.Accounts.Registration.AllowBeforeConnect { - registerValues = append(registerValues, "before-connect") - } - if config.Accounts.Registration.EmailVerification.Enabled { - registerValues = append(registerValues, "email-required") - } - if config.Accounts.RequireSasl.Enabled { - registerValues = append(registerValues, "account-required") - } - if len(registerValues) != 0 { - config.Server.capValues[caps.AccountRegistration] = strings.Join(registerValues, ",") + // hardcode this for now + config.Accounts.Registration.EnabledCredentialTypes = []string{"passphrase", "certfp"} + for i, name := range config.Accounts.Registration.EnabledCallbacks { + if name == "none" { + // we store "none" as "*" internally + config.Accounts.Registration.EnabledCallbacks[i] = "*" } } @@ -1458,329 +536,124 @@ func LoadConfig(filename string) (config *Config, err error) { } config.Server.MaxSendQBytes = int(maxSendQBytes) - config.languageManager, err = languages.NewManager(config.Languages.Enabled, config.Languages.Path, config.Languages.Default) - if err != nil { - return nil, fmt.Errorf("Could not load languages: %s", err.Error()) - } - config.Server.capValues[caps.Languages] = config.languageManager.CapValue() - - if len(config.Fakelag.CommandBudgets) != 0 { - // normalize command names to uppercase: - commandBudgets := make(map[string]int, len(config.Fakelag.CommandBudgets)) - for command, budget := range config.Fakelag.CommandBudgets { - commandBudgets[strings.ToUpper(command)] = budget - } - config.Fakelag.CommandBudgets = commandBudgets - } else { - config.Fakelag.CommandBudgets = nil - } - - if config.Server.Relaymsg.Enabled { - for _, char := range protocolBreakingNameCharacters { - if strings.ContainsRune(config.Server.Relaymsg.Separators, char) { - return nil, fmt.Errorf("RELAYMSG separators cannot include the characters %s", protocolBreakingNameCharacters) - } - } - config.Server.capValues[caps.Relaymsg] = config.Server.Relaymsg.Separators - } else { - config.Server.supportedCaps.Disable(caps.Relaymsg) - } - - config.Debug.recoverFromErrors = utils.BoolDefaultTrue(config.Debug.RecoverFromErrors) - - // process operator definitions, store them to config.operators - operclasses, err := config.OperatorClasses() - if err != nil { - return nil, err - } - opers, err := config.Operators(operclasses) - if err != nil { - return nil, err - } - config.operators = opers - - // parse default channel modes - config.Channels.defaultModes = ParseDefaultChannelModes(config.Channels.DefaultModes) - - if config.Accounts.Registration.BcryptCost == 0 { - config.Accounts.Registration.BcryptCost = passwd.DefaultCost - } - - if config.Channels.MaxChannelsPerClient == 0 { - config.Channels.MaxChannelsPerClient = 100 - } - if config.Channels.Registration.MaxChannelsPerAccount == 0 { - config.Channels.Registration.MaxChannelsPerAccount = 15 - } - - config.Server.Compatibility.forceTrailing = utils.BoolDefaultTrue(config.Server.Compatibility.ForceTrailing) - config.Server.Compatibility.allowTruncation = utils.BoolDefaultTrue(config.Server.Compatibility.AllowTruncation) - - config.loadMOTD() - - // in the current implementation, we disable history by creating a history buffer - // with zero capacity. but the `enabled` config option MUST be respected regardless - // of this detail - if !config.History.Enabled { - config.History.ChannelLength = 0 - config.History.ClientLength = 0 - config.Server.supportedCaps.Disable(caps.Chathistory) - config.Server.supportedCaps.Disable(caps.EventPlayback) - config.Server.supportedCaps.Disable(caps.ZNCPlayback) - } - - if !config.History.Enabled || !config.History.Persistent.Enabled { - config.History.Persistent.Enabled = false - config.History.Persistent.UnregisteredChannels = false - config.History.Persistent.RegisteredChannels = PersistentDisabled - config.History.Persistent.DirectMessages = PersistentDisabled - } - - if config.History.Persistent.Enabled && !config.Datastore.MySQL.Enabled { - return nil, fmt.Errorf("You must configure a MySQL server in order to enable persistent history") - } - - if config.History.ZNCMax == 0 { - config.History.ZNCMax = config.History.ChathistoryMax - } - - if config.History.Restrictions.QueryCutoff != "" { - config.History.Restrictions.queryCutoff, err = historyCutoffFromString(config.History.Restrictions.QueryCutoff) + // get language files + config.Languages.Data = make(map[string]languages.LangData) + if config.Languages.Enabled { + files, err := ioutil.ReadDir(config.Languages.Path) if err != nil { - return nil, fmt.Errorf("invalid value of history.query-restrictions: %w", err) + return nil, fmt.Errorf("Could not load language files: %s", err.Error()) } - } else { - if config.History.Restrictions.EnforceRegistrationDate_ { - config.History.Restrictions.queryCutoff = HistoryCutoffRegistrationTime + + for _, f := range files { + // skip dirs + if f.IsDir() { + continue + } + + // only load core .lang.yaml files, and ignore help/irc files + name := f.Name() + lowerName := strings.ToLower(name) + if !strings.HasSuffix(lowerName, ".lang.yaml") { + continue + } + // don't load our example files in practice + if strings.HasPrefix(lowerName, "example") { + continue + } + + // load core info file + data, err = ioutil.ReadFile(filepath.Join(config.Languages.Path, name)) + if err != nil { + return nil, fmt.Errorf("Could not load language file [%s]: %s", name, err.Error()) + } + + var langInfo languages.LangData + err = yaml.Unmarshal(data, &langInfo) + if err != nil { + return nil, fmt.Errorf("Could not parse language file [%s]: %s", name, err.Error()) + } + langInfo.Translations = make(map[string]string) + + // load actual translation files + var tlList map[string]string + + // load irc strings file + ircName := strings.TrimSuffix(name, ".lang.yaml") + "-irc.lang.json" + + data, err = ioutil.ReadFile(filepath.Join(config.Languages.Path, ircName)) + if err != nil { + return nil, fmt.Errorf("Could not load language's irc file [%s]: %s", ircName, err.Error()) + } + + err = json.Unmarshal(data, &tlList) + if err != nil { + return nil, fmt.Errorf("Could not parse language's irc file [%s]: %s", ircName, err.Error()) + } + + for key, value := range tlList { + // because of how crowdin works, this is how we skip untranslated lines + if key == value || value == "" { + continue + } + langInfo.Translations[key] = value + } + + // load help strings file + helpName := strings.TrimSuffix(name, ".lang.yaml") + "-help.lang.json" + + data, err = ioutil.ReadFile(filepath.Join(config.Languages.Path, helpName)) + if err != nil { + return nil, fmt.Errorf("Could not load language's help file [%s]: %s", helpName, err.Error()) + } + + err = json.Unmarshal(data, &tlList) + if err != nil { + return nil, fmt.Errorf("Could not parse language's help file [%s]: %s", helpName, err.Error()) + } + + for key, value := range tlList { + // because of how crowdin works, this is how we skip untranslated lines + if key == value || value == "" { + continue + } + langInfo.Translations[key] = value + } + + // confirm that values are correct + if langInfo.Code == "en" { + return nil, fmt.Errorf("Cannot have language file with code 'en' (this is the default language using strings inside the server code). If you're making an English variant, name it with a more specific code") + } + + if langInfo.Code == "" || langInfo.Name == "" || langInfo.Contributors == "" { + return nil, fmt.Errorf("Code, name or contributors is empty in language file [%s]", name) + } + + if len(langInfo.Translations) == 0 { + return nil, fmt.Errorf("Language [%s / %s] contains no translations", langInfo.Code, langInfo.Name) + } + + // check for duplicate languages + _, exists := config.Languages.Data[strings.ToLower(langInfo.Code)] + if exists { + return nil, fmt.Errorf("Language code [%s] defined twice", langInfo.Code) + } + + // and insert into lang info + config.Languages.Data[strings.ToLower(langInfo.Code)] = langInfo + } + + // confirm that default language exists + if config.Languages.Default == "" { + config.Languages.Default = "en" } else { - config.History.Restrictions.queryCutoff = HistoryCutoffNone + config.Languages.Default = strings.ToLower(config.Languages.Default) + } + + _, exists := config.Languages.Data[config.Languages.Default] + if config.Languages.Default != "en" && !exists { + return nil, fmt.Errorf("Cannot find default language [%s]", config.Languages.Default) } } - config.Roleplay.addSuffix = utils.BoolDefaultTrue(config.Roleplay.AddSuffix) - - config.Datastore.MySQL.ExpireTime = time.Duration(config.History.Restrictions.ExpireTime) - config.Datastore.MySQL.TrackAccountMessages = config.History.Retention.EnableAccountIndexing - if config.Datastore.MySQL.MaxConns == 0 { - // #1622: not putting an upper limit on the number of MySQL connections is - // potentially dangerous. as a naive heuristic, assume they're running on the - // same machine: - config.Datastore.MySQL.MaxConns = runtime.NumCPU() - } - - config.Server.Cloaks.Initialize() - if config.Server.Cloaks.Enabled { - if !utils.IsHostname(config.Server.Cloaks.Netname) { - return nil, fmt.Errorf("Invalid netname for cloaked hostnames: %s", config.Server.Cloaks.Netname) - } - } - - err = config.processExtjwt() - if err != nil { - return nil, err - } - - // now that all postprocessing is complete, regenerate ISUPPORT: - err = config.generateISupport() - if err != nil { - return nil, err - } - - // #1428: Tor listeners should never see STS - config.Server.supportedCapsWithoutSTS = caps.NewSet() - config.Server.supportedCapsWithoutSTS.Union(config.Server.supportedCaps) - config.Server.supportedCapsWithoutSTS.Disable(caps.STS) - return config, nil } - -func (config *Config) getOutputPath(filename string) string { - return filepath.Join(config.Server.OutputPath, filename) -} - -func (config *Config) isRelaymsgIdentifier(nick string) bool { - if !config.Server.Relaymsg.Enabled { - return false - } - - if strings.HasPrefix(nick, "#") { - return false // #2114 - } - - for _, char := range config.Server.Relaymsg.Separators { - if strings.ContainsRune(nick, char) { - return true - } - } - return false -} - -// setISupport sets up our RPL_ISUPPORT reply. -func (config *Config) generateISupport() (err error) { - maxTargetsString := strconv.Itoa(maxTargets) - - // add RPL_ISUPPORT tokens - isupport := &config.Server.isupport - isupport.Initialize() - isupport.Add("AWAYLEN", strconv.Itoa(config.Limits.AwayLen)) - isupport.Add("BOT", "B") - var casemappingToken string - switch config.Server.Casemapping { - default: - casemappingToken = "ascii" // this is published for ascii, precis, or permissive - case CasemappingRFC1459: - casemappingToken = "rfc1459" - case CasemappingRFC1459Strict: - casemappingToken = "rfc1459-strict" - } - isupport.Add("CASEMAPPING", casemappingToken) - isupport.Add("CHANLIMIT", fmt.Sprintf("%s:%d", chanTypes, config.Channels.MaxChannelsPerClient)) - isupport.Add("CHANMODES", chanmodesToken) - if config.History.Enabled && config.History.ChathistoryMax > 0 { - isupport.Add("CHATHISTORY", strconv.Itoa(config.History.ChathistoryMax)) - // Kiwi expects this legacy token name: - isupport.Add("draft/CHATHISTORY", strconv.Itoa(config.History.ChathistoryMax)) - } - isupport.Add("CHANNELLEN", strconv.Itoa(config.Limits.ChannelLen)) - isupport.Add("CHANTYPES", chanTypes) - isupport.Add("ELIST", "U") - isupport.Add("EXCEPTS", "") - if config.Extjwt.Default.Enabled() || len(config.Extjwt.Services) != 0 { - isupport.Add("EXTJWT", "1") - } - isupport.Add("EXTBAN", ",m") - isupport.Add("FORWARD", "f") - isupport.Add("INVEX", "") - isupport.Add("KICKLEN", strconv.Itoa(config.Limits.KickLen)) - isupport.Add("MAXLIST", fmt.Sprintf("beI:%s", strconv.Itoa(config.Limits.ChanListModes))) - isupport.Add("MAXTARGETS", maxTargetsString) - isupport.Add("MSGREFTYPES", "msgid,timestamp") - isupport.Add("MODES", "") - isupport.Add("MONITOR", strconv.Itoa(config.Limits.MonitorEntries)) - isupport.Add("NETWORK", config.Network.Name) - isupport.Add("NICKLEN", strconv.Itoa(config.Limits.NickLen)) - isupport.Add("PREFIX", "(qaohv)~&@%+") - if config.Roleplay.Enabled { - isupport.Add("RPCHAN", "E") - isupport.Add("RPUSER", "E") - } - isupport.Add("SAFELIST", "") - isupport.Add("STATUSMSG", "~&@%+") - isupport.Add("TARGMAX", fmt.Sprintf("NAMES:1,LIST:1,KICK:,WHOIS:1,USERHOST:10,PRIVMSG:%s,TAGMSG:%s,NOTICE:%s,MONITOR:%d", maxTargetsString, maxTargetsString, maxTargetsString, config.Limits.MonitorEntries)) - isupport.Add("TOPICLEN", strconv.Itoa(config.Limits.TopicLen)) - if config.Server.Casemapping == CasemappingPRECIS { - isupport.Add("UTF8MAPPING", precisUTF8MappingToken) - } - if config.Server.EnforceUtf8 { - isupport.Add("UTF8ONLY", "") - } - isupport.Add("WHOX", "") - - err = isupport.RegenerateCachedReply() - return -} - -// Diff returns changes in supported caps across a rehash. -func (config *Config) Diff(oldConfig *Config) (addedCaps, removedCaps *caps.Set) { - addedCaps = caps.NewSet() - removedCaps = caps.NewSet() - if oldConfig == nil { - return - } - - if oldConfig.Server.capValues[caps.Languages] != config.Server.capValues[caps.Languages] { - // XXX updated caps get a DEL line and then a NEW line with the new value - addedCaps.Add(caps.Languages) - removedCaps.Add(caps.Languages) - } - - if !oldConfig.Accounts.AuthenticationEnabled && config.Accounts.AuthenticationEnabled { - addedCaps.Add(caps.SASL) - } else if oldConfig.Accounts.AuthenticationEnabled && !config.Accounts.AuthenticationEnabled { - removedCaps.Add(caps.SASL) - } - - if oldConfig.Limits.Multiline.MaxBytes != 0 && config.Limits.Multiline.MaxBytes == 0 { - removedCaps.Add(caps.Multiline) - } else if oldConfig.Limits.Multiline.MaxBytes == 0 && config.Limits.Multiline.MaxBytes != 0 { - addedCaps.Add(caps.Multiline) - } else if oldConfig.Limits.Multiline != config.Limits.Multiline { - removedCaps.Add(caps.Multiline) - addedCaps.Add(caps.Multiline) - } - - if oldConfig.Server.STS.Enabled != config.Server.STS.Enabled || oldConfig.Server.capValues[caps.STS] != config.Server.capValues[caps.STS] { - // XXX: STS is always removed by CAP NEW sts=duration=0, not CAP DEL - // so the appropriate notify is always a CAP NEW; put it in addedCaps for any change - addedCaps.Add(caps.STS) - } - - return -} - -// determine whether we need to resize / create / destroy -// the in-memory history buffers: -func (config *Config) historyChangedFrom(oldConfig *Config) bool { - return config.History.Enabled != oldConfig.History.Enabled || - config.History.ChannelLength != oldConfig.History.ChannelLength || - config.History.ClientLength != oldConfig.History.ClientLength || - config.History.AutoresizeWindow != oldConfig.History.AutoresizeWindow || - config.History.Persistent != oldConfig.History.Persistent -} - -func compileGuestRegexp(guestFormat string, casemapping Casemapping) (standard, folded *regexp.Regexp, err error) { - if strings.Count(guestFormat, "?") != 0 || strings.Count(guestFormat, "*") != 1 { - err = errors.New("guest format must contain 1 '*' and no '?'s") - return - } - - standard, err = utils.CompileGlob(guestFormat, true) - if err != nil { - return - } - - starIndex := strings.IndexByte(guestFormat, '*') - initial := guestFormat[:starIndex] - final := guestFormat[starIndex+1:] - initialFolded, err := casefoldWithSetting(initial, casemapping) - if err != nil { - return - } - finalFolded, err := casefoldWithSetting(final, casemapping) - if err != nil { - return - } - folded, err = utils.CompileGlob(fmt.Sprintf("%s*%s", initialFolded, finalFolded), false) - return -} - -func (config *Config) loadMOTD() error { - if config.Server.MOTD != "" { - file, err := os.Open(config.Server.MOTD) - if err != nil { - return err - } - defer file.Close() - contents, err := io.ReadAll(file) - if err != nil { - return err - } - - lines := bytes.Split(contents, []byte{'\n'}) - for i, line := range lines { - lineToSend := string(bytes.TrimRight(line, "\r\n")) - if len(lineToSend) == 0 && i == len(lines)-1 { - // if the last line of the MOTD was properly terminated with \n, - // there's no need to send a blank line to clients - continue - } - if config.Server.MOTDFormatting { - lineToSend = ircfmt.Unescape(lineToSend) - } - // "- " is the required prefix for MOTD - lineToSend = fmt.Sprintf("- %s", lineToSend) - config.Server.motdLines = append(config.Server.motdLines, lineToSend) - } - } - return nil -} diff --git a/irc/config_test.go b/irc/config_test.go deleted file mode 100644 index 05466daa..00000000 --- a/irc/config_test.go +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright (c) 2020 Shivaram Lingamneni -// released under the MIT license - -package irc - -import ( - "reflect" - "testing" -) - -func TestEnvironmentOverrides(t *testing.T) { - var config Config - config.Server.Compatibility.SendUnprefixedSasl = true - config.History.Enabled = true - defaultUserModes := "+i" - config.Accounts.DefaultUserModes = &defaultUserModes - config.Server.WebSockets.AllowedOrigins = []string{"https://www.ircv3.net"} - config.Server.MOTD = "long.motd.txt" // overwrite this - env := []string{ - `USER=shivaram`, // unrelated var - `ORAGONO_USER=oragono`, // this should be ignored as well - `ERGO__NETWORK__NAME=example.com`, - `ORAGONO__SERVER__COMPATIBILITY__FORCE_TRAILING=false`, - `ORAGONO__SERVER__COERCE_IDENT="~user"`, - `ERGO__SERVER__MOTD=short.motd.txt`, - `ORAGONO__ACCOUNTS__NICK_RESERVATION__ENABLED=true`, - `ERGO__ACCOUNTS__DEFAULT_USER_MODES="+iR"`, - `ORAGONO__SERVER__IP_CLOAKING={"enabled": true, "enabled-for-always-on": true, "netname": "irc", "cidr-len-ipv4": 32, "cidr-len-ipv6": 64, "num-bits": 64}`, - } - for _, envPair := range env { - _, _, err := mungeFromEnvironment(&config, envPair) - if err != nil { - t.Errorf("couldn't apply override `%s`: %v", envPair, err) - } - } - - if config.Network.Name != "example.com" { - t.Errorf("unexpected value of network.name: %s", config.Network.Name) - } - if config.Server.CoerceIdent != "~user" { - t.Errorf("unexpected value of coerce-ident: %s", config.Server.CoerceIdent) - } - if config.Server.MOTD != "short.motd.txt" { - t.Errorf("unexpected value of motd: %s", config.Server.MOTD) - } - if !config.Accounts.NickReservation.Enabled { - t.Errorf("did not set bool as expected") - } - if !config.Server.Compatibility.SendUnprefixedSasl { - t.Errorf("overwrote unrelated field") - } - if !config.History.Enabled { - t.Errorf("overwrote unrelated field") - } - if !reflect.DeepEqual(config.Server.WebSockets.AllowedOrigins, []string{"https://www.ircv3.net"}) { - t.Errorf("overwrote unrelated field: %#v", config.Server.WebSockets.AllowedOrigins) - } - - cloakConf := config.Server.Cloaks - if !(cloakConf.Enabled == true && cloakConf.EnabledForAlwaysOn == true && cloakConf.Netname == "irc" && cloakConf.CidrLenIPv6 == 64) { - t.Errorf("bad value of Cloaks: %#v", config.Server.Cloaks) - } - - if *config.Server.Compatibility.ForceTrailing != false { - t.Errorf("couldn't set unset ptr field to false") - } - - if *config.Accounts.DefaultUserModes != "+iR" { - t.Errorf("couldn't override pre-set ptr field") - } -} - -func TestEnvironmentOverrideErrors(t *testing.T) { - var config Config - config.Server.Compatibility.SendUnprefixedSasl = true - config.History.Enabled = true - - invalidEnvs := []string{ - `ORAGONO__=asdf`, - `ORAGONO__SERVER__=asdf`, - `ORAGONO__SERVER____=asdf`, - `ORAGONO__NONEXISTENT_KEY=1`, - `ORAGONO__SERVER__NONEXISTENT_KEY=1`, - // invalid yaml: - `ORAGONO__SERVER__IP_CLOAKING__NETNAME="`, - // invalid type: - `ORAGONO__SERVER__IP_CLOAKING__NUM_BITS=asdf`, - `ORAGONO__SERVER__STS=[]`, - // index into non-struct: - `ORAGONO__NETWORK__NAME__QUX=1`, - // private field: - `ORAGONO__SERVER__PASSWORDBYTES="asdf"`, - } - - for _, env := range invalidEnvs { - success, _, err := mungeFromEnvironment(&config, env) - if err == nil || success { - t.Errorf("accepted invalid env override `%s`", env) - } - } -} diff --git a/irc/connection_limits/limiter.go b/irc/connection_limits/limiter.go index c5c2f902..0a4e3813 100644 --- a/irc/connection_limits/limiter.go +++ b/irc/connection_limits/limiter.go @@ -4,277 +4,155 @@ package connection_limits import ( - "crypto/md5" "errors" "fmt" + "net" "sync" - "time" - - "github.com/ergochat/ergo/irc/flatip" - "github.com/ergochat/ergo/irc/utils" ) -var ( - ErrLimitExceeded = errors.New("too many concurrent connections") - ErrThrottleExceeded = errors.New("too many recent connection attempts") -) - -type CustomLimitConfig struct { - Nets []string - MaxConcurrent int `yaml:"max-concurrent-connections"` - MaxPerWindow int `yaml:"max-connections-per-window"` -} - -// tuples the key-value pair of a CIDR and its custom limit/throttle values -type customLimit struct { - name [16]byte - customID string // operator-configured identifier for a custom net - maxConcurrent int - maxPerWindow int - nets []flatip.IPNet -} - -type limiterKey struct { - maskedIP flatip.IP - prefixLen uint8 // 0 for the fake nets we generate for custom limits -} - // LimiterConfig controls the automated connection limits. -// rawLimiterConfig contains all the YAML-visible fields; -// LimiterConfig contains additional denormalized private fields -type rawLimiterConfig struct { - Count bool - MaxConcurrent int `yaml:"max-concurrent-connections"` - - Throttle bool - Window time.Duration - MaxPerWindow int `yaml:"max-connections-per-window"` - - CidrLenIPv4 int `yaml:"cidr-len-ipv4"` - CidrLenIPv6 int `yaml:"cidr-len-ipv6"` - - Exempted []string - - CustomLimits map[string]CustomLimitConfig `yaml:"custom-limits"` -} - type LimiterConfig struct { - rawLimiterConfig - - exemptedNets []flatip.IPNet - customLimits []customLimit + Enabled bool + CidrLenIPv4 int `yaml:"cidr-len-ipv4"` + CidrLenIPv6 int `yaml:"cidr-len-ipv6"` + ConnsPerSubnet int `yaml:"connections-per-subnet"` + IPsPerSubnet int `yaml:"ips-per-subnet"` // legacy name for ConnsPerSubnet + Exempted []string } -func (config *LimiterConfig) UnmarshalYAML(unmarshal func(interface{}) error) (err error) { - if err = unmarshal(&config.rawLimiterConfig); err != nil { - return err - } - return config.postprocess() -} - -func (config *LimiterConfig) postprocess() (err error) { - exemptedNets, err := utils.ParseNetList(config.Exempted) - if err != nil { - return fmt.Errorf("Could not parse limiter exemption list: %v", err.Error()) - } - config.exemptedNets = make([]flatip.IPNet, len(exemptedNets)) - for i, exempted := range exemptedNets { - config.exemptedNets[i] = flatip.FromNetIPNet(exempted) - } - - for identifier, customLimitConf := range config.CustomLimits { - nets := make([]flatip.IPNet, len(customLimitConf.Nets)) - for i, netStr := range customLimitConf.Nets { - normalizedNet, err := flatip.ParseToNormalizedNet(netStr) - if err != nil { - return fmt.Errorf("Bad net %s in custom-limits block %s: %w", netStr, identifier, err) - } - nets[i] = normalizedNet - } - if len(customLimitConf.Nets) == 0 { - // see #1421: this is the legacy config format where the - // dictionary key of the block is a CIDR string - normalizedNet, err := flatip.ParseToNormalizedNet(identifier) - if err != nil { - return fmt.Errorf("Custom limit block %s has no defined nets", identifier) - } - nets = []flatip.IPNet{normalizedNet} - } - config.customLimits = append(config.customLimits, customLimit{ - maxConcurrent: customLimitConf.MaxConcurrent, - maxPerWindow: customLimitConf.MaxPerWindow, - name: md5.Sum([]byte(identifier)), - customID: identifier, - nets: nets, - }) - } - - return nil -} +var ( + errTooManyClients = errors.New("Too many clients in subnet") +) // Limiter manages the automated client connection limits. type Limiter struct { sync.Mutex - config *LimiterConfig + enabled bool + ipv4Mask net.IPMask + ipv6Mask net.IPMask + // subnetLimit is the maximum number of clients per subnet + subnetLimit int + // population holds IP -> count of clients connected from there + population map[string]int - // IP/CIDR -> count of clients connected from there: - limiter map[limiterKey]int - // IP/CIDR -> throttle state: - throttler map[limiterKey]ThrottleDetails + // exemptedIPs holds IPs that are exempt from limits + exemptedIPs map[string]bool + // exemptedNets holds networks that are exempt from limits + exemptedNets []net.IPNet } -// addrToKey canonicalizes `addr` to a string key, and returns -// the relevant connection limit and throttle max-per-window values -func (cl *Limiter) addrToKey(addr flatip.IP) (key limiterKey, customID string, limit int, throttle int) { - for _, custom := range cl.config.customLimits { - for _, net := range custom.nets { - if net.Contains(addr) { - return limiterKey{maskedIP: custom.name, prefixLen: 0}, custom.customID, custom.maxConcurrent, custom.maxPerWindow - } - } - } - - var prefixLen int - if addr.IsIPv4() { - prefixLen = cl.config.CidrLenIPv4 - addr = addr.Mask(prefixLen, 32) - prefixLen += 96 +// maskAddr masks the given IPv4/6 address with our cidr limit masks. +func (cl *Limiter) maskAddr(addr net.IP) net.IP { + if addr.To4() == nil { + // IPv6 addr + addr = addr.Mask(cl.ipv6Mask) } else { - prefixLen = cl.config.CidrLenIPv6 - addr = addr.Mask(prefixLen, 128) + // IPv4 addr + addr = addr.Mask(cl.ipv4Mask) } - return limiterKey{maskedIP: addr, prefixLen: uint8(prefixLen)}, "", cl.config.MaxConcurrent, cl.config.MaxPerWindow + return addr } // AddClient adds a client to our population if possible. If we can't, throws an error instead. -func (cl *Limiter) AddClient(addr flatip.IP) error { +// 'force' is used to add already-existing clients (i.e. ones that are already on the network). +func (cl *Limiter) AddClient(addr net.IP, force bool) error { cl.Lock() defer cl.Unlock() - // we don't track populations for exempted addresses or nets - this is by design - if flatip.IPInNets(addr, cl.config.exemptedNets) { + if !cl.enabled { return nil } - addrString, _, maxConcurrent, maxPerWindow := cl.addrToKey(addr) - - // check limiter - var count int - if cl.config.Count { - count = cl.limiter[addrString] + 1 - if count > maxConcurrent { - return ErrLimitExceeded + // check exempted lists + // we don't track populations for exempted addresses or nets - this is by design + if cl.exemptedIPs[addr.String()] { + return nil + } + for _, ex := range cl.exemptedNets { + if ex.Contains(addr) { + return nil } } - if cl.config.Throttle { - details := cl.throttler[addrString] // retrieve mutable throttle state from the map - // add in constant state to process the limiting operation - g := GenericThrottle{ - ThrottleDetails: details, - Duration: cl.config.Window, - Limit: maxPerWindow, - } - throttled, _ := g.Touch() // actually check the limit - cl.throttler[addrString] = g.ThrottleDetails // store modified mutable state - if throttled { - // back out the limiter add - return ErrThrottleExceeded - } + // check population + cl.maskAddr(addr) + addrString := addr.String() + + if cl.population[addrString]+1 > cl.subnetLimit && !force { + return errTooManyClients } - // success, record in limiter - if cl.config.Count { - cl.limiter[addrString] = count - } + cl.population[addrString] = cl.population[addrString] + 1 return nil } // RemoveClient removes the given address from our population -func (cl *Limiter) RemoveClient(addr flatip.IP) { +func (cl *Limiter) RemoveClient(addr net.IP) { cl.Lock() defer cl.Unlock() - if !cl.config.Count || flatip.IPInNets(addr, cl.config.exemptedNets) { + if !cl.enabled { return } - addrString, _, _, _ := cl.addrToKey(addr) - count := cl.limiter[addrString] - count -= 1 - if count < 0 { - count = 0 + addrString := addr.String() + cl.population[addrString] = cl.population[addrString] - 1 + + // safety limiter + if cl.population[addrString] < 0 { + cl.population[addrString] = 0 } - cl.limiter[addrString] = count } -type LimiterStatus struct { - Exempt bool +// NewLimiter returns a new connection limit handler. +// The handler is functional, but disabled; it can be enabled via `ApplyConfig`. +func NewLimiter() *Limiter { + var cl Limiter - Count int - MaxCount int + // initialize empty population; all other state is configurable + cl.population = make(map[string]int) - Throttle int - MaxPerWindow int - ThrottleDuration time.Duration -} - -func (cl *Limiter) Status(addr flatip.IP) (netName string, status LimiterStatus) { - cl.Lock() - defer cl.Unlock() - - if flatip.IPInNets(addr, cl.config.exemptedNets) { - status.Exempt = true - return - } - - status.ThrottleDuration = cl.config.Window - - limiterKey, customID, maxConcurrent, maxPerWindow := cl.addrToKey(addr) - status.MaxCount = maxConcurrent - status.MaxPerWindow = maxPerWindow - - status.Count = cl.limiter[limiterKey] - status.Throttle = cl.throttler[limiterKey].Count - - netName = customID - if netName == "" { - netName = flatip.IPNet{ - IP: limiterKey.maskedIP, - PrefixLen: limiterKey.prefixLen, - }.String() - } - - return -} - -// ResetThrottle resets the throttle count for an IP -func (cl *Limiter) ResetThrottle(addr flatip.IP) { - cl.Lock() - defer cl.Unlock() - - if !cl.config.Throttle || flatip.IPInNets(addr, cl.config.exemptedNets) { - return - } - - addrString, _, _, _ := cl.addrToKey(addr) - delete(cl.throttler, addrString) + return &cl } // ApplyConfig atomically applies a config update to a connection limit handler -func (cl *Limiter) ApplyConfig(config *LimiterConfig) { +func (cl *Limiter) ApplyConfig(config LimiterConfig) error { + // assemble exempted nets + exemptedIPs := make(map[string]bool) + var exemptedNets []net.IPNet + for _, cidr := range config.Exempted { + ipaddr := net.ParseIP(cidr) + _, netaddr, err := net.ParseCIDR(cidr) + + if ipaddr == nil && err != nil { + return fmt.Errorf("Could not parse exempted IP/network [%s]", cidr) + } + + if ipaddr != nil { + exemptedIPs[ipaddr.String()] = true + } else { + exemptedNets = append(exemptedNets, *netaddr) + } + } + cl.Lock() defer cl.Unlock() - if cl.limiter == nil { - cl.limiter = make(map[limiterKey]int) - } - if cl.throttler == nil { - cl.throttler = make(map[limiterKey]ThrottleDetails) + cl.enabled = config.Enabled + cl.ipv4Mask = net.CIDRMask(config.CidrLenIPv4, 32) + cl.ipv6Mask = net.CIDRMask(config.CidrLenIPv6, 128) + // subnetLimit is explicitly NOT capped at a minimum of one. + // this is so that CL config can be used to allow ONLY clients from exempted IPs/nets + cl.subnetLimit = config.ConnsPerSubnet + // but: check if the current key was left unset, but the legacy was set: + if cl.subnetLimit == 0 && config.IPsPerSubnet != 0 { + cl.subnetLimit = config.IPsPerSubnet } + cl.exemptedIPs = exemptedIPs + cl.exemptedNets = exemptedNets - cl.config = config + return nil } diff --git a/irc/connection_limits/limiter_test.go b/irc/connection_limits/limiter_test.go deleted file mode 100644 index 0b7638a3..00000000 --- a/irc/connection_limits/limiter_test.go +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (c) 2018 Shivaram Lingamneni -// released under the MIT license - -package connection_limits - -import ( - "crypto/md5" - "testing" - "time" - - "github.com/ergochat/ergo/irc/flatip" -) - -func easyParseIP(ipstr string) (result flatip.IP) { - result, err := flatip.ParseIP(ipstr) - if err != nil { - panic(err) - } - return -} - -var baseConfig = LimiterConfig{ - rawLimiterConfig: rawLimiterConfig{ - Count: true, - MaxConcurrent: 4, - - Throttle: true, - Window: time.Second * 600, - MaxPerWindow: 8, - - CidrLenIPv4: 32, - CidrLenIPv6: 64, - - Exempted: []string{"localhost"}, - - CustomLimits: map[string]CustomLimitConfig{ - "google": { - Nets: []string{"8.8.0.0/16"}, - MaxConcurrent: 128, - MaxPerWindow: 256, - }, - }, - }, -} - -func TestKeying(t *testing.T) { - config := baseConfig - config.postprocess() - var limiter Limiter - limiter.ApplyConfig(&config) - - // an ipv4 /32 looks like a /128 to us after applying the 4-in-6 mapping - key, _, maxConc, maxWin := limiter.addrToKey(easyParseIP("1.1.1.1")) - assertEqual(key.prefixLen, uint8(128), t) - assertEqual(key.maskedIP[12:], []byte{1, 1, 1, 1}, t) - assertEqual(maxConc, 4, t) - assertEqual(maxWin, 8, t) - - testIPv6 := easyParseIP("2607:5301:201:3100::7426") - key, _, maxConc, maxWin = limiter.addrToKey(testIPv6) - assertEqual(key.prefixLen, uint8(64), t) - assertEqual(flatip.IP(key.maskedIP), easyParseIP("2607:5301:201:3100::"), t) - assertEqual(maxConc, 4, t) - assertEqual(maxWin, 8, t) - - key, _, maxConc, maxWin = limiter.addrToKey(easyParseIP("8.8.4.4")) - assertEqual(key.prefixLen, uint8(0), t) - assertEqual([16]byte(key.maskedIP), md5.Sum([]byte("google")), t) - assertEqual(maxConc, 128, t) - assertEqual(maxWin, 256, t) -} - -func TestLimits(t *testing.T) { - regularIP := easyParseIP("2607:5301:201:3100::7426") - config := baseConfig - config.postprocess() - var limiter Limiter - limiter.ApplyConfig(&config) - - for i := 0; i < 4; i++ { - err := limiter.AddClient(regularIP) - if err != nil { - t.Errorf("ip should not be blocked, but %v", err) - } - } - err := limiter.AddClient(regularIP) - if err != ErrLimitExceeded { - t.Errorf("ip should be blocked, but %v", err) - } - limiter.RemoveClient(regularIP) - err = limiter.AddClient(regularIP) - if err != nil { - t.Errorf("ip should not be blocked, but %v", err) - } -} diff --git a/irc/connection_limits/throttler.go b/irc/connection_limits/throttler.go index 53fe3749..aa29d241 100644 --- a/irc/connection_limits/throttler.go +++ b/irc/connection_limits/throttler.go @@ -4,48 +4,178 @@ package connection_limits import ( + "fmt" + "net" + "sync" "time" ) +// ThrottlerConfig controls the automated connection throttling. +type ThrottlerConfig struct { + Enabled bool + CidrLenIPv4 int `yaml:"cidr-len-ipv4"` + CidrLenIPv6 int `yaml:"cidr-len-ipv6"` + ConnectionsPerCidr int `yaml:"max-connections"` + DurationString string `yaml:"duration"` + Duration time.Duration `yaml:"duration-time"` + BanDurationString string `yaml:"ban-duration"` + BanDuration time.Duration + BanMessage string `yaml:"ban-message"` + Exempted []string +} + // ThrottleDetails holds the connection-throttling details for a subnet/IP. type ThrottleDetails struct { - Start time.Time - Count int + Start time.Time + ClientCount int } -// GenericThrottle allows enforcing limits of the form -// "at most X events per time window of duration Y" -type GenericThrottle struct { - ThrottleDetails // variable state: what events have been seen - // these are constant after creation: - Duration time.Duration // window length to consider - Limit int // number of events allowed per window +// Throttler manages automated client connection throttling. +type Throttler struct { + sync.RWMutex + + enabled bool + ipv4Mask net.IPMask + ipv6Mask net.IPMask + subnetLimit int + duration time.Duration + population map[string]ThrottleDetails + + // used by the server to ban clients that go over this limit + banDuration time.Duration + banMessage string + + // exemptedIPs holds IPs that are exempt from limits + exemptedIPs map[string]bool + // exemptedNets holds networks that are exempt from limits + exemptedNets []net.IPNet } -// Touch checks whether an additional event is allowed: -// it either denies it (by returning false) or allows it (by returning true) -// and records it -func (g *GenericThrottle) Touch() (throttled bool, remainingTime time.Duration) { - return g.touch(time.Now().UTC()) -} - -func (g *GenericThrottle) touch(now time.Time) (throttled bool, remainingTime time.Duration) { - if g.Limit == 0 { - return // limit of 0 disables throttling - } - - elapsed := now.Sub(g.Start) - if elapsed > g.Duration { - // reset window, record the operation - g.Start = now - g.Count = 1 - return false, 0 - } else if g.Count >= g.Limit { - // we are throttled - return true, g.Start.Add(g.Duration).Sub(now) +// maskAddr masks the given IPv4/6 address with our cidr limit masks. +func (ct *Throttler) maskAddr(addr net.IP) net.IP { + if addr.To4() == nil { + // IPv6 addr + addr = addr.Mask(ct.ipv6Mask) } else { - // we are not throttled, record the operation - g.Count += 1 - return false, 0 + // IPv4 addr + addr = addr.Mask(ct.ipv4Mask) } + + return addr +} + +// ResetFor removes any existing count for the given address. +func (ct *Throttler) ResetFor(addr net.IP) { + ct.Lock() + defer ct.Unlock() + + if !ct.enabled { + return + } + + // remove + ct.maskAddr(addr) + addrString := addr.String() + delete(ct.population, addrString) +} + +// AddClient introduces a new client connection if possible. If we can't, throws an error instead. +func (ct *Throttler) AddClient(addr net.IP) error { + ct.Lock() + defer ct.Unlock() + + if !ct.enabled { + return nil + } + + // check exempted lists + if ct.exemptedIPs[addr.String()] { + return nil + } + for _, ex := range ct.exemptedNets { + if ex.Contains(addr) { + return nil + } + } + + // check throttle + ct.maskAddr(addr) + addrString := addr.String() + + details, exists := ct.population[addrString] + if !exists || details.Start.Add(ct.duration).Before(time.Now()) { + details = ThrottleDetails{ + Start: time.Now(), + } + } + + if details.ClientCount+1 > ct.subnetLimit { + return errTooManyClients + } + + details.ClientCount++ + ct.population[addrString] = details + + return nil +} + +func (ct *Throttler) BanDuration() time.Duration { + ct.RLock() + defer ct.RUnlock() + + return ct.banDuration +} + +func (ct *Throttler) BanMessage() string { + ct.RLock() + defer ct.RUnlock() + + return ct.banMessage +} + +// NewThrottler returns a new client connection throttler. +// The throttler is functional, but disabled; it can be enabled via `ApplyConfig`. +func NewThrottler() *Throttler { + var ct Throttler + + // initialize empty population; all other state is configurable + ct.population = make(map[string]ThrottleDetails) + + return &ct +} + +// ApplyConfig atomically applies a config update to a throttler +func (ct *Throttler) ApplyConfig(config ThrottlerConfig) error { + // assemble exempted nets + exemptedIPs := make(map[string]bool) + var exemptedNets []net.IPNet + for _, cidr := range config.Exempted { + ipaddr := net.ParseIP(cidr) + _, netaddr, err := net.ParseCIDR(cidr) + + if ipaddr == nil && err != nil { + return fmt.Errorf("Could not parse exempted IP/network [%s]", cidr) + } + + if ipaddr != nil { + exemptedIPs[ipaddr.String()] = true + } else { + exemptedNets = append(exemptedNets, *netaddr) + } + } + + ct.Lock() + defer ct.Unlock() + + ct.enabled = config.Enabled + ct.ipv4Mask = net.CIDRMask(config.CidrLenIPv4, 32) + ct.ipv6Mask = net.CIDRMask(config.CidrLenIPv6, 128) + ct.subnetLimit = config.ConnectionsPerCidr + ct.duration = config.Duration + ct.banDuration = config.BanDuration + ct.banMessage = config.BanMessage + ct.exemptedIPs = exemptedIPs + ct.exemptedNets = exemptedNets + + return nil } diff --git a/irc/connection_limits/throttler_test.go b/irc/connection_limits/throttler_test.go deleted file mode 100644 index d44ec860..00000000 --- a/irc/connection_limits/throttler_test.go +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright (c) 2018 Shivaram Lingamneni -// released under the MIT license - -package connection_limits - -import ( - "reflect" - "testing" - "time" -) - -func assertEqual(supplied, expected interface{}, t *testing.T) { - if !reflect.DeepEqual(supplied, expected) { - t.Errorf("expected %v but got %v", expected, supplied) - } -} - -func TestGenericThrottle(t *testing.T) { - minute, _ := time.ParseDuration("1m") - second, _ := time.ParseDuration("1s") - zero, _ := time.ParseDuration("0s") - - throttler := GenericThrottle{ - Duration: minute, - Limit: 2, - } - - now := time.Now() - throttled, remaining := throttler.touch(now) - assertEqual(throttled, false, t) - assertEqual(remaining, zero, t) - - now = now.Add(second) - throttled, remaining = throttler.touch(now) - assertEqual(throttled, false, t) - assertEqual(remaining, zero, t) - - now = now.Add(second) - throttled, remaining = throttler.touch(now) - assertEqual(throttled, true, t) - assertEqual(remaining, 58*second, t) - - now = now.Add(minute) - throttled, remaining = throttler.touch(now) - assertEqual(throttled, false, t) - assertEqual(remaining, zero, t) -} - -func TestGenericThrottleDisabled(t *testing.T) { - minute, _ := time.ParseDuration("1m") - throttler := GenericThrottle{ - Duration: minute, - Limit: 0, - } - - for i := 0; i < 1024; i += 1 { - throttled, _ := throttler.Touch() - if throttled { - t.Error("disabled throttler should not throttle") - } - } -} - -func makeTestThrottler(v4len, v6len int) *Limiter { - minute, _ := time.ParseDuration("1m") - maxConnections := 3 - config := LimiterConfig{ - rawLimiterConfig: rawLimiterConfig{ - Count: false, - Throttle: true, - CidrLenIPv4: v4len, - CidrLenIPv6: v6len, - MaxPerWindow: maxConnections, - Window: minute, - }, - } - config.postprocess() - var limiter Limiter - limiter.ApplyConfig(&config) - return &limiter -} - -func TestConnectionThrottle(t *testing.T) { - throttler := makeTestThrottler(32, 64) - addr := easyParseIP("8.8.8.8") - - for i := 0; i < 3; i += 1 { - err := throttler.AddClient(addr) - assertEqual(err, nil, t) - } - err := throttler.AddClient(addr) - assertEqual(err, ErrThrottleExceeded, t) -} - -func TestConnectionThrottleIPv6(t *testing.T) { - throttler := makeTestThrottler(32, 64) - - var err error - err = throttler.AddClient(easyParseIP("2001:0db8::1")) - assertEqual(err, nil, t) - err = throttler.AddClient(easyParseIP("2001:0db8::2")) - assertEqual(err, nil, t) - err = throttler.AddClient(easyParseIP("2001:0db8::3")) - assertEqual(err, nil, t) - - err = throttler.AddClient(easyParseIP("2001:0db8::4")) - assertEqual(err, ErrThrottleExceeded, t) -} - -func TestConnectionThrottleIPv4(t *testing.T) { - throttler := makeTestThrottler(24, 64) - - var err error - err = throttler.AddClient(easyParseIP("192.168.1.101")) - assertEqual(err, nil, t) - err = throttler.AddClient(easyParseIP("192.168.1.102")) - assertEqual(err, nil, t) - err = throttler.AddClient(easyParseIP("192.168.1.103")) - assertEqual(err, nil, t) - - err = throttler.AddClient(easyParseIP("192.168.1.104")) - assertEqual(err, ErrThrottleExceeded, t) -} diff --git a/irc/connection_limits/tor.go b/irc/connection_limits/tor.go deleted file mode 100644 index 643f79cf..00000000 --- a/irc/connection_limits/tor.go +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) 2019 Shivaram Lingamneni -// released under the MIT license - -package connection_limits - -import ( - "sync" - "time" -) - -// TorLimiter is a combined limiter and throttler for use on connections -// proxied from a Tor hidden service (so we don't have meaningful IPs, -// a notion of CIDR width, etc.) -type TorLimiter struct { - sync.Mutex - - numConnections int - maxConnections int - throttle GenericThrottle -} - -func (tl *TorLimiter) Configure(maxConnections int, duration time.Duration, maxConnectionsPerDuration int) { - tl.Lock() - defer tl.Unlock() - tl.maxConnections = maxConnections - tl.throttle.Duration = duration - tl.throttle.Limit = maxConnectionsPerDuration -} - -func (tl *TorLimiter) AddClient() error { - tl.Lock() - defer tl.Unlock() - - if tl.maxConnections != 0 && tl.maxConnections <= tl.numConnections { - return ErrLimitExceeded - } - throttled, _ := tl.throttle.Touch() - if throttled { - return ErrThrottleExceeded - } - tl.numConnections += 1 - return nil -} - -func (tl *TorLimiter) RemoveClient() { - tl.Lock() - tl.numConnections -= 1 - tl.Unlock() -} diff --git a/irc/constants.go b/irc/constants.go index cf6b257c..ba2b5661 100644 --- a/irc/constants.go +++ b/irc/constants.go @@ -5,10 +5,20 @@ package irc +import "fmt" + const ( + // SemVer is the semantic version of Oragono. + SemVer = "0.10.4-unreleased" +) + +var ( + // Ver is the full version of Oragono, used in responses to clients. + Ver = fmt.Sprintf("oragono-%s", SemVer) + // maxLastArgLength is used to simply cap off the final argument when creating general messages where we need to select a limit. // for instance, in MONITOR lists, RPL_ISUPPORT lists, etc. - maxLastArgLength = 1024 + maxLastArgLength = 400 // maxTargets is the maximum number of targets for PRIVMSG and NOTICE. maxTargets = 4 ) diff --git a/irc/custime/parseduration.go b/irc/custime/parseduration.go index fb859df2..7f18aad5 100644 --- a/irc/custime/parseduration.go +++ b/irc/custime/parseduration.go @@ -75,9 +75,8 @@ var unitMap = map[string]int64{ "m": int64(time.Minute), "h": int64(time.Hour), "d": int64(time.Hour * 24), - "w": int64(time.Hour * 24 * 7), "mo": int64(time.Hour * 24 * 30), - "y": int64(time.Hour * 24 * 365), + "y": int64(time.Hour * 24 * 265), } // ParseDuration parses a duration string. @@ -182,18 +181,3 @@ func ParseDuration(s string) (time.Duration, error) { } return time.Duration(d), nil } - -type Duration time.Duration - -func (d *Duration) UnmarshalYAML(unmarshal func(interface{}) error) error { - var orig string - var err error - if err = unmarshal(&orig); err != nil { - return err - } - result, err := ParseDuration(orig) - if err == nil { - *d = Duration(result) - } - return err -} diff --git a/irc/database.go b/irc/database.go index a815e483..a347c92e 100644 --- a/irc/database.go +++ b/irc/database.go @@ -6,1336 +6,134 @@ package irc import ( "encoding/base64" - "encoding/json" "fmt" "log" "os" - "strconv" "strings" - "time" - "github.com/ergochat/ergo/irc/bunt" - "github.com/ergochat/ergo/irc/datastore" - "github.com/ergochat/ergo/irc/modes" - "github.com/ergochat/ergo/irc/utils" + "github.com/oragono/oragono/irc/passwd" "github.com/tidwall/buntdb" ) const ( - // TODO migrate metadata keys as well - // 'version' of the database schema + keySchemaVersion = "db.version" // latest schema of the db - latestDbSchema = 23 + latestDbSchema = "2" + // key for the primary salt used by the ircd + keySalt = "crypto.salt" ) -var ( - schemaVersionUUID = utils.UUID{0, 255, 85, 13, 212, 10, 191, 121, 245, 152, 142, 89, 97, 141, 219, 87} // AP9VDdQKv3n1mI5ZYY3bVw - cloakSecretUUID = utils.UUID{170, 214, 184, 208, 116, 181, 67, 75, 161, 23, 233, 16, 113, 251, 94, 229} // qta40HS1Q0uhF-kQcfte5Q - - keySchemaVersion = bunt.BuntKey(datastore.TableMetadata, schemaVersionUUID) - keyCloakSecret = bunt.BuntKey(datastore.TableMetadata, cloakSecretUUID) -) - -type SchemaChanger func(*Config, *buntdb.Tx) error - -type SchemaChange struct { - InitialVersion int // the change will take this version - TargetVersion int // and transform it into this version - Changer SchemaChanger -} - -func checkDBReadyForInit(path string) error { - _, err := os.Stat(path) - if err == nil { - return fmt.Errorf("Datastore already exists (delete it manually to continue): %s", path) - } else if !os.IsNotExist(err) { - return fmt.Errorf("Datastore path %s is inaccessible: %w", path, err) - } - return nil -} - -// InitDB creates the database, implementing the `oragono initdb` command. -func InitDB(path string) error { - if err := checkDBReadyForInit(path); err != nil { - return err - } - - if err := initializeDB(path); err != nil { - return fmt.Errorf("Could not save datastore: %w", err) - } - return nil -} - -// internal database initialization code -func initializeDB(path string) error { +// InitDB creates the database. +func InitDB(path string) { + // prepare kvstore db + //TODO(dan): fail if already exists instead? don't want to overwrite good data + os.Remove(path) store, err := buntdb.Open(path) if err != nil { - return err + log.Fatal(fmt.Sprintf("Failed to open datastore: %s", err.Error())) } defer store.Close() err = store.Update(func(tx *buntdb.Tx) error { + // set base db salt + salt, err := passwd.NewSalt() + encodedSalt := base64.StdEncoding.EncodeToString(salt) + if err != nil { + log.Fatal("Could not generate cryptographically-secure salt for the user:", err.Error()) + } + tx.Set(keySalt, encodedSalt, nil) + // set schema version - tx.Set(keySchemaVersion, strconv.Itoa(latestDbSchema), nil) - tx.Set(keyCloakSecret, utils.GenerateSecretKey(), nil) + tx.Set(keySchemaVersion, "2", nil) return nil }) - return err + if err != nil { + log.Fatal("Could not save datastore:", err.Error()) + } } // OpenDatabase returns an existing database, performing a schema version check. -func OpenDatabase(config *Config) (*buntdb.DB, error) { - return openDatabaseInternal(config, config.Datastore.AutoUpgrade) -} - -// open the database, giving it at most one chance to auto-upgrade the schema -func openDatabaseInternal(config *Config, allowAutoupgrade bool) (db *buntdb.DB, err error) { - db, err = buntdb.Open(config.Datastore.Path) +func OpenDatabase(path string) (*buntdb.DB, error) { + // open data store + db, err := buntdb.Open(path) if err != nil { - return + return nil, err } - defer func() { - if err != nil && db != nil { - db.Close() - db = nil - } - }() - - // read the current version string - var version int - err = db.View(func(tx *buntdb.Tx) (err error) { - version, err = retrieveSchemaVersion(tx) - return err - }) - if err != nil { - return - } - - if version == latestDbSchema { - // success - return - } - - // XXX quiesce the DB so we can be sure it's safe to make a backup copy - db.Close() - db = nil - if allowAutoupgrade { - err = performAutoUpgrade(version, config) - if err != nil { - return - } - // successful autoupgrade, let's try this again: - return openDatabaseInternal(config, false) - } else { - err = &utils.IncompatibleSchemaError{CurrentVersion: version, RequiredVersion: latestDbSchema} - return - } -} - -func retrieveSchemaVersion(tx *buntdb.Tx) (version int, err error) { - if val, err := tx.Get(keySchemaVersion); err == nil { - return strconv.Atoi(val) - } - // legacy key: - if val, err := tx.Get("db.version"); err == nil { - return strconv.Atoi(val) - } - return 0, buntdb.ErrNotFound -} - -func performAutoUpgrade(currentVersion int, config *Config) (err error) { - path := config.Datastore.Path - log.Printf("attempting to auto-upgrade schema from version %d to %d\n", currentVersion, latestDbSchema) - timestamp := time.Now().UTC().Format("2006-01-02-15.04.05.000Z") - backupPath := fmt.Sprintf("%s.v%d.%s.bak", path, currentVersion, timestamp) - log.Printf("making a backup of current database at %s\n", backupPath) - err = utils.CopyFile(path, backupPath) - if err != nil { - return err - } - - err = UpgradeDB(config) - if err != nil { - // database upgrade is a single transaction, so we don't need to restore the backup; - // we can just delete it - os.Remove(backupPath) - } - return err -} - -// UpgradeDB upgrades the datastore to the latest schema. -func UpgradeDB(config *Config) (err error) { - // #715: test that the database exists - _, err = os.Stat(config.Datastore.Path) - if err != nil { - return err - } - - store, err := buntdb.Open(config.Datastore.Path) - if err != nil { - return err - } - defer store.Close() - - var version int - err = store.Update(func(tx *buntdb.Tx) error { - for { - if version == 0 { - version, err = retrieveSchemaVersion(tx) - if err != nil { - return err - } - } - if version == latestDbSchema { - // success! - break - } - change, ok := getSchemaChange(version) - if !ok { - // unable to upgrade to the desired version, roll back - return &utils.IncompatibleSchemaError{CurrentVersion: version, RequiredVersion: latestDbSchema} - } - log.Printf("attempting to update schema from version %d\n", version) - err := change.Changer(config, tx) - if err != nil { - return err - } - version = change.TargetVersion - _, _, err = tx.Set(keySchemaVersion, strconv.Itoa(version), nil) - if err != nil { - return err - } - log.Printf("successfully updated schema to version %d\n", version) + // check db version + err = db.View(func(tx *buntdb.Tx) error { + version, _ := tx.Get(keySchemaVersion) + if version != latestDbSchema { + return fmt.Errorf("Database must be updated. Expected schema v%s, got v%s", latestDbSchema, version) } return nil }) if err != nil { - log.Printf("database upgrade failed and was rolled back: %v\n", err) + // close the db + db.Close() + return nil, err } - return err + + return db, nil } -func LoadCloakSecret(dstore datastore.Datastore) (result string, err error) { - val, err := dstore.Get(datastore.TableMetadata, cloakSecretUUID) +// UpgradeDB upgrades the datastore to the latest schema. +func UpgradeDB(path string) { + store, err := buntdb.Open(path) if err != nil { - return + log.Fatal(fmt.Sprintf("Failed to open datastore: %s", err.Error())) } - return string(val), nil -} + defer store.Close() -func StoreCloakSecret(dstore datastore.Datastore, secret string) { - // TODO error checking - dstore.Set(datastore.TableMetadata, cloakSecretUUID, []byte(secret), time.Time{}) -} + err = store.Update(func(tx *buntdb.Tx) error { + version, _ := tx.Get(keySchemaVersion) -func schemaChangeV1toV2(config *Config, tx *buntdb.Tx) error { - // == version 1 -> 2 == - // account key changes and account.verified key bugfix. + // == version 1 -> 2 == + // account key changes and account.verified key bugfix. + if version == "1" { + log.Println("Updating store v1 to v2") - var keysToRemove []string - newKeys := make(map[string]string) + var keysToRemove []string + newKeys := make(map[string]string) - tx.AscendKeys("account *", func(key, value string) bool { - keysToRemove = append(keysToRemove, key) - splitkey := strings.Split(key, " ") + tx.AscendKeys("account *", func(key, value string) bool { + keysToRemove = append(keysToRemove, key) + splitkey := strings.Split(key, " ") - // work around bug - if splitkey[2] == "exists" { - // manually create new verified key - newVerifiedKey := fmt.Sprintf("%s.verified %s", splitkey[0], splitkey[1]) - newKeys[newVerifiedKey] = "1" - } else if splitkey[1] == "%s" { - return true - } - - newKey := fmt.Sprintf("%s.%s %s", splitkey[0], splitkey[2], splitkey[1]) - newKeys[newKey] = value - - return true - }) - - for _, key := range keysToRemove { - tx.Delete(key) - } - for key, value := range newKeys { - tx.Set(key, value, nil) - } - - return nil -} - -// 1. channel founder names should be casefolded -// 2. founder should be explicitly granted the ChannelFounder user mode -// 3. explicitly initialize stored channel modes to the server default values -func schemaChangeV2ToV3(config *Config, tx *buntdb.Tx) error { - var channels []string - prefix := "channel.exists " - tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { - if !strings.HasPrefix(key, prefix) { - return false - } - chname := strings.TrimPrefix(key, prefix) - channels = append(channels, chname) - return true - }) - - // founder names should be casefolded - // founder should be explicitly granted the ChannelFounder user mode - for _, channel := range channels { - founderKey := "channel.founder " + channel - founder, _ := tx.Get(founderKey) - if founder != "" { - founder, err := CasefoldName(founder) - if err == nil { - tx.Set(founderKey, founder, nil) - accountToUmode := map[string]modes.Mode{ - founder: modes.ChannelFounder, + // work around bug + if splitkey[2] == "exists" { + // manually create new verified key + newVerifiedKey := fmt.Sprintf("%s.verified %s", splitkey[0], splitkey[1]) + newKeys[newVerifiedKey] = "1" + } else if splitkey[1] == "%s" { + return true } - atustr, _ := json.Marshal(accountToUmode) - tx.Set("channel.accounttoumode "+channel, string(atustr), nil) + + newKey := fmt.Sprintf("%s.%s %s", splitkey[0], splitkey[2], splitkey[1]) + newKeys[newKey] = value + + return true + }) + + for _, key := range keysToRemove { + tx.Delete(key) } - } - } - - // explicitly store the channel modes - defaultModes := config.Channels.defaultModes - modeStrings := make([]string, len(defaultModes)) - for i, mode := range defaultModes { - modeStrings[i] = string(mode) - } - defaultModeString := strings.Join(modeStrings, "") - for _, channel := range channels { - tx.Set("channel.modes "+channel, defaultModeString, nil) - } - - return nil -} - -// 1. ban info format changed (from `legacyBanInfo` below to `IPBanInfo`) -// 2. dlines against individual IPs are normalized into dlines against the appropriate /128 network -func schemaChangeV3ToV4(config *Config, tx *buntdb.Tx) error { - type ipRestrictTime struct { - Duration time.Duration - Expires time.Time - } - type legacyBanInfo struct { - Reason string `json:"reason"` - OperReason string `json:"oper_reason"` - OperName string `json:"oper_name"` - Time *ipRestrictTime `json:"time"` - } - - now := time.Now() - legacyToNewInfo := func(old legacyBanInfo) (new_ IPBanInfo) { - new_.Reason = old.Reason - new_.OperReason = old.OperReason - new_.OperName = old.OperName - - if old.Time == nil { - new_.TimeCreated = now - new_.Duration = 0 - } else { - new_.TimeCreated = old.Time.Expires.Add(-1 * old.Time.Duration) - new_.Duration = old.Time.Duration - } - return - } - - var keysToDelete []string - - prefix := "bans.dline " - dlines := make(map[string]IPBanInfo) - tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { - if !strings.HasPrefix(key, prefix) { - return false - } - keysToDelete = append(keysToDelete, key) - - var lbinfo legacyBanInfo - id := strings.TrimPrefix(key, prefix) - err := json.Unmarshal([]byte(value), &lbinfo) - if err != nil { - log.Printf("error unmarshaling legacy dline: %v\n", err) - return true - } - // legacy keys can be either an IP or a CIDR - hostNet, err := utils.NormalizedNetFromString(id) - if err != nil { - log.Printf("error unmarshaling legacy dline network: %v\n", err) - return true - } - dlines[utils.NetToNormalizedString(hostNet)] = legacyToNewInfo(lbinfo) - - return true - }) - - setOptions := func(info IPBanInfo) *buntdb.SetOptions { - if info.Duration == 0 { - return nil - } - ttl := info.TimeCreated.Add(info.Duration).Sub(now) - return &buntdb.SetOptions{Expires: true, TTL: ttl} - } - - // store the new dlines - for id, info := range dlines { - b, err := json.Marshal(info) - if err != nil { - log.Printf("error marshaling migrated dline: %v\n", err) - continue - } - tx.Set(fmt.Sprintf("bans.dlinev2 %s", id), string(b), setOptions(info)) - } - - // same operations against klines - prefix = "bans.kline " - klines := make(map[string]IPBanInfo) - tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { - if !strings.HasPrefix(key, prefix) { - return false - } - keysToDelete = append(keysToDelete, key) - mask := strings.TrimPrefix(key, prefix) - var lbinfo legacyBanInfo - err := json.Unmarshal([]byte(value), &lbinfo) - if err != nil { - log.Printf("error unmarshaling legacy kline: %v\n", err) - return true - } - klines[mask] = legacyToNewInfo(lbinfo) - return true - }) - - for mask, info := range klines { - b, err := json.Marshal(info) - if err != nil { - log.Printf("error marshaling migrated kline: %v\n", err) - continue - } - tx.Set(fmt.Sprintf("bans.klinev2 %s", mask), string(b), setOptions(info)) - } - - // clean up all the old entries - for _, key := range keysToDelete { - tx.Delete(key) - } - - return nil -} - -// create new key tracking channels that belong to an account -func schemaChangeV4ToV5(config *Config, tx *buntdb.Tx) error { - founderToChannels := make(map[string][]string) - prefix := "channel.founder " - tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { - if !strings.HasPrefix(key, prefix) { - return false - } - channel := strings.TrimPrefix(key, prefix) - founderToChannels[value] = append(founderToChannels[value], channel) - return true - }) - - for founder, channels := range founderToChannels { - tx.Set(fmt.Sprintf("account.channels %s", founder), strings.Join(channels, ","), nil) - } - return nil -} - -// custom nick enforcement was a separate db key, now it's part of settings -func schemaChangeV5ToV6(config *Config, tx *buntdb.Tx) error { - accountToEnforcement := make(map[string]NickEnforcementMethod) - prefix := "account.customenforcement " - tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { - if !strings.HasPrefix(key, prefix) { - return false - } - account := strings.TrimPrefix(key, prefix) - method, err := nickReservationFromString(value) - if err == nil { - accountToEnforcement[account] = method - } else { - log.Printf("skipping corrupt custom enforcement value for %s\n", account) - } - return true - }) - - for account, method := range accountToEnforcement { - var settings AccountSettings - settings.NickEnforcement = method - text, err := json.Marshal(settings) - if err != nil { - return err - } - tx.Delete(prefix + account) - tx.Set(fmt.Sprintf("account.settings %s", account), string(text), nil) - } - return nil -} - -type maskInfoV7 struct { - TimeCreated time.Time - CreatorNickmask string - CreatorAccount string -} - -func schemaChangeV6ToV7(config *Config, tx *buntdb.Tx) error { - now := time.Now().UTC() - var channels []string - prefix := "channel.exists " - tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { - if !strings.HasPrefix(key, prefix) { - return false - } - channels = append(channels, strings.TrimPrefix(key, prefix)) - return true - }) - - converter := func(key string) { - oldRawValue, err := tx.Get(key) - if err != nil { - return - } - var masks []string - err = json.Unmarshal([]byte(oldRawValue), &masks) - if err != nil { - return - } - newCookedValue := make(map[string]maskInfoV7) - for _, mask := range masks { - normalizedMask, err := CanonicalizeMaskWildcard(mask) - if err != nil { - continue + for key, value := range newKeys { + tx.Set(key, value, nil) } - newCookedValue[normalizedMask] = maskInfoV7{ - TimeCreated: now, - CreatorNickmask: "*", - CreatorAccount: "*", - } - } - newRawValue, err := json.Marshal(newCookedValue) - if err != nil { - return - } - tx.Set(key, string(newRawValue), nil) - } - prefixes := []string{ - "channel.banlist %s", - "channel.exceptlist %s", - "channel.invitelist %s", - } - for _, channel := range channels { - for _, prefix := range prefixes { - converter(fmt.Sprintf(prefix, channel)) + tx.Set(keySchemaVersion, "2", nil) } - } - return nil -} -type accountSettingsLegacyV7 struct { - AutoreplayLines *int - NickEnforcement NickEnforcementMethod - AllowBouncer MulticlientAllowedSetting - AutoreplayJoins bool -} - -type accountSettingsLegacyV8 struct { - AutoreplayLines *int - NickEnforcement NickEnforcementMethod - AllowBouncer MulticlientAllowedSetting - ReplayJoins ReplayJoinsSetting -} - -// #616: change autoreplay-joins to replay-joins -func schemaChangeV7ToV8(config *Config, tx *buntdb.Tx) error { - prefix := "account.settings " - var accounts, blobs []string - tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { - var legacy accountSettingsLegacyV7 - var current accountSettingsLegacyV8 - if !strings.HasPrefix(key, prefix) { - return false - } - account := strings.TrimPrefix(key, prefix) - err := json.Unmarshal([]byte(value), &legacy) - if err != nil { - log.Printf("corrupt record for %s: %v\n", account, err) - return true - } - current.AutoreplayLines = legacy.AutoreplayLines - current.NickEnforcement = legacy.NickEnforcement - current.AllowBouncer = legacy.AllowBouncer - if legacy.AutoreplayJoins { - current.ReplayJoins = ReplayJoinsAlways - } else { - current.ReplayJoins = ReplayJoinsCommandsOnly - } - blob, err := json.Marshal(current) - if err != nil { - log.Printf("could not marshal record for %s: %v\n", account, err) - return true - } - accounts = append(accounts, account) - blobs = append(blobs, string(blob)) - return true + return nil }) - for i, account := range accounts { - tx.Set(prefix+account, blobs[i], nil) - } - return nil -} - -type accountCredsLegacyV8 struct { - Version uint - PassphraseSalt []byte // legacy field, not used by v1 and later - PassphraseHash []byte - Certificate string -} - -type accountCredsLegacyV9 struct { - Version uint - PassphraseSalt []byte // legacy field, not used by v1 and later - PassphraseHash []byte - Certfps []string -} - -// #530: support multiple client certificate fingerprints -func schemaChangeV8ToV9(config *Config, tx *buntdb.Tx) error { - prefix := "account.credentials " - var accounts, blobs []string - tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { - var legacy accountCredsLegacyV8 - var current accountCredsLegacyV9 - if !strings.HasPrefix(key, prefix) { - return false - } - account := strings.TrimPrefix(key, prefix) - err := json.Unmarshal([]byte(value), &legacy) - if err != nil { - log.Printf("corrupt record for %s: %v\n", account, err) - return true - } - current.Version = legacy.Version - current.PassphraseSalt = legacy.PassphraseSalt // ugh can't get rid of this - current.PassphraseHash = legacy.PassphraseHash - if legacy.Certificate != "" { - current.Certfps = []string{legacy.Certificate} - } - blob, err := json.Marshal(current) - if err != nil { - log.Printf("could not marshal record for %s: %v\n", account, err) - return true - } - accounts = append(accounts, account) - blobs = append(blobs, string(blob)) - return true - }) - for i, account := range accounts { - tx.Set(prefix+account, blobs[i], nil) - } - return nil -} - -// #836: account registration time at nanosecond resolution -// (mostly to simplify testing) -func schemaChangeV9ToV10(config *Config, tx *buntdb.Tx) error { - prefix := "account.registered.time " - var accounts, times []string - tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { - if !strings.HasPrefix(key, prefix) { - return false - } - account := strings.TrimPrefix(key, prefix) - accounts = append(accounts, account) - times = append(times, value) - return true - }) - for i, account := range accounts { - time, err := strconv.ParseInt(times[i], 10, 64) - if err != nil { - log.Printf("corrupt registration time entry for %s: %v\n", account, err) - continue - } - time = time * 1000000000 - tx.Set(prefix+account, strconv.FormatInt(time, 10), nil) - } - return nil -} - -// #952: move the cloak secret into the database, -// generate a new one if necessary -func schemaChangeV10ToV11(config *Config, tx *buntdb.Tx) error { - cloakSecret := config.Server.Cloaks.LegacySecretValue - if cloakSecret == "" || cloakSecret == "siaELnk6Kaeo65K3RCrwJjlWaZ-Bt3WuZ2L8MXLbNb4" { - cloakSecret = utils.GenerateSecretKey() - } - _, _, err := tx.Set(keyCloakSecret, cloakSecret, nil) - return err -} - -// #1027: NickEnforcementTimeout (2) was removed, -// NickEnforcementStrict was 3 and is now 2 -func schemaChangeV11ToV12(config *Config, tx *buntdb.Tx) error { - prefix := "account.settings " - var accounts, rawSettings []string - tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { - if !strings.HasPrefix(key, prefix) { - return false - } - account := strings.TrimPrefix(key, prefix) - accounts = append(accounts, account) - rawSettings = append(rawSettings, value) - return true - }) - - for i, account := range accounts { - var settings AccountSettings - err := json.Unmarshal([]byte(rawSettings[i]), &settings) - if err != nil { - log.Printf("corrupt account settings entry for %s: %v\n", account, err) - continue - } - // upgrade NickEnforcementTimeout (which was 2) to NickEnforcementStrict (currently 2), - // fix up the old value of NickEnforcementStrict (3) to the current value (2) - if int(settings.NickEnforcement) == 3 { - settings.NickEnforcement = NickEnforcementMethod(2) - text, err := json.Marshal(settings) - if err != nil { - return err - } - tx.Set(prefix+account, string(text), nil) - } - } - return nil -} - -type accountCredsLegacyV13 struct { - Version CredentialsVersion - PassphraseHash []byte - Certfps []string -} - -// see #212 / #284. this packs the legacy salts into a single passphrase hash, -// allowing legacy passphrases to be verified using the new API `checkLegacyPassphrase`. -func schemaChangeV12ToV13(config *Config, tx *buntdb.Tx) error { - salt, err := tx.Get("crypto.salt") if err != nil { - return nil // no change required - } - tx.Delete("crypto.salt") - rawSalt, err := base64.StdEncoding.DecodeString(salt) - if err != nil { - return nil // just throw away the creds at this point - } - prefix := "account.credentials " - var accounts []string - var credentials []accountCredsLegacyV13 - tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { - if !strings.HasPrefix(key, prefix) { - return false - } - account := strings.TrimPrefix(key, prefix) - - var credsOld accountCredsLegacyV9 - err = json.Unmarshal([]byte(value), &credsOld) - if err != nil { - return true - } - // skip if these aren't legacy creds! - if credsOld.Version != 0 { - return true - } - - var credsNew accountCredsLegacyV13 - credsNew.Version = 0 // mark hash for migration - credsNew.Certfps = credsOld.Certfps - credsNew.PassphraseHash = append(credsNew.PassphraseHash, rawSalt...) - credsNew.PassphraseHash = append(credsNew.PassphraseHash, credsOld.PassphraseSalt...) - credsNew.PassphraseHash = append(credsNew.PassphraseHash, credsOld.PassphraseHash...) - - accounts = append(accounts, account) - credentials = append(credentials, credsNew) - return true - }) - - for i, account := range accounts { - bytesOut, err := json.Marshal(credentials[i]) - if err != nil { - return err - } - _, _, err = tx.Set(prefix+account, string(bytesOut), nil) - if err != nil { - return err - } + log.Fatal("Could not update datastore:", err.Error()) } - return nil -} - -// channel registration time and topic set time at nanosecond resolution -func schemaChangeV13ToV14(config *Config, tx *buntdb.Tx) error { - prefix := "channel.registered.time " - var channels, times []string - tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { - if !strings.HasPrefix(key, prefix) { - return false - } - channel := strings.TrimPrefix(key, prefix) - channels = append(channels, channel) - times = append(times, value) - return true - }) - - billion := int64(time.Second) - for i, channel := range channels { - regTime, err := strconv.ParseInt(times[i], 10, 64) - if err != nil { - log.Printf("corrupt registration time entry for %s: %v\n", channel, err) - continue - } - regTime = regTime * billion - tx.Set(prefix+channel, strconv.FormatInt(regTime, 10), nil) - - topicTimeKey := "channel.topic.settime " + channel - topicSetAt, err := tx.Get(topicTimeKey) - if err == nil { - if setTime, err := strconv.ParseInt(topicSetAt, 10, 64); err == nil { - tx.Set(topicTimeKey, strconv.FormatInt(setTime*billion, 10), nil) - } - } - } - return nil -} - -// #1327: delete any invalid klines -func schemaChangeV14ToV15(config *Config, tx *buntdb.Tx) error { - prefix := "bans.klinev2 " - var keys []string - tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { - if !strings.HasPrefix(key, prefix) { - return false - } - if key != strings.TrimSpace(key) { - keys = append(keys, key) - } - return true - }) - // don't bother trying to fix these up - for _, key := range keys { - tx.Delete(key) - } - return nil -} - -// #1330: delete any stale realname records -func schemaChangeV15ToV16(config *Config, tx *buntdb.Tx) error { - prefix := "account.realname " - verifiedPrefix := "account.verified " - var keys []string - tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { - if !strings.HasPrefix(key, prefix) { - return false - } - acct := strings.TrimPrefix(key, prefix) - verifiedKey := verifiedPrefix + acct - _, verifiedErr := tx.Get(verifiedKey) - if verifiedErr != nil { - keys = append(keys, key) - } - return true - }) - for _, key := range keys { - tx.Delete(key) - } - return nil -} - -// #1346: remove vhost request queue -func schemaChangeV16ToV17(config *Config, tx *buntdb.Tx) error { - prefix := "vhostQueue " - var keys []string - tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { - if !strings.HasPrefix(key, prefix) { - return false - } - keys = append(keys, key) - return true - }) - - for _, key := range keys { - tx.Delete(key) - } - return nil -} - -// #1274: we used to suspend accounts by deleting their "verified" key, -// now we save some metadata under a new key -func schemaChangeV17ToV18(config *Config, tx *buntdb.Tx) error { - now := time.Now().UTC() - - exists := "account.exists " - suspended := "account.suspended " - verif := "account.verified " - verifCode := "account.verificationcode " - - var accounts []string - - tx.AscendGreaterOrEqual("", exists, func(key, value string) bool { - if !strings.HasPrefix(key, exists) { - return false - } - account := strings.TrimPrefix(key, exists) - _, verifiedErr := tx.Get(verif + account) - _, verifCodeErr := tx.Get(verifCode + account) - if verifiedErr != nil && verifCodeErr != nil { - // verified key not present, but there's no code either, - // this is a suspension - accounts = append(accounts, account) - } - return true - }) - - type accountSuspensionV18 struct { - TimeCreated time.Time - Duration time.Duration - OperName string - Reason string - } - - for _, account := range accounts { - var sus accountSuspensionV18 - sus.TimeCreated = now - sus.OperName = "*" - sus.Reason = "[unknown]" - susBytes, err := json.Marshal(sus) - if err != nil { - return err - } - tx.Set(suspended+account, string(susBytes), nil) - } - - return nil -} - -// #1345: persist the channel-user modes of always-on clients -func schemaChangeV18To19(config *Config, tx *buntdb.Tx) error { - channelToAmodesCache := make(map[string]map[string]modes.Mode) - joinedto := "account.joinedto " - var accounts []string - var channels [][]string - tx.AscendGreaterOrEqual("", joinedto, func(key, value string) bool { - if !strings.HasPrefix(key, joinedto) { - return false - } - accounts = append(accounts, strings.TrimPrefix(key, joinedto)) - var ch []string - if value != "" { - ch = strings.Split(value, ",") - } - channels = append(channels, ch) - return true - }) - - for i := 0; i < len(accounts); i++ { - account := accounts[i] - channels := channels[i] - tx.Delete(joinedto + account) - newValue := make(map[string]string, len(channels)) - for _, channel := range channels { - chcfname, err := CasefoldChannel(channel) - if err != nil { - continue - } - // get amodes from the channelToAmodesCache, fill if necessary - amodes, ok := channelToAmodesCache[chcfname] - if !ok { - amodeStr, _ := tx.Get("channel.accounttoumode " + chcfname) - if amodeStr != "" { - jErr := json.Unmarshal([]byte(amodeStr), &amodes) - if jErr != nil { - log.Printf("error retrieving amodes for %s: %v\n", channel, jErr) - amodes = nil - } - } - // setting/using the nil value here is ok - channelToAmodesCache[chcfname] = amodes - } - if mode, ok := amodes[account]; ok { - newValue[channel] = string(mode) - } else { - newValue[channel] = "" - } - } - newValueBytes, jErr := json.Marshal(newValue) - if jErr != nil { - log.Printf("couldn't serialize new mode values for v19: %v\n", jErr) - continue - } - tx.Set("account.channeltomodes "+account, string(newValueBytes), nil) - } - - return nil -} - -// #1490: start tracking join times for always-on clients -func schemaChangeV19To20(config *Config, tx *buntdb.Tx) error { - type joinData struct { - Modes string - JoinTime int64 - } - - var accounts []string - var data []string - - now := time.Now().UnixNano() - - prefix := "account.channeltomodes " - tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { - if !strings.HasPrefix(key, prefix) { - return false - } - accounts = append(accounts, strings.TrimPrefix(key, prefix)) - data = append(data, value) - return true - }) - - for i, account := range accounts { - var existingMap map[string]string - err := json.Unmarshal([]byte(data[i]), &existingMap) - if err != nil { - return err - } - newMap := make(map[string]joinData) - for channel, modeStr := range existingMap { - newMap[channel] = joinData{ - Modes: modeStr, - JoinTime: now, - } - } - serialized, err := json.Marshal(newMap) - if err != nil { - return err - } - tx.Set(prefix+account, string(serialized), nil) - } - - return nil -} - -// #734: move the email address into the settings object, -// giving people a way to change it -func schemaChangeV20To21(config *Config, tx *buntdb.Tx) error { - type accountSettingsv21 struct { - AutoreplayLines *int - NickEnforcement NickEnforcementMethod - AllowBouncer MulticlientAllowedSetting - ReplayJoins ReplayJoinsSetting - AlwaysOn PersistentStatus - AutoreplayMissed bool - DMHistory HistoryStatus - AutoAway PersistentStatus - Email string - } - var accounts []string - var emails []string - callbackPrefix := "account.callback " - tx.AscendGreaterOrEqual("", callbackPrefix, func(key, value string) bool { - if !strings.HasPrefix(key, callbackPrefix) { - return false - } - account := strings.TrimPrefix(key, callbackPrefix) - if _, err := tx.Get("account.verified " + account); err != nil { - return true - } - if strings.HasPrefix(value, "mailto:") { - accounts = append(accounts, account) - emails = append(emails, strings.TrimPrefix(value, "mailto:")) - } - return true - }) - for i, account := range accounts { - var settings accountSettingsv21 - email := emails[i] - settingsKey := "account.settings " + account - settingsStr, err := tx.Get(settingsKey) - if err == nil && settingsStr != "" { - json.Unmarshal([]byte(settingsStr), &settings) - } - settings.Email = email - settingsBytes, err := json.Marshal(settings) - if err != nil { - log.Printf("couldn't marshal settings for %s: %v\n", account, err) - } else { - tx.Set(settingsKey, string(settingsBytes), nil) - } - tx.Delete(callbackPrefix + account) - } - return nil -} - -// #1676: we used to have ReplayJoinsNever, now it's desupported -func schemaChangeV21To22(config *Config, tx *buntdb.Tx) error { - type accountSettingsv22 struct { - AutoreplayLines *int - NickEnforcement NickEnforcementMethod - AllowBouncer MulticlientAllowedSetting - ReplayJoins ReplayJoinsSetting - AlwaysOn PersistentStatus - AutoreplayMissed bool - DMHistory HistoryStatus - AutoAway PersistentStatus - Email string - } - - var accounts []string - var serializedSettings []string - settingsPrefix := "account.settings " - tx.AscendGreaterOrEqual("", settingsPrefix, func(key, value string) bool { - if !strings.HasPrefix(key, settingsPrefix) { - return false - } - if value == "" { - return true - } - account := strings.TrimPrefix(key, settingsPrefix) - if _, err := tx.Get("account.verified " + account); err != nil { - return true - } - var settings accountSettingsv22 - err := json.Unmarshal([]byte(value), &settings) - if err != nil { - log.Printf("error (v21-22) processing settings for %s: %v\n", account, err) - return true - } - // if necessary, change ReplayJoinsNever (2) to ReplayJoinsCommandsOnly (0) - if settings.ReplayJoins == ReplayJoinsSetting(2) { - settings.ReplayJoins = ReplayJoinsSetting(0) - if b, err := json.Marshal(settings); err == nil { - accounts = append(accounts, account) - serializedSettings = append(serializedSettings, string(b)) - } else { - log.Printf("error (v21-22) processing settings for %s: %v\n", account, err) - } - } - return true - }) - - for i, account := range accounts { - tx.Set(settingsPrefix+account, serializedSettings[i], nil) - } - return nil -} - -// first phase of document-oriented database refactor: channels -func schemaChangeV22ToV23(config *Config, tx *buntdb.Tx) error { - keyChannelExists := "channel.exists " - var channelNames []string - tx.AscendGreaterOrEqual("", keyChannelExists, func(key, value string) bool { - if !strings.HasPrefix(key, keyChannelExists) { - return false - } - channelNames = append(channelNames, strings.TrimPrefix(key, keyChannelExists)) - return true - }) - for _, channelName := range channelNames { - channel, err := loadLegacyChannel(tx, channelName) - if err != nil { - log.Printf("error loading legacy channel %s: %v", channelName, err) - continue - } - channel.UUID = utils.GenerateUUIDv4() - newKey := bunt.BuntKey(datastore.TableChannels, channel.UUID) - j, err := json.Marshal(channel) - if err != nil { - log.Printf("error marshaling channel %s: %v", channelName, err) - continue - } - tx.Set(newKey, string(j), nil) - deleteLegacyChannel(tx, channelName) - } - - // purges - keyChannelPurged := "channel.purged " - var purgeKeys []string - var channelPurges []ChannelPurgeRecord - tx.AscendGreaterOrEqual("", keyChannelPurged, func(key, value string) bool { - if !strings.HasPrefix(key, keyChannelPurged) { - return false - } - purgeKeys = append(purgeKeys, key) - cfname := strings.TrimPrefix(key, keyChannelPurged) - var record ChannelPurgeRecord - err := json.Unmarshal([]byte(value), &record) - if err != nil { - log.Printf("error unmarshaling channel purge for %s: %v", cfname, err) - return true - } - record.NameCasefolded = cfname - record.UUID = utils.GenerateUUIDv4() - channelPurges = append(channelPurges, record) - return true - }) - for _, record := range channelPurges { - newKey := bunt.BuntKey(datastore.TableChannelPurges, record.UUID) - j, err := json.Marshal(record) - if err != nil { - log.Printf("error marshaling channel purge %s: %v", record.NameCasefolded, err) - continue - } - tx.Set(newKey, string(j), nil) - } - for _, purgeKey := range purgeKeys { - tx.Delete(purgeKey) - } - - // clean up denormalized account-to-channels mapping - keyAccountChannels := "account.channels " - var accountToChannels []string - tx.AscendGreaterOrEqual("", keyAccountChannels, func(key, value string) bool { - if !strings.HasPrefix(key, keyAccountChannels) { - return false - } - accountToChannels = append(accountToChannels, key) - return true - }) - for _, key := range accountToChannels { - tx.Delete(key) - } - - // migrate cloak secret - val, _ := tx.Get("crypto.cloak_secret") - tx.Set(keyCloakSecret, val, nil) - - // bump the legacy version key to mark the database as downgrade-incompatible - tx.Set("db.version", "23", nil) - - return nil -} - -func getSchemaChange(initialVersion int) (result SchemaChange, ok bool) { - for _, change := range allChanges { - if initialVersion == change.InitialVersion { - return change, true - } - } return } - -var allChanges = []SchemaChange{ - { - InitialVersion: 1, - TargetVersion: 2, - Changer: schemaChangeV1toV2, - }, - { - InitialVersion: 2, - TargetVersion: 3, - Changer: schemaChangeV2ToV3, - }, - { - InitialVersion: 3, - TargetVersion: 4, - Changer: schemaChangeV3ToV4, - }, - { - InitialVersion: 4, - TargetVersion: 5, - Changer: schemaChangeV4ToV5, - }, - { - InitialVersion: 5, - TargetVersion: 6, - Changer: schemaChangeV5ToV6, - }, - { - InitialVersion: 6, - TargetVersion: 7, - Changer: schemaChangeV6ToV7, - }, - { - InitialVersion: 7, - TargetVersion: 8, - Changer: schemaChangeV7ToV8, - }, - { - InitialVersion: 8, - TargetVersion: 9, - Changer: schemaChangeV8ToV9, - }, - { - InitialVersion: 9, - TargetVersion: 10, - Changer: schemaChangeV9ToV10, - }, - { - InitialVersion: 10, - TargetVersion: 11, - Changer: schemaChangeV10ToV11, - }, - { - InitialVersion: 11, - TargetVersion: 12, - Changer: schemaChangeV11ToV12, - }, - { - InitialVersion: 12, - TargetVersion: 13, - Changer: schemaChangeV12ToV13, - }, - { - InitialVersion: 13, - TargetVersion: 14, - Changer: schemaChangeV13ToV14, - }, - { - InitialVersion: 14, - TargetVersion: 15, - Changer: schemaChangeV14ToV15, - }, - { - InitialVersion: 15, - TargetVersion: 16, - Changer: schemaChangeV15ToV16, - }, - { - InitialVersion: 16, - TargetVersion: 17, - Changer: schemaChangeV16ToV17, - }, - { - InitialVersion: 17, - TargetVersion: 18, - Changer: schemaChangeV17ToV18, - }, - { - InitialVersion: 18, - TargetVersion: 19, - Changer: schemaChangeV18To19, - }, - { - InitialVersion: 19, - TargetVersion: 20, - Changer: schemaChangeV19To20, - }, - { - InitialVersion: 20, - TargetVersion: 21, - Changer: schemaChangeV20To21, - }, - { - InitialVersion: 21, - TargetVersion: 22, - Changer: schemaChangeV21To22, - }, - { - InitialVersion: 22, - TargetVersion: 23, - Changer: schemaChangeV22ToV23, - }, -} diff --git a/irc/datastore/datastore.go b/irc/datastore/datastore.go deleted file mode 100644 index c9d40a1b..00000000 --- a/irc/datastore/datastore.go +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) 2022 Shivaram Lingamneni -// released under the MIT license - -package datastore - -import ( - "time" - - "github.com/ergochat/ergo/irc/utils" -) - -type Table uint16 - -// XXX these are persisted and must remain stable; -// do not reorder, when deleting use _ to ensure that the deleted value is skipped -const ( - TableMetadata Table = iota - TableChannels - TableChannelPurges -) - -type KV struct { - UUID utils.UUID - Value []byte -} - -// A Datastore provides the following abstraction: -// 1. Tables, each keyed on a UUID (the implementation is free to merge -// the table name and the UUID into a single key as long as the rest of -// the contract can be satisfied). Table names are [a-z0-9_]+ -// 2. The ability to efficiently enumerate all uuid-value pairs in a table -// 3. Gets, sets, and deletes for individual (table, uuid) keys -type Datastore interface { - Backoff() time.Duration - - GetAll(table Table) ([]KV, error) - - // This is rarely used because it would typically lead to TOCTOU races - Get(table Table, key utils.UUID) (value []byte, err error) - - Set(table Table, key utils.UUID, value []byte, expiration time.Time) error - - // Note that deleting a nonexistent key is not considered an error - Delete(table Table, key utils.UUID) error -} diff --git a/irc/dline.go b/irc/dline.go index 0795614f..e0491228 100644 --- a/irc/dline.go +++ b/irc/dline.go @@ -4,81 +4,84 @@ package irc import ( - "encoding/json" "fmt" - "strings" + "net" "sync" "time" - "github.com/ergochat/ergo/irc/flatip" + "encoding/json" + "github.com/tidwall/buntdb" ) const ( - keyDlineEntry = "bans.dlinev2 %s" + keyDlineEntry = "bans.dline %s" ) +// IPRestrictTime contains the expiration info about the given IP. +type IPRestrictTime struct { + // Duration is how long this block lasts for. + Duration time.Duration `json:"duration"` + // Expires is when this block expires. + Expires time.Time `json:"expires"` +} + +// IsExpired returns true if the time has expired. +func (iptime *IPRestrictTime) IsExpired() bool { + return iptime.Expires.Before(time.Now()) +} + // IPBanInfo holds info about an IP/net ban. type IPBanInfo struct { - // RequireSASL indicates a "soft" ban; connections are allowed but they must SASL - RequireSASL bool // Reason is the ban reason. Reason string `json:"reason"` // OperReason is an oper ban reason. OperReason string `json:"oper_reason"` // OperName is the oper who set the ban. OperName string `json:"oper_name"` - // time of ban creation - TimeCreated time.Time - // duration of the ban; 0 means "permanent" - Duration time.Duration -} - -func (info IPBanInfo) timeLeft() time.Duration { - return time.Until(info.TimeCreated.Add(info.Duration)) -} - -func (info IPBanInfo) TimeLeft() string { - if info.Duration == 0 { - return "indefinite" - } else { - return info.timeLeft().Truncate(time.Second).String() - } + // Time holds details about the duration, if it exists. + Time *IPRestrictTime `json:"time"` } // BanMessage returns the ban message. func (info IPBanInfo) BanMessage(message string) string { - reason := info.Reason - if reason == "" { - reason = "No reason given" - } - message = fmt.Sprintf(message, reason) - if info.Duration != 0 { - message += fmt.Sprintf(" [%s]", info.TimeLeft()) + message = fmt.Sprintf(message, info.Reason) + if info.Time != nil { + message += fmt.Sprintf(" [%s]", info.Time.Duration.String()) } return message } +// dLineAddr contains the address itself and expiration time for a given network. +type dLineAddr struct { + // Address is the address that is blocked. + Address net.IP + // Info contains information on the ban. + Info IPBanInfo +} + +// dLineNet contains the net itself and expiration time for a given network. +type dLineNet struct { + // Network is the network that is blocked. + Network net.IPNet + // Info contains information on the ban. + Info IPBanInfo +} + // DLineManager manages and dlines. type DLineManager struct { - sync.RWMutex // tier 1 - persistenceMutex sync.Mutex // tier 2 - // networks that are dlined: - networks map[flatip.IPNet]IPBanInfo - // this keeps track of expiration timers for temporary bans - expirationTimers map[flatip.IPNet]*time.Timer - server *Server + sync.RWMutex // tier 1 + // addresses that are dlined + addresses map[string]*dLineAddr + // networks that are dlined + networks map[string]*dLineNet } // NewDLineManager returns a new DLineManager. -func NewDLineManager(server *Server) *DLineManager { +func NewDLineManager() *DLineManager { var dm DLineManager - dm.networks = make(map[flatip.IPNet]IPBanInfo) - dm.expirationTimers = make(map[flatip.IPNet]*time.Timer) - dm.server = server - - dm.loadFromDatastore() - + dm.addresses = make(map[string]*dLineAddr) + dm.networks = make(map[string]*dLineNet) return &dm } @@ -89,192 +92,154 @@ func (dm *DLineManager) AllBans() map[string]IPBanInfo { dm.RLock() defer dm.RUnlock() - for key, info := range dm.networks { - allb[key.HumanReadableString()] = info + for name, info := range dm.addresses { + allb[name] = info.Info + } + for name, info := range dm.networks { + allb[name] = info.Info } return allb } // AddNetwork adds a network to the blocked list. -func (dm *DLineManager) AddNetwork(network flatip.IPNet, duration time.Duration, requireSASL bool, reason, operReason, operName string) error { - dm.persistenceMutex.Lock() - defer dm.persistenceMutex.Unlock() - - // assemble ban info - info := IPBanInfo{ - RequireSASL: requireSASL, - Reason: reason, - OperReason: operReason, - OperName: operName, - TimeCreated: time.Now().UTC(), - Duration: duration, +func (dm *DLineManager) AddNetwork(network net.IPNet, length *IPRestrictTime, reason, operReason, operName string) { + netString := network.String() + dln := dLineNet{ + Network: network, + Info: IPBanInfo{ + Time: length, + Reason: reason, + OperReason: operReason, + OperName: operName, + }, } - - id := dm.addNetworkInternal(network, info) - return dm.persistDline(id, info) -} - -func (dm *DLineManager) addNetworkInternal(flatnet flatip.IPNet, info IPBanInfo) (id flatip.IPNet) { - id = flatnet - - var timeLeft time.Duration - if info.Duration != 0 { - timeLeft = info.timeLeft() - if timeLeft <= 0 { - return - } - } - dm.Lock() - defer dm.Unlock() - - dm.networks[flatnet] = info - - dm.cancelTimer(flatnet) - - if info.Duration == 0 { - return - } - - // set up new expiration timer - timeCreated := info.TimeCreated - processExpiration := func() { - dm.Lock() - defer dm.Unlock() - - banInfo, ok := dm.networks[flatnet] - if ok && banInfo.TimeCreated.Equal(timeCreated) { - delete(dm.networks, flatnet) - // TODO(slingamn) here's where we'd remove it from the radix tree - delete(dm.expirationTimers, flatnet) - } - } - dm.expirationTimers[flatnet] = time.AfterFunc(timeLeft, processExpiration) - - return -} - -func (dm *DLineManager) cancelTimer(flatnet flatip.IPNet) { - oldTimer := dm.expirationTimers[flatnet] - if oldTimer != nil { - oldTimer.Stop() - delete(dm.expirationTimers, flatnet) - } -} - -func (dm *DLineManager) persistDline(id flatip.IPNet, info IPBanInfo) error { - // save in datastore - dlineKey := fmt.Sprintf(keyDlineEntry, id.String()) - // assemble json from ban info - b, err := json.Marshal(info) - if err != nil { - dm.server.logger.Error("internal", "couldn't marshal d-line", err.Error()) - return err - } - bstr := string(b) - var setOptions *buntdb.SetOptions - if info.Duration != 0 { - setOptions = &buntdb.SetOptions{Expires: true, TTL: info.Duration} - } - - err = dm.server.store.Update(func(tx *buntdb.Tx) error { - _, _, err := tx.Set(dlineKey, bstr, setOptions) - return err - }) - if err != nil { - dm.server.logger.Error("internal", "couldn't store d-line", err.Error()) - } - return err -} - -func (dm *DLineManager) unpersistDline(id flatip.IPNet) error { - dlineKey := fmt.Sprintf(keyDlineEntry, id.String()) - return dm.server.store.Update(func(tx *buntdb.Tx) error { - _, err := tx.Delete(dlineKey) - return err - }) + dm.networks[netString] = &dln + dm.Unlock() } // RemoveNetwork removes a network from the blocked list. -func (dm *DLineManager) RemoveNetwork(network flatip.IPNet) error { - dm.persistenceMutex.Lock() - defer dm.persistenceMutex.Unlock() +func (dm *DLineManager) RemoveNetwork(network net.IPNet) { + netString := network.String() + dm.Lock() + delete(dm.networks, netString) + dm.Unlock() +} - id := network - - present := func() bool { - dm.Lock() - defer dm.Unlock() - _, ok := dm.networks[id] - delete(dm.networks, id) - dm.cancelTimer(id) - return ok - }() - - if !present { - return errNoExistingBan +// AddIP adds an IP address to the blocked list. +func (dm *DLineManager) AddIP(addr net.IP, length *IPRestrictTime, reason, operReason, operName string) { + addrString := addr.String() + dla := dLineAddr{ + Address: addr, + Info: IPBanInfo{ + Time: length, + Reason: reason, + OperReason: operReason, + OperName: operName, + }, } + dm.Lock() + dm.addresses[addrString] = &dla + dm.Unlock() +} - return dm.unpersistDline(id) +// RemoveIP removes an IP from the blocked list. +func (dm *DLineManager) RemoveIP(addr net.IP) { + addrString := addr.String() + dm.Lock() + delete(dm.addresses, addrString) + dm.Unlock() } // CheckIP returns whether or not an IP address was banned, and how long it is banned for. -func (dm *DLineManager) CheckIP(addr flatip.IP) (isBanned bool, info IPBanInfo) { +func (dm *DLineManager) CheckIP(addr net.IP) (isBanned bool, info *IPBanInfo) { + // check IP addr + addrString := addr.String() + dm.RLock() + addrInfo := dm.addresses[addrString] + dm.RUnlock() + + if addrInfo != nil { + if addrInfo.Info.Time != nil { + if addrInfo.Info.Time.IsExpired() { + // ban on IP has expired, remove it from our blocked list + dm.RemoveIP(addr) + } else { + return true, &addrInfo.Info + } + } else { + return true, &addrInfo.Info + } + } + + // check networks + doCleanup := false + defer func() { + if doCleanup { + go func() { + dm.Lock() + defer dm.Unlock() + for key, netInfo := range dm.networks { + if netInfo.Info.Time.IsExpired() { + delete(dm.networks, key) + } + } + }() + } + }() + dm.RLock() defer dm.RUnlock() - // check networks - // TODO(slingamn) use a radix tree as the data plane for this - for flatnet, info := range dm.networks { - if flatnet.Contains(addr) { - return true, info + for _, netInfo := range dm.networks { + if netInfo.Info.Time != nil && netInfo.Info.Time.IsExpired() { + // expired ban, ignore and clean up later + doCleanup = true + } else if netInfo.Network.Contains(addr) { + return true, &netInfo.Info } } // no matches! - return + return false, nil } -func (dm *DLineManager) loadFromDatastore() { - dlinePrefix := fmt.Sprintf(keyDlineEntry, "") - dm.server.store.View(func(tx *buntdb.Tx) error { - tx.AscendGreaterOrEqual("", dlinePrefix, func(key, value string) bool { - if !strings.HasPrefix(key, dlinePrefix) { - return false - } +func (s *Server) loadDLines() { + s.dlines = NewDLineManager() + // load from datastore + s.store.View(func(tx *buntdb.Tx) error { + //TODO(dan): We could make this safer + tx.AscendKeys("bans.dline *", func(key, value string) bool { // get address name - key = strings.TrimPrefix(key, dlinePrefix) + key = key[len("bans.dline "):] // load addr/net - hostNet, err := flatip.ParseToNormalizedNet(key) + var hostAddr net.IP + var hostNet *net.IPNet + _, hostNet, err := net.ParseCIDR(key) if err != nil { - dm.server.logger.Error("internal", "bad dline cidr", err.Error()) - return true + hostAddr = net.ParseIP(key) } // load ban info var info IPBanInfo - err = json.Unmarshal([]byte(value), &info) - if err != nil { - dm.server.logger.Error("internal", "bad dline data", err.Error()) - return true - } + json.Unmarshal([]byte(value), &info) // set opername if it isn't already set if info.OperName == "" { - info.OperName = dm.server.name + info.OperName = s.name } // add to the server - dm.addNetworkInternal(hostNet, info) + if hostNet == nil { + s.dlines.AddIP(hostAddr, info.Time, info.Reason, info.OperReason, info.OperName) + } else { + s.dlines.AddNetwork(*hostNet, info.Time, info.Reason, info.OperReason, info.OperName) + } - return true + return true // true to continue I guess? }) return nil }) } - -func (server *Server) loadDLines() { - server.dlines = NewDLineManager(server) -} diff --git a/irc/email/dkim.go b/irc/email/dkim.go deleted file mode 100644 index 10952478..00000000 --- a/irc/email/dkim.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) 2020 Shivaram Lingamneni -// released under the MIT license - -package email - -import ( - "errors" - dkim "github.com/toorop/go-dkim" - "os" -) - -var ( - ErrMissingFields = errors.New("DKIM config is missing fields") -) - -type DKIMConfig struct { - Domain string - Selector string - KeyFile string `yaml:"key-file"` - keyBytes []byte -} - -func (dkim *DKIMConfig) Postprocess() (err error) { - if dkim.Domain != "" { - if dkim.Selector == "" || dkim.KeyFile == "" { - return ErrMissingFields - } - dkim.keyBytes, err = os.ReadFile(dkim.KeyFile) - if err != nil { - return err - } - } - return nil -} - -var defaultOptions = dkim.SigOptions{ - Version: 1, - Canonicalization: "relaxed/relaxed", - Algo: "rsa-sha256", - Headers: []string{"from", "to", "subject", "message-id", "date"}, - BodyLength: 0, - QueryMethods: []string{"dns/txt"}, - AddSignatureTimestamp: true, - SignatureExpireIn: 0, -} - -func DKIMSign(message []byte, dkimConfig DKIMConfig) (result []byte, err error) { - options := defaultOptions - options.PrivateKey = dkimConfig.keyBytes - options.Domain = dkimConfig.Domain - options.Selector = dkimConfig.Selector - err = dkim.Sign(&message, options) - return message, err -} diff --git a/irc/email/email.go b/irc/email/email.go deleted file mode 100644 index 1d6bd1ce..00000000 --- a/irc/email/email.go +++ /dev/null @@ -1,268 +0,0 @@ -// Copyright (c) 2020 Shivaram Lingamneni -// released under the MIT license - -package email - -import ( - "bufio" - "bytes" - "errors" - "fmt" - "io" - "net" - "os" - "regexp" - "strings" - "time" - - "github.com/ergochat/ergo/irc/custime" - "github.com/ergochat/ergo/irc/smtp" - "github.com/ergochat/ergo/irc/utils" -) - -var ( - ErrBlacklistedAddress = errors.New("Email address is blacklisted") - ErrInvalidAddress = errors.New("Email address is invalid") - ErrNoMXRecord = errors.New("Couldn't resolve MX record") -) - -type BlacklistSyntax uint - -const ( - BlacklistSyntaxGlob BlacklistSyntax = iota - BlacklistSyntaxRegexp -) - -func blacklistSyntaxFromString(status string) (BlacklistSyntax, error) { - switch strings.ToLower(status) { - case "glob", "": - return BlacklistSyntaxGlob, nil - case "re", "regex", "regexp": - return BlacklistSyntaxRegexp, nil - default: - return BlacklistSyntaxRegexp, fmt.Errorf("Unknown blacklist syntax type `%s`", status) - } -} - -func (bs *BlacklistSyntax) UnmarshalYAML(unmarshal func(interface{}) error) error { - var orig string - var err error - if err = unmarshal(&orig); err != nil { - return err - } - if result, err := blacklistSyntaxFromString(orig); err == nil { - *bs = result - return nil - } else { - return err - } -} - -type MTAConfig struct { - Server string - Port int - Username string - Password string - ImplicitTLS bool `yaml:"implicit-tls"` -} - -type MailtoConfig struct { - // legacy config format assumed the use of an MTA/smarthost, - // so server, port, etc. appear directly at top level - // XXX: see https://github.com/go-yaml/yaml/issues/63 - MTAConfig `yaml:",inline"` - Enabled bool - Sender string - HeloDomain string `yaml:"helo-domain"` - RequireTLS bool `yaml:"require-tls"` - Protocol string `yaml:"protocol"` - LocalAddress string `yaml:"local-address"` - localAddress net.Addr - VerifyMessageSubject string `yaml:"verify-message-subject"` - DKIM DKIMConfig - MTAReal MTAConfig `yaml:"mta"` - AddressBlacklist []string `yaml:"address-blacklist"` - AddressBlacklistSyntax BlacklistSyntax `yaml:"address-blacklist-syntax"` - AddressBlacklistFile string `yaml:"address-blacklist-file"` - blacklistRegexes []*regexp.Regexp - Timeout time.Duration - PasswordReset struct { - Enabled bool - Cooldown custime.Duration - Timeout custime.Duration - } `yaml:"password-reset"` -} - -func (config *MailtoConfig) compileBlacklistEntry(source string) (re *regexp.Regexp, err error) { - if config.AddressBlacklistSyntax == BlacklistSyntaxGlob { - return utils.CompileGlob(source, false) - } else { - return regexp.Compile(fmt.Sprintf("^%s$", source)) - } -} - -func (config *MailtoConfig) processBlacklistFile(filename string) (result []*regexp.Regexp, err error) { - f, err := os.Open(filename) - if err != nil { - return - } - defer f.Close() - reader := bufio.NewReader(f) - lineNo := 0 - for { - line, err := reader.ReadString('\n') - lineNo++ - line = strings.TrimSpace(line) - if line != "" && line[0] != '#' { - if compiled, compileErr := config.compileBlacklistEntry(line); compileErr == nil { - result = append(result, compiled) - } else { - return result, fmt.Errorf("Failed to compile line %d of blacklist-regex-file `%s`: %w", lineNo, line, compileErr) - } - } - switch err { - case io.EOF: - return result, nil - case nil: - continue - default: - return result, err - } - } -} - -func (config *MailtoConfig) Postprocess(heloDomain string) (err error) { - if config.Sender == "" { - return errors.New("Invalid mailto sender address") - } - - // check for MTA config fields at top level, - // copy to MTAReal if present - if config.Server != "" && config.MTAReal.Server == "" { - config.MTAReal = config.MTAConfig - } - - if config.HeloDomain == "" { - config.HeloDomain = heloDomain - } - - if config.AddressBlacklistFile != "" { - config.blacklistRegexes, err = config.processBlacklistFile(config.AddressBlacklistFile) - if err != nil { - return err - } - } else if len(config.AddressBlacklist) != 0 { - config.blacklistRegexes = make([]*regexp.Regexp, 0, len(config.AddressBlacklist)) - for _, reg := range config.AddressBlacklist { - compiled, err := config.compileBlacklistEntry(reg) - if err != nil { - return err - } - config.blacklistRegexes = append(config.blacklistRegexes, compiled) - } - } - - config.Protocol = strings.ToLower(config.Protocol) - if config.Protocol == "" { - config.Protocol = "tcp" - } - if !(config.Protocol == "tcp" || config.Protocol == "tcp4" || config.Protocol == "tcp6") { - return fmt.Errorf("Invalid protocol for email sending: `%s`", config.Protocol) - } - - if config.LocalAddress != "" { - ipAddr := net.ParseIP(config.LocalAddress) - if ipAddr == nil { - return fmt.Errorf("Could not parse local-address for email sending: `%s`", config.LocalAddress) - } - config.localAddress = &net.TCPAddr{ - IP: ipAddr, - Port: 0, - } - } - - if config.MTAConfig.Server != "" { - // smarthost, nothing more to validate - return nil - } - - return config.DKIM.Postprocess() -} - -// are we sending email directly, as opposed to deferring to an MTA? -func (config *MailtoConfig) DirectSendingEnabled() bool { - return config.MTAReal.Server == "" -} - -// get the preferred MX record hostname, "" on error -func lookupMX(domain string) (server string) { - var minPref uint16 - results, err := net.LookupMX(domain) - if err != nil { - return - } - for _, result := range results { - if minPref == 0 || result.Pref < minPref { - server, minPref = result.Host, result.Pref - } - } - return -} - -func ComposeMail(config MailtoConfig, recipient, subject string) (message bytes.Buffer) { - fmt.Fprintf(&message, "From: %s\r\n", config.Sender) - fmt.Fprintf(&message, "To: %s\r\n", recipient) - dkimDomain := config.DKIM.Domain - if dkimDomain != "" { - fmt.Fprintf(&message, "Message-ID: <%s@%s>\r\n", utils.GenerateSecretKey(), dkimDomain) - } else { - // #2108: send Message-ID even if dkim is not enabled - fmt.Fprintf(&message, "Message-ID: <%s-%s>\r\n", utils.GenerateSecretKey(), config.Sender) - } - fmt.Fprintf(&message, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z)) - fmt.Fprintf(&message, "Subject: %s\r\n", subject) - message.WriteString("\r\n") // blank line: end headers, begin message body - return message -} - -func SendMail(config MailtoConfig, recipient string, msg []byte) (err error) { - recipientLower := strings.ToLower(recipient) - for _, reg := range config.blacklistRegexes { - if reg.MatchString(recipientLower) { - return ErrBlacklistedAddress - } - } - - if config.DKIM.Domain != "" { - msg, err = DKIMSign(msg, config.DKIM) - if err != nil { - return - } - } - - var addr string - var auth smtp.Auth - var implicitTLS bool - if !config.DirectSendingEnabled() { - addr = fmt.Sprintf("%s:%d", config.MTAReal.Server, config.MTAReal.Port) - if config.MTAReal.Username != "" && config.MTAReal.Password != "" { - auth = smtp.PlainAuth("", config.MTAReal.Username, config.MTAReal.Password, config.MTAReal.Server) - } - implicitTLS = config.MTAReal.ImplicitTLS - } else { - idx := strings.IndexByte(recipient, '@') - if idx == -1 { - return ErrInvalidAddress - } - mx := lookupMX(recipient[idx+1:]) - if mx == "" { - return ErrNoMXRecord - } - addr = fmt.Sprintf("%s:smtp", mx) - } - - return smtp.SendMail( - addr, auth, config.HeloDomain, config.Sender, []string{recipient}, msg, - config.RequireTLS, implicitTLS, config.Protocol, config.localAddress, config.Timeout, - ) -} diff --git a/irc/errors.go b/irc/errors.go index aed797eb..1c43a5c7 100644 --- a/irc/errors.go +++ b/irc/errors.go @@ -5,78 +5,43 @@ package irc -import ( - "errors" - "fmt" - "time" - - "github.com/ergochat/ergo/irc/utils" -) +import "errors" // Runtime Errors var ( - errAccountAlreadyRegistered = errors.New(`Account already exists`) - errAccountAlreadyUnregistered = errors.New(`That account name was registered previously and can't be reused`) - errAccountAlreadyVerified = errors.New(`Account is already verified`) + errAccountAlreadyRegistered = errors.New("Account already exists") + errAccountAlreadyVerified = errors.New("Account is already verified") errAccountCantDropPrimaryNick = errors.New("Can't unreserve primary nickname") errAccountCreation = errors.New("Account could not be created") + errAccountCredUpdate = errors.New("Could not update password hash to new method") errAccountDoesNotExist = errors.New("Account does not exist") errAccountInvalidCredentials = errors.New("Invalid account credentials") - errAccountBadPassphrase = errors.New(`Passphrase contains forbidden characters or is otherwise invalid`) errAccountNickReservationFailed = errors.New("Could not (un)reserve nick") errAccountNotLoggedIn = errors.New("You're not logged into an account") - errAccountAlreadyLoggedIn = errors.New("You're already logged into an account") errAccountTooManyNicks = errors.New("Account has too many reserved nicks") - errAccountUnverified = errors.New(`Account is not yet verified`) - errAccountSuspended = errors.New(`Account has been suspended`) + errAccountUnverified = errors.New("Account is not yet verified") errAccountVerificationFailed = errors.New("Account verification failed") errAccountVerificationInvalidCode = errors.New("Invalid account verification code") - errAccountUpdateFailed = errors.New(`Error while updating your account information`) - errAccountMustHoldNick = errors.New(`You must hold that nickname in order to register it`) - errAuthzidAuthcidMismatch = errors.New(`authcid and authzid must be the same`) - errCertfpAlreadyExists = errors.New(`An account already exists for your certificate fingerprint`) - errChannelNotOwnedByAccount = errors.New("Channel not owned by the specified account") - errChannelTransferNotOffered = errors.New(`You weren't offered ownership of that channel`) + errCallbackFailed = errors.New("Account verification could not be sent") + errCertfpAlreadyExists = errors.New("An account already exists with your certificate") errChannelAlreadyRegistered = errors.New("Channel is already registered") - errChannelNotRegistered = errors.New("Channel is not registered") - errChannelNameInUse = errors.New(`Channel name in use`) - errInvalidChannelName = errors.New(`Invalid channel name`) + errChannelNameInUse = errors.New("Channel name in use") + errInvalidChannelName = errors.New("Invalid channel name") errMonitorLimitExceeded = errors.New("Monitor limit exceeded") errNickMissing = errors.New("nick missing") - errNicknameInvalid = errors.New("invalid nickname") errNicknameInUse = errors.New("nickname in use") - errInsecureReattach = errors.New("insecure reattach") errNicknameReserved = errors.New("nickname is reserved") - errNickAccountMismatch = errors.New(`Your nickname must match your account name; try logging out and logging back in with SASL`) errNoExistingBan = errors.New("Ban does not exist") - errNoSuchChannel = errors.New(`No such channel`) - errChannelPurged = errors.New(`This channel was purged by the server operators and cannot be used`) - errChannelPurgedAlready = errors.New(`This channel was already purged and cannot be purged again`) - errConfusableIdentifier = errors.New("This identifier is confusable with one already in use") - errInsufficientPrivs = errors.New("Insufficient privileges") - errInvalidUsername = errors.New("Invalid username") - errFeatureDisabled = errors.New(`That feature is disabled`) - errBanned = errors.New("IP or nickmask banned") - errInvalidParams = utils.ErrInvalidParams - errNoVhost = errors.New(`You do not have an approved vhost`) - errLimitExceeded = errors.New("Limit exceeded") - errNoop = errors.New("Action was a no-op") - errCASFailed = errors.New("Compare-and-swap update of database value failed") - errEmptyCredentials = errors.New("No more credentials are approved") - errCredsExternallyManaged = errors.New("Credentials are externally managed and cannot be changed here") - errNoSCRAMCredentials = errors.New("SCRAM credentials are not initialized for this account; consult the user guide") - errInvalidMultilineBatch = errors.New("Invalid multiline batch") - errTimedOut = errors.New("Operation timed out") - errInvalidUtf8 = errors.New("Message rejected for invalid utf8") - errClientDestroyed = errors.New("Client was already destroyed") - errTooManyChannels = errors.New("You have joined too many channels") - errWrongChannelKey = errors.New("Cannot join password-protected channel without the password") - errInviteOnly = errors.New("Cannot join invite-only channel without an invite") - errRegisteredOnly = errors.New("Cannot join registered-only channel without an account") - errValidEmailRequired = errors.New("A valid email address is required for account registration") - errInvalidAccountRename = errors.New("Account renames can only change the casefolding of the account name") - errNameReserved = errors.New(`Name reserved due to a prior registration`) - errInvalidBearerTokenType = errors.New("invalid bearer token type") + errNoSuchChannel = errors.New("No such channel") + errRenamePrivsNeeded = errors.New("Only chanops can rename channels") + errSaslFail = errors.New("SASL failed") +) + +// Socket Errors +var ( + errNoPeerCerts = errors.New("Client did not provide a certificate") + errNotTLS = errors.New("Not a TLS connection") + errReadQ = errors.New("ReadQ Exceeded") ) // String Errors @@ -86,18 +51,19 @@ var ( errInvalidCharacter = errors.New("Invalid character") ) -type CertKeyError struct { - Err error -} - -func (ck *CertKeyError) Error() string { - return fmt.Sprintf("Invalid TLS cert/key pair: %v", ck.Err) -} - -type ThrottleError struct { - time.Duration -} - -func (te *ThrottleError) Error() string { - return fmt.Sprintf(`Please wait at least %v and try again`, te.Duration.Round(time.Millisecond)) -} +// Config Errors +var ( + ErrDatastorePathMissing = errors.New("Datastore path missing") + ErrInvalidCertKeyPair = errors.New("tls cert+key: invalid pair") + ErrLimitsAreInsane = errors.New("Limits aren't setup properly, check them and make them sane") + ErrLineLengthsTooSmall = errors.New("Line lengths must be 512 or greater (check the linelen section under server->limits)") + ErrLoggerExcludeEmpty = errors.New("Encountered logging type '-' with no type to exclude") + ErrLoggerFilenameMissing = errors.New("Logging configuration specifies 'file' method but 'filename' is empty") + ErrLoggerHasNoTypes = errors.New("Logger has no types to log") + ErrNetworkNameMissing = errors.New("Network name missing") + ErrNoFingerprintOrPassword = errors.New("Fingerprint or password needs to be specified") + ErrNoListenersDefined = errors.New("Server listening addresses missing") + ErrOperClassDependencies = errors.New("OperClasses contains a looping dependency, or a class extends from a class that doesn't exist") + ErrServerNameMissing = errors.New("Server name missing") + ErrServerNameNotHostname = errors.New("Server name must match the format of a hostname") +) diff --git a/irc/fakelag.go b/irc/fakelag.go index 4a533981..a3ed64b2 100644 --- a/irc/fakelag.go +++ b/irc/fakelag.go @@ -4,7 +4,6 @@ package irc import ( - "maps" "time" ) @@ -25,51 +24,33 @@ const ( // this is intentionally not threadsafe, because it should only be touched // from the loop that accepts the client's input and runs commands type Fakelag struct { - config FakelagConfig - suspended bool - nowFunc func() time.Time - sleepFunc func(time.Duration) + window time.Duration + burstLimit uint + throttleMessagesPerWindow uint + cooldown time.Duration + nowFunc func() time.Time + sleepFunc func(time.Duration) state FakelagState burstCount uint // number of messages sent in the current burst lastTouch time.Time } -func (fl *Fakelag) Initialize(config FakelagConfig) { - fl.config = config - // XXX don't share mutable member CommandBudgets: - if config.CommandBudgets != nil { - fl.config.CommandBudgets = maps.Clone(config.CommandBudgets) - } - fl.nowFunc = time.Now - fl.sleepFunc = time.Sleep - fl.state = FakelagBursting -} - -// Idempotently turn off fakelag if it's enabled -func (fl *Fakelag) Suspend() { - if fl.config.Enabled { - fl.suspended = true - fl.config.Enabled = false - } -} - -// Idempotently turn fakelag back on if it was previously Suspend'ed -func (fl *Fakelag) Unsuspend() { - if fl.suspended { - fl.config.Enabled = true - fl.suspended = false +func NewFakelag(window time.Duration, burstLimit uint, throttleMessagesPerWindow uint, cooldown time.Duration) *Fakelag { + return &Fakelag{ + window: window, + burstLimit: burstLimit, + throttleMessagesPerWindow: throttleMessagesPerWindow, + cooldown: cooldown, + nowFunc: time.Now, + sleepFunc: time.Sleep, + state: FakelagBursting, } } // register a new command, sleep if necessary to delay it -func (fl *Fakelag) Touch(command string) { - if !fl.config.Enabled { - return - } - - if budget, ok := fl.config.CommandBudgets[command]; ok && budget > 0 { - fl.config.CommandBudgets[command] = budget - 1 +func (fl *Fakelag) Touch() { + if fl == nil { return } @@ -80,12 +61,12 @@ func (fl *Fakelag) Touch(command string) { if fl.state == FakelagBursting { // determine if the previous burst is over - if elapsed > fl.config.Cooldown { + if elapsed > fl.cooldown { fl.burstCount = 0 } fl.burstCount++ - if fl.burstCount > fl.config.BurstLimit { + if fl.burstCount > fl.burstLimit { // reset burst window for next time fl.burstCount = 0 // transition to throttling @@ -97,27 +78,16 @@ func (fl *Fakelag) Touch(command string) { } if fl.state == FakelagThrottled { - if elapsed > fl.config.Cooldown { + if elapsed > fl.cooldown { // let them burst again fl.state = FakelagBursting - fl.burstCount = 1 return } - var sleepDuration time.Duration - if fl.config.MessagesPerWindow > 0 { - // space them out by at least window/messagesperwindow - sleepDuration = time.Duration((int64(fl.config.Window) / int64(fl.config.MessagesPerWindow)) - int64(elapsed)) - } else { - // only burst messages are allowed: sleep until cooldown expires, - // then count this as a burst message - sleepDuration = time.Duration(int64(fl.config.Cooldown) - int64(elapsed)) - fl.state = FakelagBursting - fl.burstCount = 1 - } - if sleepDuration > 0 { - fl.sleepFunc(sleepDuration) - // the touch time should take into account the time we slept - fl.lastTouch = fl.nowFunc() + // space them out by at least window/messagesperwindow + sleepDuration := time.Duration((int64(fl.window) / int64(fl.throttleMessagesPerWindow)) - int64(elapsed)) + if sleepDuration < 0 { + sleepDuration = 0 } + fl.sleepFunc(sleepDuration) } } diff --git a/irc/fakelag_test.go b/irc/fakelag_test.go index e1079466..b2141039 100644 --- a/irc/fakelag_test.go +++ b/irc/fakelag_test.go @@ -40,27 +40,20 @@ func (mt *mockTime) lastSleep() (slept bool, duration time.Duration) { } func newFakelagForTesting(window time.Duration, burstLimit uint, throttleMessagesPerWindow uint, cooldown time.Duration) (*Fakelag, *mockTime) { - fl := Fakelag{} - fl.config = FakelagConfig{ - Enabled: true, - Window: window, - BurstLimit: burstLimit, - MessagesPerWindow: throttleMessagesPerWindow, - Cooldown: cooldown, - } + fl := NewFakelag(window, burstLimit, throttleMessagesPerWindow, cooldown) mt := new(mockTime) mt.now, _ = time.Parse("Mon Jan 2 15:04:05 -0700 MST 2006", "Mon Jan 2 15:04:05 -0700 MST 2006") mt.lastCheckedSleep = -1 fl.nowFunc = mt.Now fl.sleepFunc = mt.Sleep - return &fl, mt + return fl, mt } func TestFakelag(t *testing.T) { window, _ := time.ParseDuration("1s") fl, mt := newFakelagForTesting(window, 3, 2, window) - fl.Touch("") + fl.Touch() slept, _ := mt.lastSleep() if slept { t.Fatalf("should not have slept") @@ -69,7 +62,7 @@ func TestFakelag(t *testing.T) { interval, _ := time.ParseDuration("100ms") for i := 0; i < 2; i++ { mt.pause(interval) - fl.Touch("") + fl.Touch() slept, _ := mt.lastSleep() if slept { t.Fatalf("should not have slept") @@ -77,7 +70,7 @@ func TestFakelag(t *testing.T) { } mt.pause(interval) - fl.Touch("") + fl.Touch() if fl.state != FakelagThrottled { t.Fatalf("should be throttled") } @@ -90,19 +83,17 @@ func TestFakelag(t *testing.T) { t.Fatalf("incorrect sleep time: %v != %v", expected, duration) } - // send another message without a pause; we should have to sleep for 500 msec - fl.Touch("") + fl.Touch() if fl.state != FakelagThrottled { t.Fatalf("should be throttled") } slept, duration = mt.lastSleep() - expected, _ = time.ParseDuration("500ms") - if duration != expected { - t.Fatalf("incorrect sleep time: %v != %v", duration, expected) + if duration != interval { + t.Fatalf("incorrect sleep time: %v != %v", interval, duration) } mt.pause(interval * 6) - fl.Touch("") + fl.Touch() if fl.state != FakelagThrottled { t.Fatalf("should still be throttled") } @@ -112,7 +103,7 @@ func TestFakelag(t *testing.T) { } mt.pause(window * 2) - fl.Touch("") + fl.Touch() if fl.state != FakelagBursting { t.Fatalf("should be bursting again") } @@ -121,35 +112,3 @@ func TestFakelag(t *testing.T) { t.Fatalf("should not have slept") } } - -func TestSuspend(t *testing.T) { - window, _ := time.ParseDuration("1s") - fl, _ := newFakelagForTesting(window, 3, 2, window) - assertEqual(fl.config.Enabled, true) - - // suspend idempotently disables - fl.Suspend() - assertEqual(fl.config.Enabled, false) - fl.Suspend() - assertEqual(fl.config.Enabled, false) - // unsuspend idempotently enables - fl.Unsuspend() - assertEqual(fl.config.Enabled, true) - fl.Unsuspend() - assertEqual(fl.config.Enabled, true) - fl.Suspend() - assertEqual(fl.config.Enabled, false) - - fl2, _ := newFakelagForTesting(window, 3, 2, window) - fl2.config.Enabled = false - - // if we were never enabled, suspend and unsuspend are both no-ops - fl2.Suspend() - assertEqual(fl2.config.Enabled, false) - fl2.Suspend() - assertEqual(fl2.config.Enabled, false) - fl2.Unsuspend() - assertEqual(fl2.config.Enabled, false) - fl2.Unsuspend() - assertEqual(fl2.config.Enabled, false) -} diff --git a/irc/flatip/adhoc.go b/irc/flatip/adhoc.go deleted file mode 100644 index 6c994c56..00000000 --- a/irc/flatip/adhoc.go +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright 2020 Shivaram Lingamneni -// Released under the MIT license - -package flatip - -// begin ad-hoc utilities - -// ParseToNormalizedNet attempts to interpret a string either as an IP -// network in CIDR notation, returning an IPNet, or as an IP address, -// returning an IPNet that contains only that address. -func ParseToNormalizedNet(netstr string) (ipnet IPNet, err error) { - _, ipnet, err = ParseCIDR(netstr) - if err == nil { - return - } - ip, err := ParseIP(netstr) - if err == nil { - ipnet.IP = ip - ipnet.PrefixLen = 128 - } - return -} - -// IPInNets is a convenience function for testing whether an IP is contained -// in any member of a slice of IPNet's. -func IPInNets(addr IP, nets []IPNet) bool { - for _, net := range nets { - if net.Contains(addr) { - return true - } - } - return false -} diff --git a/irc/flatip/flatip.go b/irc/flatip/flatip.go deleted file mode 100644 index 0084cf2b..00000000 --- a/irc/flatip/flatip.go +++ /dev/null @@ -1,220 +0,0 @@ -// Copyright 2020 Shivaram Lingamneni -// Copyright 2009 The Go Authors -// Released under the MIT license - -package flatip - -import ( - "bytes" - "errors" - "net" -) - -var ( - v4InV6Prefix = []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff} - - IPv6loopback = IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1} - IPv6zero = IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} - IPv4zero = IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 0, 0, 0, 0} - - ErrInvalidIPString = errors.New("String could not be interpreted as an IP address") -) - -// packed versions of net.IP and net.IPNet; these are pure value types, -// so they can be compared with == and used as map keys. - -// IP is a 128-bit representation of an IP address, using the 4-in-6 mapping -// to represent IPv4 addresses. -type IP [16]byte - -// IPNet is a IP network. In a valid value, all bits after PrefixLen are zeroes. -type IPNet struct { - IP - PrefixLen uint8 -} - -// NetIP converts an IP into a net.IP. -func (ip IP) NetIP() (result net.IP) { - result = make(net.IP, 16) - copy(result[:], ip[:]) - return -} - -// FromNetIP converts a net.IP into an IP. -func FromNetIP(ip net.IP) (result IP) { - if len(ip) == 16 { - copy(result[:], ip[:]) - } else { - result[10] = 0xff - result[11] = 0xff - copy(result[12:], ip[:]) - } - return -} - -// IPv4 returns the IP address representation of a.b.c.d -func IPv4(a, b, c, d byte) (result IP) { - copy(result[:12], v4InV6Prefix) - result[12] = a - result[13] = b - result[14] = c - result[15] = d - return -} - -// ParseIP parses a string representation of an IP address into an IP. -// Unlike net.ParseIP, it returns an error instead of a zero value on failure, -// since the zero value of `IP` is a representation of a valid IP (::0, the -// IPv6 "unspecified address"). -func ParseIP(ipstr string) (ip IP, err error) { - // TODO reimplement this without net.ParseIP - netip := net.ParseIP(ipstr) - if netip == nil { - err = ErrInvalidIPString - return - } - netip = netip.To16() - copy(ip[:], netip) - return -} - -// String returns the string representation of an IP -func (ip IP) String() string { - // TODO reimplement this without using (net.IP).String() - return (net.IP)(ip[:]).String() -} - -// IsIPv4 returns whether the IP is an IPv4 address. -func (ip IP) IsIPv4() bool { - return bytes.Equal(ip[:12], v4InV6Prefix) -} - -// IsLoopback returns whether the IP is a loopback address. -func (ip IP) IsLoopback() bool { - if ip.IsIPv4() { - return ip[12] == 127 - } else { - return ip == IPv6loopback - } -} - -func (ip IP) IsUnspecified() bool { - return ip == IPv4zero || ip == IPv6zero -} - -func rawCidrMask(length int) (m IP) { - n := uint(length) - for i := 0; i < 16; i++ { - if n >= 8 { - m[i] = 0xff - n -= 8 - continue - } - m[i] = ^byte(0xff >> n) - return - } - return -} - -func (ip IP) applyMask(mask IP) (result IP) { - for i := 0; i < 16; i += 1 { - result[i] = ip[i] & mask[i] - } - return -} - -func cidrMask(ones, bits int) (result IP) { - switch bits { - case 32: - return rawCidrMask(96 + ones) - case 128: - return rawCidrMask(ones) - default: - return - } -} - -// Mask returns the result of masking ip with the CIDR mask of -// length 'ones', out of a total of 'bits' (which must be either -// 32 for an IPv4 subnet or 128 for an IPv6 subnet). -func (ip IP) Mask(ones, bits int) (result IP) { - return ip.applyMask(cidrMask(ones, bits)) -} - -// ToNetIPNet converts an IPNet into a net.IPNet. -func (cidr IPNet) ToNetIPNet() (result net.IPNet) { - return net.IPNet{ - IP: cidr.IP.NetIP(), - Mask: net.CIDRMask(int(cidr.PrefixLen), 128), - } -} - -// Contains retuns whether the network contains `ip`. -func (cidr IPNet) Contains(ip IP) bool { - maskedIP := ip.Mask(int(cidr.PrefixLen), 128) - return cidr.IP == maskedIP -} - -func (cidr IPNet) Size() (ones, bits int) { - if cidr.IP.IsIPv4() { - return int(cidr.PrefixLen) - 96, 32 - } else { - return int(cidr.PrefixLen), 128 - } -} - -// FromNetIPnet converts a net.IPNet into an IPNet. -func FromNetIPNet(network net.IPNet) (result IPNet) { - ones, _ := network.Mask.Size() - if len(network.IP) == 16 { - copy(result.IP[:], network.IP[:]) - } else { - result.IP[10] = 0xff - result.IP[11] = 0xff - copy(result.IP[12:], network.IP[:]) - ones += 96 - } - // perform masking so that equal CIDRs are == - result.IP = result.IP.Mask(ones, 128) - result.PrefixLen = uint8(ones) - return -} - -// String returns a string representation of an IPNet. -func (cidr IPNet) String() string { - ip := make(net.IP, 16) - copy(ip[:], cidr.IP[:]) - ipnet := net.IPNet{ - IP: ip, - Mask: net.CIDRMask(int(cidr.PrefixLen), 128), - } - return ipnet.String() -} - -// HumanReadableString returns a string representation of an IPNet; -// if the network contains only a single IP address, it returns -// a representation of that address. -func (cidr IPNet) HumanReadableString() string { - if cidr.PrefixLen == 128 { - return cidr.IP.String() - } - return cidr.String() -} - -// IsZero tests whether ipnet is the zero value of an IPNet, 0::0/0. -// Although this is a valid subnet, it can still be used as a sentinel -// value in some contexts. -func (ipnet IPNet) IsZero() bool { - return ipnet == IPNet{} -} - -// ParseCIDR parses a string representation of an IP network in CIDR notation, -// then returns it as an IPNet (along with the original, unmasked address). -func ParseCIDR(netstr string) (ip IP, ipnet IPNet, err error) { - // TODO reimplement this without net.ParseCIDR - nip, nipnet, err := net.ParseCIDR(netstr) - if err != nil { - return - } - return FromNetIP(nip), FromNetIPNet(*nipnet), nil -} diff --git a/irc/flatip/flatip_test.go b/irc/flatip/flatip_test.go deleted file mode 100644 index f689d6dd..00000000 --- a/irc/flatip/flatip_test.go +++ /dev/null @@ -1,208 +0,0 @@ -package flatip - -import ( - "bytes" - "fmt" - "math/rand" - "net" - "reflect" - "testing" - "time" -) - -func easyParseIP(ipstr string) (result net.IP) { - result = net.ParseIP(ipstr) - if result == nil { - panic(ipstr) - } - return -} - -func easyParseFlat(ipstr string) (result IP) { - x := easyParseIP(ipstr) - return FromNetIP(x) -} - -func easyParseIPNet(nipstr string) (result net.IPNet) { - _, nip, err := net.ParseCIDR(nipstr) - if err != nil { - panic(err) - } - return *nip -} - -func TestBasic(t *testing.T) { - nip := easyParseIP("8.8.8.8") - flatip := FromNetIP(nip) - if flatip.String() != "8.8.8.8" { - t.Errorf("conversions don't work") - } -} - -func TestLoopback(t *testing.T) { - localhost_v4 := easyParseFlat("127.0.0.1") - localhost_v4_again := easyParseFlat("127.2.3.4") - google := easyParseFlat("8.8.8.8") - loopback_v6 := easyParseFlat("::1") - google_v6 := easyParseFlat("2607:f8b0:4006:801::2004") - - if !(localhost_v4.IsLoopback() && localhost_v4_again.IsLoopback() && loopback_v6.IsLoopback()) { - t.Errorf("can't detect loopbacks") - } - - if google_v6.IsLoopback() || google.IsLoopback() { - t.Errorf("incorrectly detected loopbacks") - } -} - -func TestContains(t *testing.T) { - nipnet := easyParseIPNet("8.8.0.0/16") - flatipnet := FromNetIPNet(nipnet) - nip := easyParseIP("8.8.8.8") - flatip_ := FromNetIP(nip) - if !flatipnet.Contains(flatip_) { - t.Errorf("contains doesn't work") - } -} - -var testIPStrs = []string{ - "8.8.8.8", - "127.0.0.1", - "1.1.1.1", - "128.127.65.64", - "2001:0db8::1", - "::1", - "255.255.255.255", -} - -func doMaskingTest(ip net.IP, t *testing.T) { - flat := FromNetIP(ip) - netLen := len(ip) * 8 - for i := 0; i < netLen; i++ { - masked := flat.Mask(i, netLen) - netMask := net.CIDRMask(i, netLen) - netMasked := ip.Mask(netMask) - if !bytes.Equal(masked[:], netMasked.To16()) { - t.Errorf("Masking %s with %d/%d; expected %s, got %s", ip.String(), i, netLen, netMasked.String(), masked.String()) - } - } -} - -func assertEqual(found, expected interface{}) { - if !reflect.DeepEqual(found, expected) { - panic(fmt.Sprintf("expected %#v, found %#v", expected, found)) - } -} - -func TestSize(t *testing.T) { - _, net, err := ParseCIDR("8.8.8.8/24") - if err != nil { - panic(err) - } - ones, bits := net.Size() - assertEqual(ones, 24) - assertEqual(bits, 32) - - _, net, err = ParseCIDR("2001::0db8/64") - if err != nil { - panic(err) - } - ones, bits = net.Size() - assertEqual(ones, 64) - assertEqual(bits, 128) - - _, net, err = ParseCIDR("2001::0db8/96") - if err != nil { - panic(err) - } - ones, bits = net.Size() - assertEqual(ones, 96) - assertEqual(bits, 128) -} - -func TestMasking(t *testing.T) { - for _, ipstr := range testIPStrs { - doMaskingTest(easyParseIP(ipstr), t) - } -} - -func TestMaskingFuzz(t *testing.T) { - r := rand.New(rand.NewSource(time.Now().UnixNano())) - buf := make([]byte, 4) - for i := 0; i < 10000; i++ { - r.Read(buf) - doMaskingTest(net.IP(buf), t) - } - - buf = make([]byte, 16) - for i := 0; i < 10000; i++ { - r.Read(buf) - doMaskingTest(net.IP(buf), t) - } -} - -func BenchmarkMasking(b *testing.B) { - ip := easyParseIP("2001:0db8::42") - flat := FromNetIP(ip) - b.ResetTimer() - - for i := 0; i < b.N; i++ { - flat.Mask(64, 128) - } -} - -func BenchmarkMaskingLegacy(b *testing.B) { - ip := easyParseIP("2001:0db8::42") - mask := net.CIDRMask(64, 128) - b.ResetTimer() - - for i := 0; i < b.N; i++ { - ip.Mask(mask) - } -} - -func BenchmarkMaskingCached(b *testing.B) { - i := easyParseIP("2001:0db8::42") - flat := FromNetIP(i) - mask := cidrMask(64, 128) - b.ResetTimer() - - for i := 0; i < b.N; i++ { - flat.applyMask(mask) - } -} - -func BenchmarkMaskingConstruct(b *testing.B) { - for i := 0; i < b.N; i++ { - cidrMask(69, 128) - } -} - -func BenchmarkContains(b *testing.B) { - ip := easyParseIP("2001:0db8::42") - flat := FromNetIP(ip) - _, ipnet, err := net.ParseCIDR("2001:0db8::/64") - if err != nil { - panic(err) - } - flatnet := FromNetIPNet(*ipnet) - b.ResetTimer() - - for i := 0; i < b.N; i++ { - flatnet.Contains(flat) - } -} - -func BenchmarkContainsLegacy(b *testing.B) { - ip := easyParseIP("2001:0db8::42") - _, ipnetptr, err := net.ParseCIDR("2001:0db8::/64") - if err != nil { - panic(err) - } - ipnet := *ipnetptr - b.ResetTimer() - - for i := 0; i < b.N; i++ { - ipnet.Contains(ip) - } -} diff --git a/irc/flock/flock.go b/irc/flock/flock.go deleted file mode 100644 index 5f4f5913..00000000 --- a/irc/flock/flock.go +++ /dev/null @@ -1,24 +0,0 @@ -//go:build !(plan9 || solaris) - -package flock - -import ( - "errors" - - "github.com/gofrs/flock" -) - -var ( - CouldntAcquire = errors.New("Couldn't acquire flock (is another Ergo running?)") -) - -func TryAcquireFlock(path string) (fl Flocker, err error) { - f := flock.New(path) - success, err := f.TryLock() - if err != nil { - return nil, err - } else if !success { - return nil, CouldntAcquire - } - return f, nil -} diff --git a/irc/flock/flock_iface.go b/irc/flock/flock_iface.go deleted file mode 100644 index 87614571..00000000 --- a/irc/flock/flock_iface.go +++ /dev/null @@ -1,14 +0,0 @@ -package flock - -// documentation for github.com/gofrs/flock incorrectly claims that -// Flock implements sync.Locker; it does not because the Unlock method -// has a return type (err). -type Flocker interface { - Unlock() error -} - -type noopFlocker struct{} - -func (n *noopFlocker) Unlock() error { - return nil -} diff --git a/irc/flock/flock_unsupported.go b/irc/flock/flock_unsupported.go deleted file mode 100644 index 8cb5e587..00000000 --- a/irc/flock/flock_unsupported.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build plan9 || solaris - -package flock - -func TryAcquireFlock(path string) (fl Flocker, err error) { - return &noopFlocker{}, nil -} diff --git a/irc/gateways.go b/irc/gateways.go index 08adac93..83c2dc74 100644 --- a/irc/gateways.go +++ b/irc/gateways.go @@ -6,136 +6,86 @@ package irc import ( - "errors" + "fmt" "net" - "github.com/ergochat/ergo/irc/flatip" - "github.com/ergochat/ergo/irc/modes" - "github.com/ergochat/ergo/irc/utils" -) - -var ( - errBadGatewayAddress = errors.New("PROXY/WEBIRC commands are not accepted from this IP address") - errBadProxyLine = errors.New("Invalid PROXY/WEBIRC command") -) - -const ( - // https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt - // "a 108-byte buffer is always enough to store all the line and a trailing zero - // for string processing." - maxProxyLineLen = 107 + "github.com/oragono/oragono/irc/modes" + "github.com/oragono/oragono/irc/passwd" + "github.com/oragono/oragono/irc/utils" ) type webircConfig struct { - PasswordString string `yaml:"password"` - Password []byte `yaml:"password-bytes"` - Fingerprint *string // legacy name for certfp, #1050 - Certfp string + PasswordString string `yaml:"password"` + Password []byte `yaml:"password-bytes"` + Fingerprint string Hosts []string - AcceptHostname bool `yaml:"accept-hostname"` - allowedNets []net.IPNet } // Populate fills out our password or fingerprint. func (wc *webircConfig) Populate() (err error) { + if wc.Fingerprint == "" && wc.PasswordString == "" { + return ErrNoFingerprintOrPassword + } + if wc.PasswordString != "" { - wc.Password, err = decodeLegacyPasswordHash(wc.PasswordString) - if err != nil { - return - } + var password []byte + password, err = passwd.DecodePasswordHash(wc.PasswordString) + wc.Password = password } - - certfp := wc.Certfp - if certfp == "" && wc.Fingerprint != nil { - certfp = *wc.Fingerprint - } - if certfp != "" { - wc.Certfp, err = utils.NormalizeCertfp(certfp) - } - if err != nil { - return - } - - if wc.Certfp == "" && wc.PasswordString == "" { - return errors.New("webirc block has no certfp or password specified") - } - - wc.allowedNets, err = utils.ParseNetList(wc.Hosts) return err } +func isGatewayAllowed(addr net.Addr, gatewaySpec string) bool { + // "localhost" includes any loopback IP or unix domain socket + if gatewaySpec == "localhost" { + return utils.AddrIsLocal(addr) + } + + ip := utils.AddrToIP(addr) + if ip == nil { + return false + } + + // exact IP match + if ip.String() == gatewaySpec { + return true + } + + // CIDR match + _, gatewayNet, err := net.ParseCIDR(gatewaySpec) + if err != nil { + return false + } + return gatewayNet.Contains(ip) +} + // ApplyProxiedIP applies the given IP to the client. -func (client *Client) ApplyProxiedIP(session *Session, proxiedIP net.IP, tls bool) (err error, quitMsg string) { - // PROXY and WEBIRC are never accepted from a Tor listener, even if the address itself - // is whitelisted. Furthermore, don't accept PROXY or WEBIRC if we already accepted - // a proxied IP from any source (PROXY, WEBIRC, or X-Forwarded-For): - if session.isTor || session.proxiedIP != nil { - return errBadProxyLine, "" - } - +func (client *Client) ApplyProxiedIP(proxiedIP string, tls bool) (exiting bool) { // ensure IP is sane - if proxiedIP == nil { - return errBadProxyLine, "proxied IP is not valid" + parsedProxiedIP := net.ParseIP(proxiedIP) + if parsedProxiedIP == nil { + client.Quit(fmt.Sprintf(client.t("Proxied IP address is not valid: [%s]"), proxiedIP)) + return true } - proxiedIP = proxiedIP.To16() - isBanned, requireSASL, banMsg := client.server.checkBans(client.server.Config(), proxiedIP, true) + isBanned, banMsg := client.server.checkBans(parsedProxiedIP) if isBanned { - return errBanned, banMsg + client.Quit(banMsg) + return true } - client.requireSASL = requireSASL - if requireSASL { - client.requireSASLMessage = banMsg - } - // successfully added a limiter entry for the proxied IP; - // remove the entry for the real IP if applicable (#197) - client.server.connectionLimiter.RemoveClient(flatip.FromNetIP(session.realIP)) // given IP is sane! override the client's current IP - client.server.logger.Info("connect-ip", "Accepted proxy IP for client", proxiedIP.String()) + client.proxiedIP = parsedProxiedIP + client.rawHostname = utils.LookupHostname(proxiedIP) + client.hostname = client.rawHostname - client.stateMutex.Lock() - defer client.stateMutex.Unlock() - client.proxiedIP = proxiedIP - session.proxiedIP = proxiedIP - // nickmask will be updated when the client completes registration // set tls info - session.certfp = "" - session.peerCerts = nil - client.SetMode(modes.TLS, tls) - - return nil, "" -} - -// handle the PROXY command: http://www.haproxy.org/download/1.8/doc/proxy-protocol.txt -// PROXY must be sent as the first message in the session and has the syntax: -// PROXY TCP[46] SOURCEIP DESTIP SOURCEPORT DESTPORT\r\n -// unfortunately, an ipv6 SOURCEIP can start with a double colon; in this case, -// the message is invalid IRC and can't be parsed normally, hence the special handling. -func handleProxyCommand(server *Server, client *Client, session *Session, line string) (err error) { - var quitMsg string - defer func() { - if err != nil { - if quitMsg == "" { - quitMsg = client.t("Bad or unauthorized PROXY command") - } - client.Quit(quitMsg, session) - } - }() - - ip, err := utils.ParseProxyLineV1(line) - if err != nil { - return err - } else if ip == nil { - return nil - } - - if utils.IPInNets(client.realIP, server.Config().Server.proxyAllowedFromNets) { - // assume PROXY connections are always secure - err, quitMsg = client.ApplyProxiedIP(session, ip, true) - return + client.certfp = "" + if tls { + client.flags[modes.TLS] = true } else { - // real source IP is not authorized to issue PROXY: - return errBadGatewayAddress + delete(client.flags, modes.TLS) } + + return false } diff --git a/irc/getters.go b/irc/getters.go index 0e038c8f..5ef80d0c 100644 --- a/irc/getters.go +++ b/irc/getters.go @@ -4,156 +4,83 @@ package irc import ( - "fmt" - "maps" - "net" - "time" - - "github.com/ergochat/ergo/irc/caps" - "github.com/ergochat/ergo/irc/languages" - "github.com/ergochat/ergo/irc/modes" - "github.com/ergochat/ergo/irc/utils" + "github.com/oragono/oragono/irc/isupport" + "github.com/oragono/oragono/irc/modes" + "sync/atomic" ) -func (server *Server) Config() (config *Config) { - return server.config.Load() +func (server *Server) MaxSendQBytes() int { + return int(atomic.LoadUint32(&server.maxSendQBytes)) } -func (server *Server) GetOperator(name string) (oper *Oper) { - name, err := CasefoldName(name) - if err != nil { - return +func (server *Server) SetMaxSendQBytes(m int) { + atomic.StoreUint32(&server.maxSendQBytes, uint32(m)) +} + +func (server *Server) ISupport() *isupport.List { + server.configurableStateMutex.RLock() + defer server.configurableStateMutex.RUnlock() + return server.isupport +} + +func (server *Server) Limits() Limits { + server.configurableStateMutex.RLock() + defer server.configurableStateMutex.RUnlock() + return server.limits +} + +func (server *Server) Password() []byte { + server.configurableStateMutex.RLock() + defer server.configurableStateMutex.RUnlock() + return server.password +} + +func (server *Server) RecoverFromErrors() bool { + server.configurableStateMutex.RLock() + defer server.configurableStateMutex.RUnlock() + return server.recoverFromErrors +} + +func (server *Server) ProxyAllowedFrom() []string { + server.configurableStateMutex.RLock() + defer server.configurableStateMutex.RUnlock() + return server.proxyAllowedFrom +} + +func (server *Server) WebIRCConfig() []webircConfig { + server.configurableStateMutex.RLock() + defer server.configurableStateMutex.RUnlock() + return server.webirc +} + +func (server *Server) DefaultChannelModes() modes.Modes { + server.configurableStateMutex.RLock() + defer server.configurableStateMutex.RUnlock() + return server.defaultChannelModes +} + +func (server *Server) ChannelRegistrationEnabled() bool { + server.configurableStateMutex.RLock() + defer server.configurableStateMutex.RUnlock() + return server.channelRegistrationEnabled +} + +func (server *Server) AccountConfig() *AccountConfig { + server.configurableStateMutex.RLock() + defer server.configurableStateMutex.RUnlock() + if server.config == nil { + return nil } - return server.Config().operators[name] + return &server.config.Accounts } -func (server *Server) Languages() (lm *languages.Manager) { - return server.Config().languageManager -} - -func (server *Server) Defcon() uint32 { - return server.defcon.Load() -} - -func (server *Server) SetDefcon(defcon uint32) { - server.defcon.Store(defcon) -} - -func (client *Client) Sessions() (sessions []*Session) { - client.stateMutex.RLock() - sessions = client.sessions - client.stateMutex.RUnlock() - return -} - -type SessionData struct { - ctime time.Time - atime time.Time - ip net.IP - hostname string - certfp string - deviceID string - connInfo string - sessionID int64 - caps []string -} - -func (client *Client) AllSessionData(currentSession *Session, hasPrivs bool) (data []SessionData, currentIndex int) { - currentIndex = -1 - client.stateMutex.RLock() - defer client.stateMutex.RUnlock() - - data = make([]SessionData, len(client.sessions)) - for i, session := range client.sessions { - if session == currentSession { - currentIndex = i - } - data[i] = SessionData{ - atime: session.lastActive, - ctime: session.ctime, - hostname: session.rawHostname, - certfp: session.certfp, - deviceID: session.deviceID, - sessionID: session.sessionID, - } - if session.proxiedIP != nil { - data[i].ip = session.proxiedIP - } else { - data[i].ip = session.realIP - } - if hasPrivs { - data[i].connInfo = utils.DescribeConn(session.socket.conn.UnderlyingConn().Conn) - } - data[i].caps = session.capabilities.Strings(caps.Cap302, nil, 300) +func (server *Server) FakelagConfig() *FakelagConfig { + server.configurableStateMutex.RLock() + defer server.configurableStateMutex.RUnlock() + if server.config == nil { + return nil } - return -} - -func (client *Client) AddSession(session *Session) (success bool, numSessions int, lastSeen time.Time, wasAway, nowAway string) { - config := client.server.Config() - client.stateMutex.Lock() - defer client.stateMutex.Unlock() - - // client may be dying and ineligible to receive another session - if client.destroyed { - return - } - // success, attach the new session to the client - session.client = client - session.sessionID = client.nextSessionID - client.nextSessionID++ - newSessions := make([]*Session, len(client.sessions)+1) - copy(newSessions, client.sessions) - newSessions[len(newSessions)-1] = session - if client.accountSettings.AutoreplayMissed || session.deviceID != "" { - lastSeen = client.lastSeen[session.deviceID] - client.setLastSeen(time.Now().UTC(), session.deviceID) - } - client.sessions = newSessions - wasAway = client.awayMessage - if client.autoAwayEnabledNoMutex(config) { - client.setAutoAwayNoMutex(config) - } else { - if session.awayMessage != "" && session.awayMessage != "*" { - // set the away message - client.awayMessage = session.awayMessage - } else if session.awayMessage == "" && !session.awayAt.IsZero() { - // weird edge case: explicit `AWAY` or `AWAY :` during pre-registration makes the client back - client.awayMessage = "" - } - // else: the client sent no AWAY command at all, no-op - // or: the client sent `AWAY *`, which should not modify the publicly visible away state - } - nowAway = client.awayMessage - return true, len(client.sessions), lastSeen, wasAway, nowAway -} - -func (client *Client) removeSession(session *Session) (success bool, length int) { - if len(client.sessions) == 0 { - return - } - sessions := make([]*Session, 0, len(client.sessions)-1) - for _, currentSession := range client.sessions { - if session == currentSession { - success = true - } else { - sessions = append(sessions, currentSession) - } - } - client.sessions = sessions - length = len(sessions) - return -} - -// #1650: show an arbitrarily chosen session IP and hostname in RPL_WHOISACTUALLY -func (client *Client) getWhoisActually() (ip net.IP, hostname string) { - client.stateMutex.RLock() - defer client.stateMutex.RUnlock() - - for _, session := range client.sessions { - return session.IP(), session.rawHostname - } - return utils.IPv4LoopbackAddress, client.server.name + return &server.config.Fakelag } func (client *Client) Nick() string { @@ -174,12 +101,6 @@ func (client *Client) NickCasefolded() string { return client.nickCasefolded } -func (client *Client) NickMaskCasefolded() string { - client.stateMutex.RLock() - defer client.stateMutex.RUnlock() - return client.nickMaskCasefolded -} - func (client *Client) Username() string { client.stateMutex.RLock() defer client.stateMutex.RUnlock() @@ -192,107 +113,22 @@ func (client *Client) Hostname() string { return client.hostname } -func (client *Client) Away() (result bool, message string) { - client.stateMutex.Lock() - message = client.awayMessage - client.stateMutex.Unlock() - result = client.awayMessage != "" - return -} - -func (session *Session) SetAway(awayMessage string) (wasAway, nowAway string) { - client := session.client - config := client.server.Config() - - client.stateMutex.Lock() - defer client.stateMutex.Unlock() - - session.awayMessage = awayMessage - session.awayAt = time.Now().UTC() - - wasAway = client.awayMessage - if client.autoAwayEnabledNoMutex(config) { - client.setAutoAwayNoMutex(config) - } else if awayMessage != "*" { - client.awayMessage = awayMessage - } // else: `AWAY *`, should not modify publicly visible away state - nowAway = client.awayMessage - return -} - -func (client *Client) autoAwayEnabledNoMutex(config *Config) bool { - return client.registered && client.alwaysOn && - persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway) -} - -func (client *Client) setAutoAwayNoMutex(config *Config) { - // aggregate the away statuses of the individual sessions: - var globalAwayState string - var awaySetAt time.Time - for _, cSession := range client.sessions { - if cSession.awayMessage == "" { - // a session is active, we are not auto-away - client.awayMessage = "" - return - } else if cSession.awayAt.After(awaySetAt) && cSession.awayMessage != "*" { - // choose the latest valid away message from any session - globalAwayState = cSession.awayMessage - awaySetAt = cSession.awayAt - } - } - if awaySetAt.IsZero() { - // no sessions, enable auto-away - client.awayMessage = config.languageManager.Translate(client.languages, `User is currently disconnected`) - } else { - client.awayMessage = globalAwayState - } -} - -func (client *Client) AlwaysOn() (alwaysOn bool) { - client.stateMutex.RLock() - alwaysOn = client.registered && client.alwaysOn - client.stateMutex.RUnlock() - return -} - -// uniqueIdentifiers returns the strings for which the server enforces per-client -// uniqueness/ownership; no two clients can have colliding casefolded nicks or -// skeletons. -func (client *Client) uniqueIdentifiers() (nickCasefolded string, skeleton string) { +func (client *Client) Realname() string { client.stateMutex.RLock() defer client.stateMutex.RUnlock() - return client.nickCasefolded, client.skeleton + return client.realname } -func (client *Client) Oper() *Oper { +func (client *Client) Registered() bool { client.stateMutex.RLock() defer client.stateMutex.RUnlock() - return client.oper + return client.registered } -func (client *Client) Registered() (result bool) { - // `registered` is only written from the client's own goroutine, but may be - // read from other goroutines; therefore, the client's own goroutine may read - // the value without synchronization, but must write it with synchronization, - // and other goroutines must read it with synchronization +func (client *Client) Destroyed() bool { client.stateMutex.RLock() - result = client.registered - client.stateMutex.RUnlock() - return -} - -func (client *Client) RawHostname() (result string) { - client.stateMutex.Lock() - result = client.rawHostname - client.stateMutex.Unlock() - return -} - -func (client *Client) AwayMessage() (result string) { - client.stateMutex.RLock() - result = client.awayMessage - client.stateMutex.RUnlock() - return + defer client.stateMutex.RUnlock() + return client.isDestroyed } func (client *Client) Account() string { @@ -304,122 +140,57 @@ func (client *Client) Account() string { func (client *Client) AccountName() string { client.stateMutex.RLock() defer client.stateMutex.RUnlock() + if client.accountName == "" { + return "*" + } return client.accountName } -func (client *Client) Login(account ClientAccount) { - alwaysOn := persistenceEnabled(client.server.Config().Accounts.Multiclient.AlwaysOn, account.Settings.AlwaysOn) - client.stateMutex.Lock() - defer client.stateMutex.Unlock() - client.account = account.NameCasefolded - client.accountName = account.Name - client.accountSettings = account.Settings - // mark always-on here: it will not be respected until the client is registered - client.alwaysOn = alwaysOn - client.accountRegDate = account.RegisteredAt - return -} - -func (client *Client) setAccountName(name string) { - // XXX this assumes validation elsewhere - client.stateMutex.Lock() - defer client.stateMutex.Unlock() - client.accountName = name -} - -func (client *Client) setCloakedHostname(cloak string) { - client.stateMutex.Lock() - defer client.stateMutex.Unlock() - client.cloakedHostname = cloak - client.updateNickMaskNoMutex() -} - -func (client *Client) CloakedHostname() string { - client.stateMutex.Lock() - defer client.stateMutex.Unlock() - return client.cloakedHostname -} - -func (client *Client) historyCutoff() (cutoff time.Time) { - client.stateMutex.Lock() - if client.account != "" { - cutoff = client.accountRegDate - } else { - cutoff = client.ctime +func (client *Client) SetAccountName(account string) (changed bool) { + var casefoldedAccount string + var err error + if account != "" { + if casefoldedAccount, err = CasefoldName(account); err != nil { + return + } } - client.stateMutex.Unlock() + + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + changed = client.account != casefoldedAccount + client.account = casefoldedAccount + client.accountName = account return } -func (client *Client) Logout() { - client.stateMutex.Lock() - client.account = "" - client.accountName = "*" - client.alwaysOn = false - client.accountRegDate = time.Time{} - client.accountSettings = AccountSettings{} - client.stateMutex.Unlock() -} - -func (client *Client) AccountSettings() (result AccountSettings) { +func (client *Client) Authorized() bool { client.stateMutex.RLock() - result = client.accountSettings - client.stateMutex.RUnlock() - return + defer client.stateMutex.RUnlock() + return client.authorized } -func (client *Client) SetAccountSettings(settings AccountSettings) { - // we mark dirty if the client is transitioning to always-on - var becameAlwaysOn bool - alwaysOn := persistenceEnabled(client.server.Config().Accounts.Multiclient.AlwaysOn, settings.AlwaysOn) +func (client *Client) SetAuthorized(authorized bool) { client.stateMutex.Lock() - if client.registered { - // only allow the client to become always-on if their nick equals their account name - alwaysOn = alwaysOn && client.nick == client.accountName - becameAlwaysOn = (!client.alwaysOn && alwaysOn) - client.alwaysOn = alwaysOn - } - client.accountSettings = settings - client.stateMutex.Unlock() - if becameAlwaysOn { - client.markDirty(IncludeAllAttrs) - } + defer client.stateMutex.Unlock() + client.authorized = authorized } -func (client *Client) Languages() (languages []string) { +func (client *Client) PreregNick() string { client.stateMutex.RLock() - languages = client.languages - client.stateMutex.RUnlock() - return languages + defer client.stateMutex.RUnlock() + return client.preregNick } -func (client *Client) SetLanguages(languages []string) { +func (client *Client) SetPreregNick(preregNick string) { client.stateMutex.Lock() - client.languages = languages - client.stateMutex.Unlock() + defer client.stateMutex.Unlock() + client.preregNick = preregNick } func (client *Client) HasMode(mode modes.Mode) bool { - // client.flags has its own synch - return client.modes.HasMode(mode) -} - -func (client *Client) SetMode(mode modes.Mode, on bool) bool { - return client.modes.SetMode(mode, on) -} - -func (client *Client) SetRealname(realname string) { - client.stateMutex.Lock() - // TODO: make this configurable - client.realname = realname - if len(realname) > 128 { - client.realname = client.realname[:128] - } - alwaysOn := client.registered && client.alwaysOn - client.stateMutex.Unlock() - if alwaysOn { - client.markDirty(IncludeRealname) - } + client.stateMutex.RLock() + defer client.stateMutex.RUnlock() + return client.flags[mode] } func (client *Client) Channels() (result []*Channel) { @@ -435,159 +206,28 @@ func (client *Client) Channels() (result []*Channel) { return } -func (client *Client) NumChannels() int { - client.stateMutex.RLock() - defer client.stateMutex.RUnlock() - return len(client.channels) -} - -func (client *Client) WhoWas() (result WhoWas) { - return client.Details().WhoWas -} - -func (client *Client) Details() (result ClientDetails) { - client.stateMutex.RLock() - defer client.stateMutex.RUnlock() - return client.detailsNoMutex() -} - -func (client *Client) detailsNoMutex() (result ClientDetails) { - result.nick = client.nick - result.nickCasefolded = client.nickCasefolded - result.username = client.username - result.hostname = client.hostname - result.realname = client.realname - result.ip = client.getIPNoMutex() - result.nickMask = client.nickMaskString - result.nickMaskCasefolded = client.nickMaskCasefolded - result.account = client.account - result.accountName = client.accountName - return -} - -func (client *Client) UpdateActive(session *Session) { - now := time.Now().UTC() - client.stateMutex.Lock() - defer client.stateMutex.Unlock() - client.lastActive = now - session.lastActive = now -} - -func (client *Client) Realname() string { - client.stateMutex.RLock() - result := client.realname - client.stateMutex.RUnlock() - return result -} - -func (client *Client) IsExpiredAlwaysOn(config *Config) (result bool) { - client.stateMutex.Lock() - defer client.stateMutex.Unlock() - return client.checkAlwaysOnExpirationNoMutex(config, false) -} - -func (client *Client) checkAlwaysOnExpirationNoMutex(config *Config, ignoreRegistration bool) (result bool) { - if !((client.registered || ignoreRegistration) && client.alwaysOn) { - return false - } - deadline := time.Duration(config.Accounts.Multiclient.AlwaysOnExpiration) - if deadline == 0 { - return false - } - now := time.Now() - for _, ts := range client.lastSeen { - if now.Sub(ts) < deadline { - return false - } - } - return true -} - -func (client *Client) GetReadMarker(cfname string) (result string) { - client.stateMutex.RLock() - t, ok := client.readMarkers[cfname] - client.stateMutex.RUnlock() - if ok { - return fmt.Sprintf("timestamp=%s", t.Format(IRCv3TimestampFormat)) - } - return "*" -} - -func (client *Client) copyReadMarkers() (result map[string]time.Time) { - client.stateMutex.RLock() - defer client.stateMutex.RUnlock() - return maps.Clone(client.readMarkers) -} - -func (client *Client) SetReadMarker(cfname string, now time.Time) (result time.Time) { - client.stateMutex.Lock() - defer client.stateMutex.Unlock() - - if client.readMarkers == nil { - client.readMarkers = make(map[string]time.Time) - } - result = updateLRUMap(client.readMarkers, cfname, now, maxReadMarkers) - client.dirtyTimestamps = true - return -} - -func updateLRUMap(lru map[string]time.Time, key string, val time.Time, maxItems int) (result time.Time) { - if currentVal := lru[key]; currentVal.After(val) { - return currentVal - } - - lru[key] = val - // evict the least-recently-used entry if necessary - if maxItems < len(lru) { - var minKey string - var minVal time.Time - for key, val := range lru { - if minVal.IsZero() || val.Before(minVal) { - minKey, minVal = key, val - } - } - delete(lru, minKey) - } - return val -} - -func (client *Client) shouldFlushTimestamps() (result bool) { - client.stateMutex.Lock() - defer client.stateMutex.Unlock() - - result = client.dirtyTimestamps && client.registered && client.alwaysOn - client.dirtyTimestamps = false - return -} - -func (client *Client) setKlined() { - client.stateMutex.Lock() - client.isKlined = true - client.stateMutex.Unlock() -} - func (channel *Channel) Name() string { channel.stateMutex.RLock() defer channel.stateMutex.RUnlock() return channel.name } +func (channel *Channel) setName(name string) { + channel.stateMutex.Lock() + defer channel.stateMutex.Unlock() + channel.name = name +} + func (channel *Channel) NameCasefolded() string { channel.stateMutex.RLock() defer channel.stateMutex.RUnlock() return channel.nameCasefolded } -func (channel *Channel) Rename(name, nameCasefolded string) { +func (channel *Channel) setNameCasefolded(nameCasefolded string) { channel.stateMutex.Lock() - channel.name = name - if channel.nameCasefolded != nameCasefolded { - channel.nameCasefolded = nameCasefolded - if channel.registeredFounder != "" { - channel.registeredTime = time.Now().UTC() - } - } - channel.stateMutex.Unlock() + defer channel.stateMutex.Unlock() + channel.nameCasefolded = nameCasefolded } func (channel *Channel) Members() (result []*Client) { @@ -596,16 +236,34 @@ func (channel *Channel) Members() (result []*Client) { return channel.membersCache } -func (channel *Channel) setUserLimit(limit int) { +func (channel *Channel) UserLimit() uint64 { + channel.stateMutex.RLock() + defer channel.stateMutex.RUnlock() + return channel.userLimit +} + +func (channel *Channel) setUserLimit(limit uint64) { channel.stateMutex.Lock() channel.userLimit = limit channel.stateMutex.Unlock() } +func (channel *Channel) Key() string { + channel.stateMutex.RLock() + defer channel.stateMutex.RUnlock() + return channel.key +} + func (channel *Channel) setKey(key string) { channel.stateMutex.Lock() - defer channel.stateMutex.Unlock() channel.key = key + channel.stateMutex.Unlock() +} + +func (channel *Channel) HasMode(mode modes.Mode) bool { + channel.stateMutex.RLock() + defer channel.stateMutex.RUnlock() + return channel.flags[mode] } func (channel *Channel) Founder() string { @@ -614,50 +272,17 @@ func (channel *Channel) Founder() string { return channel.registeredFounder } -func (channel *Channel) HighestUserMode(client *Client) (result modes.Mode) { - channel.stateMutex.RLock() - defer channel.stateMutex.RUnlock() - if clientData, ok := channel.members[client]; ok { - return clientData.modes.HighestChannelUserMode() +// set a channel mode, return whether it was already set +func (channel *Channel) setMode(mode modes.Mode, enable bool) (already bool) { + channel.stateMutex.Lock() + already = (channel.flags[mode] == enable) + if !already { + if enable { + channel.flags[mode] = true + } else { + delete(channel.flags, mode) + } } + channel.stateMutex.Unlock() return } - -func (channel *Channel) Settings() (result ChannelSettings) { - channel.stateMutex.RLock() - result = channel.settings - channel.stateMutex.RUnlock() - return result -} - -func (channel *Channel) SetSettings(settings ChannelSettings) { - channel.stateMutex.Lock() - channel.settings = settings - channel.stateMutex.Unlock() - channel.MarkDirty(IncludeSettings) -} - -func (channel *Channel) setForward(forward string) { - channel.stateMutex.Lock() - channel.forward = forward - channel.stateMutex.Unlock() -} - -func (channel *Channel) Ctime() (ctime time.Time) { - channel.stateMutex.RLock() - ctime = channel.createdTime - channel.stateMutex.RUnlock() - return -} - -func (channel *Channel) getAmode(cfaccount string) (result modes.Mode) { - channel.stateMutex.RLock() - defer channel.stateMutex.RUnlock() - return channel.accountToUMode[cfaccount] -} - -func (channel *Channel) UUID() utils.UUID { - channel.stateMutex.RLock() - defer channel.stateMutex.RUnlock() - return channel.uuid -} diff --git a/irc/handlers.go b/irc/handlers.go index 57d8662b..47af0c4b 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -8,6 +8,8 @@ package irc import ( "bytes" + "encoding/base64" + "encoding/json" "fmt" "net" "os" @@ -19,575 +21,418 @@ import ( "strings" "time" - "github.com/ergochat/irc-go/ircfmt" - "github.com/ergochat/irc-go/ircmsg" - "github.com/ergochat/irc-go/ircutils" - "golang.org/x/crypto/bcrypt" - - "github.com/ergochat/ergo/irc/caps" - "github.com/ergochat/ergo/irc/custime" - "github.com/ergochat/ergo/irc/flatip" - "github.com/ergochat/ergo/irc/history" - "github.com/ergochat/ergo/irc/jwt" - "github.com/ergochat/ergo/irc/modes" - "github.com/ergochat/ergo/irc/oauth2" - "github.com/ergochat/ergo/irc/sno" - "github.com/ergochat/ergo/irc/utils" + "github.com/goshuirc/irc-go/ircfmt" + "github.com/goshuirc/irc-go/ircmatch" + "github.com/goshuirc/irc-go/ircmsg" + "github.com/oragono/oragono/irc/caps" + "github.com/oragono/oragono/irc/custime" + "github.com/oragono/oragono/irc/modes" + "github.com/oragono/oragono/irc/passwd" + "github.com/oragono/oragono/irc/sno" + "github.com/oragono/oragono/irc/utils" + "github.com/tidwall/buntdb" ) -// helper function to parse ACC callbacks, e.g., mailto:person@example.com, tel:16505551234 -func parseCallback(spec string, config *Config) (callbackNamespace string, callbackValue string, err error) { - // XXX if we don't require verification, ignore any callback that was passed here - // (to avoid confusion in the case where the ircd has no mail server configured) - if !config.Accounts.Registration.EmailVerification.Enabled { - callbackNamespace = "*" - return +// ACC [REGISTER|VERIFY] ... +func accHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + // make sure reg is enabled + if !server.AccountConfig().Registration.Enabled { + rb.Add(nil, server.name, ERR_REG_UNSPECIFIED_ERROR, client.nick, "*", client.t("Account registration is disabled")) + return false } - callback := strings.ToLower(spec) - if colonIndex := strings.IndexByte(callback, ':'); colonIndex != -1 { - callbackNamespace, callbackValue = callback[:colonIndex], callback[colonIndex+1:] + + subcommand := strings.ToLower(msg.Params[0]) + + if subcommand == "register" { + return accRegisterHandler(server, client, msg, rb) + } else if subcommand == "verify" { + return accVerifyHandler(server, client, msg, rb) } else { - // "If a callback namespace is not ... provided, the IRC server MUST use mailto"" + rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.nick, "ACC", msg.Params[0], client.t("Unknown subcommand")) + } + + return false +} + +// helper function to parse ACC callbacks, e.g., mailto:person@example.com, tel:16505551234 +func parseCallback(spec string, config *AccountConfig) (callbackNamespace string, callbackValue string) { + callback := strings.ToLower(spec) + if callback == "*" { + callbackNamespace = "*" + } else if strings.Contains(callback, ":") { + callbackValues := strings.SplitN(callback, ":", 2) + callbackNamespace, callbackValue = callbackValues[0], callbackValues[1] + } else { + // "the IRC server MAY choose to use mailto as a default" callbackNamespace = "mailto" callbackValue = callback } - if config.Accounts.Registration.EmailVerification.Enabled { - if callbackNamespace != "mailto" { - err = errValidEmailRequired - } else if strings.IndexByte(callbackValue, '@') < 1 { - err = errValidEmailRequired + // ensure the callback namespace is valid + // need to search callback list, maybe look at using a map later? + for _, name := range config.Registration.EnabledCallbacks { + if callbackNamespace == name { + return + } + } + // error value + callbackNamespace = "" + return +} + +// ACC REGISTER [callback_namespace:] [cred_type] : +func accRegisterHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + // clients can't reg new accounts if they're already logged in + if client.LoggedIntoAccount() { + if server.AccountConfig().Registration.AllowMultiplePerConnection { + server.accounts.Logout(client) + } else { + rb.Add(nil, server.name, ERR_REG_UNSPECIFIED_ERROR, client.nick, "*", client.t("You're already logged into an account")) + return false } } - return -} - -func registrationErrorToMessage(config *Config, client *Client, err error) (message string) { - if emailError := registrationCallbackErrorText(config, client, err); emailError != "" { - return emailError + // get and sanitise account name + account := strings.TrimSpace(msg.Params[1]) + casefoldedAccount, err := CasefoldName(account) + // probably don't need explicit check for "*" here... but let's do it anyway just to make sure + if err != nil || msg.Params[1] == "*" { + rb.Add(nil, server.name, ERR_REG_UNSPECIFIED_ERROR, client.nick, account, client.t("Account name is not valid")) + return false } - switch err { - case errAccountAlreadyRegistered, errAccountAlreadyVerified, errAccountAlreadyUnregistered, errAccountAlreadyLoggedIn, errAccountCreation, errAccountMustHoldNick, errAccountBadPassphrase, errCertfpAlreadyExists, errFeatureDisabled, errAccountBadPassphrase, errNameReserved: - message = err.Error() - case errLimitExceeded: - message = `There have been too many registration attempts recently; try again later` - default: - // default response: let's be risk-averse about displaying internal errors - // to the clients, especially for something as sensitive as accounts - message = `Could not register` + if len(msg.Params) < 4 { + rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.nick, msg.Command, client.t("Not enough parameters")) + return false } - return -} -func announcePendingReg(client *Client, rb *ResponseBuffer, accountName string) { - client.server.snomasks.Send(sno.LocalAccounts, fmt.Sprintf(ircfmt.Unescape("Client $c[grey][$r%s$c[grey]] attempted to register account $c[grey][$r%s$c[grey]] from IP %s, pending verification"), client.Nick(), accountName, rb.session.IP().String())) + callbackSpec := msg.Params[2] + callbackNamespace, callbackValue := parseCallback(callbackSpec, server.AccountConfig()) + + if callbackNamespace == "" { + rb.Add(nil, server.name, ERR_REG_INVALID_CALLBACK, client.nick, account, callbackSpec, client.t("Callback namespace is not supported")) + return false + } + + // get credential type/value + var credentialType, credentialValue string + + if len(msg.Params) > 4 { + credentialType = strings.ToLower(msg.Params[3]) + credentialValue = msg.Params[4] + } else { + // exactly 4 params + credentialType = "passphrase" // default from the spec + credentialValue = msg.Params[3] + } + + // ensure the credential type is valid + var credentialValid bool + for _, name := range server.AccountConfig().Registration.EnabledCredentialTypes { + if credentialType == name { + credentialValid = true + } + } + if credentialType == "certfp" && client.certfp == "" { + rb.Add(nil, server.name, ERR_REG_INVALID_CRED_TYPE, client.nick, credentialType, callbackNamespace, client.t("You are not using a TLS certificate")) + return false + } + + if !credentialValid { + rb.Add(nil, server.name, ERR_REG_INVALID_CRED_TYPE, client.nick, credentialType, callbackNamespace, client.t("Credential type is not supported")) + return false + } + + var passphrase, certfp string + if credentialType == "certfp" { + certfp = client.certfp + } else if credentialType == "passphrase" { + passphrase = credentialValue + } + err = server.accounts.Register(client, account, callbackNamespace, callbackValue, passphrase, certfp) + if err != nil { + msg := "Unknown" + code := ERR_UNKNOWNERROR + if err == errCertfpAlreadyExists { + msg = "An account already exists for your certificate fingerprint" + } else if err == errAccountAlreadyRegistered { + msg = "Account already exists" + code = ERR_ACCOUNT_ALREADY_EXISTS + } + if err == errAccountAlreadyRegistered || err == errAccountCreation || err == errCertfpAlreadyExists { + msg = err.Error() + } + rb.Add(nil, server.name, code, client.nick, "ACC", "REGISTER", client.t(msg)) + return false + } + + // automatically complete registration + if callbackNamespace == "*" { + err := server.accounts.Verify(client, casefoldedAccount, "") + if err != nil { + return false + } + sendSuccessfulRegResponse(client, rb, false) + } else { + messageTemplate := client.t("Account created, pending verification; verification code has been sent to %s:%s") + message := fmt.Sprintf(messageTemplate, callbackNamespace, callbackValue) + rb.Add(nil, server.name, RPL_REG_VERIFICATION_REQUIRED, client.nick, casefoldedAccount, message) + } + + return false } // helper function to dispatch messages when a client successfully registers -func sendSuccessfulRegResponse(service *ircService, client *Client, rb *ResponseBuffer) { - details := client.Details() - if service != nil { - service.Notice(rb, client.t("Account created")) - } - client.server.snomasks.Send(sno.LocalAccounts, fmt.Sprintf(ircfmt.Unescape("Client $c[grey][$r%s$c[grey]] registered account $c[grey][$r%s$c[grey]] from IP %s"), details.nickMask, details.accountName, rb.session.IP().String())) - sendSuccessfulAccountAuth(service, client, rb, false) -} - -// sendSuccessfulAccountAuth means that an account auth attempt completed successfully, and is used to dispatch messages. -func sendSuccessfulAccountAuth(service *ircService, client *Client, rb *ResponseBuffer, forSASL bool) { - details := client.Details() - - if service != nil { - service.Notice(rb, fmt.Sprintf(client.t("You're now logged in as %s"), details.accountName)) +func sendSuccessfulRegResponse(client *Client, rb *ResponseBuffer, forNS bool) { + if forNS { + rb.Notice(client.t("Account created")) } else { - //TODO(dan): some servers send this numeric even for NickServ logins iirc? to confirm and maybe do too - rb.Add(nil, client.server.name, RPL_LOGGEDIN, details.nick, details.nickMask, details.accountName, fmt.Sprintf(client.t("You are now logged in as %s"), details.accountName)) - if forSASL { - rb.Add(nil, client.server.name, RPL_SASLSUCCESS, details.nick, client.t("Authentication successful")) - } + rb.Add(nil, client.server.name, RPL_REGISTRATION_SUCCESS, client.nick, client.AccountName(), client.t("Account created")) } - - if client.Registered() { - // dispatch account-notify - for friend := range client.FriendsMonitors(caps.AccountNotify) { - if friend != rb.session { - friend.Send(nil, details.nickMask, "ACCOUNT", details.accountName) - } - } - if rb.session.capabilities.Has(caps.AccountNotify) { - rb.Add(nil, details.nickMask, "ACCOUNT", details.accountName) - } - client.server.sendLoginSnomask(details.nickMask, details.accountName) - } - - // #1479: for Tor clients, replace the hostname with the always-on cloak here - // (for normal clients, this would discard the IP-based cloak, but with Tor - // there's no such concern) - if rb.session.isTor { - config := client.server.Config() - if config.Server.Cloaks.EnabledForAlwaysOn { - cloakedHostname := config.Server.Cloaks.ComputeAccountCloak(details.accountName) - client.setCloakedHostname(cloakedHostname) - if client.registered { - client.sendChghost(details.nickMask, client.Hostname()) - } - } - } - - client.server.logger.Info("accounts", "client", details.nick, "logged into account", details.accountName) + sendSuccessfulSaslAuth(client, rb, forNS) } -func (server *Server) sendLoginSnomask(nickMask, accountName string) { - server.snomasks.Send(sno.LocalAccounts, fmt.Sprintf(ircfmt.Unescape("Client $c[grey][$r%s$c[grey]] logged into account $c[grey][$r%s$c[grey]]"), nickMask, accountName)) +// sendSuccessfulSaslAuth means that a SASL auth attempt completed successfully, and is used to dispatch messages. +func sendSuccessfulSaslAuth(client *Client, rb *ResponseBuffer, forNS bool) { + account := client.AccountName() + + if forNS { + rb.Notice(fmt.Sprintf(client.t("You're now logged in as %s"), client.AccountName())) + } else { + rb.Add(nil, client.server.name, RPL_LOGGEDIN, client.nick, client.nickMaskString, account, fmt.Sprintf("You are now logged in as %s", account)) + rb.Add(nil, client.server.name, RPL_SASLSUCCESS, client.nick, client.t("SASL authentication successful")) + } + + // dispatch account-notify + for friend := range client.Friends(caps.AccountNotify) { + friend.Send(nil, client.nickMaskString, "ACCOUNT", account) + } + + client.server.snomasks.Send(sno.LocalAccounts, fmt.Sprintf(ircfmt.Unescape("Client $c[grey][$r%s$c[grey]] logged into account $c[grey][$r%s$c[grey]]"), client.nickMaskString, account)) } -// ACCEPT -// nicklist is a comma-delimited list of nicknames; each may be prefixed with - -// to indicate that it should be removed from the list -func acceptHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - for _, tNick := range strings.Split(msg.Params[0], ",") { - add := true - if strings.HasPrefix(tNick, "-") { - add = false - tNick = strings.TrimPrefix(tNick, "-") - } +// ACC VERIFY +func accVerifyHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + account := strings.TrimSpace(msg.Params[1]) + err := server.accounts.Verify(client, account, msg.Params[2]) - target := server.clients.Get(tNick) - if target == nil { - rb.Add(nil, server.name, "FAIL", "ACCEPT", "INVALID_USER", utils.SafeErrorParam(tNick), client.t("No such user")) - continue - } + var code string + var message string - if add { - server.accepts.Accept(client, target) - } else { - server.accepts.Unaccept(client, target) - } + if err == errAccountVerificationInvalidCode { + code = ERR_ACCOUNT_INVALID_VERIFY_CODE + message = err.Error() + } else if err == errAccountAlreadyVerified { + code = ERR_ACCOUNT_ALREADY_VERIFIED + message = err.Error() + } else if err != nil { + code = ERR_UNKNOWNERROR + message = errAccountVerificationFailed.Error() + } - // https://github.com/solanum-ircd/solanum/blob/main/doc/features/modeg.txt - // Charybdis/Solanum define various error numerics that could be sent here, - // but this doesn't seem important to me. One thing to note is that we are not - // imposing an upper bound on the size of the accept list, since in our - // implementation you can only ACCEPT clients who are actually present, - // and an attacker attempting to DoS has much easier resource exhaustion - // strategies available (for example, channel history buffers). + if err == nil { + sendSuccessfulRegResponse(client, rb, false) + } else { + rb.Add(nil, server.name, code, client.nick, account, client.t(message)) } return false } -const ( - saslMaxResponseLength = 8192 // implementation-defined sanity check, long enough for bearer tokens -) - // AUTHENTICATE [||*] -func authenticateHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - session := rb.session - config := server.Config() - details := client.Details() - - if client.isSTSOnly { - rb.Add(nil, server.name, ERR_SASLFAIL, details.nick, client.t("SASL authentication failed")) - return false - } - - if details.account != "" { - rb.Add(nil, server.name, ERR_SASLALREADY, details.nick, client.t("You're already logged into an account")) - return false - } - +func authenticateHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { // sasl abort - if !config.Accounts.AuthenticationEnabled || len(msg.Params) == 1 && msg.Params[0] == "*" { - rb.Add(nil, server.name, ERR_SASLABORTED, details.nick, client.t("SASL authentication aborted")) - session.sasl.Clear() + if !server.AccountConfig().AuthenticationEnabled || len(msg.Params) == 1 && msg.Params[0] == "*" { + rb.Add(nil, server.name, ERR_SASLABORTED, client.nick, client.t("SASL authentication aborted")) + client.saslInProgress = false + client.saslMechanism = "" + client.saslValue = "" return false } - // start new sasl session: parameter is the authentication mechanism - if session.sasl.mechanism == "" { + // start new sasl session + if !client.saslInProgress { mechanism := strings.ToUpper(msg.Params[0]) _, mechanismIsEnabled := EnabledSaslMechanisms[mechanism] - // The spec says: "The AUTHENTICATE command MUST be used before registration - // is complete and with the sasl capability enabled." Enforcing this universally - // would simplify the implementation somewhat, but we've never enforced it before - // and I don't want to break working clients that use PLAIN or EXTERNAL - // and violate this MUST (e.g. by sending CAP END too early). - if client.registered && !(mechanism == "PLAIN" || mechanism == "EXTERNAL") { - rb.Add(nil, server.name, ERR_SASLFAIL, details.nick, client.t("SASL is only allowed before connection registration")) - return false - } - if mechanismIsEnabled { - session.sasl.mechanism = mechanism - if !config.Server.Compatibility.SendUnprefixedSasl { - // normal behavior - rb.Add(nil, server.name, "AUTHENTICATE", "+") - } else { - // gross hack: send a raw message to ensure no tags or prefix - rb.Flush(true) - rb.session.SendRawMessage(ircmsg.MakeMessage(nil, "", "AUTHENTICATE", "+"), true) - } + client.saslInProgress = true + client.saslMechanism = mechanism + rb.Add(nil, server.name, "AUTHENTICATE", "+") } else { - rb.Add(nil, server.name, ERR_SASLFAIL, details.nick, client.t("SASL authentication failed")) + rb.Add(nil, server.name, ERR_SASLFAIL, client.nick, client.t("SASL authentication failed")) } return false } - // continue existing sasl session: parameter is a message chunk - done, value, err := session.sasl.value.Add(msg.Params[0]) - if err == nil { - if done { - // call actual handler - handler := EnabledSaslMechanisms[session.sasl.mechanism] - return handler(server, client, session, value, rb) - } else { - return false // wait for continuation line + // continue existing sasl session + rawData := msg.Params[0] + + if len(rawData) > 400 { + rb.Add(nil, server.name, ERR_SASLTOOLONG, client.nick, client.t("SASL message too long")) + client.saslInProgress = false + client.saslMechanism = "" + client.saslValue = "" + return false + } else if len(rawData) == 400 { + client.saslValue += rawData + // allow 4 'continuation' lines before rejecting for length + if len(client.saslValue) > 400*4 { + rb.Add(nil, server.name, ERR_SASLFAIL, client.nick, client.t("SASL authentication failed: Passphrase too long")) + client.saslInProgress = false + client.saslMechanism = "" + client.saslValue = "" + return false + } + return false + } + if rawData != "+" { + client.saslValue += rawData + } + + var data []byte + var err error + if client.saslValue != "+" { + data, err = base64.StdEncoding.DecodeString(client.saslValue) + if err != nil { + rb.Add(nil, server.name, ERR_SASLFAIL, client.nick, client.t("SASL authentication failed: Invalid b64 encoding")) + client.saslInProgress = false + client.saslMechanism = "" + client.saslValue = "" + return false } } - // else: error handling - switch err { - case ircutils.ErrSASLTooLong: - rb.Add(nil, server.name, ERR_SASLTOOLONG, details.nick, client.t("SASL message too long")) - case ircutils.ErrSASLLimitExceeded: - rb.Add(nil, server.name, ERR_SASLFAIL, details.nick, client.t("SASL authentication failed: Passphrase too long")) - default: - rb.Add(nil, server.name, ERR_SASLFAIL, details.nick, client.t("SASL authentication failed: Invalid b64 encoding")) + + // call actual handler + handler, handlerExists := EnabledSaslMechanisms[client.saslMechanism] + + // like 100% not required, but it's good to be safe I guess + if !handlerExists { + rb.Add(nil, server.name, ERR_SASLFAIL, client.nick, client.t("SASL authentication failed")) + client.saslInProgress = false + client.saslMechanism = "" + client.saslValue = "" + return false } - session.sasl.Clear() - return false + + // let the SASL handler do its thing + exiting := handler(server, client, client.saslMechanism, data, rb) + + if client.LoggedIntoAccount() && server.AccountConfig().SkipServerPassword { + client.SetAuthorized(true) + } + + // wait 'til SASL is done before emptying the sasl vars + client.saslInProgress = false + client.saslMechanism = "" + client.saslValue = "" + + return exiting } // AUTHENTICATE PLAIN -func authPlainHandler(server *Server, client *Client, session *Session, value []byte, rb *ResponseBuffer) bool { - defer session.sasl.Clear() - +func authPlainHandler(server *Server, client *Client, mechanism string, value []byte, rb *ResponseBuffer) bool { splitValue := bytes.Split(value, []byte{'\000'}) - // PLAIN has separate "authorization ID" (which user you want to become) - // and "authentication ID" (whose password you want to use). the first is optional: - // [authzid] \x00 authcid \x00 password - var authzid, authcid string + var accountKey, authzid string if len(splitValue) == 3 { - authzid, authcid = string(splitValue[0]), string(splitValue[1]) + accountKey = string(splitValue[0]) + authzid = string(splitValue[1]) - if authzid != "" && authcid != authzid { - rb.Add(nil, server.name, ERR_SASLFAIL, client.Nick(), client.t("SASL authentication failed: authcid and authzid should be the same")) + if accountKey == "" { + accountKey = authzid + } else if accountKey != authzid { + rb.Add(nil, server.name, ERR_SASLFAIL, client.nick, client.t("SASL authentication failed: authcid and authzid should be the same")) return false } } else { - rb.Add(nil, server.name, ERR_SASLFAIL, client.Nick(), client.t("SASL authentication failed: Invalid auth blob")) + rb.Add(nil, server.name, ERR_SASLFAIL, client.nick, client.t("SASL authentication failed: Invalid auth blob")) return false } - // see #843: strip the device ID for the benefit of clients that don't - // distinguish user/ident from account name - if strudelIndex := strings.IndexByte(authcid, '@'); strudelIndex != -1 { - var deviceID string - authcid, deviceID = authcid[:strudelIndex], authcid[strudelIndex+1:] - if !client.registered { - rb.session.deviceID = deviceID - } - } password := string(splitValue[2]) - err := server.accounts.AuthenticateByPassphrase(client, authcid, password) + err := server.accounts.AuthenticateByPassphrase(client, accountKey, password) if err != nil { - sendAuthErrorResponse(client, rb, err) - return false - } else if !fixupNickEqualsAccount(client, rb, server.Config(), "") { + msg := authErrorToMessage(server, err) + rb.Add(nil, server.name, ERR_SASLFAIL, client.nick, fmt.Sprintf("%s: %s", client.t("SASL authentication failed"), client.t(msg))) return false } - sendSuccessfulAccountAuth(nil, client, rb, true) + sendSuccessfulSaslAuth(client, rb, false) return false } -// AUTHENTICATE IRCV3BEARER -func authIRCv3BearerHandler(server *Server, client *Client, session *Session, value []byte, rb *ResponseBuffer) bool { - defer session.sasl.Clear() - - // \x00 \x00 - splitValue := bytes.SplitN(value, []byte{'\000'}, 3) - if len(splitValue) != 3 { - rb.Add(nil, server.name, ERR_SASLFAIL, client.Nick(), client.t("SASL authentication failed: Invalid auth blob")) - return false - } - - err := server.accounts.AuthenticateByBearerToken(client, string(splitValue[1]), string(splitValue[2])) - if err != nil { - sendAuthErrorResponse(client, rb, err) - return false - } - - sendSuccessfulAccountAuth(nil, client, rb, true) - return false -} - -func sendAuthErrorResponse(client *Client, rb *ResponseBuffer, err error) { - msg := authErrorToMessage(client.server, err) - rb.Add(nil, client.server.name, ERR_SASLFAIL, client.nick, fmt.Sprintf("%s: %s", client.t("SASL authentication failed"), client.t(msg))) - if err == errAccountUnverified { - rb.Add(nil, client.server.name, "NOTE", "AUTHENTICATE", "VERIFICATION_REQUIRED", "*", client.t(err.Error())) - } -} - func authErrorToMessage(server *Server, err error) (msg string) { - if throttled, ok := err.(*ThrottleError); ok { - return throttled.Error() - } - - switch err { - case errAccountDoesNotExist, errAccountUnverified, errAccountInvalidCredentials, errAuthzidAuthcidMismatch, errNickAccountMismatch, errAccountSuspended, oauth2.ErrInvalidToken: - return err.Error() - default: - // don't expose arbitrary error messages to the user - server.logger.Error("internal", "sasl authentication failure", err.Error()) - return "Unknown" + if err == errAccountDoesNotExist || err == errAccountUnverified || err == errAccountInvalidCredentials { + msg = err.Error() + } else { + server.logger.Error("internal", fmt.Sprintf("sasl authentication failure: %v", err)) + msg = "Unknown" } + return } // AUTHENTICATE EXTERNAL -func authExternalHandler(server *Server, client *Client, session *Session, value []byte, rb *ResponseBuffer) bool { - defer session.sasl.Clear() - - if rb.session.certfp == "" { +func authExternalHandler(server *Server, client *Client, mechanism string, value []byte, rb *ResponseBuffer) bool { + if client.certfp == "" { rb.Add(nil, server.name, ERR_SASLFAIL, client.nick, client.t("SASL authentication failed, you are not connecting with a certificate")) return false } - // EXTERNAL doesn't carry an authentication ID (this is determined from the - // certificate), but does carry an optional authorization ID. - authzid := string(value) - var deviceID string - var err error - // see #843: strip the device ID for the benefit of clients that don't - // distinguish user/ident from account name - if strudelIndex := strings.IndexByte(authzid, '@'); strudelIndex != -1 { - authzid, deviceID = authzid[:strudelIndex], authzid[strudelIndex+1:] - } - - if err == nil { - err = server.accounts.AuthenticateByCertificate(client, rb.session.certfp, rb.session.peerCerts, authzid) - } + err := server.accounts.AuthenticateByCertFP(client) if err != nil { - sendAuthErrorResponse(client, rb, err) - return false - } else if !fixupNickEqualsAccount(client, rb, server.Config(), "") { + msg := authErrorToMessage(server, err) + rb.Add(nil, server.name, ERR_SASLFAIL, client.nick, fmt.Sprintf("%s: %s", client.t("SASL authentication failed"), client.t(msg))) return false } - sendSuccessfulAccountAuth(nil, client, rb, true) - if !client.registered && deviceID != "" { - rb.session.deviceID = deviceID - } + sendSuccessfulSaslAuth(client, rb, false) return false } -// AUTHENTICATE SCRAM-SHA-256 -func authScramHandler(server *Server, client *Client, session *Session, value []byte, rb *ResponseBuffer) bool { - continueAuth := true - defer func() { - if !continueAuth { - session.sasl.Clear() - } - }() - - // first message? if so, initialize the SCRAM conversation - if session.sasl.scramConv == nil { - if throttled, remainingTime := client.checkLoginThrottle(); throttled { - rb.Add(nil, server.name, ERR_SASLFAIL, client.Nick(), - fmt.Sprintf(client.t("Please wait at least %v and try again"), remainingTime.Round(time.Millisecond))) - continueAuth = false - return false - } - session.sasl.scramConv = server.accounts.NewScramConversation() - } - - // wait for a final AUTHENTICATE + from the client to conclude authentication - if session.sasl.scramConv.Done() { - continueAuth = false - if session.sasl.scramConv.Valid() { - authcid := session.sasl.scramConv.Username() - if strudelIndex := strings.IndexByte(authcid, '@'); strudelIndex != -1 { - var deviceID string - authcid, deviceID = authcid[:strudelIndex], authcid[strudelIndex+1:] - if !client.registered { - rb.session.deviceID = deviceID - } - } - authzid := session.sasl.scramConv.AuthzID() - if authzid != "" && authzid != authcid { - rb.Add(nil, server.name, ERR_SASLFAIL, client.nick, client.t("SASL authentication failed: authcid and authzid should be the same")) - return false - } - account, err := server.accounts.LoadAccount(authcid) - if err == nil { - server.accounts.Login(client, account) - // fixupNickEqualsAccount is not needed for unregistered clients - sendSuccessfulAccountAuth(nil, client, rb, true) - } else { - server.logger.Error("internal", "SCRAM succeeded but couldn't load account", authcid, err.Error()) - rb.Add(nil, server.name, ERR_SASLFAIL, client.nick, client.t("SASL authentication failed")) - } - } else { - rb.Add(nil, server.name, ERR_SASLFAIL, client.nick, client.t("SASL authentication failed")) - } - return false - } - - response, err := session.sasl.scramConv.Step(string(value)) - if err == nil { - sendSASLChallenge(server, rb, []byte(response)) - } else { - continueAuth = false - rb.Add(nil, server.name, ERR_SASLFAIL, client.Nick(), err.Error()) - return false - } - - return false -} - -// AUTHENTICATE OAUTHBEARER -func authOauthBearerHandler(server *Server, client *Client, session *Session, value []byte, rb *ResponseBuffer) bool { - if !server.Config().Accounts.OAuth2.Enabled { - rb.Add(nil, server.name, ERR_SASLFAIL, client.Nick(), "SASL authentication failed: mechanism not enabled") - return false - } - - if session.sasl.oauthConv == nil { - session.sasl.oauthConv = oauth2.NewOAuthBearerServer( - func(opts oauth2.OAuthBearerOptions) *oauth2.OAuthBearerError { - err := server.accounts.AuthenticateByOAuthBearer(client, opts) - switch err { - case nil: - return nil - case oauth2.ErrInvalidToken: - return &oauth2.OAuthBearerError{Status: "invalid_token", Schemes: "bearer"} - case errFeatureDisabled: - return &oauth2.OAuthBearerError{Status: "invalid_request", Schemes: "bearer"} - default: - // this is probably a misconfiguration or infrastructure error so we should log it - server.logger.Error("internal", "failed to validate OAUTHBEARER token", err.Error()) - // tell the client it was their fault even though it probably wasn't: - return &oauth2.OAuthBearerError{Status: "invalid_request", Schemes: "bearer"} - } - }, - ) - } - - challenge, done, err := session.sasl.oauthConv.Next(value) - if done { - if err == nil { - sendSuccessfulAccountAuth(nil, client, rb, true) - } else { - rb.Add(nil, server.name, ERR_SASLFAIL, client.Nick(), ircutils.SanitizeText(err.Error(), 350)) - } - session.sasl.Clear() - } else { - // ignore `err`, we need to relay the challenge (which may contain a JSON-encoded error) - // to the client - sendSASLChallenge(server, rb, challenge) - } - return false -} - -// helper to b64 a sasl response and chunk it into 400-byte lines -// as per https://ircv3.net/specs/extensions/sasl-3.1 -func sendSASLChallenge(server *Server, rb *ResponseBuffer, challenge []byte) { - for _, chunk := range ircutils.EncodeSASLResponse(challenge) { - rb.Add(nil, server.name, "AUTHENTICATE", chunk) - } -} - // AWAY [] -func awayHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - // #1996: `AWAY :` is treated the same as `AWAY` - var awayMessage string +func awayHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + var isAway bool + var text string if len(msg.Params) > 0 { - awayMessage = msg.Params[0] - awayMessage = ircmsg.TruncateUTF8Safe(awayMessage, server.Config().Limits.AwayLen) + isAway = true + text = msg.Params[0] + awayLen := server.Limits().AwayLen + if len(text) > awayLen { + text = text[:awayLen] + } } - wasAway, nowAway := rb.session.SetAway(awayMessage) + if isAway { + client.flags[modes.Away] = true + } else { + delete(client.flags, modes.Away) + } + client.awayMessage = text - if nowAway != "" { + var op modes.ModeOp + if client.flags[modes.Away] { + op = modes.Add rb.Add(nil, server.name, RPL_NOWAWAY, client.nick, client.t("You have been marked as being away")) } else { + op = modes.Remove rb.Add(nil, server.name, RPL_UNAWAY, client.nick, client.t("You are no longer marked as being away")) } + //TODO(dan): Should this be sent automagically as part of setting the flag/mode? + modech := modes.ModeChanges{modes.ModeChange{ + Mode: modes.Away, + Op: op, + }} + rb.Add(nil, server.name, "MODE", client.nick, modech.String()) - if client.registered && wasAway != nowAway { - dispatchAwayNotify(client, nowAway) - } // else: we'll send it (if applicable) after reattach - - return false -} - -func dispatchAwayNotify(client *Client, awayMessage string) { // dispatch away-notify - details := client.Details() - isBot := client.HasMode(modes.Bot) - for session := range client.FriendsMonitors(caps.AwayNotify) { - if awayMessage != "" { - session.sendFromClientInternal(false, time.Time{}, "", details.nickMask, details.accountName, isBot, nil, "AWAY", awayMessage) + for friend := range client.Friends(caps.AwayNotify) { + if client.flags[modes.Away] { + friend.SendFromClient("", client, nil, "AWAY", client.awayMessage) } else { - session.sendFromClientInternal(false, time.Time{}, "", details.nickMask, details.accountName, isBot, nil, "AWAY") - } - } -} - -// BATCH {+,-}reference-tag type [params...] -func batchHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - tag := msg.Params[0] - fail := false - sendErrors := rb.session.batch.command != "NOTICE" - if len(tag) == 0 { - fail = true - } else if tag[0] == '+' { - if len(msg.Params) < 3 || msg.Params[1] != caps.MultilineBatchType { - fail = true - } else { - err := rb.session.StartMultilineBatch(tag[1:], msg.Params[2], rb.Label, msg.ClientOnlyTags()) - fail = (err != nil) - if !fail { - // suppress ACK for the initial BATCH message (we'll apply the stored label later) - rb.Label = "" - } - } - } else if tag[0] == '-' { - batch, err := rb.session.EndMultilineBatch(tag[1:]) - fail = (err != nil) - if !fail { - histType, err := msgCommandToHistType(batch.command) - if err != nil { - histType = history.Privmsg - batch.command = "PRIVMSG" - } - // XXX changing the label inside a handler is a bit dodgy, but it works here - // because there's no way we could have triggered a flush up to this point - rb.Label = batch.responseLabel - - for _, msg := range batch.message.Split { - signatures := server.GenerateImagorSignatures(msg.Message) - if len(signatures) > 0 { - if msg.Tags == nil { - msg.Tags = make(map[string]string) - } - msg.Tags["signatures"] = signatures - } - } - dispatchMessageToTarget(client, batch.tags, histType, batch.command, batch.target, batch.message, rb) - } - } - - if fail { - rb.session.EndMultilineBatch("") - if sendErrors { - rb.Add(nil, server.name, "FAIL", "BATCH", "MULTILINE_INVALID", client.t("Invalid multiline batch")) + friend.SendFromClient("", client, nil, "AWAY") } } @@ -595,57 +440,17 @@ func batchHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon } // CAP [] -func capHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - details := client.Details() +func capHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { subCommand := strings.ToUpper(msg.Params[0]) - toAdd := caps.NewSet() - toRemove := caps.NewSet() + capabilities := caps.NewSet() var capString string - config := server.Config() - supportedCaps := config.Server.supportedCaps - if client.isSTSOnly { - supportedCaps = stsOnlyCaps - } else if rb.session.hideSTS { - supportedCaps = config.Server.supportedCapsWithoutSTS - } - - badCaps := false if len(msg.Params) > 1 { capString = msg.Params[1] - strs := strings.Fields(capString) + strs := strings.Split(capString, " ") for _, str := range strs { - remove := false - if str[0] == '-' { - str = str[1:] - remove = true - } - capab, err := caps.NameToCapability(str) - if err != nil || (!remove && !supportedCaps.Has(capab)) { - badCaps = true - } else if !remove { - toAdd.Enable(capab) - } else { - toRemove.Enable(capab) - } - } - } - - sendCapLines := func(cset *caps.Set, values caps.Values) { - version := rb.session.capVersion - // we're working around two bugs: - // 1. WeeChat 1.4 won't accept the CAP reply unless it contains the server.name source - // 2. old versions of Kiwi and The Lounge can't parse multiline CAP LS 302 (#661), - // so try as hard as possible to get the response to fit on one line. - // :server.name CAP nickname LS * :\r\n - // 1 [5 ] 1 [4 ] [2 ] - maxLen := (MaxLineLen - 2) - 1 - len(server.name) - 5 - len(details.nick) - 1 - len(subCommand) - 4 - capLines := cset.Strings(version, values, maxLen) - for i, capStr := range capLines { - if version >= caps.Cap302 && i < len(capLines)-1 { - rb.Add(nil, server.name, "CAP", details.nick, subCommand, "*", capStr) - } else { - rb.Add(nil, server.name, "CAP", details.nick, subCommand, capStr) + if len(str) > 0 { + capabilities.Enable(caps.Capability(str)) } } } @@ -653,226 +458,60 @@ func capHandler(server *Server, client *Client, msg ircmsg.Message, rb *Response switch subCommand { case "LS": if !client.registered { - rb.session.capState = caps.NegotiatingState + client.capState = caps.NegotiatingState } - if 1 < len(msg.Params) { - num, err := strconv.Atoi(msg.Params[1]) - newVersion := caps.Version(num) - if err == nil && rb.session.capVersion < newVersion { - rb.session.capVersion = newVersion - } + if len(msg.Params) > 1 && msg.Params[1] == "302" { + client.capVersion = 302 } - sendCapLines(supportedCaps, config.Server.capValues) + // weechat 1.4 has a bug here where it won't accept the CAP reply unless it contains + // the server.name source... otherwise it doesn't respond to the CAP message with + // anything and just hangs on connection. + //TODO(dan): limit number of caps and send it multiline in 3.2 style as appropriate. + rb.Add(nil, server.name, "CAP", client.nick, subCommand, SupportedCapabilities.String(client.capVersion, CapValues)) case "LIST": - // values not sent on LIST - sendCapLines(&rb.session.capabilities, nil) + rb.Add(nil, server.name, "CAP", client.nick, subCommand, client.capabilities.String(caps.Cap301, CapValues)) // values not sent on LIST so force 3.1 case "REQ": if !client.registered { - rb.session.capState = caps.NegotiatingState + client.capState = caps.NegotiatingState } // make sure all capabilities actually exist - // #511, #521: oragono.io/nope is a fake cap to trap bad clients who blindly request - // every offered capability. during registration, requesting it produces a quit, - // otherwise just a CAP NAK - if badCaps || (toAdd.Has(caps.Nope) && client.registered) { - rb.Add(nil, server.name, "CAP", details.nick, "NAK", capString) - return false - } else if toAdd.Has(caps.Nope) && !client.registered { - client.Quit(fmt.Sprintf(client.t("Requesting the %s client capability is forbidden"), caps.Nope.Name()), rb.session) - return true + for _, capability := range capabilities.List() { + if !SupportedCapabilities.Has(capability) { + rb.Add(nil, server.name, "CAP", client.nick, "NAK", capString) + return false + } } - - rb.session.capabilities.Union(toAdd) - rb.session.capabilities.Subtract(toRemove) - rb.Add(nil, server.name, "CAP", details.nick, "ACK", capString) + client.capabilities.Enable(capabilities.List()...) + rb.Add(nil, server.name, "CAP", client.nick, "ACK", capString) case "END": - if !client.registered { - rb.session.capState = caps.NegotiatedState + if !client.Registered() { + client.capState = caps.NegotiatedState } default: - rb.Add(nil, server.name, ERR_INVALIDCAPCMD, details.nick, subCommand, client.t("Invalid CAP subcommand")) + rb.Add(nil, server.name, ERR_INVALIDCAPCMD, client.nick, subCommand, client.t("Invalid CAP subcommand")) } return false } -// CHATHISTORY [] -// e.g., CHATHISTORY #ircv3 AFTER id=ytNBbt565yt4r3err3 10 -// CHATHISTORY BETWEEN [] -// e.g., CHATHISTORY #ircv3 BETWEEN timestamp=YYYY-MM-DDThh:mm:ss.sssZ timestamp=YYYY-MM-DDThh:mm:ss.sssZ + 100 -func chathistoryHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) (exiting bool) { - var items []history.Item - var target string - var channel *Channel - var sequence history.Sequence - var err error - var listTargets bool - var targets []history.TargetListing - var _, batchIdentifier = msg.GetTag("identifier") - var assuredPreposition = "error" - var limit int - - if len(batchIdentifier) == 0 { - batchIdentifier = "UNIDENTIFIED" - } - - defer func() { - // errors are sent either without a batch, or in a draft/labeled-response batch as usual - if err == utils.ErrInvalidParams { - rb.Add(nil, server.name, "FAIL", "CHATHISTORY", "INVALID_PARAMS", msg.Params[0], client.t("Invalid parameters")) - } else if !listTargets && sequence == nil { - rb.Add(nil, server.name, "FAIL", "CHATHISTORY", "INVALID_TARGET", msg.Params[0], utils.SafeErrorParam(target), client.t("Messages could not be retrieved")) - } else if err != nil { - rb.Add(nil, server.name, "FAIL", "CHATHISTORY", "MESSAGE_ERROR", msg.Params[0], client.t("Messages could not be retrieved")) - } else { - // successful responses are sent as a chathistory or history batch - if listTargets { - batchID := rb.StartNestedBatch(caps.ChathistoryTargetsBatchType) - defer rb.EndNestedBatch(batchID) - for _, target := range targets { - name := server.UnfoldName(target.CfName) - rb.Add(nil, server.name, "CHATHISTORY", "TARGETS", name, - target.Time.Format(IRCv3TimestampFormat)) - } - } else if channel != nil { - channel.replayHistoryItems(rb, items, true, batchIdentifier, assuredPreposition, limit) - } else { - client.replayPrivmsgHistory(rb, items, target, true, batchIdentifier, assuredPreposition, limit) - } - } - }() - - config := server.Config() - maxChathistoryLimit := config.History.ChathistoryMax - if maxChathistoryLimit == 0 { - return - } - preposition := strings.ToLower(msg.Params[0]) - target = msg.Params[1] - listTargets = (preposition == "targets") - - parseQueryParam := func(param string) (msgid string, timestamp time.Time, err error) { - if param == "*" && (preposition == "before" || preposition == "between") { - // XXX compatibility with kiwi, which as of February 2020 is - // using BEFORE * as a synonym for LATEST * - return - } - err = utils.ErrInvalidParams - pieces := strings.SplitN(param, "=", 2) - if len(pieces) < 2 { - return - } - identifier, value := strings.ToLower(pieces[0]), pieces[1] - if identifier == "msgid" { - msgid, err = history.NormalizeMsgid(value), nil - return - } else if identifier == "timestamp" { - timestamp, err = time.Parse(IRCv3TimestampFormat, value) - return - } - return - } - - parseHistoryLimit := func(paramIndex int) (limit int) { - if len(msg.Params) < (paramIndex + 1) { - return maxChathistoryLimit - } - limit, err := strconv.Atoi(msg.Params[paramIndex]) - if err != nil || limit == 0 || limit > maxChathistoryLimit { - limit = maxChathistoryLimit - } - return - } - - roundUp := func(endpoint time.Time) (result time.Time) { - return endpoint.Truncate(time.Millisecond).Add(time.Millisecond) - } - - paramPos := 2 - var start, end history.Selector - switch preposition { - case "targets": - // use the same selector parsing as BETWEEN, - // except that we have no target so we have one fewer parameter - paramPos = 1 - fallthrough - case "between": - start.Msgid, start.Time, err = parseQueryParam(msg.Params[paramPos]) - if err != nil { - return - } - end.Msgid, end.Time, err = parseQueryParam(msg.Params[paramPos+1]) - if err != nil { - return - } - // XXX preserve the ordering of the two parameters, since we might be going backwards, - // but round up the chronologically first one, whichever it is, to make it exclusive - if !start.Time.IsZero() && !end.Time.IsZero() { - if start.Time.Before(end.Time) { - start.Time = roundUp(start.Time) - } else { - end.Time = roundUp(end.Time) - } - } - limit = parseHistoryLimit(paramPos + 2) - case "before", "after", "around": - start.Msgid, start.Time, err = parseQueryParam(msg.Params[2]) - if err != nil { - return - } - if preposition == "after" && !start.Time.IsZero() { - start.Time = roundUp(start.Time) - } - if preposition == "before" { - end = start - start = history.Selector{} - } - limit = parseHistoryLimit(3) - case "latest": - if msg.Params[2] != "*" { - end.Msgid, end.Time, err = parseQueryParam(msg.Params[2]) - if err != nil { - return - } - if !end.Time.IsZero() { - end.Time = roundUp(end.Time) - } - start.Time = time.Now().UTC() - } - limit = parseHistoryLimit(3) - default: - err = utils.ErrInvalidParams - return - } - assuredPreposition = preposition - - if listTargets { - targets, err = client.listTargets(start, end, limit) - } else { - channel, sequence, err = server.GetHistorySequence(nil, client, target) - if err != nil || sequence == nil { - return - } - if preposition == "around" { - items, err = sequence.Around(start, limit) - } else { - items, err = sequence.Between(start, end, limit) - } - } - return +// CHANSERV [...] +func csHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + server.chanservPrivmsgHandler(client, strings.Join(msg.Params, " "), rb) + return false } // DEBUG -func debugHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { +func debugHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { param := strings.ToUpper(msg.Params[0]) switch param { case "GCSTATS": stats := debug.GCStats{ + Pause: make([]time.Duration, 10), PauseQuantiles: make([]time.Duration, 5), } debug.ReadGCStats(&stats) @@ -891,7 +530,7 @@ func debugHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon rb.Notice(fmt.Sprintf("num goroutines: %d", count)) case "PROFILEHEAP": - profFile := server.Config().getOutputPath("ergo.mprof") + profFile := "oragono.mprof" file, err := os.Create(profFile) if err != nil { rb.Notice(fmt.Sprintf("error: %s", err)) @@ -902,7 +541,7 @@ func debugHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon rb.Notice(fmt.Sprintf("written to %s", profFile)) case "STARTCPUPROFILE": - profFile := server.Config().getOutputPath("ergo.prof") + profFile := "oragono.prof" file, err := os.Create(profFile) if err != nil { rb.Notice(fmt.Sprintf("error: %s", err)) @@ -919,76 +558,15 @@ func debugHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon case "STOPCPUPROFILE": pprof.StopCPUProfile() rb.Notice(fmt.Sprintf("CPU profiling stopped")) - - case "CRASHSERVER": - code := utils.ConfirmationCode(server.name, server.ctime) - if len(msg.Params) == 1 || msg.Params[1] != code { - rb.Notice(fmt.Sprintf(client.t("To confirm, run this command: %s"), fmt.Sprintf("/DEBUG CRASHSERVER %s", code))) - return false - } - server.logger.Error("server", fmt.Sprintf("DEBUG CRASHSERVER executed by operator %s", client.Oper().Name)) - go func() { - // intentional nil dereference on a new goroutine, bypassing recover-from-errors - var i, j *int - *i = *j - }() - - default: - rb.Notice(client.t("Unrecognized DEBUG subcommand")) } return false } -func defconHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - if len(msg.Params) > 0 { - level, err := strconv.Atoi(msg.Params[0]) - if err == nil && 1 <= level && level <= 5 { - server.SetDefcon(uint32(level)) - server.snomasks.Send(sno.LocalAnnouncements, fmt.Sprintf("%s [%s] set DEFCON level to %d", client.Nick(), client.Oper().Name, level)) - } else { - rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.Nick(), msg.Command, client.t("Invalid DEFCON parameter")) - return false - } - } - rb.Notice(fmt.Sprintf(client.t("Current DEFCON level is %d"), server.Defcon())) - return false -} - -// helper for parsing the reason args to DLINE and KLINE -func getReasonsFromParams(params []string, currentArg int) (reason, operReason string) { - reason = "No reason given" - operReason = "" - if len(params) > currentArg { - reasons := strings.SplitN(strings.Join(params[currentArg:], " "), "|", 2) - if len(reasons) == 1 { - reason = strings.TrimSpace(reasons[0]) - } else if len(reasons) == 2 { - reason = strings.TrimSpace(reasons[0]) - operReason = strings.TrimSpace(reasons[1]) - } - } - return -} - -func formatBanForListing(client *Client, key string, info IPBanInfo) string { - desc := info.Reason - if info.OperReason != "" && info.OperReason != info.Reason { - desc = fmt.Sprintf("%s | %s", info.Reason, info.OperReason) - } - desc = fmt.Sprintf("%s [%s] added on [%s]", desc, info.TimeLeft(), info.TimeCreated.UTC().Format(time.RFC1123)) - banType := "Ban" - if info.RequireSASL { - banType = "SASL required" - } - return fmt.Sprintf(client.t("%[1]s - %[2]s - added by %[3]s - %[4]s"), banType, key, info.OperName, desc) -} - // DLINE [ANDKILL] [MYSELF] [duration] / [ON ] [reason [| oper reason]] // DLINE LIST -func dlineHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { +func dlineHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { // check oper permissions - oper := client.Oper() - if !oper.HasRoleCapab("ban") { + if !client.class.Capabilities["oper:local_ban"] { rb.Add(nil, server.name, ERR_NOPRIVS, client.nick, msg.Command, client.t("Insufficient oper privs")) return false } @@ -1004,7 +582,7 @@ func dlineHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon } for key, info := range bans { - client.Notice(formatBanForListing(client, key, info)) + rb.Notice(fmt.Sprintf(client.t("Ban - %[1]s - added by %[2]s - %[3]s"), key, info.OperName, info.BanMessage("%s"))) } return false @@ -1027,9 +605,8 @@ func dlineHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon // duration duration, err := custime.ParseDuration(msg.Params[currentArg]) - if err != nil { - duration = 0 - } else { + durationIsUsed := err == nil + if durationIsUsed { currentArg++ } @@ -1042,16 +619,31 @@ func dlineHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon currentArg++ // check host - hostNet, err := utils.NormalizedNetFromString(hostString) + var hostAddr net.IP + var hostNet *net.IPNet + _, hostNet, err = net.ParseCIDR(hostString) if err != nil { + hostAddr = net.ParseIP(hostString) + } + + if hostAddr == nil && hostNet == nil { rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.nick, msg.Command, client.t("Could not parse IP address or CIDR network")) return false } - if !dlineMyself && hostNet.Contains(rb.session.IP()) { - rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.nick, msg.Command, client.t("This ban matches you. To DLINE yourself, you must use the command: /DLINE MYSELF ")) - return false + if hostNet == nil { + hostString = hostAddr.String() + if !dlineMyself && hostAddr.Equal(client.IP()) { + rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.nick, msg.Command, client.t("This ban matches you. To DLINE yourself, you must use the command: /DLINE MYSELF ")) + return false + } + } else { + hostString = hostNet.String() + if !dlineMyself && hostNet.Contains(client.IP()) { + rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.nick, msg.Command, client.t("This ban matches you. To DLINE yourself, you must use the command: /DLINE MYSELF ")) + return false + } } // check remote @@ -1061,23 +653,71 @@ func dlineHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon } // get comment(s) - reason, operReason := getReasonsFromParams(msg.Params, currentArg) - - operName := oper.Name + reason := "No reason given" + operReason := "No reason given" + if len(msg.Params) > currentArg { + tempReason := strings.TrimSpace(msg.Params[currentArg]) + if len(tempReason) > 0 && tempReason != "|" { + tempReasons := strings.SplitN(tempReason, "|", 2) + if tempReasons[0] != "" { + reason = tempReasons[0] + } + if len(tempReasons) > 1 && tempReasons[1] != "" { + operReason = tempReasons[1] + } else { + operReason = reason + } + } + } + operName := client.operName if operName == "" { operName = server.name } - err = server.dlines.AddNetwork(flatip.FromNetIPNet(hostNet), duration, false, reason, operReason, operName) + // assemble ban info + var banTime *IPRestrictTime + if durationIsUsed { + banTime = &IPRestrictTime{ + Duration: duration, + Expires: time.Now().Add(duration), + } + } + + info := IPBanInfo{ + Reason: reason, + OperReason: operReason, + OperName: operName, + Time: banTime, + } + + // save in datastore + err = server.store.Update(func(tx *buntdb.Tx) error { + dlineKey := fmt.Sprintf(keyDlineEntry, hostString) + + // assemble json from ban info + b, err := json.Marshal(info) + if err != nil { + return err + } + + tx.Set(dlineKey, string(b), nil) + + return nil + }) if err != nil { rb.Notice(fmt.Sprintf(client.t("Could not successfully save new D-LINE: %s"), err.Error())) return false } + if hostNet == nil { + server.dlines.AddIP(hostAddr, banTime, reason, operReason, operName) + } else { + server.dlines.AddNetwork(*hostNet, banTime, reason, operReason, operName) + } + var snoDescription string - hostString = utils.NetToNormalizedString(hostNet) - if duration != 0 { + if durationIsUsed { rb.Notice(fmt.Sprintf(client.t("Added temporary (%[1]s) D-Line for %[2]s"), duration.String(), hostString)) snoDescription = fmt.Sprintf(ircfmt.Unescape("%s [%s]$r added temporary (%s) D-Line for %s"), client.nick, operName, duration.String(), hostString) } else { @@ -1088,30 +728,31 @@ func dlineHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon var killClient bool if andKill { - var sessionsToKill []*Session + var clientsToKill []*Client var killedClientNicks []string + var toKill bool for _, mcl := range server.clients.AllClients() { - nickKilled := false - for _, session := range mcl.Sessions() { - if hostNet.Contains(session.IP()) { - sessionsToKill = append(sessionsToKill, session) - if !nickKilled { - killedClientNicks = append(killedClientNicks, mcl.Nick()) - nickKilled = true - } - } + if hostNet == nil { + toKill = hostAddr.Equal(mcl.IP()) + } else { + toKill = hostNet.Contains(mcl.IP()) + } + + if toKill { + clientsToKill = append(clientsToKill, mcl) + killedClientNicks = append(killedClientNicks, mcl.nick) } } - for _, session := range sessionsToKill { - mcl := session.client - mcl.Quit(fmt.Sprintf(mcl.t("You have been banned from this server (%s)"), reason), session) - if session == rb.session { + for _, mcl := range clientsToKill { + mcl.exitedSnomaskSent = true + mcl.Quit(fmt.Sprintf(mcl.t("You have been banned from this server (%s)"), reason)) + if mcl == client { killClient = true } else { // if mcl == client, we kill them below - mcl.destroy(session) + mcl.destroy(false) } } @@ -1123,253 +764,124 @@ func dlineHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon return killClient } -// EXTJWT [service_name] -func extjwtHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - accountName := client.AccountName() - if accountName == "*" { - accountName = "" - } - - claims := jwt.MapClaims{ - "iss": server.name, - "sub": client.Nick(), - "account": accountName, - "umodes": []string{}, - } - - if msg.Params[0] != "*" { - channel := server.channels.Get(msg.Params[0]) - if channel == nil { - rb.Add(nil, server.name, "FAIL", "EXTJWT", "NO_SUCH_CHANNEL", client.t("No such channel")) - return false - } - - claims["channel"] = channel.Name() - - var channelModeStrings []string - for _, mode := range channel.flags.AllModes() { - channelModeStrings = append(channelModeStrings, mode.String()) - } - claims["chanModes"] = channelModeStrings - claims["joined"] = 0 - claims["cmodes"] = []string{} - if present, joinTimeSecs, cModes := channel.ClientStatus(client); present { - claims["joined"] = joinTimeSecs - var modeStrings []string - for _, cMode := range cModes { - modeStrings = append(modeStrings, string(cMode)) - } - claims["cmodes"] = modeStrings - } - } - - config := server.Config() - var serviceName string - var sConfig jwt.JwtServiceConfig - if 1 < len(msg.Params) { - serviceName = strings.ToLower(msg.Params[1]) - sConfig = config.Extjwt.Services[serviceName] - } else { - serviceName = "*" - sConfig = config.Extjwt.Default - } - - if !sConfig.Enabled() { - rb.Add(nil, server.name, "FAIL", "EXTJWT", "NO_SUCH_SERVICE", client.t("No such service")) - return false - } - - tokenString, err := sConfig.Sign(claims) - - if err == nil { - maxTokenLength := maxLastArgLength - - for maxTokenLength < len(tokenString) { - rb.Add(nil, server.name, "EXTJWT", msg.Params[0], serviceName, "*", tokenString[:maxTokenLength]) - tokenString = tokenString[maxTokenLength:] - } - rb.Add(nil, server.name, "EXTJWT", msg.Params[0], serviceName, tokenString) - } else { - rb.Add(nil, server.name, "FAIL", "EXTJWT", "UNKNOWN_ERROR", client.t("Could not generate EXTJWT token")) - } - - return false -} - // HELP [] -// HELPOP [] -func helpHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - if len(msg.Params) == 0 { +func helpHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + argument := strings.ToLower(strings.TrimSpace(strings.Join(msg.Params, " "))) + + if len(argument) < 1 { client.sendHelp("HELPOP", client.t(`HELPOP Get an explanation of , or "index" for a list of help topics.`), rb) return false } - argument := strings.ToLower(strings.TrimSpace(msg.Params[0])) - // handle index if argument == "index" { - client.sendHelp("HELP", server.helpIndexManager.GetIndex(client.Languages(), client.HasMode(modes.Operator)), rb) + if client.flags[modes.Operator] { + client.sendHelp("HELP", GetHelpIndex(client.languages, HelpIndexOpers), rb) + } else { + client.sendHelp("HELP", GetHelpIndex(client.languages, HelpIndex), rb) + } return false } helpHandler, exists := Help[argument] - if exists && (!helpHandler.oper || (helpHandler.oper && client.HasMode(modes.Operator))) { + if exists && (!helpHandler.oper || (helpHandler.oper && client.flags[modes.Operator])) { if helpHandler.textGenerator != nil { - client.sendHelp(argument, helpHandler.textGenerator(client), rb) + client.sendHelp(strings.ToUpper(argument), client.t(helpHandler.textGenerator(client)), rb) } else { - client.sendHelp(argument, client.t(helpHandler.text), rb) + client.sendHelp(strings.ToUpper(argument), client.t(helpHandler.text), rb) } } else { - rb.Add(nil, server.name, ERR_HELPNOTFOUND, client.Nick(), strings.ToUpper(utils.SafeErrorParam(argument)), client.t("Help not found")) + args := msg.Params + args = append(args, client.t("Help not found")) + rb.Add(nil, server.name, ERR_HELPNOTFOUND, args...) } return false } -// HISTORY [] -// e.g., HISTORY #ubuntu 10 -// HISTORY alice 15 -// HISTORY #darwin 1h -func historyHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - rb.Notice(client.t("This command is currently disabled. Please use CHATHISTORY")) - /* - config := server.Config() - if !config.History.Enabled { - rb.Notice(client.t("This command has been disabled by the server administrators")) - return false - } - - items, channel, err := easySelectHistory(server, client, msg.Params) - - if err == errNoSuchChannel { - rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), utils.SafeErrorParam(msg.Params[0]), client.t("No such channel")) - return false - } else if err != nil { - rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.Nick(), msg.Command, client.t("Could not retrieve history")) - return false - } - - var _, batchIdentifier = msg.GetTag("identifier") - - if len(items) != 0 { - if channel != nil { - channel.replayHistoryItems(rb, items, true, batchIdentifier) - } else { - client.replayPrivmsgHistory(rb, items, "", true, batchIdentifier) - } - } - */ - return false -} - // INFO -func infoHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - nick := client.Nick() +func infoHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { // we do the below so that the human-readable lines in info can be translated. for _, line := range infoString1 { - rb.Add(nil, server.name, RPL_INFO, nick, line) + rb.Add(nil, server.name, RPL_INFO, client.nick, line) } - rb.Add(nil, server.name, RPL_INFO, nick, fmt.Sprintf(client.t("This is Ergo version %s."), SemVer)) - if Commit != "" { - rb.Add(nil, server.name, RPL_INFO, nick, fmt.Sprintf(client.t("It was built from git hash %s."), Commit)) - } - rb.Add(nil, server.name, RPL_INFO, nick, fmt.Sprintf(client.t("It was compiled using %s."), runtime.Version())) - rb.Add(nil, server.name, RPL_INFO, nick, fmt.Sprintf(client.t("This server has been running since %s."), server.ctime.Format(time.RFC1123))) - rb.Add(nil, server.name, RPL_INFO, nick, "") - rb.Add(nil, server.name, RPL_INFO, nick, client.t("Ergo is released under the MIT license.")) - rb.Add(nil, server.name, RPL_INFO, nick, "") - rb.Add(nil, server.name, RPL_INFO, nick, client.t("Core Developers:")) + rb.Add(nil, server.name, RPL_INFO, client.nick, client.t("Oragono is released under the MIT license.")) + rb.Add(nil, server.name, RPL_INFO, client.nick, "") + rb.Add(nil, server.name, RPL_INFO, client.nick, client.t("Thanks to Jeremy Latt for founding Ergonomadic, the project this is based on")+" <3") + rb.Add(nil, server.name, RPL_INFO, client.nick, "") + rb.Add(nil, server.name, RPL_INFO, client.nick, client.t("Core Developers:")) for _, line := range infoString2 { - rb.Add(nil, server.name, RPL_INFO, nick, line) + rb.Add(nil, server.name, RPL_INFO, client.nick, line) } - rb.Add(nil, server.name, RPL_INFO, nick, client.t("Former Core Developers:")) + rb.Add(nil, server.name, RPL_INFO, client.nick, client.t("Contributors and Former Developers:")) for _, line := range infoString3 { - rb.Add(nil, server.name, RPL_INFO, nick, line) + rb.Add(nil, server.name, RPL_INFO, client.nick, line) } - rb.Add(nil, server.name, RPL_INFO, nick, client.t("For a more complete list of contributors, see our changelog:")) - rb.Add(nil, server.name, RPL_INFO, nick, " https://github.com/ergochat/ergo/blob/master/CHANGELOG.md") - rb.Add(nil, server.name, RPL_INFO, nick, "") // show translators for languages other than good ole' regular English - tlines := server.Languages().Translators() + tlines := server.languages.Translators() if 0 < len(tlines) { - rb.Add(nil, server.name, RPL_INFO, nick, client.t("Translators:")) + rb.Add(nil, server.name, RPL_INFO, client.nick, client.t("Translators:")) for _, line := range tlines { - rb.Add(nil, server.name, RPL_INFO, nick, " "+strings.Replace(line, "\n", ", ", -1)) + rb.Add(nil, server.name, RPL_INFO, client.nick, " "+line) } - rb.Add(nil, server.name, RPL_INFO, nick, "") + rb.Add(nil, server.name, RPL_INFO, client.nick, "") } - rb.Add(nil, server.name, RPL_ENDOFINFO, nick, client.t("End of /INFO")) + rb.Add(nil, server.name, RPL_ENDOFINFO, client.nick, client.t("End of /INFO")) return false } // INVITE -// UNINVITE -func inviteHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - invite := msg.Command == "INVITE" +func inviteHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { nickname := msg.Params[0] channelName := msg.Params[1] - target := server.clients.Get(nickname) - if target == nil { - rb.Add(nil, server.name, ERR_NOSUCHNICK, client.Nick(), utils.SafeErrorParam(nickname), client.t("No such nick")) + casefoldedNickname, err := CasefoldName(nickname) + target := server.clients.Get(casefoldedNickname) + if err != nil || target == nil { + rb.Add(nil, server.name, ERR_NOSUCHNICK, client.nick, nickname, client.t("No such nick")) return false } - channel := server.channels.Get(channelName) - if channel == nil { - rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), utils.SafeErrorParam(channelName), client.t("No such channel")) + casefoldedChannelName, err := CasefoldChannel(channelName) + channel := server.channels.Get(casefoldedChannelName) + if err != nil || channel == nil { + rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.nick, channelName, client.t("No such channel")) return false } - if invite { - channel.Invite(target, client, rb) - } else { - channel.Uninvite(target, client, rb) - } - + channel.Invite(target, client, rb) return false } // ISON { } -func isonHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { +func isonHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { var nicks = msg.Params - ison := make([]string, 0, len(msg.Params)) + var err error + var casefoldedNick string + ison := make([]string, 0) for _, nick := range nicks { - currentNick := server.getCurrentNick(nick) - if currentNick != "" { - ison = append(ison, currentNick) + casefoldedNick, err = CasefoldName(nick) + if err != nil { + continue + } + if iclient := server.clients.Get(casefoldedNick); iclient != nil { + ison = append(ison, iclient.nick) } } - rb.Add(nil, server.name, RPL_ISON, client.nick, strings.Join(ison, " ")) - return false -} - -// ISUPPORT -func isupportHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - server.RplISupport(client, rb) - if !client.registered { - rb.session.isupportSentPrereg = true - } + rb.Add(nil, server.name, RPL_ISON, client.nick, strings.Join(nicks, " ")) return false } // JOIN {,} [{,}] -func joinHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - // #1417: allow `JOIN 0` with a confirmation code +func joinHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + // kill JOIN 0 requests if msg.Params[0] == "0" { - expectedCode := utils.ConfirmationCode("", rb.session.ctime) - if len(msg.Params) == 1 || msg.Params[1] != expectedCode { - rb.Notice(fmt.Sprintf(client.t("Warning: /JOIN 0 will remove you from all channels. To confirm, type: /JOIN 0 %s"), expectedCode)) - } else { - for _, channel := range client.Channels() { - channel.Part(client, "", rb) - } - } + rb.Notice(client.t("JOIN 0 is not allowed")) return false } @@ -1381,135 +893,33 @@ func joinHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons } for i, name := range channels { - if name == "" { - continue // #679 - } var key string if len(keys) > i { key = keys[i] } - err, forward := server.channels.Join(client, name, key, false, rb) - if err != nil { - if forward != "" { - rb.Add(nil, server.name, ERR_LINKCHANNEL, client.Nick(), utils.SafeErrorParam(name), forward, client.t("Forwarding to another channel")) - name = forward - err, _ = server.channels.Join(client, name, key, false, rb) - } - if err != nil { - sendJoinError(client, name, rb, err) - } - } - } - return false -} - -func sendJoinError(client *Client, name string, rb *ResponseBuffer, err error) { - var code, errMsg, forbiddingMode string - switch err { - case errInsufficientPrivs: - code, errMsg = ERR_NOSUCHCHANNEL, `Only server operators can create new channels` - case errConfusableIdentifier: - code, errMsg = ERR_NOSUCHCHANNEL, `That channel name is too close to the name of another channel` - case errChannelPurged: - code, errMsg = ERR_NOSUCHCHANNEL, err.Error() - case errTooManyChannels: - code, errMsg = ERR_TOOMANYCHANNELS, `You have joined too many channels` - case errLimitExceeded: - code, forbiddingMode = ERR_CHANNELISFULL, "l" - case errWrongChannelKey: - code, forbiddingMode = ERR_BADCHANNELKEY, "k" - case errInviteOnly: - code, forbiddingMode = ERR_INVITEONLYCHAN, "i" - case errBanned: - code, forbiddingMode = ERR_BANNEDFROMCHAN, "b" - case errRegisteredOnly: - code, errMsg = ERR_NEEDREGGEDNICK, `You must be registered to join that channel` - default: - code, errMsg = ERR_NOSUCHCHANNEL, `No such channel` - } - if forbiddingMode != "" { - errMsg = fmt.Sprintf(client.t("Cannot join channel (+%s)"), forbiddingMode) - } else { - errMsg = client.t(errMsg) - } - rb.Add(nil, client.server.name, code, client.Nick(), utils.SafeErrorParam(name), errMsg) -} - -// SAJOIN [nick] #channel{,#channel} -func sajoinHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - var target *Client - var channelString string - if strings.HasPrefix(msg.Params[0], "#") { - target = client - channelString = msg.Params[0] - } else { - if len(msg.Params) == 1 { - rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.Nick(), "SAJOIN", client.t("Not enough parameters")) - return false - } else { - target = server.clients.Get(msg.Params[0]) - if target == nil { - rb.Add(nil, server.name, ERR_NOSUCHNICK, client.Nick(), utils.SafeErrorParam(msg.Params[0]), "No such nick") - return false - } - channelString = msg.Params[1] - } - } - - message := fmt.Sprintf("Operator %s ran SAJOIN %s", client.Oper().Name, strings.Join(msg.Params, " ")) - server.snomasks.Send(sno.LocalOpers, message) - server.logger.Info("opers", message) - - channels := strings.Split(channelString, ",") - for _, chname := range channels { - err, _ := server.channels.Join(target, chname, "", true, rb) - if err != nil { - sendJoinError(client, chname, rb, err) + err := server.channels.Join(client, name, key, rb) + if err == errNoSuchChannel { + rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), name, client.t("No such channel")) } } return false } // KICK {,} {,} [] -// RFC 2812 requires the number of channels to be either 1 or equal to -// the number of users. -// Addditionally, we support multiple channels and a single user. -func kickHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { +func kickHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { channels := strings.Split(msg.Params[0], ",") users := strings.Split(msg.Params[1], ",") - if (len(channels) != len(users)) && (len(users) != 1) && (len(channels) != 1) { + if (len(channels) != len(users)) && (len(users) != 1) { rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.nick, "KICK", client.t("Not enough parameters")) return false } - type kickCmd struct { - channel string - nick string - } - var kicks []kickCmd - if len(users) == 1 { - kicks = make([]kickCmd, 0, len(channels)) - // Single user, possibly multiple channels - user := users[0] - for _, channel := range channels { - if channel == "" { - continue // #679 - } - kicks = append(kicks, kickCmd{channel, user}) - } - } else { - // Multiple users, either a single channel or as many channels - // as users. - kicks = make([]kickCmd, 0, len(users)) - channel := channels[0] - for index, user := range users { - if len(channels) > 1 { - channel = channels[index] - } - if channel == "" { - continue // #679 - } - kicks = append(kicks, kickCmd{channel, user}) + var kicks [][]string + for index, channel := range channels { + if len(users) == 1 { + kicks = append(kicks, []string{channel, users[0]}) + } else { + kicks = append(kicks, []string{channel, users[index]}) } } @@ -1517,68 +927,62 @@ func kickHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons if len(msg.Params) > 2 { comment = msg.Params[2] } - if comment == "" { - comment = client.Nick() - } - for _, kick := range kicks { - channel := server.channels.Get(kick.channel) - if channel == nil { - rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.nick, utils.SafeErrorParam(kick.channel), client.t("No such channel")) + for _, info := range kicks { + chname := info[0] + nickname := info[1] + casefoldedChname, err := CasefoldChannel(chname) + channel := server.channels.Get(casefoldedChname) + if err != nil || channel == nil { + rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.nick, chname, client.t("No such channel")) continue } - target := server.clients.Get(kick.nick) - if target == nil { - rb.Add(nil, server.name, ERR_NOSUCHNICK, client.nick, utils.SafeErrorParam(kick.nick), client.t("No such nick")) + casefoldedNickname, err := CasefoldName(nickname) + target := server.clients.Get(casefoldedNickname) + if err != nil || target == nil { + rb.Add(nil, server.name, ERR_NOSUCHNICK, client.nick, nickname, client.t("No such nick")) continue } - channel.Kick(client, target, comment, rb, false) + + if comment == "" { + comment = nickname + } + channel.Kick(client, target, comment, rb) } return false } // KILL -func killHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { +func killHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { nickname := msg.Params[0] - var comment string + comment := "" if len(msg.Params) > 1 { comment = msg.Params[1] } - target := server.clients.Get(nickname) - if target == nil { - rb.Add(nil, client.server.name, ERR_NOSUCHNICK, client.Nick(), utils.SafeErrorParam(nickname), client.t("No such nick")) + casefoldedNickname, err := CasefoldName(nickname) + target := server.clients.Get(casefoldedNickname) + if err != nil || target == nil { + rb.Add(nil, client.server.name, ERR_NOSUCHNICK, client.nick, nickname, client.t("No such nick")) return false - } else if target.AlwaysOn() { - rb.Add(nil, client.server.name, ERR_UNKNOWNERROR, client.Nick(), "KILL", fmt.Sprintf(client.t("Client %s is always-on and cannot be fully removed by /KILL; consider /UBAN ADD instead"), target.Nick())) } - quitMsg := "Killed" - if comment != "" { - quitMsg = fmt.Sprintf("Killed by %s: %s", client.Nick(), comment) - } + quitMsg := fmt.Sprintf("Killed (%s (%s))", client.nick, comment) - var snoLine string - if comment == "" { - snoLine = fmt.Sprintf(ircfmt.Unescape("%s was killed by %s"), target.Nick(), client.Nick()) - } else { - snoLine = fmt.Sprintf(ircfmt.Unescape("%s was killed by %s $c[grey][$r%s$c[grey]]"), target.Nick(), client.Nick(), comment) - } - server.snomasks.Send(sno.LocalKills, snoLine) + server.snomasks.Send(sno.LocalKills, fmt.Sprintf(ircfmt.Unescape("%s$r was killed by %s $c[grey][$r%s$c[grey]]"), target.nick, client.nick, comment)) + target.exitedSnomaskSent = true - target.Quit(quitMsg, nil) - target.destroy(nil) + target.Quit(quitMsg) + target.destroy(false) return false } // KLINE [ANDKILL] [MYSELF] [duration] [ON ] [reason [| oper reason]] // KLINE LIST -func klineHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - details := client.Details() +func klineHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { // check oper permissions - oper := client.Oper() - if !oper.HasRoleCapab("ban") { - rb.Add(nil, server.name, ERR_NOPRIVS, details.nick, msg.Command, client.t("Insufficient oper privs")) + if !client.class.Capabilities["oper:local_ban"] { + rb.Add(nil, server.name, ERR_NOPRIVS, client.nick, msg.Command, client.t("Insufficient oper privs")) return false } @@ -1593,7 +997,7 @@ func klineHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon } for key, info := range bans { - client.Notice(formatBanForListing(client, key, info)) + client.Notice(fmt.Sprintf(client.t("Ban - %s - added by %s - %s"), key, info.OperName, info.BanMessage("%s"))) } return false @@ -1616,68 +1020,110 @@ func klineHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon // duration duration, err := custime.ParseDuration(msg.Params[currentArg]) - if err != nil { - duration = 0 - } else { + durationIsUsed := err == nil + if durationIsUsed { currentArg++ } // get mask if len(msg.Params) < currentArg+1 { - rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, details.nick, msg.Command, client.t("Not enough parameters")) + rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.nick, msg.Command, client.t("Not enough parameters")) return false } - mask := msg.Params[currentArg] + mask := strings.ToLower(msg.Params[currentArg]) currentArg++ // check mask - mask, err = CanonicalizeMaskWildcard(mask) - if err != nil { - rb.Add(nil, server.name, ERR_UNKNOWNERROR, details.nick, msg.Command, client.t("Erroneous nickname")) - return false + if !strings.Contains(mask, "!") && !strings.Contains(mask, "@") { + mask = mask + "!*@*" + } else if !strings.Contains(mask, "@") { + mask = mask + "@*" } - matcher, err := utils.CompileGlob(mask, false) - if err != nil { - rb.Add(nil, server.name, ERR_UNKNOWNERROR, details.nick, msg.Command, client.t("Erroneous nickname")) - return false - } + matcher := ircmatch.MakeMatch(mask) for _, clientMask := range client.AllNickmasks() { - if !klineMyself && matcher.MatchString(clientMask) { - rb.Add(nil, server.name, ERR_UNKNOWNERROR, details.nick, msg.Command, client.t("This ban matches you. To KLINE yourself, you must use the command: /KLINE MYSELF ")) + if !klineMyself && matcher.Match(clientMask) { + rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.nick, msg.Command, client.t("This ban matches you. To KLINE yourself, you must use the command: /KLINE MYSELF ")) return false } } // check remote if len(msg.Params) > currentArg && msg.Params[currentArg] == "ON" { - rb.Add(nil, server.name, ERR_UNKNOWNERROR, details.nick, msg.Command, client.t("Remote servers not yet supported")) + rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.nick, msg.Command, client.t("Remote servers not yet supported")) return false } // get oper name - operName := oper.Name + operName := client.operName if operName == "" { operName = server.name } // get comment(s) - reason, operReason := getReasonsFromParams(msg.Params, currentArg) + reason := "No reason given" + operReason := "No reason given" + if len(msg.Params) > currentArg { + tempReason := strings.TrimSpace(msg.Params[currentArg]) + if len(tempReason) > 0 && tempReason != "|" { + tempReasons := strings.SplitN(tempReason, "|", 2) + if tempReasons[0] != "" { + reason = tempReasons[0] + } + if len(tempReasons) > 1 && tempReasons[1] != "" { + operReason = tempReasons[1] + } else { + operReason = reason + } + } + } + + // assemble ban info + var banTime *IPRestrictTime + if durationIsUsed { + banTime = &IPRestrictTime{ + Duration: duration, + Expires: time.Now().Add(duration), + } + } + + info := IPBanInfo{ + Reason: reason, + OperReason: operReason, + OperName: operName, + Time: banTime, + } + + // save in datastore + err = server.store.Update(func(tx *buntdb.Tx) error { + klineKey := fmt.Sprintf(keyKlineEntry, mask) + + // assemble json from ban info + b, err := json.Marshal(info) + if err != nil { + return err + } + + tx.Set(klineKey, string(b), nil) + + return nil + }) - err = server.klines.AddMask(mask, duration, reason, operReason, operName) if err != nil { rb.Notice(fmt.Sprintf(client.t("Could not successfully save new K-LINE: %s"), err.Error())) return false } + server.klines.AddMask(mask, banTime, reason, operReason, operName) + var snoDescription string - if duration != 0 { + if durationIsUsed { rb.Notice(fmt.Sprintf(client.t("Added temporary (%[1]s) K-Line for %[2]s"), duration.String(), mask)) - snoDescription = fmt.Sprintf(ircfmt.Unescape("%s [%s]$r added temporary (%s) K-Line for %s"), details.nick, operName, duration.String(), mask) + snoDescription = fmt.Sprintf(ircfmt.Unescape("%s [%s]$r added temporary (%s) K-Line for %s"), client.nick, operName, duration.String(), mask) } else { rb.Notice(fmt.Sprintf(client.t("Added K-Line for %s"), mask)) - snoDescription = fmt.Sprintf(ircfmt.Unescape("%s [%s]$r added K-Line for %s"), details.nick, operName, mask) + snoDescription = fmt.Sprintf(ircfmt.Unescape("%s [%s]$r added K-Line for %s"), client.nick, operName, mask) } server.snomasks.Send(sno.LocalXline, snoDescription) @@ -1688,42 +1134,40 @@ func klineHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon for _, mcl := range server.clients.AllClients() { for _, clientMask := range mcl.AllNickmasks() { - if matcher.MatchString(clientMask) { + if matcher.Match(clientMask) { clientsToKill = append(clientsToKill, mcl) killedClientNicks = append(killedClientNicks, mcl.nick) - break } } } for _, mcl := range clientsToKill { - mcl.Quit(fmt.Sprintf(mcl.t("You have been banned from this server (%s)"), reason), nil) + mcl.exitedSnomaskSent = true + mcl.Quit(fmt.Sprintf(mcl.t("You have been banned from this server (%s)"), reason)) if mcl == client { killClient = true } else { // if mcl == client, we kill them below - mcl.destroy(nil) + mcl.destroy(false) } } // send snomask sort.Strings(killedClientNicks) - server.snomasks.Send(sno.LocalKills, fmt.Sprintf(ircfmt.Unescape("%s [%s] killed %d clients with a KLINE $c[grey][$r%s$c[grey]]"), details.nick, operName, len(killedClientNicks), strings.Join(killedClientNicks, ", "))) + server.snomasks.Send(sno.LocalKills, fmt.Sprintf(ircfmt.Unescape("%s [%s] killed %d clients with a KLINE $c[grey][$r%s$c[grey]]"), client.nick, operName, len(killedClientNicks), strings.Join(killedClientNicks, ", "))) } return killClient } // LANGUAGE { } -func languageHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - nick := client.Nick() +func languageHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { alreadyDoneLanguages := make(map[string]bool) var appliedLanguages []string - lm := server.Languages() - supportedLanguagesCount := lm.Count() + supportedLanguagesCount := server.languages.Count() if supportedLanguagesCount < len(msg.Params) { - rb.Add(nil, client.server.name, ERR_TOOMANYLANGUAGES, nick, strconv.Itoa(supportedLanguagesCount), client.t("You specified too many languages")) + rb.Add(nil, client.server.name, ERR_TOOMANYLANGUAGES, client.nick, strconv.Itoa(supportedLanguagesCount), client.t("You specified too many languages")) return false } @@ -1737,9 +1181,9 @@ func languageHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res continue } - _, exists := lm.Languages[value] + _, exists := server.languages.Info[value] if !exists { - rb.Add(nil, client.server.name, ERR_NOLANGUAGE, nick, fmt.Sprintf(client.t("Language %s is not supported by this server"), value)) + rb.Add(nil, client.server.name, ERR_NOLANGUAGE, client.nick, client.t("Languages are not supported by this server")) return false } @@ -1752,16 +1196,20 @@ func languageHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res appliedLanguages = append(appliedLanguages, value) } - var langsToSet []string - if !(len(appliedLanguages) == 1 && appliedLanguages[0] == "en") { - langsToSet = appliedLanguages + client.stateMutex.Lock() + if len(appliedLanguages) == 1 && appliedLanguages[0] == "en" { + // premature optimisation ahoy! + client.languages = []string{} + } else { + client.languages = appliedLanguages } - client.SetLanguages(langsToSet) + client.stateMutex.Unlock() - params := make([]string, len(appliedLanguages)+2) - params[0] = nick - copy(params[1:], appliedLanguages) - params[len(params)-1] = client.t("Language preferences have been set") + params := []string{client.nick} + for _, lang := range appliedLanguages { + params = append(params, lang) + } + params = append(params, client.t("Language preferences have been set")) rb.Add(nil, client.server.name, RPL_YOURLANGUAGESARE, params...) @@ -1769,15 +1217,7 @@ func languageHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res } // LIST [{,}] [{,}] -func listHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - config := server.Config() - if time.Since(client.ctime) < config.Channels.ListDelay && client.Account() == "" && !client.HasMode(modes.Operator) { - remaining := time.Until(client.ctime.Add(config.Channels.ListDelay)) - rb.Notice(fmt.Sprintf(client.t("This server requires that you wait %v after connecting before you can use /LIST. You have %v left."), config.Channels.ListDelay, remaining.Round(time.Millisecond))) - rb.Add(nil, server.name, RPL_LISTEND, client.Nick(), client.t("End of LIST")) - return false - } - +func listHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { // get channels var channels []string for _, param := range msg.Params { @@ -1817,35 +1257,32 @@ func listHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons } } - nick := client.Nick() - rplList := func(channel *Channel) { - members, name, topic := channel.listData() - rb.Add(nil, client.server.name, RPL_LIST, nick, name, strconv.Itoa(members), topic) - } - - clientIsOp := client.HasRoleCapabs("sajoin") if len(channels) == 0 { - for _, channel := range server.channels.ListableChannels() { - if !clientIsOp && channel.flags.HasMode(modes.Secret) && !channel.hasClient(client) { + for _, channel := range server.channels.Channels() { + if !client.flags[modes.Operator] && channel.flags[modes.Secret] { continue } if matcher.Matches(channel) { - rplList(channel) + client.RplList(channel, rb) } } } else { // limit regular users to only listing one channel - if !clientIsOp { + if !client.flags[modes.Operator] { channels = channels[:1] } for _, chname := range channels { - channel := server.channels.Get(chname) - if channel == nil || (!clientIsOp && channel.flags.HasMode(modes.Secret) && !channel.hasClient(client)) { + casefoldedChname, err := CasefoldChannel(chname) + channel := server.channels.Get(casefoldedChname) + if err != nil || channel == nil || (!client.flags[modes.Operator] && channel.flags[modes.Secret]) { + if len(chname) > 0 { + rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.nick, chname, client.t("No such channel")) + } continue } if matcher.Matches(channel) { - rplList(channel) + client.RplList(channel, rb) } } } @@ -1854,34 +1291,53 @@ func listHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons } // LUSERS [ []] -func lusersHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - server.Lusers(client, rb) +func lusersHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + //TODO(vegax87) Fix network statistics and additional parameters + var totalcount, invisiblecount, opercount int + + for _, onlineusers := range server.clients.AllClients() { + totalcount++ + if onlineusers.flags[modes.Invisible] { + invisiblecount++ + } + if onlineusers.flags[modes.Operator] { + opercount++ + } + } + rb.Add(nil, server.name, RPL_LUSERCLIENT, client.nick, fmt.Sprintf(client.t("There are %[1]d users and %[2]d invisible on %[3]d server(s)"), totalcount, invisiblecount, 1)) + rb.Add(nil, server.name, RPL_LUSEROP, client.nick, fmt.Sprintf(client.t("%d IRC Operators online"), opercount)) + rb.Add(nil, server.name, RPL_LUSERCHANNELS, client.nick, fmt.Sprintf(client.t("%d channels formed"), server.channels.Len())) + rb.Add(nil, server.name, RPL_LUSERME, client.nick, fmt.Sprintf(client.t("I have %[1]d clients and %[2]d servers"), totalcount, 1)) return false } // MODE [ [...]] -func modeHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - if 0 < len(msg.Params[0]) && msg.Params[0][0] == '#' { +func modeHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + _, errChan := CasefoldChannel(msg.Params[0]) + + if errChan == nil { return cmodeHandler(server, client, msg, rb) } return umodeHandler(server, client, msg, rb) } // MODE [ [...]] -func cmodeHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - channel := server.channels.Get(msg.Params[0]) +func cmodeHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + channelName, err := CasefoldChannel(msg.Params[0]) + channel := server.channels.Get(channelName) - if channel == nil { - rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.nick, utils.SafeErrorParam(msg.Params[0]), client.t("No such channel")) + if err != nil || channel == nil { + rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.nick, msg.Params[0], client.t("No such channel")) return false } - var changes modes.ModeChanges + // applied mode changes + applied := make(modes.ModeChanges, 0) + if 1 < len(msg.Params) { // parse out real mode changes params := msg.Params[1:] - var unknown map[rune]bool - changes, unknown = modes.ParseChannelModeChanges(params...) + changes, unknown := ParseChannelModeChanges(params...) // alert for unknown mode changes for char := range unknown { @@ -1890,58 +1346,54 @@ func cmodeHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon if len(unknown) == 1 && len(changes) == 0 { return false } + + // apply mode changes + applied = channel.ApplyChannelModeChanges(client, msg.Command == "SAMODE", changes, rb) } - isSamode := msg.Command == "SAMODE" - if isSamode { - message := fmt.Sprintf("Operator %s ran SAMODE %s", client.Oper().Name, strings.Join(msg.Params, " ")) - server.snomasks.Send(sno.LocalOpers, message) - server.logger.Info("opers", message) + // save changes to banlist/exceptlist/invexlist + var banlistUpdated, exceptlistUpdated, invexlistUpdated bool + for _, change := range applied { + if change.Mode == modes.BanMask { + banlistUpdated = true + } else if change.Mode == modes.ExceptMask { + exceptlistUpdated = true + } else if change.Mode == modes.InviteMask { + invexlistUpdated = true + } } - // process mode changes, include list operations (an empty set of changes does a list) - applied := channel.ApplyChannelModeChanges(client, isSamode, changes, rb) - details := client.Details() - isBot := client.HasMode(modes.Bot) - announceCmodeChanges(channel, applied, details.nickMask, details.accountName, details.account, isBot, rb) + if (banlistUpdated || exceptlistUpdated || invexlistUpdated) && channel.IsRegistered() { + go server.channelRegistry.StoreChannel(channel, true) + } + // send out changes + if len(applied) > 0 { + //TODO(dan): we should change the name of String and make it return a slice here + args := append([]string{channel.name}, strings.Split(applied.String(), " ")...) + for _, member := range channel.Members() { + if member == client { + rb.Add(nil, client.nickMaskString, "MODE", args...) + } else { + member.Send(nil, client.nickMaskString, "MODE", args...) + } + } + } else { + args := append([]string{client.nick, channel.name}, channel.modeStrings(client)...) + rb.Add(nil, client.nickMaskString, RPL_CHANNELMODEIS, args...) + rb.Add(nil, client.nickMaskString, RPL_CHANNELCREATED, client.nick, channel.name, strconv.FormatInt(channel.createdTime.Unix(), 10)) + } return false } -func announceCmodeChanges(channel *Channel, applied modes.ModeChanges, source, accountName, account string, isBot bool, rb *ResponseBuffer) { - // send out changes - if len(applied) > 0 { - message := utils.MakeMessage("") - changeStrings := applied.Strings() - for _, changeString := range changeStrings { - message.Split = append(message.Split, utils.MessagePair{Message: changeString}) - } - args := append([]string{channel.name}, changeStrings...) - rb.AddFromClient(message.Time, message.Msgid, source, accountName, isBot, nil, "MODE", args...) - for _, member := range channel.Members() { - for _, session := range member.Sessions() { - if session != rb.session { - session.sendFromClientInternal(false, message.Time, message.Msgid, source, accountName, isBot, nil, "MODE", args...) - } - } - } - channel.AddHistoryItem(history.Item{ - Type: history.Mode, - Nick: source, - Account: accountName, - Message: message, - Target: channel.NameCasefolded(), - IsBot: isBot, - }, account) - } -} - // MODE [ [...]] -func umodeHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - cDetails := client.Details() - target := server.clients.Get(msg.Params[0]) - if target == nil { - rb.Add(nil, server.name, ERR_NOSUCHNICK, cDetails.nick, utils.SafeErrorParam(msg.Params[0]), client.t("No such nick")) +func umodeHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + nickname, err := CasefoldName(msg.Params[0]) + target := server.clients.Get(nickname) + if err != nil || target == nil { + if len(msg.Params[0]) > 0 { + rb.Add(nil, server.name, ERR_NOSUCHNICK, client.nick, msg.Params[0], client.t("No such nick")) + } return false } @@ -1950,19 +1402,13 @@ func umodeHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon if !hasPrivs { if len(msg.Params) > 1 { - rb.Add(nil, server.name, ERR_USERSDONTMATCH, cDetails.nick, client.t("Can't change modes for other users")) + rb.Add(nil, server.name, ERR_USERSDONTMATCH, client.nick, client.t("Can't change modes for other users")) } else { - rb.Add(nil, server.name, ERR_USERSDONTMATCH, cDetails.nick, client.t("Can't view modes for other users")) + rb.Add(nil, server.name, ERR_USERSDONTMATCH, client.nick, client.t("Can't view modes for other users")) } return false } - if msg.Command == "SAMODE" { - message := fmt.Sprintf("Operator %s ran SAMODE %s", client.Oper().Name, strings.Join(msg.Params, " ")) - server.snomasks.Send(sno.LocalOpers, message) - server.logger.Info("opers", message) - } - // applied mode changes applied := make(modes.ModeChanges, 0) @@ -1973,44 +1419,33 @@ func umodeHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon // alert for unknown mode changes for char := range unknown { - rb.Add(nil, server.name, ERR_UNKNOWNMODE, cDetails.nick, string(char), client.t("is an unknown mode character to me")) + rb.Add(nil, server.name, ERR_UNKNOWNMODE, client.nick, string(char), client.t("is an unknown mode character to me")) } if len(unknown) == 1 && len(changes) == 0 { return false } // apply mode changes - applied = ApplyUserModeChanges(target, changes, msg.Command == "SAMODE", nil) + applied = target.applyUserModeChanges(msg.Command == "SAMODE", changes) } if len(applied) > 0 { - args := append([]string{targetNick}, applied.Strings()...) - rb.Add(nil, cDetails.nickMask, "MODE", args...) + rb.Add(nil, client.nickMaskString, "MODE", targetNick, applied.String()) } else if hasPrivs { - rb.Add(nil, server.name, RPL_UMODEIS, targetNick, target.ModeString()) - if target.HasMode(modes.Operator) { - masks := server.snomasks.String(target) + rb.Add(nil, target.nickMaskString, RPL_UMODEIS, targetNick, target.ModeString()) + if client.flags[modes.LocalOperator] || client.flags[modes.Operator] { + masks := server.snomasks.String(client) if 0 < len(masks) { - rb.Add(nil, server.name, RPL_SNOMASKIS, targetNick, masks, client.t("Server notice masks")) + rb.Add(nil, target.nickMaskString, RPL_SNOMASKIS, targetNick, masks, client.t("Server notice masks")) } } } return false } -// get the correct capitalization of a nick (if it's online), otherwise return "" -func (server *Server) getCurrentNick(nick string) (result string) { - if service, isService := ErgoServices[strings.ToLower(nick)]; isService { - return service.Name - } else if iclient := server.clients.Get(nick); iclient != nil { - return iclient.Nick() - } - return "" -} - // MONITOR [params...] -func monitorHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - handler, exists := monitorSubcommands[strings.ToLower(msg.Params[0])] +func monitorHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + handler, exists := metadataSubcommands[strings.ToLower(msg.Params[0])] if !exists { rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.Nick(), "MONITOR", msg.Params[0], client.t("Unknown subcommand")) @@ -2021,7 +1456,7 @@ func monitorHandler(server *Server, client *Client, msg ircmsg.Message, rb *Resp } // MONITOR - {,} -func monitorRemoveHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { +func monitorRemoveHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { if len(msg.Params) < 2 { rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.Nick(), msg.Command, client.t("Not enough parameters")) return false @@ -2029,14 +1464,18 @@ func monitorRemoveHandler(server *Server, client *Client, msg ircmsg.Message, rb targets := strings.Split(msg.Params[1], ",") for _, target := range targets { - server.monitorManager.Remove(rb.session, target) + cfnick, err := CasefoldName(target) + if err != nil { + continue + } + server.monitorManager.Remove(client, cfnick) } return false } // MONITOR + {,} -func monitorAddHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { +func monitorAddHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { if len(msg.Params) < 2 { rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.Nick(), msg.Command, client.t("Not enough parameters")) return false @@ -2045,30 +1484,34 @@ func monitorAddHandler(server *Server, client *Client, msg ircmsg.Message, rb *R var online []string var offline []string - limits := server.Config().Limits + limit := server.Limits().MonitorEntries targets := strings.Split(msg.Params[1], ",") for _, target := range targets { // check name length - if len(target) < 1 || len(targets) > limits.NickLen { + if len(target) < 1 || len(targets) > server.limits.NickLen { continue } // add target - err := server.monitorManager.Add(rb.session, target, limits.MonitorEntries) + casefoldedTarget, err := CasefoldName(target) + if err != nil { + continue + } + + err = server.monitorManager.Add(client, casefoldedTarget, limit) if err == errMonitorLimitExceeded { - rb.Add(nil, server.name, ERR_MONLISTFULL, client.Nick(), strconv.Itoa(limits.MonitorEntries), strings.Join(targets, ",")) + rb.Add(nil, server.name, ERR_MONLISTFULL, client.Nick(), strconv.Itoa(server.limits.MonitorEntries), strings.Join(targets, ",")) break } else if err != nil { continue } - currentNick := server.getCurrentNick(target) // add to online / offline lists - if currentNick != "" { - online = append(online, currentNick) - } else { + if targetClient := server.clients.Get(casefoldedTarget); targetClient == nil { offline = append(offline, target) + } else { + online = append(online, targetClient.Nick()) } } @@ -2083,59 +1526,57 @@ func monitorAddHandler(server *Server, client *Client, msg ircmsg.Message, rb *R } // MONITOR C -func monitorClearHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - server.monitorManager.RemoveAll(rb.session) +func monitorClearHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + server.monitorManager.RemoveAll(client) return false } // MONITOR L -func monitorListHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - nick := client.Nick() - monitorList := server.monitorManager.List(rb.session) +func monitorListHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + monitorList := server.monitorManager.List(client) var nickList []string for _, cfnick := range monitorList { replynick := cfnick - currentNick := server.getCurrentNick(cfnick) // report the uncasefolded nick if it's available, i.e., the client is online - if currentNick != "" { - replynick = currentNick + if mclient := server.clients.Get(cfnick); mclient != nil { + replynick = mclient.Nick() } nickList = append(nickList, replynick) } - for _, line := range utils.BuildTokenLines(maxLastArgLength, nickList, ",") { - rb.Add(nil, server.name, RPL_MONLIST, nick, line) + for _, line := range utils.ArgsToStrings(maxLastArgLength, nickList, ",") { + rb.Add(nil, server.name, RPL_MONLIST, client.Nick(), line) } - rb.Add(nil, server.name, RPL_ENDOFMONLIST, nick, "End of MONITOR list") + rb.Add(nil, server.name, RPL_ENDOFMONLIST, "End of MONITOR list") return false } // MONITOR S -func monitorStatusHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { +func monitorStatusHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { var online []string var offline []string - monitorList := server.monitorManager.List(rb.session) + monitorList := server.monitorManager.List(client) for _, name := range monitorList { - currentNick := server.getCurrentNick(name) - if currentNick != "" { - online = append(online, currentNick) - } else { + target := server.clients.Get(name) + if target == nil { offline = append(offline, name) + } else { + online = append(online, target.Nick()) } } if len(online) > 0 { - for _, line := range utils.BuildTokenLines(maxLastArgLength, online, ",") { + for _, line := range utils.ArgsToStrings(maxLastArgLength, online, ",") { rb.Add(nil, server.name, RPL_MONONLINE, client.Nick(), line) } } if len(offline) > 0 { - for _, line := range utils.BuildTokenLines(maxLastArgLength, offline, ",") { + for _, line := range utils.ArgsToStrings(maxLastArgLength, offline, ",") { rb.Add(nil, server.name, RPL_MONOFFLINE, client.Nick(), line) } } @@ -2144,1203 +1585,470 @@ func monitorStatusHandler(server *Server, client *Client, msg ircmsg.Message, rb } // MOTD -func motdHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { +func motdHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { server.MOTD(client, rb) return false } // NAMES [{,} [target]] -func namesHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { +func namesHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { var channels []string if len(msg.Params) > 0 { channels = strings.Split(msg.Params[0], ",") } - // implement the modern behavior: https://modern.ircdocs.horse/#names-message - // "Servers MAY only return information about the first and silently ignore the others." - // "If no parameter is given for this command, servers SHOULD return one RPL_ENDOFNAMES numeric - // with the parameter set to an asterix character" + // TODO: in a post-federation world, process `target` (server to forward request to) if len(channels) == 0 { - rb.Add(nil, server.name, RPL_ENDOFNAMES, client.Nick(), "*", client.t("End of NAMES list")) + for _, channel := range server.channels.Channels() { + channel.Names(client, rb) + } return false } - chname := channels[0] - success := false - channel := server.channels.Get(chname) - if channel != nil { - if !channel.flags.HasMode(modes.Secret) || channel.hasClient(client) || client.HasRoleCapabs("sajoin") { + for _, chname := range channels { + channel := server.channels.Get(chname) + if channel != nil { channel.Names(client, rb) - success = true + } else if chname != "" { + rb.Add(nil, server.name, RPL_ENDOFNAMES, client.Nick(), chname, client.t("End of NAMES list")) } } - if !success { // channel.Names() sends this numeric itself on success - rb.Add(nil, server.name, RPL_ENDOFNAMES, client.Nick(), chname, client.t("End of NAMES list")) - } return false } // NICK -func nickHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - newNick := msg.Params[0] - if client.registered { - if client.account == "" && server.Config().Accounts.NickReservation.ForbidAnonNickChanges { - rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.Nick(), client.t("You may not change your nickname")) - return false - } - performNickChange(server, client, client, nil, newNick, rb) +func nickHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + if client.Registered() { + performNickChange(server, client, client, msg.Params[0], rb) } else { - if newNick == "" { - // #1933: this would leave (*Client).preregNick at its zero value of "", - // which is the same condition as NICK not having been sent yet --- - // so we need to send an error immediately - rb.Add(nil, server.name, ERR_NONICKNAMEGIVEN, "*", client.t("No nickname given")) - return false - } - client.preregNick = newNick + client.SetPreregNick(msg.Params[0]) } return false } -// check whether a PRIVMSG or NOTICE is too long to be relayed without truncation -func validateLineLen(msgType history.ItemType, source, target, payload string) (ok bool) { - // :source PRIVMSG #target :payload\r\n - // 1: initial colon on prefix - // 1: space between prefix and command - // 1: space between command and target (first parameter) - // 1: space between target and payload (second parameter) - // 1: colon to send the payload as a trailing (we force trailing for PRIVMSG and NOTICE) - // 2: final \r\n - limit := MaxLineLen - 7 - limit -= len(source) - switch msgType { - case history.Privmsg: - limit -= 7 - case history.Notice: - limit -= 6 - default: - return true - } - limit -= len(target) - limit -= len(payload) - return limit >= 0 -} - -// check validateLineLen for an entire SplitMessage (which may consist of multiple lines) -func validateSplitMessageLen(msgType history.ItemType, source, target string, message utils.SplitMessage) (ok bool) { - if message.Is512() { - return validateLineLen(msgType, source, target, message.Message) - } else { - for _, messagePair := range message.Split { - if !validateLineLen(msgType, source, target, messagePair.Message) { - return false - } - } - return true - } -} - -// helper to store a batched PRIVMSG in the session object -func absorbBatchedMessage(server *Server, client *Client, msg ircmsg.Message, batchTag string, histType history.ItemType, rb *ResponseBuffer) { - var failParams []string - defer func() { - if failParams != nil { - if histType != history.Notice { - params := make([]string, 1+len(failParams)) - params[0] = "BATCH" - copy(params[1:], failParams) - rb.Add(nil, server.name, "FAIL", params...) - } - rb.session.EndMultilineBatch("") - } - }() - - if batchTag != rb.session.batch.label { - failParams = []string{"MULTILINE_INVALID", client.t("Incorrect batch tag sent")} - return - } else if len(msg.Params) < 2 { - failParams = []string{"MULTILINE_INVALID", client.t("Invalid multiline batch")} - return - } - rb.session.batch.command = msg.Command - isConcat, _ := msg.GetTag(caps.MultilineConcatTag) - if isConcat && len(msg.Params[1]) == 0 { - failParams = []string{"MULTILINE_INVALID", client.t("Cannot send a blank line with the multiline concat tag")} - return - } - if !isConcat && len(rb.session.batch.message.Split) != 0 { - rb.session.batch.lenBytes++ // bill for the newline - } - rb.session.batch.message.Append(msg.Params[1], isConcat, msg.ClientOnlyTags()) - rb.session.batch.lenBytes += len(msg.Params[1]) - config := server.Config() - if config.Limits.Multiline.MaxBytes < rb.session.batch.lenBytes { - failParams = []string{ - "MULTILINE_MAX_BYTES", - strconv.Itoa(config.Limits.Multiline.MaxBytes), - fmt.Sprintf(client.t("Multiline batch byte limit %d exceeded"), config.Limits.Multiline.MaxBytes), - } - } else if config.Limits.Multiline.MaxLines != 0 && config.Limits.Multiline.MaxLines < rb.session.batch.message.LenLines() { - failParams = []string{ - "MULTILINE_MAX_LINES", - strconv.Itoa(config.Limits.Multiline.MaxLines), - fmt.Sprintf(client.t("Multiline batch line limit %d exceeded"), config.Limits.Multiline.MaxLines), - } - } -} - // NOTICE {,} -// PRIVMSG {,} -// TAGMSG {,} -func messageHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - histType, err := msgCommandToHistType(msg.Command) - if err != nil { - return false - } - - if isBatched, batchTag := msg.GetTag("batch"); isBatched { - absorbBatchedMessage(server, client, msg, batchTag, histType, rb) - return false - } - - clientOnlyTags := msg.ClientOnlyTags() - if histType == history.Tagmsg && len(clientOnlyTags) == 0 { - // nothing to do - return false - } - +func noticeHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + clientOnlyTags := utils.GetClientOnlyTags(msg.Tags) targets := strings.Split(msg.Params[0], ",") - var message string - if len(msg.Params) > 1 { - message = msg.Params[1] - } - if histType != history.Tagmsg && message == "" { - rb.Add(nil, server.name, ERR_NOTEXTTOSEND, client.Nick(), client.t("No text to send")) - return false - } + message := msg.Params[1] - isCTCP := utils.IsRestrictedCTCPMessage(message) - if histType == history.Privmsg && !isCTCP { - client.UpdateActive(rb.session) - } - - if rb.session.isTor && isCTCP { - // note that error replies are never sent for NOTICE - if histType != history.Notice { - rb.Notice(client.t("CTCP messages are disabled over Tor")) - } - return false - } + // split privmsg + splitMsg := server.splitMessage(message, !client.capabilities.Has(caps.MaxLine)) for i, targetString := range targets { // max of four targets per privmsg - if i == maxTargets { + if i > maxTargets-1 { break } + prefixes, targetString := modes.SplitChannelMembershipPrefixes(targetString) + lowestPrefix := modes.GetLowestChannelModePrefix(prefixes) - config := server.Config() - if config.isRelaymsgIdentifier(targetString) { - if histType == history.Privmsg { - rb.Add(nil, server.name, ERR_NOSUCHNICK, client.Nick(), targetString, client.t("Relayed users cannot receive private messages")) + target, cerr := CasefoldChannel(targetString) + if cerr == nil { + channel := server.channels.Get(target) + if channel == nil { + // errors silently ignored with NOTICE as per RFC + continue + } + if !channel.CanSpeak(client) { + // errors silently ignored with NOTICE as per RFC + continue + } + msgid := server.generateMessageID() + channel.SplitNotice(msgid, lowestPrefix, clientOnlyTags, client, splitMsg, rb) + } else { + target, err := CasefoldName(targetString) + if err != nil { + continue + } + if target == "chanserv" { + server.chanservNoticeHandler(client, message, rb) + continue + } else if target == "nickserv" { + server.nickservNoticeHandler(client, message, rb) + continue } - // TAGMSG/NOTICEs are intentionally silently dropped - continue - } - // each target gets distinct msgids - splitMsg := utils.MakeMessage(message) - signatures := server.GenerateImagorSignaturesFromMessage(&msg) - if len(signatures) > 0 { - if clientOnlyTags == nil { - clientOnlyTags = make(map[string]string) + user := server.clients.Get(target) + if user == nil { + // errors silently ignored with NOTICE as per RFC + continue + } + if !user.capabilities.Has(caps.MessageTags) { + clientOnlyTags = nil + } + msgid := server.generateMessageID() + // restrict messages appropriately when +R is set + // intentionally make the sending user think the message went through fine + if !user.flags[modes.RegisteredOnly] || client.registered { + user.SendSplitMsgFromClient(msgid, client, clientOnlyTags, "NOTICE", user.nick, splitMsg) + } + if client.capabilities.Has(caps.EchoMessage) { + rb.AddSplitMessageFromClient(msgid, client, clientOnlyTags, "NOTICE", user.nick, splitMsg) } - clientOnlyTags["signatures"] = signatures } - dispatchMessageToTarget(client, clientOnlyTags, histType, msg.Command, targetString, splitMsg, rb) } return false } -// Not really sure how to do this in Go -var endChars = map[int32]bool{ - ' ': true, - '@': true, - ':': true, - '!': true, - '?': true, -} - -func detectMentions(message string) (mentions []string) { - buf := "" - mentions = []string{} - working := false - for _, char := range message { - if char == '@' { - working = true - continue - } - if !working { - continue - } - if _, stop := endChars[char]; stop { - working = false - mentions = append(mentions, buf) - buf = "" - } else { - buf += string(char) - } - } - if len(buf) != 0 { - mentions = append(mentions, buf) - } - return -} - -func dispatchMessageToTarget(client *Client, tags map[string]string, histType history.ItemType, command, target string, message utils.SplitMessage, rb *ResponseBuffer) { - server := client.server - - prefixes, target := modes.SplitChannelMembershipPrefixes(target) - lowestPrefix := modes.GetLowestChannelModePrefix(prefixes) - - if len(target) == 0 { - return - } else if target[0] == '#' { - channel := server.channels.Get(target) - if channel == nil { - if histType != history.Notice { - rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), utils.SafeErrorParam(target), client.t("No such channel")) - } - return - } - // This likely isn't that great for performance. Should figure out some way to deal with this at some point - mentions := detectMentions(message.Message) - channel.SendSplitMessage(command, lowestPrefix, tags, client, message, rb) - for _, mention := range mentions { - user := client.server.clients.Get(mention) - if user != nil { - user.RedisBroadcast("MENTION", channel.Name(), message.Msgid) - } - } - } else if target[0] == '$' && len(target) > 2 && client.Oper().HasRoleCapab("massmessage") { - details := client.Details() - matcher, err := utils.CompileGlob(target[2:], false) - if err != nil { - rb.Add(nil, server.name, ERR_UNKNOWNERROR, details.nick, command, client.t("Erroneous target")) - return - } - - nickMaskString := details.nickMask - accountName := details.accountName - isBot := client.HasMode(modes.Bot) - for _, tClient := range server.clients.AllClients() { - if (target[1] == '$' && matcher.MatchString(tClient.server.name)) || // $$servername - (target[1] == '#' && matcher.MatchString(tClient.Hostname())) { // $#hostname - - tnick := tClient.Nick() - for _, session := range tClient.Sessions() { - session.sendSplitMsgFromClientInternal(false, nickMaskString, accountName, isBot, nil, command, tnick, message) - } - } - } - } else { - // PMs - lowercaseTarget := strings.ToLower(target) - service, isService := ErgoServices[lowercaseTarget] - _, isZNC := zncHandlers[lowercaseTarget] - - if isService || isZNC { - details := client.Details() - rb.addEchoMessage(tags, details.nickMask, details.accountName, command, target, message) - if histType != history.Privmsg { - return // NOTICE and TAGMSG to services are ignored - } - if isService { - servicePrivmsgHandler(service, server, client, message.Message, rb) - } else if isZNC { - zncPrivmsgHandler(client, lowercaseTarget, message.Message, rb) - } - return - } - - user := server.clients.Get(target) - if user == nil { - if histType != history.Notice { - rb.Add(nil, server.name, ERR_NOSUCHNICK, client.Nick(), target, "No such nick") - } - return - } - - // Restrict CTCP message for target user with +T - if user.modes.HasMode(modes.UserNoCTCP) && message.IsRestrictedCTCPMessage() { - return - } - - tDetails := user.Details() - tnick := tDetails.nick - - details := client.Details() - if details.account == "" && server.Defcon() <= 3 { - rb.Add(nil, server.name, ERR_NEEDREGGEDNICK, client.Nick(), tnick, client.t("Direct messages from unregistered users are temporarily restricted")) - return - } - // restrict messages appropriately when +R is set - if details.account == "" && user.HasMode(modes.RegisteredOnly) && !server.accepts.MaySendTo(client, user) { - rb.Add(nil, server.name, ERR_NEEDREGGEDNICK, client.Nick(), tnick, client.t("You must be registered to send a direct message to this user")) - return - } - if client.HasMode(modes.RegisteredOnly) && tDetails.account == "" { - // #1688: auto-ACCEPT on DM - server.accepts.Accept(client, user) - } - if !client.server.Config().Server.Compatibility.allowTruncation { - if !validateSplitMessageLen(histType, client.NickMaskString(), tnick, message) { - rb.Add(nil, server.name, ERR_INPUTTOOLONG, client.Nick(), client.t("Line too long to be relayed without truncation")) - return - } - } - nickMaskString := details.nickMask - accountName := details.accountName - var deliverySessions []*Session - deliverySessions = append(deliverySessions, user.Sessions()...) - // all sessions of the sender, except the originating session, get a copy as well: - if client != user { - for _, session := range client.Sessions() { - if session != rb.session { - deliverySessions = append(deliverySessions, session) - } - } - } - - isBot := client.HasMode(modes.Bot) - for _, session := range deliverySessions { - hasTagsCap := session.capabilities.Has(caps.MessageTags) - // don't send TAGMSG at all if they don't have the tags cap - if histType == history.Tagmsg && hasTagsCap { - session.sendFromClientInternal(false, message.Time, message.Msgid, nickMaskString, accountName, isBot, tags, command, tnick) - } else if histType != history.Tagmsg && !(session.isTor && message.IsRestrictedCTCPMessage()) { - tagsToSend := tags - if !hasTagsCap { - tagsToSend = nil - } - session.sendSplitMsgFromClientInternal(false, nickMaskString, accountName, isBot, tagsToSend, command, tnick, message) - } - } - - // the originating session may get an echo message: - rb.addEchoMessage(tags, nickMaskString, accountName, command, tnick, message) - if histType == history.Privmsg { - //TODO(dan): possibly implement cooldown of away notifications to users - if away, awayMessage := user.Away(); away { - rb.Add(nil, server.name, RPL_AWAY, client.Nick(), tnick, awayMessage) - } - } - - config := server.Config() - if !config.History.Enabled { - return - } - item := history.Item{ - Type: histType, - Message: message, - Tags: tags, - Target: user.Account(), - Account: client.Account(), - } - - client.addHistoryItem(user, item, &details, &tDetails, config) - user.RedisBroadcast("MENTION", user.NickCasefolded(), message.Msgid) - - } -} - -func itemIsStorable(item *history.Item, config *Config) bool { - switch item.Type { - case history.Tagmsg: - if config.History.TagmsgStorage.Default { - for _, blacklistedTag := range config.History.TagmsgStorage.Blacklist { - if _, ok := item.Tags[blacklistedTag]; ok { - return false - } - } - return true - } else { - for _, whitelistedTag := range config.History.TagmsgStorage.Whitelist { - if _, ok := item.Tags[whitelistedTag]; ok { - return true - } - } - return false - } - case history.Privmsg, history.Notice: - // don't store CTCP other than ACTION - return !item.Message.IsRestrictedCTCPMessage() - default: - return true - } -} - // NPC -func npcHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { +func npcHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { target := msg.Params[0] fakeSource := msg.Params[1] - message := msg.Params[2:] + message := msg.Params[2] - sendRoleplayMessage(server, client, fakeSource, target, false, false, message, rb) + _, err := CasefoldName(fakeSource) + if err != nil { + client.Send(nil, client.server.name, ERR_CANNOTSENDRP, target, client.t("Fake source must be a valid nickname")) + return false + } + + sourceString := fmt.Sprintf(npcNickMask, fakeSource, client.nick) + + sendRoleplayMessage(server, client, sourceString, target, false, message, rb) return false } // NPCA -func npcaHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { +func npcaHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { target := msg.Params[0] fakeSource := msg.Params[1] - message := msg.Params[2:] + message := msg.Params[2] + sourceString := fmt.Sprintf(npcNickMask, fakeSource, client.nick) - sendRoleplayMessage(server, client, fakeSource, target, false, true, message, rb) + _, err := CasefoldName(fakeSource) + if err != nil { + client.Send(nil, client.server.name, ERR_CANNOTSENDRP, target, client.t("Fake source must be a valid nickname")) + return false + } + + sendRoleplayMessage(server, client, sourceString, target, true, message, rb) return false } -// OPER [password] -func operHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - if client.HasMode(modes.Operator) { - rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.Nick(), "OPER", client.t("You're already opered-up!")) - return false - } - - // must pass at least one check, and all enabled checks - var checkPassed, checkFailed, passwordFailed bool - oper := server.GetOperator(msg.Params[0]) - if oper != nil { - if oper.Certfp != "" { - if oper.Certfp == rb.session.certfp { - checkPassed = true - } else { - checkFailed = true - } - } - if !checkFailed && oper.Pass != nil { - if len(msg.Params) == 1 { - checkFailed = true - } else if bcrypt.CompareHashAndPassword(oper.Pass, []byte(msg.Params[1])) != nil { - checkFailed = true - passwordFailed = true - } else { - checkPassed = true - } - } - } - - if !checkPassed || checkFailed { - rb.Add(nil, server.name, ERR_PASSWDMISMATCH, client.Nick(), client.t("Password incorrect")) - // #951: only disconnect them if we actually tried to check a password for them - if passwordFailed { - client.Quit(client.t("Password incorrect"), rb.session) - return true - } else { - return false - } - } - - if oper != nil { - applyOper(client, oper, rb) - } +// NICKSERV [params...] +func nsHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + server.nickservPrivmsgHandler(client, strings.Join(msg.Params, " "), rb) return false } -// adds or removes operator status -// XXX: to add oper, this calls into ApplyUserModeChanges, but to remove oper, -// ApplyUserModeChanges calls into this, because the commands are asymmetric -// (/OPER to add, /MODE self -o to remove) -func applyOper(client *Client, oper *Oper, rb *ResponseBuffer) { - details := client.Details() - client.SetOper(oper) - newDetails := client.Details() - if details.nickMask != newDetails.nickMask { - client.sendChghost(details.nickMask, newDetails.hostname) +// OPER +func operHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + name, err := CasefoldName(msg.Params[0]) + if err != nil { + rb.Add(nil, server.name, ERR_PASSWDMISMATCH, client.nick, client.t("Password incorrect")) + return true } - - if oper != nil { - // set new modes: modes.Operator, plus anything specified in the config - modeChanges := make([]modes.ModeChange, len(oper.Modes)+1) - modeChanges[0] = modes.ModeChange{ - Mode: modes.Operator, - Op: modes.Add, - } - copy(modeChanges[1:], oper.Modes) - applied := ApplyUserModeChanges(client, modeChanges, true, oper) - - client.server.logger.Info("opers", details.nick, "opered up as", oper.Name) - client.server.snomasks.Send(sno.LocalOpers, fmt.Sprintf(ircfmt.Unescape("Client opered up $c[grey][$r%s$c[grey], $r%s$c[grey]]"), newDetails.nickMask, oper.Name)) - - rb.Broadcast(nil, client.server.name, RPL_YOUREOPER, details.nick, client.t("You are now an IRC operator")) - args := append([]string{details.nick}, applied.Strings()...) - rb.Broadcast(nil, client.server.name, "MODE", args...) - } else { - client.server.snomasks.Send(sno.LocalOpers, fmt.Sprintf(ircfmt.Unescape("Client deopered $c[grey][$r%s$c[grey]]"), newDetails.nickMask)) - } - - for _, session := range client.Sessions() { - // client may now be unthrottled by the fakelag system - session.resetFakelag() - } -} - -// DEOPER -func deoperHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - if client.Oper() == nil { - rb.Notice(client.t("Insufficient oper privs")) + if client.flags[modes.Operator] == true { + rb.Add(nil, server.name, ERR_UNKNOWNERROR, "OPER", client.t("You're already opered-up!")) return false } - // pretend they sent /MODE $nick -o - fakeModeMsg := ircmsg.MakeMessage(nil, "", "MODE", client.Nick(), "-o") - return umodeHandler(server, client, fakeModeMsg, rb) + server.configurableStateMutex.RLock() + oper := server.operators[name] + server.configurableStateMutex.RUnlock() + + password := []byte(msg.Params[1]) + err = passwd.ComparePassword(oper.Pass, password) + if (oper.Pass == nil) || (err != nil) { + rb.Add(nil, server.name, ERR_PASSWDMISMATCH, client.nick, client.t("Password incorrect")) + return true + } + + client.operName = name + client.class = oper.Class + client.whoisLine = oper.WhoisLine + + // push new vhost if one is set + if len(oper.Vhost) > 0 { + for fClient := range client.Friends(caps.ChgHost) { + fClient.SendFromClient("", client, nil, "CHGHOST", client.username, oper.Vhost) + } + // CHGHOST requires prefix nickmask to have original hostname, so do that before updating nickmask + client.vhost = oper.Vhost + client.updateNickMask("") + } + + // set new modes + var applied modes.ModeChanges + if 0 < len(oper.Modes) { + modeChanges, unknownChanges := modes.ParseUserModeChanges(strings.Split(oper.Modes, " ")...) + applied = client.applyUserModeChanges(true, modeChanges) + if 0 < len(unknownChanges) { + var runes string + for r := range unknownChanges { + runes += string(r) + } + rb.Notice(fmt.Sprintf(client.t("Could not apply mode changes: +%s"), runes)) + } + } + + rb.Add(nil, server.name, RPL_YOUREOPER, client.nick, client.t("You are now an IRC operator")) + + applied = append(applied, modes.ModeChange{ + Mode: modes.Operator, + Op: modes.Add, + }) + rb.Add(nil, server.name, "MODE", client.nick, applied.String()) + + server.snomasks.Send(sno.LocalOpers, fmt.Sprintf(ircfmt.Unescape("Client opered up $c[grey][$r%s$c[grey], $r%s$c[grey]]"), client.nickMaskString, client.operName)) + + // client may now be unthrottled by the fakelag system + client.resetFakelag() + + client.flags[modes.Operator] = true + return false } // PART {,} [] -func partHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { +func partHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { channels := strings.Split(msg.Params[0], ",") - var reason string + var reason string //TODO(dan): if this isn't supplied here, make sure the param doesn't exist in the PART message sent to other users if len(msg.Params) > 1 { reason = msg.Params[1] } for _, chname := range channels { - if chname == "" { - continue // #679 - } err := server.channels.Part(client, chname, reason, rb) if err == errNoSuchChannel { - rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.nick, utils.SafeErrorParam(chname), client.t("No such channel")) + rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.nick, chname, client.t("No such channel")) } } return false } // PASS -func passHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - if client.registered { +func passHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + if client.Registered() { rb.Add(nil, server.name, ERR_ALREADYREGISTRED, client.nick, client.t("You may not reregister")) return false } - // only give them one try to run the PASS command (if a server password is set, - // then all code paths end with this variable being set): - if rb.session.passStatus != serverPassUnsent { - return false - } - - password := msg.Params[0] - config := server.Config() - - if config.Accounts.LoginViaPassCommand { - colonIndex := strings.IndexByte(password, ':') - if colonIndex != -1 && client.Account() == "" { - account, accountPass := password[:colonIndex], password[colonIndex+1:] - if strudelIndex := strings.IndexByte(account, '@'); strudelIndex != -1 { - account, rb.session.deviceID = account[:strudelIndex], account[strudelIndex+1:] - } - err := server.accounts.AuthenticateByPassphrase(client, account, accountPass) - if err == nil { - sendSuccessfulAccountAuth(nil, client, rb, true) - // login-via-pass-command entails that we do not need to check - // an actual server password (either no password or skip-server-password) - rb.session.passStatus = serverPassSuccessful - return false - } - } - } - // if login-via-PASS failed for any reason, proceed to try and interpret the - // provided password as the server password - - serverPassword := config.Server.passwordBytes // if no password exists, skip checking - if serverPassword == nil { + if len(server.password) == 0 { + client.SetAuthorized(true) return false } // check the provided password - if bcrypt.CompareHashAndPassword(serverPassword, []byte(password)) == nil { - rb.session.passStatus = serverPassSuccessful - } else { - rb.session.passStatus = serverPassFailed + password := []byte(msg.Params[0]) + if passwd.ComparePassword(server.password, password) != nil { + rb.Add(nil, server.name, ERR_PASSWDMISMATCH, client.nick, client.t("Password incorrect")) + rb.Add(nil, server.name, "ERROR", client.t("Password incorrect")) + return true } - // if they failed the check, we'll bounce them later when they try to complete registration - // note in particular that with skip-server-password, you can give the wrong server - // password here, then successfully SASL and be admitted + client.SetAuthorized(true) return false } -// PERSISTENCE [params...] -func persistenceHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - account := client.Account() - if account == "" { - rb.Add(nil, server.name, "FAIL", "PERSISTENCE", "ACCOUNT_REQUIRED", client.t("You're not logged into an account")) - return false - } - - switch strings.ToUpper(msg.Params[0]) { - case "GET": - reportPersistenceStatus(client, rb, false) - case "SET": - if len(msg.Params) == 1 { - goto fail - } - var desiredSetting PersistentStatus - switch strings.ToUpper(msg.Params[1]) { - case "DEFAULT": - desiredSetting = PersistentUnspecified - case "OFF": - desiredSetting = PersistentDisabled - case "ON": - desiredSetting = PersistentMandatory - default: - goto fail - } - - broadcast := false - _, err := server.accounts.ModifyAccountSettings(account, - func(input AccountSettings) (output AccountSettings, err error) { - output = input - output.AlwaysOn = desiredSetting - broadcast = output.AlwaysOn != input.AlwaysOn - return - }) - if err != nil { - server.logger.Error("internal", "couldn't modify persistence setting", err.Error()) - rb.Add(nil, server.name, "FAIL", "PERSISTENCE", "UNKNOWN_ERROR", client.t("An error occurred")) - return false - } - - reportPersistenceStatus(client, rb, broadcast) - - default: - goto fail - } - - return false - -fail: - rb.Add(nil, server.name, "FAIL", "PERSISTENCE", "INVALID_PARAMS", client.t("Invalid parameters")) - return false -} - -// REDACT [:] -func redactHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - target := msg.Params[0] - targetmsgid := msg.Params[1] - //clientOnlyTags := msg.ClientOnlyTags() - var reason string - if len(msg.Params) > 2 { - reason = msg.Params[2] - } - var members []*Client // members of a channel, or both parties of a PM - var canDelete CanDelete - - msgid := utils.GenerateMessageIdStr() - time := time.Now().UTC().Round(0) - details := client.Details() - isBot := client.HasMode(modes.Bot) - - if target[0] == '#' { - channel := server.channels.Get(target) - if channel == nil { - rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), utils.SafeErrorParam(target), client.t("No such channel")) - return false - } - members = channel.Members() - canDelete = deletionPolicy(server, client, target) - } else { - targetClient := server.clients.Get(target) - if targetClient == nil { - rb.Add(nil, server.name, ERR_NOSUCHNICK, client.Nick(), target, "No such nick") - return false - } - members = []*Client{client, targetClient} - canDelete = canDeleteSelf - } - - if canDelete == canDeleteNone { - rb.Add(nil, server.name, "FAIL", "REDACT", "REDACT_FORBIDDEN", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), client.t("You are not authorized to delete messages")) - return false - } - account := "*" - if canDelete == canDeleteSelf { - account = client.account - if account == "*" { - rb.Add(nil, server.name, "FAIL", "REDACT", "REDACT_FORBIDDEN", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), client.t("You are not authorized to delete this message")) - return false - } - } - - err := server.DeleteMessage(target, targetmsgid, account) - if err == errNoop { - rb.Add(nil, server.name, "FAIL", "REDACT", "UNKNOWN_MSGID", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), client.t("This message does not exist or is too old")) - return false - } else if err != nil { - isOper := client.HasRoleCapabs("history") - if isOper { - rb.Add(nil, server.name, "FAIL", "REDACT", "REDACT_FORBIDDEN", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), fmt.Sprintf(client.t("Error deleting message: %v"), err)) - } else { - rb.Add(nil, server.name, "FAIL", "REDACT", "REDACT_FORBIDDEN", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), client.t("Could not delete message")) - } - return false - } - - if target[0] != '#' { - // If this is a PM, we just removed the message from the buffer of the other party; - // now we have to remove it from the buffer of the client who sent the REDACT command - err := server.DeleteMessage(client.Nick(), targetmsgid, account) - - if err != nil { - client.server.logger.Error("internal", fmt.Sprintf("Private message %s is not deletable by %s from their own buffer's even though we just deleted it from %s's. This is a bug, please report it in details.", targetmsgid, client.Nick(), target), client.Nick()) - isOper := client.HasRoleCapabs("history") - if isOper { - rb.Add(nil, server.name, "FAIL", "REDACT", "REDACT_FORBIDDEN", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), fmt.Sprintf(client.t("Error deleting message: %v"), err)) - } else { - rb.Add(nil, server.name, "FAIL", "REDACT", "REDACT_FORBIDDEN", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), client.t("Error deleting message")) - } - } - } - - for _, member := range members { - for _, session := range member.Sessions() { - if session.capabilities.Has(caps.MessageRedaction) { - session.sendFromClientInternal(false, time, msgid, details.nickMask, details.accountName, isBot, nil, "REDACT", target, targetmsgid, reason) - } else { - // If we wanted to send a fallback to clients which do not support - // draft/message-redaction, we would do it from here. - } - } - } - return false -} - -func reportPersistenceStatus(client *Client, rb *ResponseBuffer, broadcast bool) { - settings := client.AccountSettings() - serverSetting := client.server.Config().Accounts.Multiclient.AlwaysOn - effectiveSetting := persistenceEnabled(serverSetting, settings.AlwaysOn) - toString := func(setting PersistentStatus) string { - switch setting { - case PersistentUnspecified: - return "DEFAULT" - case PersistentDisabled: - return "OFF" - case PersistentMandatory: - return "ON" - default: - return "*" // impossible - } - } - storedSettingStr := toString(settings.AlwaysOn) - effectiveSettingStr := "OFF" - if effectiveSetting { - effectiveSettingStr = "ON" - } - rb.Add(nil, client.server.name, "PERSISTENCE", "STATUS", storedSettingStr, effectiveSettingStr) - if broadcast { - for _, session := range client.Sessions() { - if session != rb.session && session.capabilities.Has(caps.Persistence) { - session.Send(nil, client.server.name, "PERSISTENCE", "STATUS", storedSettingStr, effectiveSettingStr) - } - } - } -} - // PING [params...] -func pingHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - rb.Add(nil, server.name, "PONG", server.name, msg.Params[0]) +func pingHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + rb.Add(nil, server.name, "PONG", msg.Params...) return false } // PONG [params...] -func pongHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { +func pongHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { // client gets touched when they send this command, so we don't need to do anything return false } -// QUIT [] -func quitHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - reason := "Quit" - if len(msg.Params) > 0 { - reason += ": " + msg.Params[0] - } - client.Quit(reason, rb.session) - return true -} +// PRIVMSG {,} +func privmsgHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + clientOnlyTags := utils.GetClientOnlyTags(msg.Tags) + targets := strings.Split(msg.Params[0], ",") + message := msg.Params[1] -// REGISTER < account | * > < email | * > -func registerHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) (exiting bool) { - accountName := client.Nick() - if accountName == "*" { - accountName = client.preregNick - } + // split privmsg + splitMsg := server.splitMessage(message, !client.capabilities.Has(caps.MaxLine)) - switch msg.Params[0] { - case "*", accountName: - // ok - default: - rb.Add(nil, server.name, "FAIL", "REGISTER", "ACCOUNT_NAME_MUST_BE_NICK", utils.SafeErrorParam(msg.Params[0]), client.t("You may only register your nickname as your account name")) - return - } + for i, targetString := range targets { + // max of four targets per privmsg + if i > maxTargets-1 { + break + } + prefixes, targetString := modes.SplitChannelMembershipPrefixes(targetString) + lowestPrefix := modes.GetLowestChannelModePrefix(prefixes) - // check that accountName is valid as a non-final parameter; - // this is necessary for us to be valid and it will prevent us from emitting invalid error lines - nickErrorParam := utils.SafeErrorParam(accountName) - if accountName == "*" || accountName != nickErrorParam { - rb.Add(nil, server.name, "FAIL", "REGISTER", "INVALID_USERNAME", nickErrorParam, client.t("Username invalid or not given")) - return - } + // eh, no need to notify them + if len(targetString) < 1 { + continue + } - config := server.Config() - if !config.Accounts.Registration.Enabled { - rb.Add(nil, server.name, "FAIL", "REGISTER", "DISALLOWED", accountName, client.t("Account registration is disabled")) - return - } - if !client.registered && !config.Accounts.Registration.AllowBeforeConnect { - rb.Add(nil, server.name, "FAIL", "REGISTER", "COMPLETE_CONNECTION_REQUIRED", accountName, client.t("You must complete the connection before registering your account")) - return - } - if client.registerCmdSent || client.Account() != "" { - rb.Add(nil, server.name, "FAIL", "REGISTER", "ALREADY_REGISTERED", accountName, client.t("You have already registered or attempted to register")) - return - } - - callbackNamespace, callbackValue, err := parseCallback(msg.Params[1], config) - if err != nil { - rb.Add(nil, server.name, "FAIL", "REGISTER", "INVALID_EMAIL", accountName, client.t("A valid e-mail address is required")) - return - } - - err = server.accounts.Register(client, accountName, callbackNamespace, callbackValue, msg.Params[2], rb.session.certfp) - switch err { - case nil: - if callbackNamespace == "*" { - err := server.accounts.Verify(client, accountName, "", true) - if err == nil { - if client.registered { - if !fixupNickEqualsAccount(client, rb, config, "") { - err = errNickAccountMismatch - } - } - if err == nil { - rb.Add(nil, server.name, "REGISTER", "SUCCESS", accountName, client.t("Account successfully registered")) - sendSuccessfulRegResponse(nil, client, rb) - } + target, err := CasefoldChannel(targetString) + if err == nil { + channel := server.channels.Get(target) + if channel == nil { + rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.nick, targetString, client.t("No such channel")) + continue } - if err != nil { - server.logger.Error("internal", "accounts", "failed autoverification", accountName, err.Error()) - rb.Add(nil, server.name, "FAIL", "REGISTER", "UNKNOWN_ERROR", client.t("An error occurred")) + if !channel.CanSpeak(client) { + rb.Add(nil, client.server.name, ERR_CANNOTSENDTOCHAN, channel.name, client.t("Cannot send to channel")) + continue } + msgid := server.generateMessageID() + channel.SplitPrivMsg(msgid, lowestPrefix, clientOnlyTags, client, splitMsg, rb) } else { - rb.Add(nil, server.name, "REGISTER", "VERIFICATION_REQUIRED", accountName, fmt.Sprintf(client.t("Account created, pending verification; verification code has been sent to %s"), callbackValue)) - client.registerCmdSent = true - announcePendingReg(client, rb, accountName) - } - case errAccountAlreadyRegistered, errAccountAlreadyUnregistered, errAccountMustHoldNick: - rb.Add(nil, server.name, "FAIL", "REGISTER", "USERNAME_EXISTS", accountName, client.t("Username is already registered or otherwise unavailable")) - case errAccountBadPassphrase: - rb.Add(nil, server.name, "FAIL", "REGISTER", "INVALID_PASSWORD", accountName, client.t("Password was invalid")) - default: - if emailError := registrationCallbackErrorText(config, client, err); emailError != "" { - rb.Add(nil, server.name, "FAIL", "REGISTER", "UNACCEPTABLE_EMAIL", accountName, emailError) - } else { - rb.Add(nil, server.name, "FAIL", "REGISTER", "UNKNOWN_ERROR", accountName, client.t("Could not register")) - } - } - return -} - -// VERIFY -func verifyHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) (exiting bool) { - config := server.Config() - if !config.Accounts.Registration.Enabled { - rb.Add(nil, server.name, "FAIL", "VERIFY", "DISALLOWED", client.t("Account registration is disabled")) - return - } - if !client.registered && !config.Accounts.Registration.AllowBeforeConnect { - rb.Add(nil, server.name, "FAIL", "VERIFY", "DISALLOWED", client.t("You must complete the connection before verifying your account")) - return - } - if client.Account() != "" { - rb.Add(nil, server.name, "FAIL", "VERIFY", "ALREADY_REGISTERED", client.t("You have already registered or attempted to register")) - return - } - - accountName, verificationCode := msg.Params[0], msg.Params[1] - err := server.accounts.Verify(client, accountName, verificationCode, false) - if err == nil && client.registered { - if !fixupNickEqualsAccount(client, rb, config, "") { - err = errNickAccountMismatch - } - } - switch err { - case nil: - rb.Add(nil, server.name, "VERIFY", "SUCCESS", accountName, client.t("Account successfully registered")) - sendSuccessfulRegResponse(nil, client, rb) - case errAccountVerificationInvalidCode: - rb.Add(nil, server.name, "FAIL", "VERIFY", "INVALID_CODE", client.t("Invalid verification code")) - default: - rb.Add(nil, server.name, "FAIL", "VERIFY", "UNKNOWN_ERROR", client.t("Failed to verify account")) - } - - if err != nil && !client.registered { - // XXX pre-registration clients are exempt from fakelag; - // slow the client down to stop them spamming verify attempts - time.Sleep(time.Second) - } - - return -} - -// MARKREAD [timestamp] -func markReadHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) (exiting bool) { - if len(msg.Params) == 0 { - rb.Add(nil, server.name, "FAIL", "MARKREAD", "NEED_MORE_PARAMS", client.t("Missing parameters")) - return - } - - target := msg.Params[0] - cftarget, err := CasefoldTarget(target) - if err != nil { - rb.Add(nil, server.name, "FAIL", "MARKREAD", "INVALID_PARAMS", utils.SafeErrorParam(target), client.t("Invalid target")) - return - } - unfoldedTarget := server.UnfoldName(cftarget) - - // "MARKREAD client get command": MARKREAD - if len(msg.Params) == 1 { - rb.Add(nil, client.server.name, "MARKREAD", unfoldedTarget, client.GetReadMarker(cftarget)) - return - } - - // "MARKREAD client set command": MARKREAD - readTimestamp := strings.TrimPrefix(msg.Params[1], "timestamp=") - readTime, err := time.Parse(IRCv3TimestampFormat, readTimestamp) - if err != nil { - rb.Add(nil, server.name, "FAIL", "MARKREAD", "INVALID_PARAMS", utils.SafeErrorParam(readTimestamp), client.t("Invalid timestamp")) - return - } - result := client.SetReadMarker(cftarget, readTime) - readTimestamp = fmt.Sprintf("timestamp=%s", result.Format(IRCv3TimestampFormat)) - // inform the originating session whether it was a success or a no-op: - rb.Add(nil, server.name, "MARKREAD", unfoldedTarget, readTimestamp) - if result.Equal(readTime) { - // successful update (i.e. it moved the stored timestamp forward): - // inform other sessions - for _, session := range client.Sessions() { - if session != rb.session && session.capabilities.Has(caps.ReadMarker) { - session.Send(nil, server.name, "MARKREAD", unfoldedTarget, readTimestamp) + target, err = CasefoldName(targetString) + if target == "chanserv" { + server.chanservPrivmsgHandler(client, message, rb) + continue + } else if target == "nickserv" { + server.nickservPrivmsgHandler(client, message, rb) + continue + } + user := server.clients.Get(target) + if err != nil || user == nil { + if len(target) > 0 { + client.Send(nil, server.name, ERR_NOSUCHNICK, client.nick, target, "No such nick") + } + continue + } + if !user.capabilities.Has(caps.MessageTags) { + clientOnlyTags = nil + } + msgid := server.generateMessageID() + // restrict messages appropriately when +R is set + // intentionally make the sending user think the message went through fine + if !user.flags[modes.RegisteredOnly] || client.registered { + user.SendSplitMsgFromClient(msgid, client, clientOnlyTags, "PRIVMSG", user.nick, splitMsg) + } + if client.capabilities.Has(caps.EchoMessage) { + rb.AddSplitMessageFromClient(msgid, client, clientOnlyTags, "PRIVMSG", user.nick, splitMsg) + } + if user.flags[modes.Away] { + //TODO(dan): possibly implement cooldown of away notifications to users + rb.Add(nil, server.name, RPL_AWAY, user.nick, user.awayMessage) } } } - return -} - -// REHASH -func rehashHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - nick := client.Nick() - server.logger.Info("server", "REHASH command used by", nick) - err := server.rehash() - - if err == nil { - // we used to send RPL_REHASHING here but i don't think it really makes sense - // in the labeled-response world, since the intent is "rehash in progress" but - // it won't display until the rehash is actually complete - // TODO all operators should get a notice of some kind here - rb.Notice(client.t("Rehash complete")) - } else { - rb.Add(nil, server.name, ERR_UNKNOWNERROR, nick, "REHASH", ircutils.SanitizeText(err.Error(), 350)) - } return false } -// RELAYMSG : -func relaymsgHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) (result bool) { - config := server.Config() - if !config.Server.Relaymsg.Enabled { - rb.Add(nil, server.name, "FAIL", "RELAYMSG", "NOT_ENABLED", client.t("RELAYMSG has been disabled")) +// PROXY TCP4/6 SOURCEIP DESTIP SOURCEPORT DESTPORT +// http://www.haproxy.org/download/1.8/doc/proxy-protocol.txt +func proxyHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + // only allow unregistered clients to use this command + if client.Registered() || client.proxiedIP != nil { return false } - channel := server.channels.Get(msg.Params[0]) - if channel == nil { - rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), utils.SafeErrorParam(msg.Params[0]), client.t("No such channel")) - return false - } + for _, gateway := range server.ProxyAllowedFrom() { + if isGatewayAllowed(client.socket.conn.RemoteAddr(), gateway) { + proxiedIP := msg.Params[1] - allowedToRelay := client.HasRoleCapabs("relaymsg") || (config.Server.Relaymsg.AvailableToChanops && channel.ClientIsAtLeast(client, modes.ChannelOperator)) - if !allowedToRelay { - rb.Add(nil, server.name, "FAIL", "RELAYMSG", "PRIVS_NEEDED", client.t("You cannot relay messages to this channel")) - return false + // assume PROXY connections are always secure + return client.ApplyProxiedIP(proxiedIP, true) + } } + client.Quit(client.t("PROXY command is not usable from your address")) + return true +} - rawMessage := msg.Params[2] - if strings.TrimSpace(rawMessage) == "" { - rb.Add(nil, server.name, "FAIL", "RELAYMSG", "BLANK_MSG", client.t("The message must not be blank")) - return false +// QUIT [] +func quitHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + reason := "Quit" + if len(msg.Params) > 0 { + reason += ": " + msg.Params[0] } - message := utils.MakeMessage(rawMessage) + client.Quit(reason) + return true +} - nick := msg.Params[1] - cfnick, err := CasefoldName(nick) - if err != nil { - rb.Add(nil, server.name, "FAIL", "RELAYMSG", "INVALID_NICK", client.t("Invalid nickname")) - return false - } - if !config.isRelaymsgIdentifier(nick) { - rb.Add(nil, server.name, "FAIL", "RELAYMSG", "INVALID_NICK", fmt.Sprintf(client.t("Relayed nicknames MUST contain a relaymsg separator from this set: %s"), config.Server.Relaymsg.Separators)) - return false - } - if channel.relayNickMuted(cfnick) { - rb.Add(nil, server.name, "FAIL", "RELAYMSG", "BANNED", fmt.Sprintf(client.t("%s is banned from relaying to the channel"), nick)) - return false - } +// REHASH +func rehashHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + server.logger.Info("rehash", fmt.Sprintf("REHASH command used by %s", client.nick)) + err := server.rehash() - details := client.Details() - // #1647: we need to publish a full NUH. send ~u (or the configured alternative) - // as the user/ident, and send the relayer's hostname as the hostname: - ident := config.Server.CoerceIdent - if ident == "" { - ident = "~u" - } - // #1661: if the bot has its own account, use the account cloak, - // otherwise fall back to the hostname (which may be IP-derived) - hostname := details.hostname - if details.accountName != "" { - hostname = config.Server.Cloaks.ComputeAccountCloak(details.accountName) - } - nuh := fmt.Sprintf("%s!%s@%s", nick, ident, hostname) - - channel.AddHistoryItem(history.Item{ - Type: history.Privmsg, - Message: message, - Nick: nuh, - Target: channel.NameCasefolded(), - Account: "$RELAYMSG", - }, "") - - // 3 possibilities for tags: - // no tags, the relaymsg tag only, or the relaymsg tag together with all client-only tags - relayTag := map[string]string{ - caps.RelaymsgTagName: details.nick, - } - clientOnlyTags := msg.ClientOnlyTags() - var fullTags map[string]string - if len(clientOnlyTags) == 0 { - fullTags = relayTag + if err == nil { + rb.Add(nil, server.name, RPL_REHASHING, client.nick, "ircd.yaml", client.t("Rehashing")) } else { - fullTags = make(map[string]string, 1+len(clientOnlyTags)) - fullTags[caps.RelaymsgTagName] = details.nick - for t, v := range clientOnlyTags { - fullTags[t] = v - } - } - - // actually send the message - channelName := channel.Name() - for _, member := range channel.Members() { - for _, session := range member.Sessions() { - var tagsToUse map[string]string - if session.capabilities.Has(caps.MessageTags) { - tagsToUse = fullTags - } else if session.capabilities.Has(caps.Relaymsg) { - tagsToUse = relayTag - } - - if session == rb.session { - rb.AddSplitMessageFromClient(nuh, "*", false, tagsToUse, "PRIVMSG", channelName, message) - } else { - session.sendSplitMsgFromClientInternal(false, nuh, "*", false, tagsToUse, "PRIVMSG", channelName, message) - } - } + server.logger.Error("rehash", fmt.Sprintln("Failed to rehash:", err.Error())) + rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.nick, "REHASH", err.Error()) } return false } // RENAME [] -func renameHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - oldName, newName := msg.Params[0], msg.Params[1] - var reason string +func renameHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) (result bool) { + result = false + + errorResponse := func(err error, name string) { + // TODO: send correct error codes, e.g., ERR_CANNOTRENAME, ERR_CHANNAMEINUSE + var code string + switch err { + case errNoSuchChannel: + code = ERR_NOSUCHCHANNEL + case errRenamePrivsNeeded: + code = ERR_CHANOPRIVSNEEDED + case errInvalidChannelName: + code = ERR_UNKNOWNERROR + case errChannelNameInUse: + code = ERR_UNKNOWNERROR + default: + code = ERR_UNKNOWNERROR + } + rb.Add(nil, server.name, code, client.Nick(), "RENAME", name, err.Error()) + } + + oldName := strings.TrimSpace(msg.Params[0]) + newName := strings.TrimSpace(msg.Params[1]) + if oldName == "" || newName == "" { + errorResponse(errInvalidChannelName, "") + return + } + casefoldedOldName, err := CasefoldChannel(oldName) + if err != nil { + errorResponse(errInvalidChannelName, oldName) + return + } + + reason := "No reason" if 2 < len(msg.Params) { reason = msg.Params[2] } channel := server.channels.Get(oldName) if channel == nil { - rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), utils.SafeErrorParam(oldName), client.t("No such channel")) - return false + errorResponse(errNoSuchChannel, oldName) + return } - oldName = channel.Name() - - if !(channel.ClientIsAtLeast(client, modes.ChannelOperator) || client.HasRoleCapabs("chanreg")) { - rb.Add(nil, server.name, ERR_CHANOPRIVSNEEDED, client.Nick(), oldName, client.t("You're not a channel operator")) - return false + //TODO(dan): allow IRCops to do this? + if !channel.ClientIsAtLeast(client, modes.Operator) { + errorResponse(errRenamePrivsNeeded, oldName) + return } founder := channel.Founder() if founder != "" && founder != client.Account() { - rb.Add(nil, server.name, "FAIL", "RENAME", "CANNOT_RENAME", oldName, utils.SafeErrorParam(newName), client.t("Only channel founders can change registered channels")) - return false - } - - config := server.Config() - status, _, _ := channel.historyStatus(config) - if status == HistoryPersistent { - rb.Add(nil, server.name, "FAIL", "RENAME", "CANNOT_RENAME", oldName, utils.SafeErrorParam(newName), client.t("Channels with persistent history cannot be renamed")) + //TODO(dan): Change this to ERR_CANNOTRENAME + rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.nick, "RENAME", oldName, client.t("Only channel founders can change registered channels")) return false } // perform the channel rename - err := server.channels.Rename(oldName, newName) - if err == errInvalidChannelName { - rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), utils.SafeErrorParam(newName), client.t(err.Error())) - } else if err == errChannelNameInUse || err == errConfusableIdentifier { - rb.Add(nil, server.name, "FAIL", "RENAME", "CHANNEL_NAME_IN_USE", oldName, utils.SafeErrorParam(newName), client.t(err.Error())) - } else if err != nil { - rb.Add(nil, server.name, "FAIL", "RENAME", "CANNOT_RENAME", oldName, utils.SafeErrorParam(newName), client.t("Cannot rename channel")) - } + err = server.channels.Rename(oldName, newName) if err != nil { - return false + errorResponse(err, newName) + return } + // rename succeeded, persist it + go server.channelRegistry.Rename(channel, casefoldedOldName) + // send RENAME messages - clientPrefix := client.NickMaskString() for _, mcl := range channel.Members() { - mDetails := mcl.Details() - for _, mSession := range mcl.Sessions() { - targetRb := rb - targetPrefix := clientPrefix - if mSession != rb.session { - targetRb = NewResponseBuffer(mSession) - targetPrefix = mDetails.nickMask - } - if mSession.capabilities.Has(caps.ChannelRename) { - if reason != "" { - targetRb.Add(nil, clientPrefix, "RENAME", oldName, newName, reason) - } else { - targetRb.Add(nil, clientPrefix, "RENAME", oldName, newName) - } + if mcl.capabilities.Has(caps.Rename) { + mcl.Send(nil, client.nickMaskString, "RENAME", oldName, newName, reason) + } else { + mcl.Send(nil, mcl.nickMaskString, "PART", oldName, fmt.Sprintf(mcl.t("Channel renamed: %s"), reason)) + if mcl.capabilities.Has(caps.ExtendedJoin) { + mcl.Send(nil, mcl.nickMaskString, "JOIN", newName, mcl.AccountName(), mcl.realname) } else { - if reason != "" { - targetRb.Add(nil, targetPrefix, "PART", oldName, fmt.Sprintf(mcl.t("Channel renamed: %s"), reason)) - } else { - targetRb.Add(nil, targetPrefix, "PART", oldName, mcl.t("Channel renamed")) - } - if mSession.capabilities.Has(caps.ExtendedJoin) { - targetRb.Add(nil, targetPrefix, "JOIN", newName, mDetails.accountName, mDetails.realname) - } else { - targetRb.Add(nil, targetPrefix, "JOIN", newName) - } - channel.SendTopic(mcl, targetRb, false) - if !targetRb.session.capabilities.Has(caps.NoImplicitNames) { - channel.Names(mcl, targetRb) - } - } - if mcl != client { - targetRb.Send(false) + mcl.Send(nil, mcl.nickMaskString, "JOIN", newName) } } } @@ -3348,90 +2056,155 @@ func renameHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo return false } -// SANICK -func sanickHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - targetNick := msg.Params[0] - target := server.clients.Get(targetNick) - if target == nil { - rb.Add(nil, server.name, "FAIL", "SANICK", "NO_SUCH_NICKNAME", utils.SafeErrorParam(targetNick), client.t("No such nick")) +// RESUME [timestamp] +func resumeHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + oldnick := msg.Params[0] + + if strings.Contains(oldnick, " ") { + rb.Add(nil, server.name, ERR_CANNOT_RESUME, "*", client.t("Cannot resume connection, old nickname contains spaces")) return false } - performNickChange(server, client, target, nil, msg.Params[1], rb) + + if client.Registered() { + rb.Add(nil, server.name, ERR_CANNOT_RESUME, oldnick, client.t("Cannot resume connection, connection registration has already been completed")) + return false + } + + var timestamp *time.Time + if 1 < len(msg.Params) { + ts, err := time.Parse("2006-01-02T15:04:05.999Z", msg.Params[1]) + if err == nil { + timestamp = &ts + } else { + rb.Add(nil, server.name, ERR_CANNOT_RESUME, oldnick, client.t("Timestamp is not in 2006-01-02T15:04:05.999Z format, ignoring it")) + } + } + + client.resumeDetails = &ResumeDetails{ + OldNick: oldnick, + Timestamp: timestamp, + } + + return false +} + +// SANICK +func sanickHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + targetNick := strings.TrimSpace(msg.Params[0]) + target := server.clients.Get(targetNick) + if target == nil { + rb.Add(nil, server.name, ERR_NOSUCHNICK, client.nick, msg.Params[0], client.t("No such nick")) + return false + } + performNickChange(server, client, target, msg.Params[1], rb) return false } // SCENE -func sceneHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { +func sceneHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { target := msg.Params[0] - message := msg.Params[1:] + message := msg.Params[1] + sourceString := fmt.Sprintf(sceneNickMask, client.nick) - sendRoleplayMessage(server, client, "", target, true, false, message, rb) + sendRoleplayMessage(server, client, sourceString, target, false, message, rb) return false } -// SETNAME -func setnameHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - realname := msg.Params[0] - if len(msg.Params) != 1 { - // workaround for clients that turn unknown commands into raw IRC lines, - // so you can do `/setname Jane Doe` in the client and get the expected result - realname = strings.Join(msg.Params, " ") - } - if realname == "" { - rb.Add(nil, server.name, "FAIL", "SETNAME", "INVALID_REALNAME", client.t("Realname is not valid")) +// TAGMSG {,} +func tagmsgHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + clientOnlyTags := utils.GetClientOnlyTags(msg.Tags) + // no client-only tags, so we can drop it + if clientOnlyTags == nil { return false } - client.SetRealname(realname) - details := client.Details() + targets := strings.Split(msg.Params[0], ",") - // alert friends - now := time.Now().UTC() - friends := client.FriendsMonitors(caps.SetName) - delete(friends, rb.session) - isBot := client.HasMode(modes.Bot) - for session := range friends { - session.sendFromClientInternal(false, now, "", details.nickMask, details.accountName, isBot, nil, "SETNAME", details.realname) + for i, targetString := range targets { + // max of four targets per privmsg + if i > maxTargets-1 { + break + } + prefixes, targetString := modes.SplitChannelMembershipPrefixes(targetString) + lowestPrefix := modes.GetLowestChannelModePrefix(prefixes) + + // eh, no need to notify them + if len(targetString) < 1 { + continue + } + + target, err := CasefoldChannel(targetString) + if err == nil { + channel := server.channels.Get(target) + if channel == nil { + rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.nick, targetString, client.t("No such channel")) + continue + } + if !channel.CanSpeak(client) { + rb.Add(nil, client.server.name, ERR_CANNOTSENDTOCHAN, channel.name, client.t("Cannot send to channel")) + continue + } + msgid := server.generateMessageID() + + channel.TagMsg(msgid, lowestPrefix, clientOnlyTags, client, rb) + } else { + target, err = CasefoldName(targetString) + user := server.clients.Get(target) + if err != nil || user == nil { + if len(target) > 0 { + client.Send(nil, server.name, ERR_NOSUCHNICK, client.nick, target, client.t("No such nick")) + } + continue + } + msgid := server.generateMessageID() + + // end user can't receive tagmsgs + if !user.capabilities.Has(caps.MessageTags) { + continue + } + user.SendFromClient(msgid, client, clientOnlyTags, "TAGMSG", user.nick) + if client.capabilities.Has(caps.EchoMessage) { + rb.AddFromClient(msgid, client, clientOnlyTags, "TAGMSG", user.nick) + } + if user.flags[modes.Away] { + //TODO(dan): possibly implement cooldown of away notifications to users + rb.Add(nil, server.name, RPL_AWAY, user.nick, user.awayMessage) + } + } } - // respond to the user unconditionally, even if they don't have the cap - rb.AddFromClient(now, "", details.nickMask, details.accountName, isBot, nil, "SETNAME", details.realname) - return false -} - -// SUMMON [parameters] -func summonHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - rb.Add(nil, server.name, ERR_SUMMONDISABLED, client.Nick(), client.t("SUMMON has been disabled")) return false } // TIME -func timeHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - rb.Add(nil, server.name, RPL_TIME, client.nick, server.name, time.Now().UTC().Format(time.RFC1123)) +func timeHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + rb.Add(nil, server.name, RPL_TIME, client.nick, server.name, time.Now().Format(time.RFC1123)) return false } // TOPIC [] -func topicHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - channel := server.channels.Get(msg.Params[0]) - if channel == nil { - rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.nick, utils.SafeErrorParam(msg.Params[0]), client.t("No such channel")) +func topicHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + name, err := CasefoldChannel(msg.Params[0]) + channel := server.channels.Get(name) + if err != nil || channel == nil { + if len(msg.Params[0]) > 0 { + rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.nick, msg.Params[0], client.t("No such channel")) + } return false } if len(msg.Params) > 1 { channel.SetTopic(client, msg.Params[1], rb) } else { - channel.SendTopic(client, rb, true) + channel.SendTopic(client, rb) } return false } // UNDLINE | -func unDLineHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { +func unDLineHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { // check oper permissions - oper := client.Oper() - if !oper.HasRoleCapab("ban") { + if !client.class.Capabilities["oper:local_unban"] { rb.Add(nil, server.name, ERR_NOPRIVS, client.nick, msg.Command, client.t("Insufficient oper privs")) return false } @@ -3440,180 +2213,181 @@ func unDLineHandler(server *Server, client *Client, msg ircmsg.Message, rb *Resp hostString := msg.Params[0] // check host - hostNet, err := flatip.ParseToNormalizedNet(hostString) + var hostAddr net.IP + var hostNet *net.IPNet + _, hostNet, err := net.ParseCIDR(hostString) if err != nil { + hostAddr = net.ParseIP(hostString) + } + + if hostAddr == nil && hostNet == nil { rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.nick, msg.Command, client.t("Could not parse IP address or CIDR network")) return false } - err = server.dlines.RemoveNetwork(hostNet) + if hostNet == nil { + hostString = hostAddr.String() + } else { + hostString = hostNet.String() + } + + // save in datastore + err = server.store.Update(func(tx *buntdb.Tx) error { + dlineKey := fmt.Sprintf(keyDlineEntry, hostString) + + // check if it exists or not + val, err := tx.Get(dlineKey) + if val == "" { + return errNoExistingBan + } else if err != nil { + return err + } + + tx.Delete(dlineKey) + return nil + }) if err != nil { rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.nick, msg.Command, fmt.Sprintf(client.t("Could not remove ban [%s]"), err.Error())) return false } - hostString = hostNet.String() + if hostNet == nil { + server.dlines.RemoveIP(hostAddr) + } else { + server.dlines.RemoveNetwork(*hostNet) + } + rb.Notice(fmt.Sprintf(client.t("Removed D-Line for %s"), hostString)) server.snomasks.Send(sno.LocalXline, fmt.Sprintf(ircfmt.Unescape("%s$r removed D-Line for %s"), client.nick, hostString)) return false } // UNKLINE -func unKLineHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - details := client.Details() +func unKLineHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { // check oper permissions - oper := client.Oper() - if !oper.HasRoleCapab("ban") { - rb.Add(nil, server.name, ERR_NOPRIVS, details.nick, msg.Command, client.t("Insufficient oper privs")) + if !client.class.Capabilities["oper:local_unban"] { + rb.Add(nil, server.name, ERR_NOPRIVS, client.nick, msg.Command, client.t("Insufficient oper privs")) return false } // get host mask := msg.Params[0] - mask, err := CanonicalizeMaskWildcard(mask) + + if !strings.Contains(mask, "!") && !strings.Contains(mask, "@") { + mask = mask + "!*@*" + } else if !strings.Contains(mask, "@") { + mask = mask + "@*" + } + + // save in datastore + err := server.store.Update(func(tx *buntdb.Tx) error { + klineKey := fmt.Sprintf(keyKlineEntry, mask) + + // check if it exists or not + val, err := tx.Get(klineKey) + if val == "" { + return errNoExistingBan + } else if err != nil { + return err + } + + tx.Delete(klineKey) + return nil + }) + if err != nil { - rb.Add(nil, server.name, ERR_UNKNOWNERROR, details.nick, msg.Command, client.t("Erroneous nickname")) + rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.nick, msg.Command, fmt.Sprintf(client.t("Could not remove ban [%s]"), err.Error())) return false } - err = server.klines.RemoveMask(mask) - - if err != nil { - rb.Add(nil, server.name, ERR_UNKNOWNERROR, details.nick, msg.Command, fmt.Sprintf(client.t("Could not remove ban [%s]"), err.Error())) - return false - } + server.klines.RemoveMask(mask) rb.Notice(fmt.Sprintf(client.t("Removed K-Line for %s"), mask)) - server.snomasks.Send(sno.LocalXline, fmt.Sprintf(ircfmt.Unescape("%s$r removed K-Line for %s"), details.nick, mask)) + server.snomasks.Send(sno.LocalXline, fmt.Sprintf(ircfmt.Unescape("%s$r removed K-Line for %s"), client.nick, mask)) return false } // USER * 0 -func userHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - if client.registered { - rb.Add(nil, server.name, ERR_ALREADYREGISTRED, client.Nick(), client.t("You may not reregister")) +func userHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + if client.Registered() { + rb.Add(nil, server.name, ERR_ALREADYREGISTRED, client.nick, client.t("You may not reregister")) return false } - username, realname := msg.Params[0], msg.Params[3] - if len(realname) == 0 { - rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.Nick(), "USER", client.t("Not enough parameters")) + if client.username != "" && client.realname != "" { return false } - config := server.Config() - if config.Limits.RealnameLen > 0 && len(realname) > config.Limits.RealnameLen { - realname = ircmsg.TruncateUTF8Safe(realname, config.Limits.RealnameLen) + + // confirm that username is valid + // + _, err := CasefoldName(msg.Params[0]) + if err != nil { + rb.Add(nil, "", "ERROR", client.t("Malformed username")) + return true } - // #843: we accept either: `USER user:pass@clientid` or `USER user@clientid` - if strudelIndex := strings.IndexByte(username, '@'); strudelIndex != -1 { - username, rb.session.deviceID = username[:strudelIndex], username[strudelIndex+1:] - if colonIndex := strings.IndexByte(username, ':'); colonIndex != -1 { - var password string - username, password = username[:colonIndex], username[colonIndex+1:] - err := server.accounts.AuthenticateByPassphrase(client, username, password) - if err == nil { - sendSuccessfulAccountAuth(nil, client, rb, true) - } else { - // this is wrong, but send something for debugging that will show up in a raw transcript - rb.Add(nil, server.name, ERR_SASLFAIL, client.Nick(), client.t("SASL authentication failed")) - } - } + if !client.HasUsername() { + client.username = "~" + msg.Params[0] + // don't bother updating nickmask here, it's not valid anyway } - - err := client.SetNames(username, realname, false) - if err == errInvalidUsername { - // if client's using a unicode nick or something weird, let's just set 'em up with a stock username instead. - // fixes clients that just use their nick as a username so they can still use the interesting nick - if client.preregNick == username { - client.SetNames("user", realname, false) - } else { - rb.Add(nil, server.name, ERR_INVALIDUSERNAME, client.Nick(), client.t("Malformed username")) - } + if client.realname == "" { + client.realname = msg.Params[3] } return false } -// does `target` have an operator status that is visible to `client`? -func operStatusVisible(client, target *Client, hasPrivs bool) bool { - targetOper := target.Oper() - if targetOper == nil { - return false - } - if client == target || hasPrivs { - return true - } - return !targetOper.Hidden -} - // USERHOST { } -func userhostHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - hasPrivs := client.HasMode(modes.Operator) - returnedClients := make(ClientSet) +func userhostHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + returnedNicks := make(map[string]bool) - var tl utils.TokenLineBuilder - tl.Initialize(maxLastArgLength, " ") for i, nickname := range msg.Params { if i >= 10 { break } - target := server.clients.Get(nickname) - if target == nil { + casefoldedNickname, err := CasefoldName(nickname) + target := server.clients.Get(casefoldedNickname) + if err != nil || target == nil { + rb.Add(nil, client.server.name, ERR_NOSUCHNICK, client.nick, nickname, client.t("No such nick")) + return false + } + if returnedNicks[casefoldedNickname] { continue } + // to prevent returning multiple results for a single nick - if returnedClients.Has(target) { - continue - } - returnedClients.Add(target) + returnedNicks[casefoldedNickname] = true var isOper, isAway string - if operStatusVisible(client, target, hasPrivs) { + if target.flags[modes.Operator] { isOper = "*" } - if away, _ := target.Away(); away { + if target.flags[modes.Away] { isAway = "-" } else { isAway = "+" } - details := target.Details() - tl.Add(fmt.Sprintf("%s%s=%s%s@%s", details.nick, isOper, isAway, details.username, details.hostname)) + rb.Add(nil, client.server.name, RPL_USERHOST, client.nick, fmt.Sprintf("%s%s=%s%s@%s", target.nick, isOper, isAway, target.username, target.hostname)) } - lines := tl.Lines() - if lines == nil { - lines = []string{""} - } - nick := client.Nick() - for _, line := range lines { - rb.Add(nil, client.server.name, RPL_USERHOST, nick, line) - } - - return false -} - -// USERS [parameters] -func usersHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - rb.Add(nil, server.name, ERR_USERSDISABLED, client.Nick(), client.t("USERS has been disabled")) return false } // VERSION -func versionHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { +func versionHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { rb.Add(nil, server.name, RPL_VERSION, client.nick, Ver, server.name) - server.RplISupport(client, rb) + client.RplISupport(rb) return false } // WEBIRC [:flag1 flag2=x flag3] -func webircHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { +func webircHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { // only allow unregistered clients to use this command - if client.registered || client.proxiedIP != nil { + if client.Registered() || client.proxiedIP != nil { return false } @@ -3633,357 +2407,80 @@ func webircHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo lkey := strings.ToLower(key) if lkey == "tls" || lkey == "secure" { // only accept "tls" flag if the gateway's connection to us is secure as well - if client.HasMode(modes.TLS) || client.realIP.IsLoopback() { + if client.flags[modes.TLS] || utils.AddrIsLocal(client.socket.conn.RemoteAddr()) { secure = true } } } } - config := server.Config() - givenPassword := []byte(msg.Params[0]) - for _, info := range config.Server.WebIRC { - if utils.IPInNets(client.realIP, info.allowedNets) { - // confirm password and/or fingerprint - if 0 < len(info.Password) && bcrypt.CompareHashAndPassword(info.Password, givenPassword) != nil { - continue - } - if info.Certfp != "" && info.Certfp != rb.session.certfp { - continue - } - - candidateIP := msg.Params[3] - err, quitMsg := client.ApplyProxiedIP(rb.session, net.ParseIP(candidateIP), secure) - if err != nil { - client.Quit(quitMsg, rb.session) - return true - } else { - if info.AcceptHostname { - candidateHostname := msg.Params[2] - if candidateHostname != candidateIP { - if utils.IsHostname(candidateHostname) { - rb.session.rawHostname = candidateHostname - } else { - // log this at debug level since it may be spammy - server.logger.Debug("internal", "invalid hostname from WEBIRC", candidateHostname) - } - } + for _, info := range server.WebIRCConfig() { + for _, gateway := range info.Hosts { + if isGatewayAllowed(client.socket.conn.RemoteAddr(), gateway) { + // confirm password and/or fingerprint + givenPassword := msg.Params[0] + if 0 < len(info.Password) && passwd.ComparePasswordString(info.Password, givenPassword) != nil { + continue } - return false + if 0 < len(info.Fingerprint) && client.certfp != info.Fingerprint { + continue + } + + proxiedIP := msg.Params[3] + return client.ApplyProxiedIP(proxiedIP, secure) } } } - client.Quit(client.t("WEBIRC command is not usable from your address or incorrect password given"), rb.session) + client.Quit(client.t("WEBIRC command is not usable from your address or incorrect password given")) return true } -type whoxFields uint32 // bitset to hold the WHOX field values, 'a' through 'z' - -func (fields whoxFields) Add(field rune) (result whoxFields) { - index := int(field) - int('a') - if 0 <= index && index < 26 { - return fields | (1 << index) - } else { - return fields - } -} - -func (fields whoxFields) Has(field rune) bool { - index := int(field) - int('a') - if 0 <= index && index < 26 { - return (fields & (1 << index)) != 0 - } else { - return false - } -} - -// rplWhoReply returns the WHO(X) reply between one user and another channel/user. -// who format: -// [*][~|&|@|%|+][B] : -// whox format: -// [*][~|&|@|%|+][B] : -func (client *Client) rplWhoReply(channel *Channel, target *Client, rb *ResponseBuffer, canSeeIPs, canSeeOpers, includeRFlag, isWhox bool, fields whoxFields, whoType string) { - params := []string{client.Nick()} - - details := target.Details() - - if fields.Has('t') { - params = append(params, whoType) - } - if fields.Has('c') { - fChannel := "*" - if channel != nil { - fChannel = channel.name - } - params = append(params, fChannel) - } - if fields.Has('u') { - params = append(params, details.username) - } - if fields.Has('i') { - fIP := "255.255.255.255" - if canSeeIPs || client == target { - // you can only see a target's IP if they're you or you're an oper - ip, _ := target.getWhoisActually() - fIP = utils.IPStringToHostname(ip.String()) - } - params = append(params, fIP) - } - if fields.Has('h') { - params = append(params, details.hostname) - } - if fields.Has('s') { - params = append(params, target.server.name) - } - if fields.Has('n') { - params = append(params, details.nick) - } - if fields.Has('f') { // "flags" (away + oper state + channel status prefix + bot) - var flags strings.Builder - if away, _ := target.Away(); away { - flags.WriteRune('G') // Gone - } else { - flags.WriteRune('H') // Here - } - - if target.HasMode(modes.Operator) && operStatusVisible(client, target, canSeeOpers) { - flags.WriteRune('*') - } - - if channel != nil { - flags.WriteString(channel.ClientPrefixes(target, rb.session.capabilities.Has(caps.MultiPrefix))) - } - - if target.HasMode(modes.Bot) { - flags.WriteRune('B') - } - - if includeRFlag && details.account != "" { - flags.WriteRune('r') - } - - params = append(params, flags.String()) - - } - if fields.Has('d') { // server hops from us to target - params = append(params, "0") - } - if fields.Has('l') { - params = append(params, fmt.Sprintf("%d", target.IdleSeconds())) - } - if fields.Has('a') { - fAccount := "0" - if details.accountName != "*" { - // WHOX uses "0" to mean "no account" - fAccount = details.accountName - } - params = append(params, fAccount) - } - if fields.Has('o') { - // channel oplevel, not implemented - params = append(params, "*") - } - if fields.Has('r') { - params = append(params, details.realname) - } - - numeric := RPL_WHOSPCRPL - if !isWhox { - numeric = RPL_WHOREPLY - // if this isn't WHOX, stick hops + realname at the end - params = append(params, "0 "+details.realname) - } - - rb.Add(nil, client.server.name, numeric, params...) -} - -func serviceWhoReply(client *Client, service *ircService, rb *ResponseBuffer, isWhox bool, fields whoxFields, whoType string) { - params := []string{client.Nick()} - - if fields.Has('t') { - params = append(params, whoType) - } - if fields.Has('c') { - params = append(params, "*") - } - if fields.Has('u') { - params = append(params, service.Name) - } - if fields.Has('i') { - params = append(params, "127.0.0.1") - } - if fields.Has('h') { - params = append(params, "localhost") - } - if fields.Has('s') { - params = append(params, client.server.name) - } - if fields.Has('n') { - params = append(params, service.Name) - } - if fields.Has('f') { // "flags" (away + oper state + channel status prefix + bot) - params = append(params, "H") - } - if fields.Has('d') { // server hops from us to target - params = append(params, "0") - } - if fields.Has('l') { // idle seconds - params = append(params, "0") - } - if fields.Has('a') { // account, services are considered not to have one - params = append(params, "0") - } - if fields.Has('o') { // channel oplevel, not implemented - params = append(params, "*") - } - if fields.Has('r') { - params = append(params, service.Realname(client)) - } - - numeric := RPL_WHOSPCRPL - if !isWhox { - numeric = RPL_WHOREPLY - // if this isn't WHOX, stick hops + realname at the end - params = append(params, "0 "+service.Realname(client)) - } - - rb.Add(nil, client.server.name, numeric, params...) -} - -// WHO [%,] -func whoHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - origMask := utils.SafeErrorParam(msg.Params[0]) - if origMask != msg.Params[0] { - rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.Nick(), "WHO", client.t("First param must be a mask or channel")) +// WHO [ [o]] +func whoHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + if msg.Params[0] == "" { + rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.nick, "WHO", client.t("First param must be a mask or channel")) return false } - // https://modern.ircdocs.horse/#who-message - // "1. A channel name, in which case the channel members are listed." - // "2. An exact nickname, in which case a single user is returned." - // "3. A mask pattern, in which case all visible users whose nickname matches are listed." - var isChannel bool - var isBareNick bool - mask := origMask - var err error - if origMask[0] == '#' { - mask, err = CasefoldChannel(origMask) - isChannel = true - } else if !strings.ContainsAny(origMask, protocolBreakingNameCharacters) { - isBareNick = true - } else { - mask, err = CanonicalizeMaskWildcard(origMask) - } - - if err != nil { - rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.Nick(), "WHO", client.t("Mask isn't valid")) - return false - } - - // include the r flag only if nick and account are synonymous - config := server.Config() - includeRFlag := config.Accounts.NickReservation.Enabled && - config.Accounts.NickReservation.Method == NickEnforcementStrict && - !config.Accounts.NickReservation.AllowCustomEnforcement && - config.Accounts.NickReservation.ForceNickEqualsAccount - - sFields := "cuhsnf" - whoType := "0" - isWhox := false - if len(msg.Params) > 1 && strings.Contains(msg.Params[1], "%") { - isWhox = true - whoxData := msg.Params[1] - fieldStart := strings.Index(whoxData, "%") - sFields = whoxData[fieldStart+1:] - - typeIndex := strings.Index(sFields, ",") - if typeIndex > -1 && typeIndex < (len(sFields)-1) { // make sure there's , and a value after it - whoType = sFields[typeIndex+1:] - sFields = strings.ToLower(sFields[:typeIndex]) - } - } - var fields whoxFields - for _, field := range sFields { - fields = fields.Add(field) - } - - // successfully parsed query, ensure we send the success response: - defer func() { - rb.Add(nil, server.name, RPL_ENDOFWHO, client.Nick(), origMask, client.t("End of WHO list")) - }() - - // XXX #1730: https://datatracker.ietf.org/doc/html/rfc1459#section-4.5.1 - // 'If the "o" parameter is passed only operators are returned according to - // the name mask supplied.' - // see discussion on #1730, we just return no results in this case. - if len(msg.Params) > 1 && msg.Params[1] == "o" { - return false - } - - oper := client.Oper() - hasPrivs := oper.HasRoleCapab("sajoin") - canSeeIPs := oper.HasRoleCapab("ban") - if isChannel { - channel := server.channels.Get(mask) - if channel != nil { - isJoined := channel.hasClient(client) - if !channel.flags.HasMode(modes.Secret) || isJoined || hasPrivs { - var members []*Client - if hasPrivs { - members = channel.Members() - } else { - members = channel.auditoriumFriends(client) - } - for _, member := range members { - if !member.HasMode(modes.Invisible) || isJoined || hasPrivs { - client.rplWhoReply(channel, member, rb, canSeeIPs, oper != nil, includeRFlag, isWhox, fields, whoType) - } - } - } - } - } else if isBareNick { - if mclient := server.clients.Get(mask); mclient != nil { - client.rplWhoReply(nil, mclient, rb, canSeeIPs, oper != nil, includeRFlag, isWhox, fields, whoType) - } else if service, ok := ErgoServices[strings.ToLower(mask)]; ok { - serviceWhoReply(client, service, rb, isWhox, fields, whoType) - } - } else { - // Construct set of channels the client is in. - userChannels := make(ChannelSet) - for _, channel := range client.Channels() { - userChannels.Add(channel) - } - - // Another client is a friend if they share at least one channel, or they are the same client. - isFriend := func(otherClient *Client) bool { - if client == otherClient { - return true - } - - for _, channel := range otherClient.Channels() { - if channel.flags.HasMode(modes.Auditorium) { - return false // TODO this should respect +v etc. - } - if userChannels.Has(channel) { - return true - } - } + var mask string + if len(msg.Params) > 0 { + casefoldedMask, err := Casefold(msg.Params[0]) + if err != nil { + rb.Add(nil, server.name, ERR_UNKNOWNERROR, "WHO", client.t("Mask isn't valid")) return false } + mask = casefoldedMask + } + friends := client.Friends() + + //TODO(dan): is this used and would I put this param in the Modern doc? + // if not, can we remove it? + //var operatorOnly bool + //if len(msg.Params) > 1 && msg.Params[1] == "o" { + // operatorOnly = true + //} + + if mask[0] == '#' { + // TODO implement wildcard matching + //TODO(dan): ^ only for opers + channel := server.channels.Get(mask) + if channel != nil { + whoChannel(client, channel, friends, rb) + } + } else { for mclient := range server.clients.FindAll(mask) { - if hasPrivs || !mclient.HasMode(modes.Invisible) || isFriend(mclient) { - client.rplWhoReply(nil, mclient, rb, canSeeIPs, oper != nil, includeRFlag, isWhox, fields, whoType) - } + client.rplWhoReply(nil, mclient, rb) } } + rb.Add(nil, server.name, RPL_ENDOFWHO, client.nick, mask, client.t("End of WHO list")) return false } // WHOIS [] {,} -func whoisHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { +func whoisHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { var masksString string //var target string @@ -3994,140 +2491,69 @@ func whoisHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon masksString = msg.Params[0] } - handleService := func(nick string) bool { - cfnick, _ := CasefoldName(nick) - service, ok := ErgoServices[cfnick] - hostname := "localhost" - config := server.Config() - if config.Server.OverrideServicesHostname != "" { - hostname = config.Server.OverrideServicesHostname - } - if !ok { - return false - } - clientNick := client.Nick() - rb.Add(nil, client.server.name, RPL_WHOISUSER, clientNick, service.Name, service.Name, hostname, "*", service.Realname(client)) - // #1080: - rb.Add(nil, client.server.name, RPL_WHOISOPERATOR, clientNick, service.Name, client.t("is a network service")) - // hehe - if client.HasMode(modes.TLS) { - rb.Add(nil, client.server.name, RPL_WHOISSECURE, clientNick, service.Name, client.t("is using a secure connection")) - } - return true + if len(strings.TrimSpace(masksString)) < 1 { + rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.nick, msg.Command, client.t("No masks given")) + return false } - hasPrivs := client.HasRoleCapabs("samode") - if hasPrivs { - for _, mask := range strings.Split(masksString, ",") { - matches := server.clients.FindAll(mask) - if len(matches) == 0 && !handleService(mask) { - rb.Add(nil, client.server.name, ERR_NOSUCHNICK, client.Nick(), utils.SafeErrorParam(mask), client.t("No such nick")) + if client.flags[modes.Operator] { + masks := strings.Split(masksString, ",") + for _, mask := range masks { + casefoldedMask, err := Casefold(mask) + if err != nil { + rb.Add(nil, client.server.name, ERR_NOSUCHNICK, client.nick, mask, client.t("No such nick")) + continue + } + matches := server.clients.FindAll(casefoldedMask) + if len(matches) == 0 { + rb.Add(nil, client.server.name, ERR_NOSUCHNICK, client.nick, mask, client.t("No such nick")) continue } for mclient := range matches { - client.getWhoisOf(mclient, hasPrivs, rb) + client.getWhoisOf(mclient, rb) } } } else { - // only get the first request; also require a nick, not a mask - nick := strings.Split(masksString, ",")[0] - mclient := server.clients.Get(nick) - if mclient != nil { - client.getWhoisOf(mclient, hasPrivs, rb) - } else if !handleService(nick) { - rb.Add(nil, client.server.name, ERR_NOSUCHNICK, client.Nick(), utils.SafeErrorParam(masksString), client.t("No such nick")) + // only get the first request + casefoldedMask, err := Casefold(strings.Split(masksString, ",")[0]) + mclient := server.clients.Get(casefoldedMask) + if err != nil || mclient == nil { + rb.Add(nil, client.server.name, ERR_NOSUCHNICK, client.nick, masksString, client.t("No such nick")) + // fall through, ENDOFWHOIS is always sent + } else { + client.getWhoisOf(mclient, rb) } - // fall through, ENDOFWHOIS is always sent } - rb.Add(nil, server.name, RPL_ENDOFWHOIS, client.nick, utils.SafeErrorParam(masksString), client.t("End of /WHOIS list")) + rb.Add(nil, server.name, RPL_ENDOFWHOIS, client.nick, masksString, client.t("End of /WHOIS list")) return false } // WHOWAS [ []] -func whowasHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { +func whowasHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { nicknames := strings.Split(msg.Params[0], ",") - // 0 means "all the entries", as does a negative number - var count int + var count int64 if len(msg.Params) > 1 { - count, _ = strconv.Atoi(msg.Params[1]) - if count < 0 { - count = 0 - } + count, _ = strconv.ParseInt(msg.Params[1], 10, 64) } - cnick := client.Nick() - canSeeIP := client.Oper().HasRoleCapab("ban") + //var target string + //if len(msg.Params) > 2 { + // target = msg.Params[2] + //} for _, nickname := range nicknames { results := server.whoWas.Find(nickname, count) if len(results) == 0 { - rb.Add(nil, server.name, ERR_WASNOSUCHNICK, cnick, utils.SafeErrorParam(nickname), client.t("There was no such nickname")) + if len(nickname) > 0 { + rb.Add(nil, server.name, ERR_WASNOSUCHNICK, client.nick, nickname, client.t("There was no such nickname")) + } } else { for _, whoWas := range results { - rb.Add(nil, server.name, RPL_WHOWASUSER, cnick, whoWas.nick, whoWas.username, whoWas.hostname, "*", whoWas.realname) - if canSeeIP { - rb.Add(nil, server.name, RPL_WHOWASIP, cnick, whoWas.nick, fmt.Sprintf(client.t("was connecting from %s"), utils.IPStringToHostname(whoWas.ip.String()))) - } + rb.Add(nil, server.name, RPL_WHOWASUSER, client.nick, whoWas.nickname, whoWas.username, whoWas.hostname, "*", whoWas.realname) } } - rb.Add(nil, server.name, RPL_ENDOFWHOWAS, cnick, utils.SafeErrorParam(nickname), client.t("End of WHOWAS")) + if len(nickname) > 0 { + rb.Add(nil, server.name, RPL_ENDOFWHOWAS, client.nick, nickname, client.t("End of WHOWAS")) + } } return false } - -// ZNC [params] -func zncHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - params := msg.Params[1:] - // #1205: compatibility with Palaver, which sends `ZNC *playback :play ...` - if len(params) == 1 && strings.IndexByte(params[0], ' ') != -1 { - params = strings.Fields(params[0]) - } - zncModuleHandler(client, msg.Params[0], params, rb) - return false -} - -// REACT : -func reactHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - // This directly uses SQL stuff, since it's targeted at CEF, which requires a DB. - _, _, target, sender, _, pm, err := server.historyDB.GetMessage(msg.Params[0]) - if err != nil { - return false - } - - var operation string - if server.historyDB.HasReactionFromUser(msg.Params[0], client.AccountName(), msg.Params[1]) { - server.historyDB.DeleteReaction(msg.Params[0], client.AccountName(), msg.Params[1]) - operation = "DEL" - } else { - server.historyDB.AddReaction(msg.Params[0], client.AccountName(), msg.Params[1]) - operation = "ADD" - } - - if pm { - server.clients.Get(target).Send(nil, client.NickMaskString(), "REACT", operation, msg.Params[0], msg.Params[1]) - server.clients.Get(sender).Send(nil, client.NickMaskString(), "REACT", operation, msg.Params[0], msg.Params[1]) - } else { - server.channels.Get(target).BroadcastFrom(client.NickMaskString(), "REACT", operation, msg.Params[0], msg.Params[1]) - } - - return false -} - -// fake handler for unknown commands -func unknownCommandHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - var message string - if strings.HasPrefix(msg.Command, "/") { - message = fmt.Sprintf(client.t("Unknown command; if you are using /QUOTE, the correct syntax is /QUOTE %[1]s, not /QUOTE %[2]s"), - strings.TrimPrefix(msg.Command, "/"), msg.Command) - } else { - message = client.t("Unknown command") - } - - rb.Add(nil, server.name, ERR_UNKNOWNCOMMAND, client.Nick(), utils.SafeErrorParam(msg.Command), message) - return false -} - -// fake handler for invalid utf8 -func invalidUtf8Handler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - rb.Add(nil, server.name, "FAIL", utils.SafeErrorParam(msg.Command), "INVALID_UTF8", client.t("Message rejected for containing invalid UTF-8")) - return false -} diff --git a/irc/help.go b/irc/help.go index eb8c056e..9c81e99b 100644 --- a/irc/help.go +++ b/irc/help.go @@ -7,9 +7,8 @@ import ( "fmt" "sort" "strings" - "sync" - "github.com/ergochat/ergo/irc/languages" + "github.com/oragono/oragono/irc/languages" ) // HelpEntryType represents the different sorts of help entries that can exist. @@ -37,7 +36,7 @@ type HelpEntry struct { var ( cmodeHelpText = `== Channel Modes == -Ergo supports the following channel modes: +Oragono supports the following channel modes: +b | Client masks that are banned from the channel (e.g. *!*@127.0.0.1) +e | Client masks that are exempted from bans. @@ -45,21 +44,12 @@ Ergo supports the following channel modes: +i | Invite-only mode, only invited clients can join the channel. +k | Key required when joining the channel. +l | Client join limit for the channel. - +f | Users who are unable to join this channel (due to another mode) are forwarded - to the provided channel instead. +m | Moderated mode, only privileged clients can talk on the channel. +n | No-outside-messages mode, only users that are on the channel can send | messages to it. - +R | Only registered users can join the channel. - +M | Only registered or voiced users can speak in the channel. + +R | Only registered users can talk in the channel. +s | Secret mode, channel won't show up in /LIST or whois replies. +t | Only channel opers can modify the topic. - +E | Roleplaying commands are enabled in the channel. - +C | Clients are blocked from sending CTCP messages in the channel. - +u | Auditorium mode: JOIN, PART, QUIT, NAMES, and WHO are hidden - from unvoiced clients. - +U | Op-moderated mode: messages from unprivileged clients are sent - only to channel operators. = Prefixes = @@ -70,24 +60,20 @@ Ergo supports the following channel modes: +v (+) | Voice channel mode.` umodeHelpText = `== User Modes == -Ergo supports the following user modes: +Oragono supports the following user modes: +a | User is marked as being away. This mode is set with the /AWAY command. +i | User is marked as invisible (their channels are hidden from whois replies). +o | User is an IRC operator. - +R | User only accepts messages from other registered users. + +R | User only accepts messages from other registered users. +s | Server Notice Masks (see help with /HELPOP snomasks). - +Z | User is connected via TLS. - +B | User is a bot. - +E | User can receive roleplaying commands. - +T | CTCP messages to the user are blocked.` + +Z | User is connected via TLS.` snomaskHelpText = `== Server Notice Masks == -Ergo supports the following server notice masks for operators: +Oragono supports the following server notice masks for operators: a | Local announcements. c | Local client connections. - d | Local client disconnects. j | Local channel actions. k | Local kills. n | Local nick changes. @@ -96,7 +82,6 @@ Ergo supports the following server notice masks for operators: t | Local /STATS usage. u | Local client account actions. x | Local X-lines (DLINE/KLINE/etc). - v | Local vhost changes. To set a snomask, do this with your nickname: @@ -110,12 +95,12 @@ For instance, this would set the kill, oper, account and xline snomasks on dan: // Help contains the help strings distributed with the IRCd. var Help = map[string]HelpEntry{ // Commands - "accept": { - text: `ACCEPT + "acc": { + text: `ACC REGISTER [callback_namespace:] [cred_type] : +ACC VERIFY -ACCEPT allows the target user to send you direct messages, overriding any -restrictions that might otherwise prevent this. Currently, the only -applicable restriction is the +R registered-only mode.`, +Used in account registration. See the relevant specs for more info: +https://oragono.io/specs.html`, }, "ambiance": { text: `AMBIANCE @@ -133,12 +118,6 @@ http://ircv3.net/specs/extensions/sasl-3.1.html`, If [message] is sent, marks you away. If [message] is not sent, marks you no longer away.`, - }, - "batch": { - text: `BATCH {+,-}reference-tag type [params...] - -BATCH initiates an IRCv3 client-to-server batch. You should never need to -issue this command manually.`, }, "cap": { text: `CAP [:] @@ -147,46 +126,27 @@ Used in capability negotiation. See the IRCv3 specs for more info: http://ircv3.net/specs/core/capability-negotiation-3.1.html http://ircv3.net/specs/core/capability-negotiation-3.2.html`, }, - "chathistory": { - text: `CHATHISTORY [params] + "chanserv": { + text: `CHANSERV [params] -CHATHISTORY is a history replay command associated with the IRCv3 -chathistory extension. See this document: -https://ircv3.net/specs/extensions/chathistory`, +ChanServ controls channel registrations.`, + }, + "cs": { + text: `CS [params] + +ChanServ controls channel registrations.`, }, "debug": { oper: true, text: `DEBUG