diff --git a/.check-gofmt.sh b/.check-gofmt.sh index 708e86d7..1daa7195 100755 --- a/.check-gofmt.sh +++ b/.check-gofmt.sh @@ -3,6 +3,10 @@ # exclude vendor/ SOURCES="./oragono.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 diff --git a/.goreleaser.yml b/.goreleaser.yml index 391379c4..5a6e6ffd 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -45,7 +45,7 @@ archives: - README - CHANGELOG.md - oragono.motd - - oragono.yaml + - default.yaml - conventional.yaml - docs/* - languages/*.yaml diff --git a/.travis.yml b/.travis.yml index 0f03c4a7..db22526a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,7 @@ language: go go: - - "1.14.x" + - "1.15.x" before_install: # https://github.com/travis-ci/travis-ci/issues/8361 diff --git a/CHANGELOG.md b/CHANGELOG.md index cb262589..9101420e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,117 @@ # Changelog All notable changes to Oragono will be documented in this file. +## [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. diff --git a/DEVELOPING.md b/DEVELOPING.md index 143d93bc..63aba519 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -12,7 +12,7 @@ Oragono vendors all its dependencies. Because of this, Oragono is self-contained If you're upgrading the Go version used by Oragono, there are several places where it's hard-coded and must be changed: 1. `.travis.yml`, which controls the version that our CI test suite uses to build and test the code (e.g., for a PR) -2. `distrib/docker/Dockerfile`, which controls the version that the Oragono binaries in our Docker images are built with +2. `Dockerfile`, which controls the version that the Oragono binaries in our Docker images are built with 3. `go.mod`: this should be updated automatically by Go when you do module-related operations @@ -31,17 +31,26 @@ Develop branches are either used to work out implementation details in preperati 1. Run `irctest` over it to make sure nothing's severely broken. Talk to the maintainers to find out which version of irctest to run. 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/constants.go` (either change `-unreleased` to `-rc1`, or remove `-rc1`, as appropriate). +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 v0.0.0 -m "Release v0.0.0"` (`0.0.0` replaced with the real ver number). +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`, upload release to Github including the changelog and binaries. 1. If it's a proper release (i.e. not an alpha/beta), merge the updates into the `stable` branch. -1. Make the appropriate announcements (Twitter, oragono.io/news) +1. Make the appropriate announcements: + * For a release candidate: + 1. the channel topic + 1. any operators who may be interested + * For a production release: + 1. everything applicable to a release candidate + 1. Twitter + 1. oragono.io/news + 1. ircv3.net support tables, if applicable + 1. other social media? 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. 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`). +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"`. **Unreleased changelog content** diff --git a/Dockerfile b/Dockerfile index 436a0cbb..0c6ab6c2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ ## build Oragono -FROM golang:1.14-alpine AS build-env +FROM golang:1.15-alpine AS build-env RUN apk add --no-cache git make curl sed @@ -10,8 +10,8 @@ ADD . /go/src/github.com/oragono/oragono/ # 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/oragono/oragono/oragono.yaml -run sed -i 's/^\s*\"\[::1\]:6667\":.*$//' /go/src/github.com/oragono/oragono/oragono.yaml +run sed -i 's/^\(\s*\)\"127.0.0.1:6667\":.*$/\1":6667":/' /go/src/github.com/oragono/oragono/default.yaml +run sed -i 's/^\s*\"\[::1\]:6667\":.*$//' /go/src/github.com/oragono/oragono/default.yaml # make sure submodules are up-to-date RUN git submodule update --init @@ -40,7 +40,7 @@ EXPOSE 6667/tcp 6697/tcp RUN mkdir -p /ircd-bin COPY --from=build-env /go/bin/oragono /ircd-bin COPY --from=build-env /go/src/github.com/oragono/oragono/languages /ircd-bin/languages/ -COPY --from=build-env /go/src/github.com/oragono/oragono/oragono.yaml /ircd-bin/oragono.yaml +COPY --from=build-env /go/src/github.com/oragono/oragono/default.yaml /ircd-bin/default.yaml COPY distrib/docker/run.sh /ircd-bin/run.sh RUN chmod +x /ircd-bin/run.sh diff --git a/Makefile b/Makefile index 2a5043d7..748f36e0 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all install build release capdefs test smoke +.PHONY: all install build release capdefs test smoke gofmt GIT_COMMIT := $(shell git rev-parse HEAD 2> /dev/null) @@ -34,5 +34,8 @@ test: ./.check-gofmt.sh smoke: - oragono mkcerts --conf ./oragono.yaml || true - oragono run --conf ./oragono.yaml --smoke + oragono mkcerts --conf ./default.yaml || true + oragono run --conf ./default.yaml --smoke + +gofmt: + ./.check-gofmt.sh --fix diff --git a/README b/README index d7db9ca7..e199bace 100644 --- a/README +++ b/README @@ -23,10 +23,9 @@ assorted IRCv3 support. Copy the example config file to ircd.yaml with a command like: - $ cp oragono.yaml ircd.yaml + $ cp default.yaml ircd.yaml -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. +Modify the config file as you like. To generate passwords for opers and connect passwords, you can use this command: diff --git a/README.md b/README.md index 544df547..341835d0 100644 --- a/README.md +++ b/README.md @@ -42,16 +42,17 @@ If you want to take a look at a running Oragono instance or test some client cod * [IRCv3 support](https://ircv3.net/software/servers.html) * a heavy focus on developing with [specifications](https://oragono.io/specs.html) -## Installation +## Quick start guide -To go through the standard installation, download the latest release from this page: https://github.com/oragono/oragono/releases/latest +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 oragono.yaml ircd.yaml -vim ircd.yaml # modify the config file to your liking +cp default.yaml ircd.yaml +vim ircd.yaml # modify the config file to your liking oragono mkcerts +oragono run # server should be ready to go! ``` **Note:** See the [productionizing guide in our manual](https://github.com/oragono/oragono/blob/master/docs/MANUAL.md#productionizing) for recommendations on how to run a production network, including obtaining valid TLS certificates. @@ -84,7 +85,7 @@ You'll need an [up-to-date distribution of the Go language for your OS and archi ## Configuration -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. +The default config file [`default.yaml`](default.yaml) helps walk you through what each option means and changes. 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. @@ -102,14 +103,6 @@ oragono genpasswd With this, you receive a blob of text which you can plug into your configuration file. -## Running - -After this, running the server is easy! Simply run the below command and you should see the relevant startup information pop up. - -```sh -oragono run -``` - ### How to register a channel 1. Register your account with `/NS REGISTER ` diff --git a/conventional.yaml b/conventional.yaml index eaac8384..49bd495b 100644 --- a/conventional.yaml +++ b/conventional.yaml @@ -18,7 +18,7 @@ server: # This version of the config provides a public plaintext listener on # port 6667 for testing and compatibility with legacy applications. # We recommend disabling this listener in a production setting - # and replacing it with loopback-only listeners (see oragono.yaml): + # and replacing it with loopback-only listeners (see default.yaml): ":6667": # The standard SSL/TLS port for IRC is 6697. This will listen on all interfaces: @@ -100,7 +100,7 @@ server: # casemapping controls what kinds of strings are permitted as identifiers (nicknames, # channel names, account names, etc.), and how they are normalized for case. - # with the recommended default of 'precis', utf-8 identifiers that are "sane" + # with the recommended default of 'precis', UTF8 identifiers that are "sane" # (according to RFC 8265) are allowed, and the server additionally tries to protect # against confusable characters ("homoglyph attacks"). # the other options are 'ascii' (traditional ASCII-only identifiers), and 'permissive', @@ -110,6 +110,11 @@ server: # already up and running is problematic). casemapping: "precis" + # enforce-utf8 controls whether the server allows non-UTF8 bytes in messages + # (as in traditional IRC) or preemptively discards non-UTF8 messages (since + # they cannot be relayed to websocket clients). + enforce-utf8: true + # whether to look up user hostnames with reverse DNS. # (disabling this will expose user IPs instead of hostnames; # to make IP/hostname information private, see the ip-cloaking section) @@ -122,6 +127,10 @@ server: # use ident protocol to get usernames check-ident: true + # ignore the supplied user/ident string from the USER command; always set the value to + # `~user` (literally) instead. this can potentially reduce confusion and simplify bans. + suppress-ident: false + # password to login to the server # generated using "oragono genpasswd" #password: "" @@ -162,7 +171,7 @@ server: - # SHA-256 fingerprint of the TLS certificate the gateway must use to connect # (comment this out to use passwords only) - fingerprint: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + certfp: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" # password the gateway uses to connect, made with oragono genpasswd password: "$2a$04$abcdef0123456789abcdef0123456789abcdef0123456789abcde" @@ -344,7 +353,7 @@ accounts: skip-server-password: false # enable login to accounts via the PASS command, e.g., PASS account:password - # this is sometimes useful for compatibility with old clients that don't support SASL + # this is useful for compatibility with old clients that don't support SASL login-via-pass-command: false # require-sasl controls whether clients are required to have accounts @@ -367,8 +376,6 @@ accounts: additional-nick-limit: 2 # method describes how nickname reservation is handled - # timeout: let the user change to the registered nickname, give them X seconds - # to login and then rename them if they haven't done so # strict: don't let the user change to the registered nickname unless they're # already logged-in using SASL or NickServ # optional: no enforcement by default, but allow users to opt in to @@ -382,9 +389,6 @@ accounts: # to opt out of strict enforcement allow-custom-enforcement: true - # rename-timeout - this is how long users have 'til they're renamed - rename-timeout: 30s - # format for guest nicknames: # 1. these nicknames cannot be registered or reserved # 2. if a client is automatically renamed by the server, @@ -459,51 +463,12 @@ accounts: # before they can request a new one. cooldown: 168h - # vhosts that users can take without approval, using `/HS TAKE` - offer-list: - #- "oragono.test" - # 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 - # support for deferring password checking to an external LDAP server - # you should probably ignore this section! consult the grafana docs for details: - # https://grafana.com/docs/grafana/latest/auth/ldap/ - # you will probably want to set require-sasl and disable accounts.registration.enabled - # ldap: - # enabled: true - # # should we automatically create users if their LDAP login succeeds? - # autocreate: true - # # example configuration that works with Forum Systems's testing server: - # # https://www.forumsys.com/tutorials/integration-how-to/ldap/online-ldap-test-server/ - # host: "ldap.forumsys.com" - # port: 389 - # timeout: 30s - # # example "single-bind" configuration, where we bind directly to the user's entry: - # bind-dn: "uid=%s,dc=example,dc=com" - # # example "admin bind" configuration, where we bind to an initial admin user, - # # then search for the user's entry with a search filter: - # #search-base-dns: - # # - "dc=example,dc=com" - # #bind-dn: "cn=read-only-admin,dc=example,dc=com" - # #bind-password: "password" - # #search-filter: "(uid=%s)" - # # example of requiring that users be in a particular group - # # (note that this is an OR over the listed groups, not an AND): - # #require-groups: - # # - "ou=mathematicians,dc=example,dc=com" - # #group-search-filter-user-attribute: "dn" - # #group-search-filter: "(uniqueMember=%s)" - # #group-search-base-dns: - # # - "dc=example,dc=com" - # # example of group membership testing via user attributes, as in AD - # # or with OpenLDAP's "memberOf overlay" (overrides group-search-filter): - # attributes: - # member-of: "memberOf" - # pluggable authentication mechanism, via subprocess invocation # see the manual for details on how to write an authentication plugin script auth-script: @@ -597,6 +562,7 @@ oper-classes: - "chanreg" - "history" - "relaymsg-anywhere" + - "defcon" # ircd operators opers: @@ -623,7 +589,7 @@ opers: # 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. - #fingerprint: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + #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 @@ -793,6 +759,24 @@ roleplay: # 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: @@ -844,7 +828,9 @@ history: # users to do session resumption / query history after disconnections. grace-period: 1h - # options to store history messages in a persistent database (currently only MySQL): + # 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. persistent: enabled: false @@ -873,3 +859,18 @@ history: # 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" diff --git a/oragono.yaml b/default.yaml similarity index 92% rename from oragono.yaml rename to default.yaml index d94f73eb..cc02e554 100644 --- a/oragono.yaml +++ b/default.yaml @@ -126,7 +126,7 @@ server: # casemapping controls what kinds of strings are permitted as identifiers (nicknames, # channel names, account names, etc.), and how they are normalized for case. - # with the recommended default of 'precis', utf-8 identifiers that are "sane" + # with the recommended default of 'precis', UTF8 identifiers that are "sane" # (according to RFC 8265) are allowed, and the server additionally tries to protect # against confusable characters ("homoglyph attacks"). # the other options are 'ascii' (traditional ASCII-only identifiers), and 'permissive', @@ -136,10 +136,16 @@ server: # already up and running is problematic). casemapping: "precis" - # whether to look up user hostnames with reverse DNS. - # (disabling this will expose user IPs instead of hostnames; - # to make IP/hostname information private, see the ip-cloaking section) - lookup-hostnames: true + # enforce-utf8 controls whether the server allows non-UTF8 bytes in messages + # (as in traditional IRC) or preemptively discards non-UTF8 messages (since + # they cannot be relayed to websocket clients). + 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 @@ -148,6 +154,10 @@ server: # use ident protocol to get usernames check-ident: false + # ignore the supplied user/ident string from the USER command; always set the value to + # `~user` (literally) instead. this can potentially reduce confusion and simplify bans. + suppress-ident: false + # password to login to the server # generated using "oragono genpasswd" #password: "" @@ -188,7 +198,7 @@ server: - # SHA-256 fingerprint of the TLS certificate the gateway must use to connect # (comment this out to use passwords only) - fingerprint: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + certfp: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" # password the gateway uses to connect, made with oragono genpasswd password: "$2a$04$abcdef0123456789abcdef0123456789abcdef0123456789abcde" @@ -266,6 +276,7 @@ server: # DNS, users see fake domain names like pwbs2ui4377257x8.oragono. 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 @@ -370,8 +381,8 @@ accounts: skip-server-password: false # enable login to accounts via the PASS command, e.g., PASS account:password - # this is sometimes useful for compatibility with old clients that don't support SASL - login-via-pass-command: false + # this is useful for compatibility with old clients that don't support SASL + login-via-pass-command: true # require-sasl controls whether clients are required to have accounts # (and sign into them using SASL) to connect to the server @@ -393,8 +404,6 @@ accounts: additional-nick-limit: 2 # method describes how nickname reservation is handled - # timeout: let the user change to the registered nickname, give them X seconds - # to login and then rename them if they haven't done so # strict: don't let the user change to the registered nickname unless they're # already logged-in using SASL or NickServ # optional: no enforcement by default, but allow users to opt in to @@ -408,9 +417,6 @@ accounts: # to opt out of strict enforcement allow-custom-enforcement: false - # rename-timeout - this is how long users have 'til they're renamed - rename-timeout: 30s - # format for guest nicknames: # 1. these nicknames cannot be registered or reserved # 2. if a client is automatically renamed by the server, @@ -485,51 +491,12 @@ accounts: # before they can request a new one. cooldown: 168h - # vhosts that users can take without approval, using `/HS TAKE` - offer-list: - #- "oragono.test" - # 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 - # support for deferring password checking to an external LDAP server - # you should probably ignore this section! consult the grafana docs for details: - # https://grafana.com/docs/grafana/latest/auth/ldap/ - # you will probably want to set require-sasl and disable accounts.registration.enabled - # ldap: - # enabled: true - # # should we automatically create users if their LDAP login succeeds? - # autocreate: true - # # example configuration that works with Forum Systems's testing server: - # # https://www.forumsys.com/tutorials/integration-how-to/ldap/online-ldap-test-server/ - # host: "ldap.forumsys.com" - # port: 389 - # timeout: 30s - # # example "single-bind" configuration, where we bind directly to the user's entry: - # bind-dn: "uid=%s,dc=example,dc=com" - # # example "admin bind" configuration, where we bind to an initial admin user, - # # then search for the user's entry with a search filter: - # #search-base-dns: - # # - "dc=example,dc=com" - # #bind-dn: "cn=read-only-admin,dc=example,dc=com" - # #bind-password: "password" - # #search-filter: "(uid=%s)" - # # example of requiring that users be in a particular group - # # (note that this is an OR over the listed groups, not an AND): - # #require-groups: - # # - "ou=mathematicians,dc=example,dc=com" - # #group-search-filter-user-attribute: "dn" - # #group-search-filter: "(uniqueMember=%s)" - # #group-search-base-dns: - # # - "dc=example,dc=com" - # # example of group membership testing via user attributes, as in AD - # # or with OpenLDAP's "memberOf overlay" (overrides group-search-filter): - # attributes: - # member-of: "memberOf" - # pluggable authentication mechanism, via subprocess invocation # see the manual for details on how to write an authentication plugin script auth-script: @@ -623,6 +590,7 @@ oper-classes: - "chanreg" - "history" - "relaymsg-anywhere" + - "defcon" # ircd operators opers: @@ -649,7 +617,7 @@ opers: # 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. - #fingerprint: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + #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 @@ -819,6 +787,24 @@ roleplay: # 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: @@ -870,7 +856,9 @@ history: # users to do session resumption / query history after disconnections. grace-period: 1h - # options to store history messages in a persistent database (currently only MySQL): + # 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. persistent: enabled: false @@ -899,3 +887,18 @@ history: # 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" diff --git a/distrib/docker/README.md b/distrib/docker/README.md index 95b17225..d213c6bb 100644 --- a/distrib/docker/README.md +++ b/distrib/docker/README.md @@ -8,9 +8,6 @@ The `latest` tag tracks the `stable` branch of Oragono, which contains the latest stable release. The `dev` tag tracks the master branch, which may by unstable and is not recommended for production. -You can see other tags [on Docker Hub](https://hub.docker.com/r/oragono/oragono/tags) -if you wish to run a specific version of Oragono. - ## Quick start The Oragono docker image is designed to work out of the box - it comes with a @@ -104,6 +101,6 @@ If you wish to manually build the docker image, you need to do so from the root of the Oragono repository (not the `distrib/docker` directory): ```shell -docker build -f distrib/docker/Dockerfile . +docker build . ``` diff --git a/distrib/docker/run.sh b/distrib/docker/run.sh index f049dd47..fb67aa89 100644 --- a/distrib/docker/run.sh +++ b/distrib/docker/run.sh @@ -5,7 +5,7 @@ cd /ircd # make config file if [ ! -f "/ircd/ircd.yaml" ]; then - awk '{gsub(/path: languages/,"path: /ircd-bin/languages")}1' /ircd-bin/oragono.yaml > /tmp/ircd.yaml + 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) diff --git a/docs/MANUAL.md b/docs/MANUAL.md index ea58261e..422a8ddc 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -35,6 +35,7 @@ _Copyright © Daniel Oaks , Shivaram Lingamneni = limit { + return errLimitExceeded + } - if !hasPrivs && limit != 0 && chcount >= limit { - rb.Add(nil, client.server.name, ERR_CHANNELISFULL, details.nick, chname, fmt.Sprintf(client.t("Cannot join channel (+%s)"), "l")) - return + if chkey != "" && !utils.SecretTokensMatch(chkey, key) { + return errWrongChannelKey + } + + if channel.flags.HasMode(modes.InviteOnly) && + !channel.lists[modes.InviteMask].Match(details.nickMaskCasefolded) { + return errInviteOnly + } + + if channel.lists[modes.BanMask].Match(details.nickMaskCasefolded) && + !channel.lists[modes.ExceptMask].Match(details.nickMaskCasefolded) && + !channel.lists[modes.InviteMask].Match(details.nickMaskCasefolded) { + return errBanned + } + + if details.account == "" && + (channel.flags.HasMode(modes.RegisteredOnly) || channel.server.Defcon() <= 2) { + return errRegisteredOnly + } } - if !hasPrivs && chkey != "" && !utils.SecretTokensMatch(chkey, key) { - rb.Add(nil, client.server.name, ERR_BADCHANNELKEY, details.nick, chname, fmt.Sprintf(client.t("Cannot join channel (+%s)"), "k")) - return - } - - isInvited := client.CheckInvited(chcfname) || channel.lists[modes.InviteMask].Match(details.nickMaskCasefolded) - if !hasPrivs && channel.flags.HasMode(modes.InviteOnly) && !isInvited { - rb.Add(nil, client.server.name, ERR_INVITEONLYCHAN, details.nick, chname, fmt.Sprintf(client.t("Cannot join channel (+%s)"), "i")) - return - } - - if !hasPrivs && channel.lists[modes.BanMask].Match(details.nickMaskCasefolded) && - !isInvited && - !channel.lists[modes.ExceptMask].Match(details.nickMaskCasefolded) { - rb.Add(nil, client.server.name, ERR_BANNEDFROMCHAN, details.nick, chname, fmt.Sprintf(client.t("Cannot join channel (+%s)"), "b")) - return - } - - if !hasPrivs && channel.flags.HasMode(modes.RegisteredOnly) && details.account == "" && !isInvited { - rb.Add(nil, client.server.name, ERR_NEEDREGGEDNICK, details.nick, chname, client.t("You must be registered to join that channel")) - return + if joinErr := client.addChannel(channel, rb == nil); joinErr != nil { + return joinErr } client.server.logger.Debug("join", fmt.Sprintf("%s joined channel %s", details.nick, chname)) @@ -747,10 +758,8 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp channel.AddHistoryItem(histItem, details.account) } - client.addChannel(channel, rb == nil) - if rb == nil { - return + return nil } var modestr string @@ -758,6 +767,7 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp modestr = fmt.Sprintf("+%v", givenMode) } + isAway, awayMessage := client.Away() for _, member := range channel.Members() { for _, session := range member.Sessions() { if session == rb.session { @@ -774,6 +784,9 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp 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, nil, "AWAY", awayMessage) + } } } @@ -793,22 +806,26 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp 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 !start.IsZero() || !end.IsZero() { + if hasAutoreplayTimestamps { _, seq, _ := channel.server.GetHistorySequence(channel, client, "") if seq != nil { zncMax := channel.server.Config().History.ZNCMax @@ -1229,44 +1246,19 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod } // send echo-message - if rb.session.capabilities.Has(caps.EchoMessage) { - var tagsToUse map[string]string - if rb.session.capabilities.Has(caps.MessageTags) { - tagsToUse = clientOnlyTags - } - if histType == history.Tagmsg && rb.session.capabilities.Has(caps.MessageTags) { - rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, tagsToUse, command, chname) - } else { - rb.AddSplitMessageFromClient(details.nickMask, details.accountName, tagsToUse, command, chname, message) - } - } - // send echo-message to other connected sessions - for _, session := range client.Sessions() { - if session == rb.session { - continue - } - var tagsToUse map[string]string - if session.capabilities.Has(caps.MessageTags) { - tagsToUse = clientOnlyTags - } - if histType == history.Tagmsg && session.capabilities.Has(caps.MessageTags) { - session.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, tagsToUse, command, chname) - } else if histType != history.Tagmsg { - session.sendSplitMsgFromClientInternal(false, details.nickMask, details.accountName, tagsToUse, command, chname, message) - } - } + rb.addEchoMessage(clientOnlyTags, details.nickMask, details.accountName, command, chname, message) for _, member := range channel.Members() { - // echo-message is handled above, so skip sending the msg to the user themselves as well - if member == client { - continue - } if minPrefixMode != modes.Mode(0) && !channel.ClientIsAtLeast(member, minPrefixMode) { // STATUSMSG continue } for _, session := range member.Sessions() { + if session == rb.session { + continue // we already sent echo-message, if applicable + } + if isCTCP && session.isTor { continue // #753 } @@ -1431,9 +1423,7 @@ func (channel *Channel) Invite(invitee *Client, inviter *Client, rb *ResponseBuf return } - if channel.flags.HasMode(modes.InviteOnly) { - invitee.Invite(channel.NameCasefolded()) - } + invitee.Invite(channel.NameCasefolded()) for _, member := range channel.Members() { if member == inviter || member == invitee || !channel.ClientIsAtLeast(member, modes.Halfop) { @@ -1450,7 +1440,14 @@ func (channel *Channel) Invite(invitee *Client, inviter *Client, rb *ResponseBuf tnick := invitee.Nick() rb.Add(nil, inviter.server.name, RPL_INVITING, cnick, tnick, chname) invitee.Send(nil, inviter.NickMaskString(), "INVITE", tnick, chname) - if invitee.Away() { - rb.Add(nil, inviter.server.name, RPL_AWAY, cnick, tnick, invitee.AwayMessage()) + if away, awayMessage := invitee.Away(); away { + rb.Add(nil, inviter.server.name, RPL_AWAY, cnick, tnick, awayMessage) } } + +// 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 1b32ffaa..be37e0ff 100644 --- a/irc/channelmanager.go +++ b/irc/channelmanager.go @@ -5,6 +5,8 @@ package irc import ( "sync" + + "github.com/oragono/oragono/irc/utils" ) type channelManagerEntry struct { @@ -23,17 +25,17 @@ type ChannelManager struct { sync.RWMutex // tier 2 // chans is the main data structure, mapping casefolded name -> *Channel chans map[string]*channelManagerEntry - chansSkeletons StringSet // skeletons of *unregistered* chans - registeredChannels StringSet // casefolds of registered chans - registeredSkeletons StringSet // skeletons of registered chans - purgedChannels StringSet // casefolds of purged chans + chansSkeletons utils.StringSet // skeletons of *unregistered* chans + registeredChannels utils.StringSet // casefolds of registered chans + registeredSkeletons utils.StringSet // skeletons of registered chans + purgedChannels utils.StringSet // casefolds of purged chans server *Server } // NewChannelManager returns a new ChannelManager. func (cm *ChannelManager) Initialize(server *Server) { cm.chans = make(map[string]*channelManagerEntry) - cm.chansSkeletons = make(StringSet) + cm.chansSkeletons = make(utils.StringSet) cm.server = server cm.loadRegisteredChannels(server.Config()) @@ -47,8 +49,8 @@ func (cm *ChannelManager) loadRegisteredChannels(config *Config) { } rawNames := cm.server.channelRegistry.AllChannels() - registeredChannels := make(StringSet, len(rawNames)) - registeredSkeletons := make(StringSet, len(rawNames)) + registeredChannels := make(utils.StringSet, len(rawNames)) + registeredSkeletons := make(utils.StringSet, len(rawNames)) for _, name := range rawNames { cfname, err := CasefoldChannel(name) if err == nil { @@ -130,11 +132,11 @@ func (cm *ChannelManager) Join(client *Client, name string, key string, isSajoin } channel.EnsureLoaded() - channel.Join(client, key, isSajoin, rb) + err = channel.Join(client, key, isSajoin, rb) cm.maybeCleanup(channel, true) - return nil + return err } func (cm *ChannelManager) maybeCleanup(channel *Channel, afterJoin bool) { @@ -187,6 +189,10 @@ func (cm *ChannelManager) Cleanup(channel *Channel) { } 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 { @@ -283,6 +289,14 @@ func (cm *ChannelManager) Rename(name string, newName string) (err error) { cm.Lock() defer cm.Unlock() + if newCfname == cfname { + entry := cm.chans[cfname] + if entry == nil || !entry.channel.IsLoaded() { + return errNoSuchChannel + } + entry.channel.Rename(newName, cfname) + return nil + } if cm.chans[newCfname] != nil || cm.registeredChannels.Has(newCfname) { return errChannelNameInUse } diff --git a/irc/channelreg.go b/irc/channelreg.go index 31b68254..47931c03 100644 --- a/irc/channelreg.go +++ b/irc/channelreg.go @@ -4,15 +4,16 @@ package irc import ( + "encoding/json" "fmt" "strconv" "strings" "time" - "encoding/json" + "github.com/tidwall/buntdb" "github.com/oragono/oragono/irc/modes" - "github.com/tidwall/buntdb" + "github.com/oragono/oragono/irc/utils" ) // this is exclusively the *persistence* layer for channel registration; @@ -140,8 +141,8 @@ func (reg *ChannelRegistry) AllChannels() (result []string) { } // PurgedChannels returns the set of all casefolded channel names that have been purged -func (reg *ChannelRegistry) PurgedChannels() (result map[string]empty) { - result = make(map[string]empty) +func (reg *ChannelRegistry) PurgedChannels() (result utils.StringSet) { + result = make(utils.StringSet) prefix := fmt.Sprintf(keyChannelPurged, "") reg.server.store.View(func(tx *buntdb.Tx) error { @@ -150,7 +151,7 @@ func (reg *ChannelRegistry) PurgedChannels() (result map[string]empty) { return false } channel := strings.TrimPrefix(key, prefix) - result[channel] = empty{} + result.Add(channel) return true }) }) diff --git a/irc/chanserv.go b/irc/chanserv.go index d5622828..e21a44dc 100644 --- a/irc/chanserv.go +++ b/irc/chanserv.go @@ -249,7 +249,7 @@ func csAmodeHandler(server *Server, client *Client, command string, params []str } case modes.Add, modes.Remove: if len(affectedModes) > 0 { - csNotice(rb, fmt.Sprintf(client.t("Successfully set persistent mode %s%s on %s"), string(change.Op), string(change.Mode), change.Arg)) + csNotice(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 { diff --git a/irc/client.go b/irc/client.go index 599aa9ee..ab24c6b7 100644 --- a/irc/client.go +++ b/irc/client.go @@ -27,9 +27,36 @@ import ( ) const ( + // maximum line length not including tags; don't change this for a public server + MaxLineLen = 512 + // 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 + // controls how often often we write an autoreplay-missed client's + // deviceid->lastseentime mapping to the database + lastSeenWriteInterval = time.Hour +) + +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 + // Resumeable clients (clients who have negotiated caps.Resume) get longer: + ResumeableTotalTimeout = 3*time.Minute + 30*time.Second + + // round off the ping interval by this much, see below: + PingCoalesceThreshold = time.Second ) // ResumeDetails is a place to stash data at various stages of @@ -54,14 +81,14 @@ type Client struct { channels ChannelSet ctime time.Time destroyed bool - exitedSnomaskSent bool modes modes.ModeSet hostname string - invitedTo map[string]bool + invitedTo utils.StringSet isSTSOnly bool languages []string - lastActive time.Time // last time they sent a command that wasn't PONG or similar - lastSeen time.Time // last time they sent any kind of command + 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 + lastSeenLastWrite time.Time // last time `lastSeen` was written to the datastore loginThrottle connection_limits.GenericThrottle nick string nickCasefolded string @@ -75,6 +102,7 @@ type Client struct { realname string realIP net.IP registered bool + registrationTimer *time.Timer resumeID string server *Server skeleton string @@ -112,8 +140,13 @@ const ( type Session struct { client *Client + deviceID string + ctime time.Time - lastActive 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 socket *Socket realIP net.IP @@ -121,7 +154,6 @@ type Session struct { rawHostname string isTor bool - idletimer IdleTimer fakelag Fakelag deferredFakelagCount int destroyed uint32 @@ -178,8 +210,8 @@ func (s *Session) EndMultilineBatch(label string) (batch MultilineBatch, err err s.fakelag.Unsuspend() // heuristics to estimate how much data they used while fakelag was suspended - fakelagBill := (batch.lenBytes / 512) + 1 - fakelagBillLines := (batch.message.LenLines() * 60) / 512 + fakelagBill := (batch.lenBytes / MaxLineLen) + 1 + fakelagBillLines := (batch.message.LenLines() * 60) / MaxLineLen if fakelagBill < fakelagBillLines { fakelagBill = fakelagBillLines } @@ -299,7 +331,6 @@ func (server *Server) RunClient(conn IRCConn) { // give them 1k of grace over the limit: socket := NewSocket(conn, config.Server.MaxSendQBytes) client := &Client{ - lastSeen: now, lastActive: now, channels: make(ChannelSet), ctime: now, @@ -333,7 +364,6 @@ func (server *Server) RunClient(conn IRCConn) { } client.sessions = []*Session{session} - session.idletimer.Initialize(session) session.resetFakelag() if wConn.Secure { @@ -354,15 +384,16 @@ func (server *Server) RunClient(conn IRCConn) { } } + client.registrationTimer = time.AfterFunc(RegisterTimeout, client.handleRegisterTimeout) server.stats.Add() client.run(session) } -func (server *Server) AddAlwaysOnClient(account ClientAccount, chnames []string, lastSeen time.Time, uModes modes.Modes) { +func (server *Server) AddAlwaysOnClient(account ClientAccount, chnames []string, lastSeen map[string]time.Time, uModes modes.Modes, realname string) { now := time.Now().UTC() config := server.Config() - if lastSeen.IsZero() { - lastSeen = now + if lastSeen == nil && account.Settings.AutoreplayMissed { + lastSeen = map[string]time.Time{"": now} } client := &Client{ @@ -379,6 +410,7 @@ func (server *Server) AddAlwaysOnClient(account ClientAccount, chnames []string, realIP: utils.IPv4LoopbackAddress, alwaysOn: true, + realname: realname, } client.SetMode(modes.TLS, true) @@ -522,7 +554,7 @@ const ( authFailSaslRequired ) -func (client *Client) isAuthorized(config *Config, session *Session) AuthOutcome { +func (client *Client) isAuthorized(server *Server, config *Config, session *Session) AuthOutcome { saslSent := client.account != "" // PASS requirement if (config.Server.passwordBytes != nil) && session.passStatus != serverPassSuccessful && !(config.Accounts.SkipServerPassword && saslSent) { @@ -533,7 +565,8 @@ func (client *Client) isAuthorized(config *Config, session *Session) AuthOutcome return authFailTorSaslRequired } // finally, enforce require-sasl - if config.Accounts.RequireSasl.Enabled && !saslSent && !utils.IPInNets(session.IP(), config.Accounts.RequireSasl.exemptedNets) { + if !saslSent && (config.Accounts.RequireSasl.Enabled || server.Defcon() <= 2) && + !utils.IPInNets(session.IP(), config.Accounts.RequireSasl.exemptedNets) { return authFailSaslRequired } return authSuccess @@ -594,7 +627,7 @@ func (client *Client) run(session *Session) { isReattach := client.Registered() if isReattach { - session.idletimer.Touch() + client.Touch(session) if session.resumeDetails != nil { session.playResume() session.resumeDetails = nil @@ -608,8 +641,11 @@ func (client *Client) run(session *Session) { firstLine := !isReattach for { + var invalidUtf8 bool line, err := session.socket.Read() - if err != nil { + if err == errInvalidUtf8 { + invalidUtf8 = true // handle as normal, including labeling + } else if err != nil { quitMessage := "connection closed" if err == errReadQ { quitMessage = "readQ exceeded" @@ -655,7 +691,7 @@ func (client *Client) run(session *Session) { } } - msg, err := ircmsg.ParseLineStrict(line, true, 512) + msg, err := ircmsg.ParseLineStrict(line, true, MaxLineLen) if err == ircmsg.ErrorLineIsEmpty { continue } else if err == ircmsg.ErrorLineTooLong { @@ -669,6 +705,8 @@ func (client *Client) run(session *Session) { cmd, exists := Commands[msg.Command] if !exists { cmd = unknownCommand + } else if invalidUtf8 { + cmd = invalidUtf8Command } isExiting := cmd.Run(client.server, client, session, msg) @@ -711,16 +749,109 @@ func (client *Client) playReattachMessages(session *Session) { // // Touch indicates that we received a line from the client (so the connection is healthy -// at this time, modulo network latency and fakelag). `active` means not a PING or suchlike -// (i.e. the user should be sitting in front of their client). -func (client *Client) Touch(active bool, session *Session) { +// at this time, modulo network latency and fakelag). +func (client *Client) Touch(session *Session) { + var markDirty bool now := time.Now().UTC() client.stateMutex.Lock() - defer client.stateMutex.Unlock() - client.lastSeen = now - if active { - client.lastActive = now - session.lastActive = now + if client.accountSettings.AutoreplayMissed || session.deviceID != "" { + client.setLastSeen(now, session.deviceID) + if now.Sub(client.lastSeenLastWrite) > lastSeenWriteInterval { + markDirty = true + client.lastSeenLastWrite = now + } + } + client.updateIdleTimer(session, now) + client.stateMutex.Unlock() + if markDirty { + client.markDirty(IncludeLastSeen) + } +} + +func (client *Client) setLastSeen(now time.Time, deviceID string) { + if client.lastSeen == nil { + client.lastSeen = make(map[string]time.Time) + } + client.lastSeen[deviceID] = now + // evict the least-recently-used entry if necessary + if maxDeviceIDsPerClient < len(client.lastSeen) { + var minLastSeen time.Time + var minClientId string + for deviceID, lastSeen := range client.lastSeen { + if minLastSeen.IsZero() || lastSeen.Before(minLastSeen) { + minClientId, minLastSeen = deviceID, lastSeen + } + } + delete(client.lastSeen, minClientId) + } +} + +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 + if session.capabilities.Has(caps.Resume) { + totalTimeout = ResumeableTotalTimeout + } + 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() } } @@ -950,23 +1081,10 @@ func (client *Client) IdleSeconds() uint64 { return uint64(client.IdleTime().Seconds()) } -// 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 != "*" -} - -// 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 != "*" -} - // SetNames sets the client's ident and realname. func (client *Client) SetNames(username, realname string, fromIdent bool) error { - limit := client.server.Config().Limits.IdentLen + config := client.server.Config() + limit := config.Limits.IdentLen if !fromIdent { limit -= 1 // leave room for the prepended ~ } @@ -978,7 +1096,9 @@ func (client *Client) SetNames(username, realname string, fromIdent bool) error return errInvalidUsername } - if !fromIdent { + if config.Server.SuppressIdent { + username = "~user" + } else if !fromIdent { username = "~" + username } @@ -1018,29 +1138,32 @@ func (client *Client) ModeString() (str string) { } // Friends refers to clients that share a channel with this client. -func (client *Client) Friends(capabs ...caps.Capability) (result map[*Session]bool) { - result = make(map[*Session]bool) +func (client *Client) Friends(capabs ...caps.Capability) (result map[*Session]empty) { + result = make(map[*Session]empty) // look at the client's own sessions - for _, session := range client.Sessions() { - if session.capabilities.HasAll(capabs...) { - result[session] = true - } - } + addFriendsToSet(result, client, capabs...) for _, channel := range client.Channels() { for _, member := range channel.Members() { - for _, session := range member.Sessions() { - if session.capabilities.HasAll(capabs...) { - result[session] = true - } - } + addFriendsToSet(result, member, capabs...) } } return } +// helper for Friends +func addFriendsToSet(set map[*Session]empty, client *Client, capabs ...caps.Capability) { + client.stateMutex.RLock() + defer client.stateMutex.RUnlock() + for _, session := range client.sessions { + if session.capabilities.HasAll(capabs...) { + set[session] = empty{} + } + } +} + func (client *Client) SetOper(oper *Oper) { client.stateMutex.Lock() defer client.stateMutex.Unlock() @@ -1082,14 +1205,25 @@ func (client *Client) SetVHost(vhost string) (updated bool) { return } -// updateNick updates `nick` and `nickCasefolded`. -func (client *Client) updateNick(nick, nickCasefolded, skeleton string) { +// 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 } // updateNickMaskNoMutex updates the casefolded nickname and nickmask, not acquiring any mutexes. @@ -1159,11 +1293,11 @@ func (client *Client) Quit(message string, session *Session) { // #364: don't send QUIT lines to unregistered clients if client.registered { quitMsg := ircmsg.MakeMessage(nil, client.nickMaskString, "QUIT", message) - finalData, _ = quitMsg.LineBytesStrict(false, 512) + finalData, _ = quitMsg.LineBytesStrict(false, MaxLineLen) } errorMsg := ircmsg.MakeMessage(nil, "", "ERROR", message) - errorMsgBytes, _ := errorMsg.LineBytesStrict(false, 512) + errorMsgBytes, _ := errorMsg.LineBytesStrict(false, MaxLineLen) finalData = append(finalData, errorMsgBytes...) sess.socket.SetFinalData(finalData) @@ -1193,16 +1327,20 @@ func (client *Client) Quit(message string, session *Session) { func (client *Client) destroy(session *Session) { config := client.server.Config() var sessionsToDestroy []*Session + var saveLastSeen bool client.stateMutex.Lock() + details := client.detailsNoMutex() brbState := client.brbTimer.state brbAt := client.brbTimer.brbAt wasReattach := session != nil && session.client != client sessionRemoved := false registered := client.registered - alwaysOn := client.alwaysOn - saveLastSeen := alwaysOn && client.accountSettings.AutoreplayMissed + // 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 + var remainingSessions int if session == nil { sessionsToDestroy = client.sessions @@ -1215,6 +1353,20 @@ func (client *Client) destroy(session *Session) { } } + // save last seen if applicable: + if alwaysOn { + if client.accountSettings.AutoreplayMissed { + saveLastSeen = true + } else { + for _, session := range sessionsToDestroy { + if session.deviceID != "" { + saveLastSeen = true + break + } + } + } + } + // should we destroy the whole client this time? // BRB is not respected if this is a destroy of the whole client (i.e., session == nil) brbEligible := session != nil && brbState == BrbEnabled @@ -1229,18 +1381,23 @@ func (client *Client) destroy(session *Session) { if saveLastSeen { client.dirtyBits |= IncludeLastSeen } - exitedSnomaskSent := client.exitedSnomaskSent autoAway := false var awayMessage string - if alwaysOn && remainingSessions == 0 && persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway) { + if alwaysOn && !client.away && remainingSessions == 0 && + persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway) { autoAway = true client.autoAway = true client.away = true - awayMessage = config.languageManager.Translate(client.languages, `Disconnected from the server`) + awayMessage = config.languageManager.Translate(client.languages, `User is currently disconnected`) client.awayMessage = awayMessage } + if client.registrationTimer != nil { + // unconditionally stop; if the client is still unregistered it must be destroyed + client.registrationTimer.Stop() + } + client.stateMutex.Unlock() // XXX there is no particular reason to persist this state here rather than @@ -1258,13 +1415,16 @@ func (client *Client) destroy(session *Session) { // session has been attached to a new client; do not destroy it continue } - session.idletimer.Stop() + session.stopIdleTimer() // send quit/error message to client if they haven't been sent already client.Quit("", session) quitMessage = session.quitMessage session.SetDestroyed() session.socket.Close() + // clean up monitor state + client.server.monitorManager.RemoveAll(session) + // remove from connection limits var source string if session.isTor { @@ -1330,8 +1490,6 @@ func (client *Client) destroy(session *Session) { if registered { client.server.monitorManager.AlertAbout(details.nick, details.nickCasefolded, 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) @@ -1370,7 +1528,7 @@ func (client *Client) destroy(session *Session) { friend.sendFromClientInternal(false, splitQuitMessage.Time, splitQuitMessage.Msgid, details.nickMask, details.accountName, nil, "QUIT", quitMessage) } - if !exitedSnomaskSent && registered { + if registered { client.server.snomasks.Send(sno.LocalQuits, fmt.Sprintf(ircfmt.Unescape("%s$r exited the network"), details.nick)) } } @@ -1478,10 +1636,11 @@ func (session *Session) SendRawMessage(message ircmsg.IrcMessage, blocking bool) } // assemble message - line, err := message.LineBytesStrict(false, 512) + line, err := message.LineBytesStrict(false, MaxLineLen) if err != nil { - logline := fmt.Sprintf("Error assembling message for sending: %v\n%s", err, debug.Stack()) - session.client.server.logger.Error("internal", logline) + errorParams := []string{"Error assembling message for sending", err.Error(), message.Command} + errorParams = append(errorParams, message.Params...) + session.client.server.logger.Error("internal", errorParams...) message = ircmsg.MakeMessage(nil, session.client.server.name, ERR_UNKNOWNERROR, "*", "Error assembling message for sending") line, _ := message.LineBytesStrict(false, 0) @@ -1543,15 +1702,24 @@ func (session *Session) Notice(text string) { // `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) { +func (client *Client) addChannel(channel *Channel, simulated bool) (err error) { + config := client.server.Config() + client.stateMutex.Lock() - client.channels[channel] = true alwaysOn := client.alwaysOn + if client.destroyed { + err = errClientDestroyed + } else if client.oper == nil && len(client.channels) >= config.Channels.MaxChannelsPerClient { + err = errTooManyChannels + } else { + client.channels[channel] = empty{} // success + } client.stateMutex.Unlock() - if alwaysOn && !simulated { + if err == nil && alwaysOn && !simulated { client.markDirty(IncludeChannels) } + return } func (client *Client) removeChannel(channel *Channel) { @@ -1571,10 +1739,10 @@ func (client *Client) Invite(casefoldedChannel string) { defer client.stateMutex.Unlock() if client.invitedTo == nil { - client.invitedTo = make(map[string]bool) + client.invitedTo = make(utils.StringSet) } - client.invitedTo[casefoldedChannel] = true + client.invitedTo.Add(casefoldedChannel) } // Checks that the client was invited to join a given channel @@ -1582,7 +1750,7 @@ func (client *Client) CheckInvited(casefoldedChannel string) (invited bool) { client.stateMutex.Lock() defer client.stateMutex.Unlock() - invited = client.invitedTo[casefoldedChannel] + invited = client.invitedTo.Has(casefoldedChannel) // joining an invited channel "uses up" your invite, so you can't rejoin on kick delete(client.invitedTo, casefoldedChannel) return @@ -1595,7 +1763,7 @@ func (client *Client) attemptAutoOper(session *Session) { return } for _, oper := range client.server.Config().operators { - if oper.Auto && oper.Pass == nil && oper.Fingerprint != "" && oper.Fingerprint == session.certfp { + if oper.Auto && oper.Pass == nil && oper.Certfp != "" && oper.Certfp == session.certfp { rb := NewResponseBuffer(session) applyOper(client, oper, rb) rb.Send(true) @@ -1604,6 +1772,12 @@ func (client *Client) attemptAutoOper(session *Session) { } } +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, "" @@ -1624,12 +1798,28 @@ func (client *Client) historyStatus(config *Config) (status HistoryStatus, targe 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() + result = make(map[string]time.Time, len(client.lastSeen)) + for id, lastSeen := range client.lastSeen { + result[id] = lastSeen + } + return +} + // 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 IncludeLastSeen IncludeUserModes + IncludeRealname ) func (client *Client) markDirty(dirtyBits uint) { @@ -1651,7 +1841,7 @@ func (client *Client) wakeWriter() { func (client *Client) writeLoop() { for { - client.performWrite() + client.performWrite(0) client.writerSemaphore.Release() client.stateMutex.RLock() @@ -1664,12 +1854,11 @@ func (client *Client) writeLoop() { } } -func (client *Client) performWrite() { +func (client *Client) performWrite(additionalDirtyBits uint) { client.stateMutex.Lock() - dirtyBits := client.dirtyBits + dirtyBits := client.dirtyBits | additionalDirtyBits client.dirtyBits = 0 account := client.account - lastSeen := client.lastSeen client.stateMutex.Unlock() if account == "" { @@ -1686,7 +1875,7 @@ func (client *Client) performWrite() { client.server.accounts.saveChannels(account, channelNames) } if (dirtyBits & IncludeLastSeen) != 0 { - client.server.accounts.saveLastSeen(account, lastSeen) + client.server.accounts.saveLastSeen(account, client.copyLastSeen()) } if (dirtyBits & IncludeUserModes) != 0 { uModes := make(modes.Modes, 0, len(modes.SupportedUserModes)) @@ -1702,4 +1891,25 @@ func (client *Client) performWrite() { } 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.writerSemaphore.Acquire() + defer client.writerSemaphore.Release() + client.performWrite(dirtyBits) + return nil } diff --git a/irc/client_lookup_set.go b/irc/client_lookup_set.go index d24b5c55..945df6a7 100644 --- a/irc/client_lookup_set.go +++ b/irc/client_lookup_set.go @@ -28,14 +28,6 @@ func (clients *ClientManager) Initialize() { clients.bySkeleton = 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. func (clients *ClientManager) Get(nick string) *Client { casefoldedName, err := CasefoldName(nick) @@ -48,9 +40,8 @@ func (clients *ClientManager) Get(nick string) *Client { return nil } -func (clients *ClientManager) removeInternal(client *Client) (err error) { +func (clients *ClientManager) removeInternal(client *Client, oldcfnick, oldskeleton string) (err error) { // requires holding the writable Lock() - oldcfnick, oldskeleton := client.uniqueIdentifiers() if oldcfnick == "*" || oldcfnick == "" { return errNickMissing } @@ -88,7 +79,8 @@ func (clients *ClientManager) Remove(client *Client) error { clients.Lock() defer clients.Unlock() - return clients.removeInternal(client) + oldcfnick, oldskeleton := client.uniqueIdentifiers() + return clients.removeInternal(client, oldcfnick, oldskeleton) } // Handles a RESUME by attaching a session to a designated client. It is the @@ -173,7 +165,6 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick return "", errNicknameInvalid, false } - config := client.server.Config() if config.Server.Relaying.Enabled { for _, char := range config.Server.Relaying.Separators { if strings.ContainsRune(newCfNick, char) { @@ -182,7 +173,7 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick } } - if restrictedCasefoldedNicks[newCfNick] || restrictedSkeletons[newSkeleton] { + if restrictedCasefoldedNicks.Has(newCfNick) || restrictedSkeletons.Has(newSkeleton) { return "", errNicknameInvalid, false } @@ -234,18 +225,13 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick client.server.stats.AddRegistered(invisible, operator) } session.autoreplayMissedSince = lastSeen - // XXX SetNames only changes names if they are unset, so the realname change only - // takes effect on first attach to an always-on client (good), but the user/ident - // change is always a no-op (bad). we could make user/ident act the same way as - // realname, but then we'd have to send CHGHOST and i don't want to deal with that - // for performance reasons - currentClient.SetNames("user", realname, true) + // TODO: transition mechanism for #1065, clean this up eventually: + if currentClient.Realname() == "" { + currentClient.SetRealname(realname) + } // successful reattach! return newNick, nil, back } else if currentClient == client && currentClient.Nick() == newNick { - // see #1019: normally no-op nick changes are caught earlier, by performNickChange, - // but they are not detected there when force-guest-format is enabled (because - // the proposed nickname is e.g. alice and the current nickname is Guest-alice) return "", errNoop, false } // analogous checks for skeletons @@ -254,10 +240,13 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick return "", errNicknameInUse, false } - clients.removeInternal(client) + 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 - client.updateNick(newNick, newCfNick, newSkeleton) return newNick, nil, false } @@ -273,21 +262,6 @@ func (clients *ClientManager) AllClients() (result []*Client) { return } -// AllWithCaps returns all clients with the given capabilities. -func (clients *ClientManager) AllWithCaps(capabs ...caps.Capability) (sessions []*Session) { - clients.RLock() - defer clients.RUnlock() - for _, client := range clients.byNick { - for _, session := range client.Sessions() { - if session.capabilities.HasAll(capabs...) { - sessions = append(sessions, session) - } - } - } - - 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) diff --git a/irc/client_test.go b/irc/client_test.go index 7cfeaabf..d371c11f 100644 --- a/irc/client_test.go +++ b/irc/client_test.go @@ -5,11 +5,13 @@ package irc import ( "testing" + + "github.com/oragono/oragono/irc/utils" ) func TestGenerateBatchID(t *testing.T) { var session Session - s := make(StringSet) + s := make(utils.StringSet) count := 100000 for i := 0; i < count; i++ { @@ -56,3 +58,33 @@ func TestUserMasks(t *testing.T) { 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/commands.go b/irc/commands.go index 7ccff5e6..1e58726d 100644 --- a/irc/commands.go +++ b/irc/commands.go @@ -12,13 +12,12 @@ import ( // Command represents a command accepted from a client. type Command struct { - handler func(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool - oper bool - usablePreReg bool - leaveClientIdle bool // if true, leaves the client active time alone - 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 + allowedInBatch bool // allowed in client-to-server batches + minParams int + capabs []string } // Run runs this command with the given client/message. @@ -59,15 +58,8 @@ func (cmd *Command) Run(server *Server, client *Client, session *Session, msg ir exiting = server.tryRegister(client, session) } - // most servers do this only for PING/PONG, but we'll do it for any command: if client.registered { - // touch even if `exiting`, so we record the time of a QUIT accurately - session.idletimer.Touch() - } - - // TODO: eliminate idletimer entirely in favor of this measurement - if client.registered { - client.Touch(!cmd.leaveClientIdle, session) + client.Touch(session) } return exiting @@ -79,6 +71,11 @@ var unknownCommand = Command{ 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 @@ -120,6 +117,10 @@ func init() { minParams: 1, oper: true, }, + "DEFCON": { + handler: defconHandler, + capabs: []string{"defcon"}, + }, "DEOPER": { handler: deoperHandler, minParams: 0, @@ -130,6 +131,10 @@ func init() { minParams: 1, oper: true, }, + "EXTJWT": { + handler: extjwtHandler, + minParams: 1, + }, "HELP": { handler: helpHandler, minParams: 0, @@ -150,9 +155,8 @@ func init() { minParams: 2, }, "ISON": { - handler: isonHandler, - minParams: 1, - leaveClientIdle: true, + handler: isonHandler, + minParams: 1, }, "JOIN": { handler: joinHandler, @@ -234,16 +238,14 @@ func init() { minParams: 1, }, "PING": { - handler: pingHandler, - usablePreReg: true, - minParams: 1, - leaveClientIdle: true, + handler: pingHandler, + usablePreReg: true, + minParams: 1, }, "PONG": { - handler: pongHandler, - usablePreReg: true, - minParams: 1, - leaveClientIdle: true, + handler: pongHandler, + usablePreReg: true, + minParams: 1, }, "PRIVMSG": { handler: messageHandler, @@ -344,9 +346,8 @@ func init() { minParams: 4, }, "WHO": { - handler: whoHandler, - minParams: 1, - leaveClientIdle: true, + handler: whoHandler, + minParams: 1, }, "WHOIS": { handler: whoisHandler, diff --git a/irc/config.go b/irc/config.go index 09724b19..020eecee 100644 --- a/irc/config.go +++ b/irc/config.go @@ -6,6 +6,7 @@ package irc import ( + "bytes" "crypto/tls" "errors" "fmt" @@ -21,20 +22,22 @@ import ( "time" "code.cloudfoundry.org/bytefmt" + "github.com/goshuirc/irc-go/ircfmt" + "gopkg.in/yaml.v2" + "github.com/oragono/oragono/irc/caps" "github.com/oragono/oragono/irc/cloaks" "github.com/oragono/oragono/irc/connection_limits" "github.com/oragono/oragono/irc/custime" "github.com/oragono/oragono/irc/email" "github.com/oragono/oragono/irc/isupport" + "github.com/oragono/oragono/irc/jwt" "github.com/oragono/oragono/irc/languages" - "github.com/oragono/oragono/irc/ldap" "github.com/oragono/oragono/irc/logger" "github.com/oragono/oragono/irc/modes" "github.com/oragono/oragono/irc/mysql" "github.com/oragono/oragono/irc/passwd" "github.com/oragono/oragono/irc/utils" - "gopkg.in/yaml.v2" ) // here's how this works: exported (capitalized) members of the config structs @@ -257,7 +260,6 @@ type AccountConfig struct { } `yaml:"require-sasl"` DefaultUserModes *string `yaml:"default-user-modes"` defaultUserModes modes.Modes - LDAP ldap.ServerConfig LoginThrottling ThrottleConfig `yaml:"login-throttling"` SkipServerPassword bool `yaml:"skip-server-password"` LoginViaPassCommand bool `yaml:"login-via-pass-command"` @@ -265,8 +267,7 @@ type AccountConfig struct { Enabled bool AdditionalNickLimit int `yaml:"additional-nick-limit"` Method NickEnforcementMethod - AllowCustomEnforcement bool `yaml:"allow-custom-enforcement"` - RenameTimeout time.Duration `yaml:"rename-timeout"` + 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"` @@ -313,7 +314,6 @@ type VHostConfig struct { Channel string Cooldown custime.Duration } `yaml:"user-requests"` - OfferList []string `yaml:"offer-list"` } type NickEnforcementMethod int @@ -409,7 +409,8 @@ type OperConfig struct { Vhost string WhoisLine string `yaml:"whois-line"` Password string - Fingerprint string + Fingerprint *string // legacy name for certfp, #1050 + Certfp string Auto bool Modes string } @@ -497,6 +498,7 @@ type Config struct { lookupHostnames bool ForwardConfirmHostnames bool `yaml:"forward-confirm-hostnames"` CheckIdent bool `yaml:"check-ident"` + SuppressIdent bool `yaml:"suppress-ident"` MOTD string motdLines []string MOTDFormatting bool `yaml:"motd-formatting"` @@ -524,6 +526,7 @@ type Config struct { supportedCaps *caps.Set capValues caps.Values Casemapping Casemapping + EnforceUtf8 bool `yaml:"enforce-utf8"` OutputPath string `yaml:"output-path"` } @@ -536,6 +539,11 @@ type Config struct { addSuffix bool } + Extjwt struct { + Default jwt.JwtServiceConfig `yaml:",inline"` + Services map[string]jwt.JwtServiceConfig `yaml:"services"` + } + Languages struct { Enabled bool Path string @@ -608,6 +616,11 @@ type Config 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 @@ -616,8 +629,8 @@ type Config struct { // OperClass defines an assembled operator class. type OperClass struct { Title string - WhoisLine string `yaml:"whois-line"` - Capabilities StringSet // map to make lookups much easier + WhoisLine string `yaml:"whois-line"` + Capabilities utils.StringSet // map to make lookups much easier } // OperatorClasses returns a map of assembled operator classes from the given config. @@ -632,7 +645,7 @@ func (conf *Config) OperatorClasses() (map[string]*OperClass, error) { lenOfLastOcs := -1 for { if lenOfLastOcs == len(ocs) { - return nil, ErrOperClassDependencies + return nil, errors.New("OperClasses contains a looping dependency, or a class extends from a class that doesn't exist") } lenOfLastOcs = len(ocs) @@ -655,7 +668,7 @@ func (conf *Config) OperatorClasses() (map[string]*OperClass, error) { // create new operclass var oc OperClass - oc.Capabilities = make(StringSet) + oc.Capabilities = make(utils.StringSet) // get inhereted info from other operclasses if len(info.Extends) > 0 { @@ -696,14 +709,14 @@ func (conf *Config) OperatorClasses() (map[string]*OperClass, error) { // Oper represents a single assembled operator's config. type Oper struct { - Name string - Class *OperClass - WhoisLine string - Vhost string - Pass []byte - Fingerprint string - Auto bool - Modes []modes.ModeChange + Name string + Class *OperClass + WhoisLine string + Vhost string + Pass []byte + Certfp string + Auto bool + Modes []modes.ModeChange } // Operators returns a map of operator configs from the given OperClass and config. @@ -725,15 +738,19 @@ func (conf *Config) Operators(oc map[string]*OperClass) (map[string]*Oper, error return nil, fmt.Errorf("Oper %s has an invalid password hash: %s", oper.Name, err.Error()) } } - if opConf.Fingerprint != "" { - oper.Fingerprint, err = utils.NormalizeCertfp(opConf.Fingerprint) + 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 - if oper.Pass == nil && oper.Fingerprint == "" { + if oper.Pass == nil && oper.Certfp == "" { return nil, fmt.Errorf("Oper %s has neither a password nor a fingerprint", name) } @@ -810,6 +827,29 @@ func (conf *Config) prepareListeners() (err error) { 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 := ioutil.ReadFile(filename) @@ -834,24 +874,24 @@ func LoadConfig(filename string) (config *Config, err error) { config.Filename = filename if config.Network.Name == "" { - return nil, ErrNetworkNameMissing + return nil, errors.New("Network name missing") } if config.Server.Name == "" { - return nil, ErrServerNameMissing + return nil, errors.New("Server name missing") } if !utils.IsServerName(config.Server.Name) { - return nil, ErrServerNameNotHostname + return nil, errors.New("Server name must match the format of a hostname") } config.Server.nameCasefolded = strings.ToLower(config.Server.Name) if config.Datastore.Path == "" { - return nil, ErrDatastorePathMissing + return nil, errors.New("Datastore path missing") } //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 config.Limits.NickLen < 1 || config.Limits.ChannelLen < 2 || config.Limits.AwayLen < 1 || config.Limits.KickLen < 1 || config.Limits.TopicLen < 1 { - return nil, ErrLimitsAreInsane + return nil, errors.New("One or more limits values are too low") } if config.Limits.RegistrationMessages == 0 { config.Limits.RegistrationMessages = 1024 @@ -862,6 +902,10 @@ func LoadConfig(filename string) (config *Config, err error) { } } + if config.Server.CheckIdent && config.Server.SuppressIdent { + return nil, errors.New("Can't configure both check-ident and suppress-ident") + } + config.Server.supportedCaps = caps.NewCompleteSet() config.Server.capValues = make(caps.Values) @@ -964,7 +1008,7 @@ func LoadConfig(filename string) (config *Config, err error) { } } if methods["file"] && logConfig.Filename == "" { - return nil, ErrLoggerFilenameMissing + return nil, errors.New("Logging configuration specifies 'file' method but 'filename' is empty") } logConfig.MethodFile = methods["file"] logConfig.MethodStdout = methods["stdout"] @@ -983,7 +1027,7 @@ func LoadConfig(filename string) (config *Config, err error) { continue } if typeStr == "-" { - return nil, ErrLoggerExcludeEmpty + return nil, errors.New("Encountered logging type '-' with no type to exclude") } if typeStr[0] == '-' { typeStr = typeStr[1:] @@ -993,7 +1037,7 @@ func LoadConfig(filename string) (config *Config, err error) { } } if len(logConfig.Types) < 1 { - return nil, ErrLoggerHasNoTypes + return nil, errors.New("Logger has no types to log") } newLogConfigs = append(newLogConfigs, logConfig) @@ -1050,12 +1094,6 @@ func LoadConfig(filename string) (config *Config, err error) { config.Accounts.VHosts.ValidRegexp = defaultValidVhostRegex } - for _, vhost := range config.Accounts.VHosts.OfferList { - if !config.Accounts.VHosts.ValidRegexp.MatchString(vhost) { - return nil, fmt.Errorf("invalid offered vhost: %s", vhost) - } - } - config.Server.capValues[caps.SASL] = "PLAIN,EXTERNAL" if !config.Accounts.AuthenticationEnabled { config.Server.supportedCaps.Disable(caps.SASL) @@ -1134,11 +1172,16 @@ func LoadConfig(filename string) (config *Config, err error) { } 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 } @@ -1156,6 +1199,11 @@ func LoadConfig(filename string) (config *Config, err error) { } } + err = config.processExtjwt() + if err != nil { + return nil, err + } + // now that all postprocessing is complete, regenerate ISUPPORT: err = config.generateISupport() if err != nil { @@ -1193,6 +1241,9 @@ func (config *Config) generateISupport() (err error) { 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("INVEX", "") isupport.Add("KICKLEN", strconv.Itoa(config.Limits.KickLen)) isupport.Add("MAXLIST", fmt.Sprintf("beI:%s", strconv.Itoa(config.Limits.ChanListModes))) @@ -1212,6 +1263,7 @@ func (config *Config) generateISupport() (err error) { if config.Server.Casemapping == CasemappingPRECIS { isupport.Add("UTF8MAPPING", precisUTF8MappingToken) } + isupport.Add("WHOX", "") err = isupport.RegenerateCachedReply() return @@ -1255,6 +1307,16 @@ func (config *Config) Diff(oldConfig *Config) (addedCaps, removedCaps *caps.Set) 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") @@ -1280,3 +1342,34 @@ func compileGuestRegexp(guestFormat string, casemapping Casemapping) (standard, 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 := ioutil.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/errors.go b/irc/errors.go index 364ebbbc..5e3efc74 100644 --- a/irc/errors.go +++ b/irc/errors.go @@ -8,6 +8,7 @@ package irc import ( "errors" "fmt" + "time" "github.com/oragono/oragono/irc/utils" ) @@ -57,7 +58,6 @@ var ( errBanned = errors.New("IP or nickmask banned") errInvalidParams = utils.ErrInvalidParams errNoVhost = errors.New(`You do not have an approved vhost`) - errVhostsForbidden = errors.New(`An administrator has denied you the ability to use vhosts`) errLimitExceeded = errors.New("Limit exceeded") errNoop = errors.New("Action was a no-op") errCASFailed = errors.New("Compare-and-swap update of database value failed") @@ -65,13 +65,12 @@ var ( errCredsExternallyManaged = errors.New("Credentials are externally managed and cannot be changed here") errInvalidMultilineBatch = errors.New("Invalid multiline batch") errTimedOut = errors.New("Operation timed out") -) - -// Socket Errors -var ( - errNoPeerCerts = errors.New("Client did not provide a certificate") - errNotTLS = errors.New("Not a TLS connection") - errReadQ = errors.New("ReadQ Exceeded") + 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") ) // String Errors @@ -89,18 +88,10 @@ func (ck *CertKeyError) Error() string { return fmt.Sprintf("Invalid TLS cert/key pair: %v", ck.Err) } -// Config Errors -var ( - ErrDatastorePathMissing = errors.New("Datastore path missing") - 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") -) +type ThrottleError struct { + time.Duration +} + +func (te *ThrottleError) Error() string { + return fmt.Sprintf(`Please wait at least %v and try again`, te.Duration) +} diff --git a/irc/gateways.go b/irc/gateways.go index 57140b89..f7238b75 100644 --- a/irc/gateways.go +++ b/irc/gateways.go @@ -26,31 +26,39 @@ const ( ) type webircConfig struct { - PasswordString string `yaml:"password"` - Password []byte `yaml:"password-bytes"` - Fingerprint string + PasswordString string `yaml:"password"` + Password []byte `yaml:"password-bytes"` + Fingerprint *string // legacy name for certfp, #1050 + Certfp string Hosts []string allowedNets []net.IPNet } // Populate fills out our password or fingerprint. func (wc *webircConfig) Populate() (err error) { - if wc.Fingerprint == "" && wc.PasswordString == "" { - err = ErrNoFingerprintOrPassword - } - - if err == nil && wc.PasswordString != "" { + if wc.PasswordString != "" { wc.Password, err = decodeLegacyPasswordHash(wc.PasswordString) + if err != nil { + return + } } - if err == nil && wc.Fingerprint != "" { - wc.Fingerprint, err = utils.NormalizeCertfp(wc.Fingerprint) + certfp := wc.Certfp + if certfp == "" && wc.Fingerprint != nil { + certfp = *wc.Fingerprint + } + if certfp != "" { + wc.Certfp, err = utils.NormalizeCertfp(certfp) + } + if err != nil { + return } - if err == nil { - wc.allowedNets, err = utils.ParseNetList(wc.Hosts) + 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 } diff --git a/irc/getters.go b/irc/getters.go index d51bce87..ec4dc8c8 100644 --- a/irc/getters.go +++ b/irc/getters.go @@ -37,6 +37,14 @@ func (server *Server) Languages() (lm *languages.Manager) { return server.Config().languageManager } +func (server *Server) Defcon() uint32 { + return atomic.LoadUint32(&server.defcon) +} + +func (server *Server) SetDefcon(defcon uint32) { + atomic.StoreUint32(&server.defcon, defcon) +} + func (client *Client) Sessions() (sessions []*Session) { client.stateMutex.RLock() sessions = client.sessions @@ -62,6 +70,7 @@ type SessionData struct { ip net.IP hostname string certfp string + deviceID string } func (client *Client) AllSessionData(currentSession *Session) (data []SessionData, currentIndex int) { @@ -79,6 +88,7 @@ func (client *Client) AllSessionData(currentSession *Session) (data []SessionDat ctime: session.ctime, hostname: session.rawHostname, certfp: session.certfp, + deviceID: session.deviceID, } if session.proxiedIP != nil { data[i].ip = session.proxiedIP @@ -102,8 +112,9 @@ func (client *Client) AddSession(session *Session) (success bool, numSessions in newSessions := make([]*Session, len(client.sessions)+1) copy(newSessions, client.sessions) newSessions[len(newSessions)-1] = session - if client.accountSettings.AutoreplayMissed { - lastSeen = client.lastSeen + if client.accountSettings.AutoreplayMissed || session.deviceID != "" { + lastSeen = client.lastSeen[session.deviceID] + client.setLastSeen(time.Now().UTC(), session.deviceID) } client.sessions = newSessions if client.autoAway { @@ -174,9 +185,9 @@ func (client *Client) Hostname() string { return client.hostname } -func (client *Client) Away() (result bool) { +func (client *Client) Away() (result bool, message string) { client.stateMutex.Lock() - result = client.away + result, message = client.away, client.awayMessage client.stateMutex.Unlock() return } @@ -190,15 +201,9 @@ func (client *Client) SetAway(away bool, awayMessage string) (changed bool) { return } -func (client *Client) SetExitedSnomaskSent() { - client.stateMutex.Lock() - client.exitedSnomaskSent = true - client.stateMutex.Unlock() -} - func (client *Client) AlwaysOn() (alwaysOn bool) { client.stateMutex.Lock() - alwaysOn = client.alwaysOn + alwaysOn = client.registered && client.alwaysOn client.stateMutex.Unlock() return } @@ -230,20 +235,15 @@ func (client *Client) Oper() *Oper { return client.oper } -func (client *Client) Registered() bool { - client.stateMutex.RLock() - defer client.stateMutex.RUnlock() - return client.registered -} - -func (client *Client) SetRegistered() { +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 - client.stateMutex.Lock() - client.registered = true - client.stateMutex.Unlock() + client.stateMutex.RLock() + result = client.registered + client.stateMutex.RUnlock() + return } func (client *Client) RawHostname() (result string) { @@ -285,11 +285,8 @@ func (client *Client) Login(account ClientAccount) { client.account = account.NameCasefolded client.accountName = account.Name client.accountSettings = account.Settings - // check `registered` to avoid incorrectly marking a temporary (pre-reattach), - // SASL'ing client as always-on - if client.registered { - client.alwaysOn = alwaysOn - } + // mark always-on here: it will not be respected until the client is registered + client.alwaysOn = alwaysOn client.accountRegDate = account.RegisteredAt return } @@ -324,17 +321,26 @@ func (client *Client) AccountSettings() (result AccountSettings) { func (client *Client) SetAccountSettings(settings AccountSettings) { // we mark dirty if the client is transitioning to always-on - markDirty := false + var becameAlwaysOn, autoreplayMissedDisabled bool alwaysOn := persistenceEnabled(client.server.Config().Accounts.Multiclient.AlwaysOn, settings.AlwaysOn) client.stateMutex.Lock() - client.accountSettings = settings if client.registered { - markDirty = !client.alwaysOn && alwaysOn + // only allow the client to become always-on if their nick equals their account name + alwaysOn = alwaysOn && client.nick == client.accountName + autoreplayMissedDisabled = (client.accountSettings.AutoreplayMissed && !settings.AutoreplayMissed) + becameAlwaysOn = (!client.alwaysOn && alwaysOn) client.alwaysOn = alwaysOn + if autoreplayMissedDisabled { + // clear the lastSeen entry for the default session, but not for device IDs + delete(client.lastSeen, "") + } } + client.accountSettings = settings client.stateMutex.Unlock() - if markDirty { + if becameAlwaysOn { client.markDirty(IncludeAllAttrs) + } else if autoreplayMissedDisabled { + client.markDirty(IncludeLastSeen) } } @@ -363,7 +369,11 @@ func (client *Client) SetMode(mode modes.Mode, on bool) bool { func (client *Client) SetRealname(realname string) { client.stateMutex.Lock() client.realname = realname + alwaysOn := client.registered && client.alwaysOn client.stateMutex.Unlock() + if alwaysOn { + client.markDirty(IncludeRealname) + } } func (client *Client) Channels() (result []*Channel) { @@ -408,6 +418,21 @@ func (client *Client) detailsNoMutex() (result ClientDetails) { 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 (channel *Channel) Name() string { channel.stateMutex.RLock() defer channel.stateMutex.RUnlock() @@ -423,9 +448,11 @@ func (channel *Channel) NameCasefolded() string { func (channel *Channel) Rename(name, nameCasefolded string) { channel.stateMutex.Lock() channel.name = name - channel.nameCasefolded = nameCasefolded - if channel.registeredFounder != "" { - channel.registeredTime = time.Now().UTC() + if channel.nameCasefolded != nameCasefolded { + channel.nameCasefolded = nameCasefolded + if channel.registeredFounder != "" { + channel.registeredTime = time.Now().UTC() + } } channel.stateMutex.Unlock() } diff --git a/irc/handlers.go b/irc/handlers.go index af282dd5..f3bac945 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -25,6 +25,7 @@ import ( "github.com/oragono/oragono/irc/caps" "github.com/oragono/oragono/irc/custime" "github.com/oragono/oragono/irc/history" + "github.com/oragono/oragono/irc/jwt" "github.com/oragono/oragono/irc/modes" "github.com/oragono/oragono/irc/sno" "github.com/oragono/oragono/irc/utils" @@ -82,7 +83,7 @@ func sendSuccessfulRegResponse(client *Client, rb *ResponseBuffer, forNS bool) { } else { rb.Add(nil, client.server.name, RPL_REG_SUCCESS, details.nick, details.accountName, 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]]"), details.nickMask, details.accountName)) + 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(client, rb, forNS, false) } @@ -236,6 +237,15 @@ func authPlainHandler(server *Server, client *Client, mechanism string, value [] 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) if err != nil { @@ -251,6 +261,10 @@ func authPlainHandler(server *Server, client *Client, mechanism string, value [] } 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: return err.Error() @@ -280,6 +294,15 @@ func authExternalHandler(server *Server, client *Client, mechanism string, value } if err == nil { + // 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 { + var deviceID string + authzid, deviceID = authzid[:strudelIndex], authzid[strudelIndex+1:] + if !client.registered { + rb.session.deviceID = deviceID + } + } err = server.accounts.AuthenticateByCertFP(client, rb.session.certfp, authzid) } @@ -325,9 +348,9 @@ func dispatchAwayNotify(client *Client, isAway bool, awayMessage string) { details := client.Details() for session := range client.Friends(caps.AwayNotify) { if isAway { - session.sendFromClientInternal(false, time.Time{}, "", details.nickMask, details.account, nil, "AWAY", awayMessage) + session.sendFromClientInternal(false, time.Time{}, "", details.nickMask, details.accountName, nil, "AWAY", awayMessage) } else { - session.sendFromClientInternal(false, time.Time{}, "", details.nickMask, details.account, nil, "AWAY") + session.sendFromClientInternal(false, time.Time{}, "", details.nickMask, details.accountName, nil, "AWAY") } } } @@ -736,6 +759,21 @@ func debugHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Res return false } +func defconHandler(server *Server, client *Client, msg ircmsg.IrcMessage, 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" @@ -829,7 +867,7 @@ func dlineHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Res return false } - if !dlineMyself && hostNet.Contains(client.IP()) { + 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 } @@ -868,24 +906,30 @@ func dlineHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Res var killClient bool if andKill { - var clientsToKill []*Client + var sessionsToKill []*Session var killedClientNicks []string for _, mcl := range server.clients.AllClients() { - if hostNet.Contains(mcl.IP()) { - clientsToKill = append(clientsToKill, mcl) - killedClientNicks = append(killedClientNicks, mcl.nick) + 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 + } + } } } - for _, mcl := range clientsToKill { - mcl.SetExitedSnomaskSent() - mcl.Quit(fmt.Sprintf(mcl.t("You have been banned from this server (%s)"), reason), nil) - if mcl == client { + 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 { killClient = true } else { // if mcl == client, we kill them below - mcl.destroy(nil) + mcl.destroy(session) } } @@ -897,6 +941,73 @@ func dlineHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Res return killClient } +// EXTJWT [service_name] +func extjwtHandler(server *Server, client *Client, msg ircmsg.IrcMessage, 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() + claims["joined"] = 0 + claims["cmodes"] = []string{} + if present, cModes := channel.ClientStatus(client); present { + claims["joined"] = 1 + 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 := 400 + + 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 [] func helpHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { argument := strings.ToLower(strings.TrimSpace(strings.Join(msg.Params, " "))) @@ -973,6 +1084,7 @@ func infoHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp 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, "") rb.Add(nil, server.name, RPL_INFO, nick, client.t("Oragono is released under the MIT license.")) rb.Add(nil, server.name, RPL_INFO, nick, "") @@ -1052,16 +1164,10 @@ func joinHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp keys = strings.Split(msg.Params[1], ",") } - config := server.Config() - oper := client.Oper() for i, name := range channels { if name == "" { continue // #679 } - if config.Channels.MaxChannelsPerClient <= client.NumChannels() && oper == nil { - rb.Add(nil, server.name, ERR_TOOMANYCHANNELS, client.Nick(), name, client.t("You have joined too many channels")) - return false - } var key string if len(keys) > i { key = keys[i] @@ -1075,18 +1181,35 @@ func joinHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp } func sendJoinError(client *Client, name string, rb *ResponseBuffer, err error) { - var errMsg string + var code, errMsg, forbiddingMode string switch err { case errInsufficientPrivs: - errMsg = `Only server operators can create new channels` + code, errMsg = ERR_NOSUCHCHANNEL, `Only server operators can create new channels` case errConfusableIdentifier: - errMsg = `That channel name is too close to the name of another channel` + code, errMsg = ERR_NOSUCHCHANNEL, `That channel name is too close to the name of another channel` case errChannelPurged: - errMsg = err.Error() + 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: - errMsg = `No such channel` + code, errMsg = ERR_NOSUCHCHANNEL, `No such channel` } - rb.Add(nil, client.server.name, ERR_NOSUCHCHANNEL, client.Nick(), utils.SafeErrorParam(name), client.t(errMsg)) + 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} @@ -1180,14 +1303,15 @@ func killHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp 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")) + rb.Add(nil, client.server.name, ERR_NOSUCHNICK, client.Nick(), utils.SafeErrorParam(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 /NS SUSPEND instead"), target.Nick())) } quitMsg := fmt.Sprintf("Killed (%s (%s))", client.nick, comment) 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.SetExitedSnomaskSent() target.Quit(quitMsg, nil) target.destroy(nil) @@ -1319,7 +1443,6 @@ func klineHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Res } for _, mcl := range clientsToKill { - mcl.SetExitedSnomaskSent() mcl.Quit(fmt.Sprintf(mcl.t("You have been banned from this server (%s)"), reason), nil) if mcl == client { killClient = true @@ -1440,6 +1563,13 @@ func listHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp } } + nick := client.Nick() + rplList := func(channel *Channel) { + if members, name, topic := channel.listData(); members != 0 { + rb.Add(nil, client.server.name, RPL_LIST, nick, name, strconv.Itoa(members), topic) + } + } + clientIsOp := client.HasMode(modes.Operator) if len(channels) == 0 { for _, channel := range server.channels.Channels() { @@ -1447,7 +1577,7 @@ func listHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp continue } if matcher.Matches(channel) { - client.RplList(channel, rb) + rplList(channel) } } } else { @@ -1465,7 +1595,7 @@ func listHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp continue } if matcher.Matches(channel) { - client.RplList(channel, rb) + rplList(channel) } } } @@ -1632,11 +1762,7 @@ func monitorRemoveHandler(server *Server, client *Client, msg ircmsg.IrcMessage, targets := strings.Split(msg.Params[1], ",") for _, target := range targets { - cfnick, err := CasefoldName(target) - if err != nil { - continue - } - server.monitorManager.Remove(client, cfnick) + server.monitorManager.Remove(rb.session, target) } return false @@ -1662,12 +1788,7 @@ func monitorAddHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb } // add target - casefoldedTarget, err := CasefoldName(target) - if err != nil { - continue - } - - err = server.monitorManager.Add(client, casefoldedTarget, limits.MonitorEntries) + err := server.monitorManager.Add(rb.session, target, limits.MonitorEntries) if err == errMonitorLimitExceeded { rb.Add(nil, server.name, ERR_MONLISTFULL, client.Nick(), strconv.Itoa(limits.MonitorEntries), strings.Join(targets, ",")) break @@ -1696,14 +1817,14 @@ func monitorAddHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb // MONITOR C func monitorClearHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { - server.monitorManager.RemoveAll(client) + server.monitorManager.RemoveAll(rb.session) return false } // MONITOR L func monitorListHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { nick := client.Nick() - monitorList := server.monitorManager.List(client) + monitorList := server.monitorManager.List(rb.session) var nickList []string for _, cfnick := range monitorList { @@ -1730,7 +1851,7 @@ func monitorStatusHandler(server *Server, client *Client, msg ircmsg.IrcMessage, var online []string var offline []string - monitorList := server.monitorManager.List(client) + monitorList := server.monitorManager.List(rb.session) for _, name := range monitorList { currentNick := server.getCurrentNick(name) @@ -1874,7 +1995,12 @@ func messageHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R return false } - if rb.session.isTor && utils.IsRestrictedCTCPMessage(message) { + 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")) @@ -1935,18 +2061,17 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi service, isService := OragonoServices[lowercaseTarget] _, isZNC := zncHandlers[lowercaseTarget] - if histType == history.Privmsg { + 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) - return } else if isZNC { zncPrivmsgHandler(client, lowercaseTarget, message.Message, rb) - return } - } - - // NOTICE and TAGMSG to services are ignored - if isService || isZNC { return } @@ -1957,10 +2082,20 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi } 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 + } nickMaskString := details.nickMask accountName := details.accountName var deliverySessions []*Session @@ -1994,21 +2129,12 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi } // the originating session may get an echo message: - if rb.session.capabilities.Has(caps.EchoMessage) { - hasTagsCap := rb.session.capabilities.Has(caps.MessageTags) - if histType == history.Tagmsg && hasTagsCap { - rb.AddFromClient(message.Time, message.Msgid, nickMaskString, accountName, tags, command, tnick) - } else { - tagsToSend := tags - if !hasTagsCap { - tagsToSend = nil - } - rb.AddSplitMessageFromClient(nickMaskString, accountName, tagsToSend, command, tnick, message) - } - } - if histType != history.Notice && user.Away() { + rb.addEchoMessage(tags, nickMaskString, accountName, command, tnick, message) + if histType != history.Notice { //TODO(dan): possibly implement cooldown of away notifications to users - rb.Add(nil, server.name, RPL_AWAY, client.Nick(), tnick, user.AwayMessage()) + if away, awayMessage := user.Away(); away { + rb.Add(nil, server.name, RPL_AWAY, client.Nick(), tnick, awayMessage) + } } config := server.Config() @@ -2022,7 +2148,7 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi AccountName: accountName, Tags: tags, } - if !item.IsStorable() || !allowedPlusR { + if !itemIsStorable(&item, config) || !allowedPlusR { return } targetedItem := item @@ -2045,6 +2171,32 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi } } +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.IrcMessage, rb *ResponseBuffer) bool { target := msg.Params[0] @@ -2093,8 +2245,8 @@ func operHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp var checkPassed, checkFailed, passwordFailed bool oper := server.GetOperator(msg.Params[0]) if oper != nil { - if oper.Fingerprint != "" { - if oper.Fingerprint == rb.session.certfp { + if oper.Certfp != "" { + if oper.Certfp == rb.session.certfp { checkPassed = true } else { checkFailed = true @@ -2199,8 +2351,8 @@ func passHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp 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 (all code paths end with this - // variable being set): + // 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 } @@ -2211,18 +2363,17 @@ func passHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp if config.Accounts.LoginViaPassCommand { colonIndex := strings.IndexByte(password, ':') if colonIndex != -1 && client.Account() == "" { - // TODO consolidate all login throttle checks into AccountManager - throttled, _ := client.loginThrottle.Touch() - if !throttled { - account, accountPass := password[:colonIndex], password[colonIndex+1:] - err := server.accounts.AuthenticateByPassphrase(client, account, accountPass) - if err == nil { - sendSuccessfulAccountAuth(client, rb, false, 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 - } + 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(client, rb, false, 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 } } } @@ -2251,7 +2402,7 @@ func passHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp // PING [params...] func pingHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { - rb.Add(nil, server.name, "PONG", msg.Params...) + rb.Add(nil, server.name, "PONG", server.name, msg.Params[0]) return false } @@ -2351,8 +2502,7 @@ func relaymsgHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb * } // RENAME [] -func renameHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) (result bool) { - result = false +func renameHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { oldName, newName := msg.Params[0], msg.Params[1] var reason string if 2 < len(msg.Params) { @@ -2364,6 +2514,8 @@ func renameHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), utils.SafeErrorParam(oldName), client.t("No such channel")) return false } + 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 @@ -2371,14 +2523,14 @@ func renameHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re founder := channel.Founder() if founder != "" && founder != client.Account() { - rb.Add(nil, server.name, ERR_CANNOTRENAME, client.Nick(), oldName, newName, client.t("Only channel founders can change registered channels")) + 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, ERR_CANNOTRENAME, client.Nick(), oldName, newName, client.t("Channels with persistent history cannot be renamed")) + rb.Add(nil, server.name, "FAIL", "RENAME", "CANNOT_RENAME", oldName, utils.SafeErrorParam(newName), client.t("Channels with persistent history cannot be renamed")) return false } @@ -2387,9 +2539,9 @@ func renameHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re if err == errInvalidChannelName { rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), utils.SafeErrorParam(newName), client.t(err.Error())) } else if err == errChannelNameInUse { - rb.Add(nil, server.name, ERR_CHANNAMEINUSE, client.Nick(), utils.SafeErrorParam(newName), client.t(err.Error())) + 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, ERR_CANNOTRENAME, client.Nick(), oldName, utils.SafeErrorParam(newName), client.t("Cannot rename channel")) + rb.Add(nil, server.name, "FAIL", "RENAME", "CANNOT_RENAME", oldName, utils.SafeErrorParam(newName), client.t("Cannot rename channel")) } if err != nil { return false @@ -2485,13 +2637,18 @@ func sceneHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Res // SETNAME func setnameHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { realname := msg.Params[0] + if realname == "" { + rb.Add(nil, server.name, "FAIL", "SETNAME", "INVALID_REALNAME", client.t("Realname is not valid")) + return false + } + client.SetRealname(realname) details := client.Details() // alert friends now := time.Now().UTC() for session := range client.Friends(caps.SetName) { - session.sendFromClientInternal(false, now, "", details.nickMask, details.account, nil, "SETNAME", details.realname) + session.sendFromClientInternal(false, now, "", details.nickMask, details.accountName, nil, "SETNAME", details.realname) } return false @@ -2601,6 +2758,22 @@ func userHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp return false } + // #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(client, rb, false, 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")) + } + } + } + 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. @@ -2641,7 +2814,7 @@ func userhostHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb * if target.HasMode(modes.Operator) { isOper = "*" } - if target.Away() { + if away, _ := target.Away(); away { isAway = "-" } else { isAway = "+" @@ -2712,7 +2885,7 @@ func webircHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re if 0 < len(info.Password) && bcrypt.CompareHashAndPassword(info.Password, givenPassword) != nil { continue } - if info.Fingerprint != "" && info.Fingerprint != rb.session.certfp { + if info.Certfp != "" && info.Certfp != rb.session.certfp { continue } @@ -2730,7 +2903,122 @@ func webircHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re return true } -// WHO [ [o]] +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, 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 client.HasMode(modes.Operator) || client == target { + // you can only see a target's IP if they're you or you're an oper + fIP = target.IPString() + } + 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) { + flags.WriteRune('*') + } + + if channel != nil { + flags.WriteString(channel.ClientPrefixes(target, rb.session.capabilities.Has(caps.MultiPrefix))) + } + + if target.HasMode(modes.Bot) { + flags.WriteRune('B') + } + + 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') { // target's channel power level + //TODO: implement this + params = append(params, "0") + } + 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...) +} + +// WHO [%,] func whoHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { mask := msg.Params[0] var err error @@ -2748,6 +3036,26 @@ func whoHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Respo return false } + 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) + } + //TODO(dan): is this used and would I put this param in the Modern doc? // if not, can we remove it? //var operatorOnly bool @@ -2765,16 +3073,16 @@ func whoHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Respo if !channel.flags.HasMode(modes.Secret) || isJoined || isOper { for _, member := range channel.Members() { if !member.HasMode(modes.Invisible) || isJoined || isOper { - client.rplWhoReply(channel, member, rb) + client.rplWhoReply(channel, member, rb, isWhox, fields, whoType) } } } } } else { // Construct set of channels the client is in. - userChannels := make(map[*Channel]bool) + userChannels := make(ChannelSet) for _, channel := range client.Channels() { - userChannels[channel] = true + userChannels[channel] = empty{} } // Another client is a friend if they share at least one channel, or they are the same client. @@ -2784,7 +3092,7 @@ func whoHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Respo } for _, channel := range otherClient.Channels() { - if userChannels[channel] { + if _, present := userChannels[channel]; present { return true } } @@ -2793,7 +3101,7 @@ func whoHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Respo for mclient := range server.clients.FindAll(mask) { if isOper || !mclient.HasMode(modes.Invisible) || isFriend(mclient) { - client.rplWhoReply(nil, mclient, rb) + client.rplWhoReply(nil, mclient, rb, isWhox, fields, whoType) } } } @@ -2893,7 +3201,12 @@ func whowasHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re // ZNC [params] func zncHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { - zncModuleHandler(client, msg.Params[0], msg.Params[1:], rb) + 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 } @@ -2902,3 +3215,9 @@ func unknownCommandHandler(server *Server, client *Client, msg ircmsg.IrcMessage rb.Add(nil, server.name, ERR_UNKNOWNCOMMAND, client.Nick(), utils.SafeErrorParam(msg.Command), client.t("Unknown command")) return false } + +// fake handler for invalid utf8 +func invalidUtf8Handler(server *Server, client *Client, msg ircmsg.IrcMessage, 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 5c421096..3105dbd9 100644 --- a/irc/help.go +++ b/irc/help.go @@ -51,6 +51,8 @@ Oragono supports the following channel modes: +R | Only registered users can join 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. = Prefixes = @@ -66,9 +68,12 @@ 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.` + +Z | User is connected via TLS. + +B | User is a bot. + +E | User can receive roleplaying commands. + +T | User is blocked from sending CTCP messages.` snomaskHelpText = `== Server Notice Masks == Oragono supports the following server notice masks for operators: @@ -162,6 +167,20 @@ Provides various debugging commands for the IRCd.