Compare commits

...

192 Commits

Author SHA1 Message Date
Shivaram Lingamneni 60f7d1122d
Merge pull request #2161 from slingamn/logerror
fix #2141
2024-05-29 08:25:42 +02:00
Shivaram Lingamneni 289b78d2fd fix #2141
Log errors from attempting to delete a unix domain socket path
2024-05-29 02:24:08 -04:00
Shivaram Lingamneni ad0149be5e
Merge pull request #2160 from slingamn/embed
fix #2157
2024-05-29 08:04:25 +02:00
Shivaram Lingamneni d81494ac09
Merge pull request #2159 from ergochat/casefolding.2
fix #2099
2024-05-29 08:04:14 +02:00
Shivaram Lingamneni 54ca659e57
Merge pull request #2158 from slingamn/ircv3bearer.2
remove draft/bearer in favor of IRCV3BEARER
2024-05-29 08:00:07 +02:00
Shivaram Lingamneni 794b4a2483 allow null bytes in bearer tokens
(Haven't decided what to do at the spec level yet)
2024-05-29 01:54:12 -04:00
Shivaram Lingamneni af521c844f fix #2157
Embed a copy of the ergo default config in the binary;
add `ergo defaultconfig` to print it to stdout
2024-05-27 23:08:11 -04:00
Shivaram Lingamneni 7772b55cab fix #2099
Add optional support for rfc1459 and rfc1459-strict casemappings
2024-05-27 22:16:20 -04:00
Shivaram Lingamneni ed683bff79 remove draft/bearer in favor of IRCV3BEARER 2024-05-27 20:40:04 -04:00
Shivaram Lingamneni 5ee32cda1c
Merge pull request #2156 from slingamn/throttle.1
fix login throttle handling
2024-05-28 02:25:11 +02:00
Shivaram Lingamneni 218f6f2454 fix login throttle handling
We were checking the login throttle at the beginning of every SASL
conversation. This had several problems:

1. Pidgin (on Windows?) tries every mechanism in order, regardless of
the CAP advertisement. It would use up the default throttle allowance
trying unsupported mechanisms like CRAM-MD5.
2. The throttle was actually checked twice for AUTHENTICATE PLAIN
(once at the start of the conversation and once in AuthenticateByPassphrase).

The general pattern here is that we should check the throttle every time we
do something "expensive" (bcrypt verification, send a reset email) or
"dangerous" (anything that could lead to a bruteforce attack on passwords).
Therefore, delete the check from the AUTHENTICATE handler, and add one at
the beginning of the SCRAM conversation to replace it.
2024-05-26 05:19:41 -04:00
Shivaram Lingamneni ca4b9c15c5
Merge pull request #2151 from slingamn/modes_forwardport
fix deadlock on channel state mutex
2024-05-06 08:47:23 +02:00
Shivaram Lingamneni 6abb291290 fix deadlock on channel state mutex 2024-05-06 02:32:40 -04:00
Shivaram Lingamneni ccc362be84
Merge pull request #2148 from slingamn/i2p
add i2pd b32 address directions
2024-05-06 07:00:19 +02:00
Shivaram Lingamneni 19b9867409 add i2pd b32 address directions
Fixes #1686
2024-05-05 21:18:29 -04:00
Shivaram Lingamneni f6626ddb6e bump irctest 2024-05-01 11:40:19 -04:00
Shivaram Lingamneni 40ceb4956c
Merge pull request #2145 from slingamn/issue2144
fix #2144
2024-04-15 03:22:19 +02:00
Shivaram Lingamneni 74fa04c5ea
Merge pull request #2143 from slingamn/emailsending.1
fix #2142
2024-04-15 03:22:06 +02:00
Shivaram Lingamneni 15d686c593
Merge pull request #2146 from slingamn/webirc.1
add a config switch to accept hostnames from WEBIRC
2024-04-14 20:46:01 +02:00
Shivaram Lingamneni f96f918ff1 fix #2144
RPL_NAMREPLY should send = for normal channels and @ for secret channels,
as per Modern docs.
2024-04-13 21:51:59 -04:00
Shivaram Lingamneni 7726160ec7 add a config switch to accept hostnames from WEBIRC
See #1686; this allows i2pd to pass the i2p address to Ergo, which may be
useful for moderation under some circumstances.
2024-04-13 21:43:41 -04:00
Shivaram Lingamneni b426dd8f93 fix #2142
Allow specifying TCP4 or TCP6 for outgoing email sending, or choosing a
specific local address to send from.
2024-04-07 15:47:01 -04:00
Shivaram Lingamneni 1f4b5248a0
Merge pull request #2140 from slingamn/issue2139
fix #2139
2024-03-29 22:50:22 +01:00
Shivaram Lingamneni 0c804f8ea3 bump irctest 2024-03-29 13:34:52 -04:00
Shivaram Lingamneni 3d2f014d4c fix #2139
Database backup filenames contained a colon character, which is disallowed
on Windows; use period instead
2024-03-29 12:32:42 -04:00
Shivaram Lingamneni d56e4ea301
Merge pull request #2136 from slingamn/issue2135_nicknameinuse
fix #2135
2024-03-20 10:48:27 -04:00
Shivaram Lingamneni 8d082865da
fix #2133 (#2137)
* fix #2133

Don't record NICK and QUIT in history for invisible auditorium members
2024-03-17 11:42:39 -04:00
Shivaram Lingamneni 837f6ac1a2 fix #2135
Handling of reserved nicknames is special-cased due to #1594, but we want to send
ERR_NICKNAMEINUSE if the nickname is actually in use, since that doesn't pose any
client compatibility problems.
2024-03-11 01:32:39 -04:00
Shivaram Lingamneni 681e8b1292
fix #2129 (#2132)
* fix #2129

Don't print the values of environment variable overrides, just the keys

* fix unit tests
2024-02-25 10:05:36 -05:00
Shivaram Lingamneni 432d4ea860
Merge pull request #2131 from slingamn/issue2130
fix #2130
2024-02-25 03:55:54 -05:00
Shivaram Lingamneni 78f342655d clean up dead code 2024-02-25 03:52:52 -05:00
Shivaram Lingamneni cab192e2af fix #2130
We load registered channels unconditionally; reloading them again on rehash
is incorrect. This caused buggy behavior when channel registration was
disabled in the config, but some registered channels were already loaded.
2024-02-25 03:34:21 -05:00
Matt Hamilton c67835ce5c
Gracefully handle NS cert add myself <fp> (#2128)
* Gracefully handle NS cert add myself <fp>

A non-operator with the nick "mynick" attempts to register
a fingerprint to their authenticated account.

They /msg NickServ cert add mynick <fingerprint>

NickServ responds with "Insufficient privileges" because
they've accidentally invoked the operator syntax (to action
other accounts).

This patch allows the user to add the fingerprint if the client's
account is identical to the target account.

Signed-off-by: Matt Hamilton <m@tthamilton.com>

* Update nickserv.go

Compare the case-normalized target to Account()

---------

Signed-off-by: Matt Hamilton <m@tthamilton.com>
Co-authored-by: Shivaram Lingamneni <slingamn@cs.stanford.edu>
2024-02-14 09:56:37 -05:00
Shivaram Lingamneni 7afd6dbc74 bearer: close open jwt key files 2024-02-13 21:32:37 -05:00
Shivaram Lingamneni ee7f818674
implement SASL OAUTHBEARER and draft/bearer (#2122)
* implement SASL OAUTHBEARER and draft/bearer
* Upgrade JWT lib
* Fix an edge case in SASL EXTERNAL
* Accept longer SASL responses
* review fix: allow multiple token definitions
* enhance tests
* use SASL utilities from irc-go
* test expired tokens
2024-02-13 18:58:32 -05:00
Shivaram Lingamneni 8475b62da4 bump irctest 2024-02-12 23:43:28 -05:00
Shivaram Lingamneni 52d15a483c
Merge pull request #2127 from slingamn/isupport_thirteen
pull out max parameters constant in isupport impl
2024-02-12 23:40:54 -05:00
Shivaram Lingamneni f691b8c058 pull out max parameters constant in isupport impl 2024-02-11 12:38:49 -05:00
Shivaram Lingamneni 6b7bfe0c09 set up new development version 2024-02-11 00:12:22 -05:00
Shivaram Lingamneni 2098cc9f2b
Merge pull request #2126 from slingamn/go122
upgrade to go 1.22
2024-02-11 00:02:28 -05:00
Shivaram Lingamneni 4b9aa725cb upgrade to go 1.22 2024-02-10 23:46:34 -05:00
Shivaram Lingamneni 24ac3b68b4
Merge pull request #2124 from slingamn/realnamelimit
fix #2123
2024-02-10 23:25:31 -05:00
Shivaram Lingamneni 0918564edc bump irctest 2024-02-08 00:46:26 -05:00
Shivaram Lingamneni 921651f664 fix #2123
Add a configurable limit on realname length
2024-02-08 00:03:12 -05:00
Shivaram Lingamneni d97e964b35 v2.13.0: fix go release version in changelog 2024-01-14 17:42:34 -05:00
Shivaram Lingamneni 010875ec9a bump version and changelog for v2.13.0 2024-01-14 17:40:50 -05:00
Neale Pickett 7b525f8899
Add caddy reverse proxy websocket example (#2119)
* Add caddy reverse proxy websocket example

* Use consistent hostname for caddy reverse proxy
2024-01-12 13:30:53 -05:00
Neale Pickett 3839f8ae60
Explain reverse proxy setup for websockets (#2121)
* Explain reverse proxy setup for websockets

* Update MANUAL.md

Clarify that we only support `X-Forwarded-For`

---------

Co-authored-by: Shivaram Lingamneni <slingamn@cs.stanford.edu>
2024-01-11 23:20:26 -05:00
Shivaram Lingamneni 4e574b99f3 fix changelog typo 2024-01-07 01:07:36 -05:00
Shivaram Lingamneni 9d388d8cdb
Merge pull request #2118 from slingamn/changelog
bump version and changelog for 2.13.0-rc1
2024-01-07 00:42:46 -05:00
Shivaram Lingamneni 24cf5fac45 fix #2101 2024-01-07 00:38:10 -05:00
Shivaram Lingamneni d238eaac67 bump version and changelog for 2.13.0-rc1 2024-01-07 00:30:39 -05:00
Shivaram Lingamneni 0f059ea2cc
Merge pull request #2117 from slingamn/handlepanic
add panic handler to async client/channel writes
2024-01-05 00:21:23 -05:00
Shivaram Lingamneni dfe2a21b17 add panic handler to async client/channel writes
See #2113 for motivation
2024-01-05 00:18:46 -05:00
Shivaram Lingamneni 1d8bbde95c
Merge pull request #2115 from slingamn/issue2114_relaymsg
fix #2114
2024-01-04 01:03:57 -05:00
Shivaram Lingamneni 580fc7096d fix #2114
Channels with slashes (or other relaymsg separators) in their names
were being falsely detected as relaymsg identifiers.
2024-01-04 01:02:10 -05:00
Shivaram Lingamneni 15c074078a
Merge pull request #2116 from slingamn/issue2113_panic
fix #2113
2024-01-04 01:01:43 -05:00
Shivaram Lingamneni 4aa1aa371d fix #2113
Persisting always-on clients was panicking if client X believed it was
a member of channel Y, but channel Y didn't have a record of client X.
2024-01-03 10:52:34 -05:00
Shivaram Lingamneni a4d160b76d bump irctest 2023-12-24 05:14:00 -05:00
Shivaram Lingamneni 430387dec6 bump irctest 2023-12-21 12:33:54 -05:00
Shivaram Lingamneni ce162e9279
fix #2109 (#2111)
Remove numerics associated with the retired ACC spec
2023-12-21 01:10:50 -05:00
Shivaram Lingamneni 97d6f9eddb
Merge pull request #2110 from slingamn/msgid
fix #2108
2023-12-21 01:10:24 -05:00
Shivaram Lingamneni 6be1ec3ad6
Merge pull request #2107 from ergochat/dependabot/go_modules/golang.org/x/crypto-0.17.0
Bump golang.org/x/crypto from 0.5.0 to 0.17.0
2023-12-21 01:09:32 -05:00
dependabot[bot] 16ab0a67b5
Bump golang.org/x/crypto from 0.5.0 to 0.17.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.5.0 to 0.17.0.
- [Commits](https://github.com/golang/crypto/compare/v0.5.0...v0.17.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-21 04:32:32 +00:00
Shivaram Lingamneni cc1c491afe
Merge pull request #2112 from slingamn/testvector
include a fixed test vector in password tests
2023-12-20 23:30:40 -05:00
Shivaram Lingamneni 8d80cb52e6 include a fixed test vector in password tests 2023-12-20 23:28:55 -05:00
Shivaram Lingamneni e11bda643e fix #2108
Send Message-ID even if DKIM is not enabled, for compatibility with Gmail:

* A workaround for Ergo 2.12.0 is to enable DKIM
* You need to enable either DKIM or SPF (preferably both) to send to Gmail anyway
* You also need forward-confirmed reverse DNS, which can be tricky for IPv6...
2023-12-20 22:18:48 -05:00
Shivaram Lingamneni b1a0e7cc5c
bump docker base image to alpine 3.19 (#2104)
* bump docker base image to alpine 3.19

Fixes #2103
2023-12-17 23:20:55 -08:00
Shivaram Lingamneni 2d44ab1cbf
Merge pull request #2100 from slingamn/dockercomposeinit
add `init: true` to docker-compose.yml
2023-11-15 07:45:39 -08:00
Shivaram Lingamneni 3102babec8 add `init: true` to docker-compose.yml
Follows up from #2096, #2097
2023-11-15 10:32:56 -05:00
Shivaram Lingamneni a5af245102
add --init to suggested docker run invocations (#2097)
* add --init to suggested docker run invocations

See #2096; this should fix unreaped zombies when using an auth-script or
ip-check-script that spawns its own subprocesses, then exits before reaping
them.

* add a note on why --init
2023-11-15 00:19:32 -05:00
Shivaram Lingamneni 4fabeed895 bump irctest 2023-11-11 18:37:04 -05:00
Shivaram Lingamneni 5671ee2a36 set up new development version 2023-10-11 11:20:45 -04:00
Shivaram Lingamneni 4d9e80fe5b bump version and changelog for v2.12.0 2023-10-10 22:11:15 -04:00
Shivaram Lingamneni 7b3778989e fix ergo invocation in readme 2023-09-28 14:03:12 -04:00
Shivaram Lingamneni e3bcb9b8a0 fix ergo invocation in readme 2023-09-28 13:56:47 -04:00
Shivaram Lingamneni 70dfe9f594
Merge pull request #2094 from slingamn/bump_irctest
bump irctest
2023-09-24 12:05:38 -07:00
Shivaram Lingamneni 70a98ac2f1 upgrade CI image to jammy 2023-09-24 13:22:46 -04:00
Shivaram Lingamneni 046ef8ce94 bump irctest 2023-09-24 13:19:59 -04:00
Shivaram Lingamneni baf5a8465d changelog entry for #2092 2023-09-24 10:48:27 -04:00
Shivaram Lingamneni b33e1051f7
Merge pull request #2092 from progval/patch-5
Fix typo in ACCOUNT_NAME_MUST_BE_NICK code
2023-09-24 07:34:28 -07:00
Val Lorentz ddb804b622
Fix typo in ACCOUNT_NAME_MUST_BE_NICK code 2023-09-24 14:16:49 +02:00
Shivaram Lingamneni 3ec7f0e5cc clarify address-blacklist syntax 2023-09-18 19:46:39 -04:00
Shivaram Lingamneni 48d139a532 bump irctest 2023-09-18 19:46:38 -04:00
Shivaram Lingamneni 556bcba465 bump irctest 2023-09-17 23:46:15 -04:00
Shivaram Lingamneni 20bfb285f0 changelog tweaks 2023-09-17 23:40:52 -04:00
Shivaram Lingamneni 29b4be83bc bump version for v2.12.0-rc1 2023-09-17 23:07:54 -04:00
Shivaram Lingamneni 399b0b3f39
changelog for v2.12.0-rc1 (#2090)
* changelog for v2.12.0-rc1

* bump date
2023-09-17 23:04:34 -04:00
Shivaram Lingamneni e7597876d9
Merge pull request #2089 from slingamn/ident
upgrade go-ident
2023-09-11 22:44:31 -07:00
Shivaram Lingamneni 3bd3c6a88a upgrade go-ident
Fixes a socket leak (that doesn't seem to be affecting tilde.town?)
2023-09-12 01:39:49 -04:00
Shivaram Lingamneni 2013beb7c8
fix #1997 (#2088)
* Fix #1997 (allow the use of an external file for the email blacklist)
* Change config key names for blacklist (compatibility break)
* Accept globs rather than regexes for blacklist by default
* Blacklist comparison is now case-insensitive
2023-09-12 01:06:55 -04:00
Simon 6b386ce2ac Update MANUAL.md for Debian 12 syntax. 2023-09-10 01:52:38 -04:00
Shivaram Lingamneni ee22bda09c
Merge pull request #2086 from slingamn/dockerio
explicit docker.io in Dockerfile
2023-09-09 21:35:58 -07:00
Shivaram Lingamneni 202de687df explicit docker.io in Dockerfile
See #2082
2023-09-10 00:13:48 -04:00
Shivaram Lingamneni 4b00c6c48e bump irctest 2023-09-05 03:07:35 -04:00
Shivaram Lingamneni 8ac488a1ff bump irctest 2023-08-28 13:17:42 -04:00
Shivaram Lingamneni f07707dfbc
Merge pull request #2083 from slingamn/nonames.2
implement draft/no-implicit-names
2023-08-16 08:47:05 -07:00
Shivaram Lingamneni 3b3e8c0004
Merge pull request #2084 from slingamn/go_upgrade_121
bump go to v1.21
2023-08-16 08:46:34 -07:00
Shivaram Lingamneni f77d430d25 use maps.Clone from go1.21 2023-08-15 20:57:52 -04:00
Shivaram Lingamneni 28d9a7ff63 use slices.Contains from go1.21 2023-08-15 20:55:09 -04:00
Shivaram Lingamneni b3abd0bf1d use slices.Reverse from go1.21 2023-08-15 20:45:00 -04:00
Shivaram Lingamneni cc873efd0f bump go to v1.21 2023-08-15 20:37:58 -04:00
Shivaram Lingamneni 3f74612e2b implement draft/no-implicit-names 2023-08-15 20:29:57 -04:00
Shivaram Lingamneni 24ba72cfd6 bump irctest 2023-08-11 17:18:57 -04:00
Shivaram Lingamneni 17b21c8521
Merge pull request #2079 from slingamn/autojoin.1
add channel autojoin feature
2023-07-16 10:12:19 -07:00
Shivaram Lingamneni 75bd63d0bc add channel autojoin feature
See discussion on #2077
2023-07-04 21:44:18 -04:00
Shivaram Lingamneni 3c4f83cf6e
Merge pull request #2078 from tacerus/apparmor
Import AppArmor profile
2023-07-02 08:16:08 -07:00
Georg Pfuetzenreuter 67d10bc63b
Import AppArmor profile
Signed-off-by: Georg Pfuetzenreuter <mail@georg-pfuetzenreuter.net>
2023-07-02 00:07:59 +02:00
Shivaram Lingamneni 6d642bfe93
Merge pull request #2074 from slingamn/ircgo_upgrade
upgrade to irc-go v0.4.0
2023-06-13 23:53:52 -07:00
Shivaram Lingamneni ad3ad97047 upgrade to irc-go v0.4.0 2023-06-14 02:46:14 -04:00
Shivaram Lingamneni d14ff9b3d5
Merge pull request #2073 from slingamn/issue2013.1
fix #2013
2023-06-08 07:06:44 -07:00
Shivaram Lingamneni dfe84bc1c2 bump irctest 2023-06-05 04:22:40 -04:00
Shivaram Lingamneni 0f39fde647 remove insecure reattach check
See #2013; given that plaintext is deprecated now, it seems like there is no
added value from continuing to police this.
2023-06-05 04:22:40 -04:00
Shivaram Lingamneni 7d6e48ed2a bump irctest 2023-06-04 03:23:11 -04:00
Shivaram Lingamneni e4c8f041f2
Merge pull request #2072 from csmith/misc/docker-alpine-upgrade
Dockerfile: `apk upgrade` before `add`
2023-06-02 16:38:51 -07:00
Chris Smith 783b579003 Dockerfile: `apk upgrade` before `add`
The base golang image ships with some packages pre-installed,
but they're not necessarily the latest. If we try to add a
package that (transitively) depends on one of the existing ones,
it'll fail if it's expecting a newer version.

To address this, simply `apk upgrade` before trying to `apk add`.

Closes #2071
2023-06-03 00:15:58 +01:00
Shivaram Lingamneni 07cc4f8354
Merge pull request #2070 from slingamn/batchfix
fix incorrect chathistory batch types
2023-06-02 04:00:34 -07:00
Shivaram Lingamneni f100c1d0fa fix incorrect chathistory batch types
This was introduced in 38a6d17ee5
2023-06-02 06:56:45 -04:00
Shivaram Lingamneni 2aded271c5
Merge pull request #2069 from slingamn/nestedbatch.1
some cleanups
2023-06-02 00:53:35 -07:00
Shivaram Lingamneni 3d4d8228aa bump irctest 2023-06-02 02:58:32 -04:00
Shivaram Lingamneni 60af8ee491 clean up force-trailing logic 2023-06-02 02:58:09 -04:00
Shivaram Lingamneni 38a6d17ee5 clean up nested batch logic 2023-06-01 06:29:22 -04:00
Shivaram Lingamneni d082ec7ab9
don't send multiline responses to CAP LS 301 (#2068)
* don't send multiline responses to CAP LS 301

This is more or less explicitly prohibited by the spec:

https://ircv3.net/specs/extensions/capability-negotiation.html#multiline-replies-to-cap-ls-and-cap-list

* switch to whitelist model to be future-proof

* bump irctest to include test

* add a unit test
2023-05-31 23:22:16 -04:00
Shivaram Lingamneni 3e68694760
Merge pull request #2067 from slingamn/issue2066
fix #2066
2023-05-30 23:12:19 -07:00
Val Lorentz 48f8c341d7
Implement draft/message-redaction (#2065)
* Makefile: Add dependencies between targets

* Implement draft/message-redaction for channels

Permission to use REDACT mirrors permission for 'HistServ DELETE'

* Error when the given targetmsg does not exist

* gofmt

* Add CanDelete enum type

* gofmt

* Add support for PMs

* Fix documentation of allow-individual-delete.

* Remove 'TODO: add configurable fallback'

slingamn says it's probably not desirable, and I'm on the fence.
Out of laziness, let's omit it for now, as it's not a regression
compared to '/msg HistServ DELETE'.

* Revert "Makefile: Add dependencies between targets"

This reverts commit 2182b1da69.

---------

Co-authored-by: Val Lorentz <progval+git+ergo@progval.net>
2023-05-31 01:16:14 -04:00
Shivaram Lingamneni 00cfe98461 fix #2066
CHATHISTORY TARGETS response should not be in a batch unless the client has
explicitly requested the batch cap.
2023-05-29 22:22:01 -04:00
Shivaram Lingamneni bf33fba33a
Merge pull request #2064 from slingamn/issue2063
fix #2063
2023-05-22 22:27:33 -07:00
Shivaram Lingamneni 0710c7e12a bump irctest to include regression test for #2063 2023-05-23 01:19:36 -04:00
Shivaram Lingamneni e84793d7ee fix #2063
In #2058 we introduced two bugs:

* A nil dereference when an outside user attempts to speak
* Ordinary copy of a modes.ModeSet (which should only be accessed via atomics)

This fixes both issues.
2023-05-22 12:29:55 -04:00
Shivaram Lingamneni 2c0928f94d
Merge pull request #2061 from slingamn/xterm.1
upgrade to x/term instead of crypto/ssh/terminal
2023-04-19 01:26:05 -07:00
Shivaram Lingamneni 0d8dcbecf6 upgrade to x/term instead of crypto/ssh/terminal
Simplify some of the password hashing logic. This requires a bump of irctest.
2023-04-19 02:58:50 -04:00
Shivaram Lingamneni eeec481b8d
tweaks to NAMES implementation (#2058)
* tweaks to NAMES implementation

* tweak member caching

* add a benchmark for NAMES
2023-04-14 02:15:56 -04:00
Shivaram Lingamneni 378d88fee2
Merge pull request #2055 from slingamn/doc_update
add apache websocket example
2023-03-09 17:46:03 -08:00
Shivaram Lingamneni c4db4984a6
Merge pull request #2056 from avollmerhaus/master
Add bsd-rc init script to distrib
2023-03-09 16:11:09 -08:00
Aljoscha Vollmerhaus 04f8791dd6
add sections + usage information to bsd-rc README 2023-03-09 11:14:29 +01:00
Aljoscha Vollmerhaus 37eb5f5804
Add bsd-rc init script 2023-03-09 11:09:03 +01:00
Shivaram Lingamneni 6e011cd536 add apache websocket example
Fixes #2050
2023-03-09 01:05:34 -05:00
Shivaram Lingamneni 295a567eda
Merge pull request #2041 from mogad0n/killresponseupdate
Update response string when killing always on clients
2023-03-04 23:31:11 -08:00
Shivaram Lingamneni db0910d82d fix linter error
See #2052
2023-03-04 23:29:16 -08:00
Shivaram Lingamneni 374cf8ef97
Merge pull request #2053 from slingamn/killmsg
tweak KILL message
2023-02-28 19:08:46 -08:00
Shivaram Lingamneni eb83df420b tweak KILL message
Remove `<no reason supplied>`, make default KILL anonymous
2023-02-27 03:34:38 -05:00
Shivaram Lingamneni 3fca52ba38
Merge pull request #2049 from slingamn/implicittls.1
support implicit TLS for mail submission agents
2023-02-18 19:12:27 -08:00
Shivaram Lingamneni 3d1412a898
Merge pull request #2051 from slingamn/tidy
go mod tidy
2023-02-18 16:34:37 -08:00
Shivaram Lingamneni b155e5315b go mod tidy 2023-02-18 19:32:19 -05:00
Shivaram Lingamneni 7c53b9430a support implicit TLS for mail submission agents
Fixes #2048
2023-02-17 00:07:21 -05:00
Shivaram Lingamneni 3c59ce964d fix Dockerfile
This broke in #2047
2023-02-11 21:55:57 -05:00
Shivaram Lingamneni ae04fb3d0a
Merge pull request #2047 from slingamn/make
change default make target to `build`
2023-02-11 18:49:59 -08:00
Shivaram Lingamneni ba40d57afd bump irctest 2023-02-11 21:36:21 -05:00
Shivaram Lingamneni 697f34995b change default make target to `build`
Fixes #2046
2023-02-11 21:35:03 -05:00
Shivaram Lingamneni 19dbf3a531
Merge pull request #2045 from slingamn/go_upgrade
upgrade to go 1.20
2023-02-05 21:41:35 -08:00
Shivaram Lingamneni 8b6b2cabc3 upgrade to go 1.20 2023-02-06 00:37:51 -05:00
Shivaram Lingamneni 1da11ae8ae
implement draft/pre-away (#2044)
* implement draft/pre-away
* clean up some subtleties in auto-away aggregation.
* consistently apply auto-away only to always-on
* `AWAY *` should not produce user-visible changes wherever possible
2023-02-05 00:50:14 -05:00
Shivaram Lingamneni 12f7796933
Merge pull request #2042 from slingamn/msgreftypes
publish MSGREFTYPES 005 token
2023-02-04 21:49:27 -08:00
Shivaram Lingamneni fc89d72045 publish MSGREFTYPES 005 token
https://github.com/ircv3/ircv3-specifications/pull/510
2023-02-02 14:28:37 -05:00
Pratyush Desai 0653f90b4f update response when killing alwayson targets 2023-01-31 13:27:02 +05:30
Shivaram Lingamneni abb38ce8a1 bump irctest 2023-01-25 21:52:56 -05:00
Shivaram Lingamneni 5ecf19d01e
Merge pull request #2038 from slingamn/ws_optimization.1
tweaks to websocket handling
2023-01-23 04:25:14 -08:00
Shivaram Lingamneni abc71684f3 always validate UTF8 from websockets 2023-01-22 14:45:16 -05:00
Shivaram Lingamneni 9439e9b9e1 allow resizing the ws read buffer 2023-01-21 19:10:25 -05:00
Shivaram Lingamneni 5eaf7b37e5 reduce websocket read allocations
See #2037
2023-01-21 19:10:17 -05:00
Shivaram Lingamneni 4317016a09
Merge pull request #2028 from slingamn/channels_taketwo.1
refactor of channel persistence to use UUIDs
2023-01-15 08:01:37 -08:00
Shivaram Lingamneni 7193fa3a3c
Merge pull request #2036 from slingamn/docker
bump docker actions
2023-01-15 06:02:21 -08:00
Shivaram Lingamneni cd36604efe bump docker actions
Should fix https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/ ?
2023-01-15 08:59:53 -05:00
Shivaram Lingamneni 8199edee6c
Merge pull request #2035 from slingamn/actions
bump github actions
2023-01-15 05:54:20 -08:00
Shivaram Lingamneni 81832a26bc bump github actions
https://github.blog/changelog/2022-09-22-github-actions-all-actions-will-begin-running-on-node16-instead-of-node12/
2023-01-15 08:46:18 -05:00
Shivaram Lingamneni 8690a7648b
Merge pull request #2034 from slingamn/deps
dependency upgrades for v2.12 release cycle
2023-01-15 05:44:03 -08:00
Shivaram Lingamneni 7e6c658cad bump irc-go 2023-01-15 08:31:00 -05:00
Shivaram Lingamneni eb84103865 upgrade other x dependencies 2023-01-15 08:30:18 -05:00
Shivaram Lingamneni 7a82554f9d upgrade mysql 2023-01-15 08:28:13 -05:00
Shivaram Lingamneni 05e5fe3444 upgrade x/text 2023-01-15 08:27:18 -05:00
Shivaram Lingamneni 3f5de80afd upgrade buntdb and dependencies 2023-01-15 08:26:32 -05:00
Shivaram Lingamneni b2087977d0
Merge pull request #2032 from slingamn/scram.1
recommended default: advertise SCRAM
2023-01-15 04:16:28 -08:00
Shivaram Lingamneni 177133a96f
Merge pull request #2033 from slingamn/rehash
fix #2031
2023-01-15 04:13:53 -08:00
Shivaram Lingamneni b16350e559
Merge pull request #2030 from slingamn/roundtime
round wait times to the nearest millisecond
2023-01-15 04:13:43 -08:00
Shivaram Lingamneni 16e214e4fb fix #2031
Sanitize the in-band error message from REHASH
2023-01-12 06:58:18 -05:00
Shivaram Lingamneni 46d32520c7 recommended default: advertise SCRAM
Fixes #1782
2023-01-11 09:21:47 -05:00
Shivaram Lingamneni f72a6fa011 round wait times to the nearest millisecond 2023-01-08 06:36:04 -05:00
Shivaram Lingamneni 1e6dee15b2
Merge pull request #2029 from slingamn/listenconfig.2
make ReloadableListener lock-free
2023-01-06 01:40:18 -08:00
Shivaram Lingamneni 3ceff6a8b1 make ReloadableListener lock-free
Also stop attaching the *tls.Config to the wrapped connection,
since this forces it to be retained beyond its natural lifetime.
2023-01-05 20:18:14 -05:00
Shivaram Lingamneni 7ce0636276 refactor of channel persistence to use UUIDs 2023-01-04 05:06:21 -05:00
Shivaram Lingamneni bceae9b739 add standard-replies capability 2023-01-01 07:08:44 -08:00
Shivaram Lingamneni 30fbfe4cc0 disable cgo for goreleaser 2023-01-01 07:08:10 -08:00
Shivaram Lingamneni 2a828bb783 clarify the meaning of the password section 2022-12-30 07:20:46 -08:00
Shivaram Lingamneni 4b3a6cb611
Merge pull request #2025 from slingamn/cgo
Fix #2023 (disable dynamic linking by default)
2022-12-25 23:41:55 -08:00
Shivaram Lingamneni f00fd452be Fix #2023
Disable dynamic linking by default.
2022-12-26 02:27:28 -05:00
Shivaram Lingamneni f6f7315458 bump version for new development cycle 2022-12-25 03:17:21 -05:00
Shivaram Lingamneni 1e1acdae21
Merge pull request #2022 from slingamn/changelog
final updates for v2.11.0-rc1
2022-12-24 23:22:46 -08:00
Shivaram Lingamneni df8eef5b0a bump version for stable release 2022-12-25 02:09:51 -05:00
Shivaram Lingamneni 23ba58b327 final changelog for v2.11.0 2022-12-25 02:09:33 -05:00
Shivaram Lingamneni bf4f3008d4
Merge pull request #2021 from FiskFan1999/ns
Fix SAREGISTER short help in SAVERIFY command
2022-12-24 23:02:50 -08:00
William Rehwinkel 63c08ce537
Fix SAREGISTER short help in SAVERIFY command 2022-12-24 13:13:38 -05:00
Shivaram Lingamneni f7ab0fb59e tweak changelog 2022-12-19 03:55:15 -05:00
632 changed files with 77706 additions and 22464 deletions

View File

@ -12,14 +12,14 @@ on:
jobs:
build:
runs-on: "ubuntu-20.04"
runs-on: "ubuntu-22.04"
steps:
- name: "checkout repository"
uses: "actions/checkout@v2"
uses: "actions/checkout@v3"
- name: "setup go"
uses: "actions/setup-go@v2"
uses: "actions/setup-go@v3"
with:
go-version: "1.19"
go-version: "1.22"
- name: "install python3-pytest"
run: "sudo apt install -y python3-pytest"
- name: "make install"

View File

@ -18,10 +18,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Git repository
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Authenticate to container registry
uses: docker/login-action@v1
uses: docker/login-action@v2
if: github.event_name != 'pull_request'
with:
registry: ${{ env.REGISTRY }}
@ -30,16 +30,16 @@ jobs:
- name: Extract metadata
id: meta
uses: docker/metadata-action@v3
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Setup Docker buildx driver
id: buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v2
- name: Build and publish image
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
context: .
push: ${{ github.event_name != 'pull_request' }}

View File

@ -3,6 +3,8 @@
project_name: ergo
builds:
- main: ergo.go
env:
- CGO_ENABLED=0
binary: ergo
goos:
- linux

View File

@ -1,13 +1,92 @@
# Changelog
All notable changes to Ergo will be documented in this file.
## [2.11.0-rc1] - 2022-12-18
## [2.13.0] - 2024-01-14
We're pleased to be publishing the release candidate for 2.11.0 (the official release should follow in a week or so). This is another bugfix release aimed at improving client compatibility and keeping up with the IRCv3 specification process.
We're pleased to be publishing v2.13.0, a new stable release. This is a bugfix release that fixes some issues, including a crash.
This release includes no changes to the config file format or database format.
Many thanks to [@dallemon](https://github.com/dallemon), [@jwheare](https://github.com/jwheare), [@Mikaela](https://github.com/Mikaela), [@nealey](https://github.com/nealey), and [@Sheikah45](https://github.com/Sheikah45) for contributing patches, reporting issues, and helping test.
### Fixed
* Fixed a (hopefully rare) crash when persisting always-on client statuses (#2113, #2117, thanks [@Sheikah45](https://github.com/Sheikah45)!)
* Fixed not being able to message channels with `/` (or the configured `RELAYMSG` separator) in their names (#2114, thanks [@Mikaela](https://github.com/Mikaela)!)
* Verification emails now always include a `Message-ID` header, improving compatibility with Gmail (#2108, #2110)
* Improved human-readable description of `REDACT_FORBIDDEN` (#2101, thanks [@jwheare](https://github.com/jwheare)!)
### Removed
* Removed numerics associated with the retired ACC spec (#2109, #2111, thanks [@jwheare](https://github.com/jwheare)!)
### Internal
* Upgraded the Docker base image from Alpine 3.13 to 3.19. The resulting images are incompatible with Docker 19.x and lower (all currently non-EOL Docker versions should be supported). (#2103)
* Official release builds use Go 1.21.6
## [2.12.0] - 2023-10-10
We're pleased to be publishing v2.12.0, a new stable release. This is another bugfix release aimed at improving client compatibility and keeping up with the IRCv3 specification process.
This release includes changes to the config file format, one of which is a compatibility break: if you were using `accounts.email-verification.blacklist-regexes`, you can restore the previous functionality by renaming `blacklist-regexes` to `address-blacklist` and setting the additional key `address-blacklist-syntax: regex`. See [default.yaml](https://github.com/ergochat/ergo/blob/e7597876d987a6fc061b768fcf878d0035d1c85a/default.yaml#L422-L424) for an example; for more details, see the "Changed" section below.
This release includes a database change. If you have `datastore.autoupgrade` set to `true` in your configuration, it will be automatically applied when you restart Ergo. Otherwise, you can update the database manually by running `ergo upgradedb` (see the manual for complete instructions).
Many thanks to [@adsr](https://github.com/adsr), [@avollmerhaus](https://github.com/avollmerhaus), [@csmith](https://github.com/csmith), [@EchedeyLR](https://github.com/EchedeyLR), [@emersion](https://github.com/emersion), [@eskimo](https://github.com/eskimo), [@julio-b](https://github.com/julio-b), knolle, [@KoxSosen](https://github.com/KoxSosen), [@Mikaela](https://github.com/Mikaela), [@mogad0n](https://github.com/mogad0n), and [@progval](https://github.com/progval) for contributing patches, reporting issues, and helping test.
### Config changes
* Removed `accounts.email-verification.blacklist-regexes` in favor of `address-blacklist`, `address-blacklist-syntax`, and `address-blacklist-file`. See the "Changed" section below for the semantics of these new keys. (#1997, #2088)
* Added `implicit-tls` (TLS from the first byte) support for MTAs (#2048, #2049, thanks [@EchedeyLR](https://github.com/EchedeyLR)!)
### Fixed
* Fixed an edge case under `allow-truncation: true` (the recommended default is `false`) where Ergo could truncate a message in the middle of a UTF-8 codepoint (#2074)
* Fixed `CHATHISTORY TARGETS` being sent in a batch even without negotiation of the `batch` capability (#2066, thanks [@julio-b](https://github.com/julio-b)!)
* Errors from `/REHASH` are now properly sanitized before being sent to the user, fixing an edge case where they would be dropped (#2031, thanks [@eskimo](https://github.com/eskimo)!
* Fixed some edge cases in auto-away aggregation (#2044)
* Fixed a FAIL code sent by draft/account-registration (#2092, thanks [@progval](https://github.com/progval)!)
* Fixed a socket leak in the ident client (default/recommended configurations of Ergo disable ident and are not affected by this issue) (#2089)
### Changed
* Bouncer reattach from an "insecure" session is no longer disallowed. We continue to recommend that operators preemptively disable all insecure transports, such as plaintext listeners (#2013)
* Email addresses are now converted to lowercase before checking them against the blacklist (#1997, #2088)
* The default syntax for the email address blacklist is now "glob" (expressions with `*` and `?` as wildcard characters), as opposed to the full [Go regular expression syntax](https://github.com/google/re2/wiki/Syntax). To enable full regular expression syntax, set `address-blacklist-syntax: regex`.
* Due to line length limitations, some capabilities are now hidden from clients that only support version 301 CAP negotiation. To the best of our knowledge, all clients that support these capabilities also support version 302 CAP negotiation, rendering this moot (#2068)
* The default/recommended configuration now advertises the SCRAM-SHA-256 SASL method. We still do not recommend using this method in production. (#2032)
* Improved KILL messages (#2053, #2041, thanks [@mogad0n](https://github.com/mogad0n)!)
### Added
* Added support for automatically joining new clients to a channel or channels (#2077, #2079, thanks [@adsr](https://github.com/adsr)!)
* Added implicit TLS (TLS from the first byte) support for MTAs (#2048, #2049, thanks [@EchedeyLR](https://github.com/EchedeyLR)!)
* Added support for [draft/message-redaction](https://github.com/ircv3/ircv3-specifications/pull/524) (#2065, thanks [@progval](https://github.com/progval)!)
* Added support for [draft/pre-away](https://github.com/ircv3/ircv3-specifications/pull/514) (#2044)
* Added support for [draft/no-implicit-names](https://github.com/ircv3/ircv3-specifications/pull/527) (#2083)
* Added support for the [MSGREFTYPES](https://ircv3.net/specs/extensions/chathistory#isupport-tokens) 005 token (#2042)
* Ergo now advertises the [standard-replies](https://ircv3.net/specs/extensions/standard-replies) capability. Requesting this capability does not change Ergo's behavior.
### Internal
* Release builds are now statically linked by default. This should not affect normal chat operations, but may disrupt attempts to connect to external services (e.g. MTAs) that are configured using a hostname that relies on libc's name resolution behavior. To restore the old behavior, build from source with `CGO_ENABLED=1`. (#2023)
* Upgraded to Go 1.21 (#2045, #2084); official release builds use Go 1.21.3, which includes a fix for CVE-2023-44487
* The default `make` target is now `build` (which builds an `ergo` binary in the working directory) instead of `install` (which builds and installs an `ergo` binary to `${GOPATH}/bin/ergo`). Take note if building from source, or testing Ergo in development! (#2047)
* `make irctest` now depends on `make install`, in an attempt to ensure that irctest runs against the intended development version of Ergo (#2047)
## [2.11.1] - 2022-01-22
Ergo 2.11.1 is a bugfix release, fixing a denial-of-service issue in our websocket implementation. We regret the oversight.
This release includes no changes to the config file format or database file format.
### Security
* Fixed a denial-of-service issue affecting websocket clients (#2039)
## [2.11.0] - 2022-12-25
We're pleased to be publishing v2.11.0, a new stable release. This is another bugfix release aimed at improving client compatibility and keeping up with the IRCv3 specification process.
This release includes changes to the config file format, all of which are fully backwards-compatible and do not require updating the file before upgrading. It includes no changes to the database file format.
Many thanks to dedekro, [@emersion](https://github.com/emersion), [@eskimo](https://github.com/eskimo), hauser, [@jwheare](https://github.com/jwheare), [@kingter-sutjiadi](https://github.com/kingter-sutjiadi), knolle, [@Mikaela](https://github.com/Mikaela), [@mogad0n](https://github.com/mogad0n), [@PeGaSuS-Coder](https://github.com/PeGaSuS-Coder), and [@progval](https://github.com/progval) for contributing patches, reporting issues, and helping test.
Many thanks to dedekro, [@emersion](https://github.com/emersion), [@eskimo](https://github.com/eskimo), [@FiskFan1999](https://github.com/FiskFan1999), hauser, [@jwheare](https://github.com/jwheare), [@kingter-sutjiadi](https://github.com/kingter-sutjiadi), knolle, [@Mikaela](https://github.com/Mikaela), [@mogad0n](https://github.com/mogad0n), [@PeGaSuS-Coder](https://github.com/PeGaSuS-Coder), and [@progval](https://github.com/progval) for contributing patches, reporting issues, and helping test.
### Config changes
@ -19,7 +98,6 @@ Many thanks to dedekro, [@emersion](https://github.com/emersion), [@eskimo](http
* Network services like `NickServ` now appear in `WHO` responses where applicable (#1850, thanks [@emersion](https://github.com/emersion)!)
* The `extended-monitor` capability now appears under its ratified name (#2006, thanks [@progval](https://github.com/progval)!)
* `TAGMSG` no longer receives automatic `RPL_AWAY` responses (#1983, thanks [@eskimo](https://github.com/eskimo)!)
* Sending `SIGUSR1` to the Ergo process now prints a full goroutine stack dump to stderr, allowing debugging even when the HTTP pprof listener is disabled (#1975)
* `UBAN` now states explicitly that bans without a time limit have "indefinite" duration (#1988, thanks [@mogad0n](https://github.com/mogad0n)!)
### Fixed
@ -30,10 +108,12 @@ Many thanks to dedekro, [@emersion](https://github.com/emersion), [@eskimo](http
* Fixed handling of the address `::1` in WHOX output (#1980, thanks knolle!)
* Fixed handling of `AWAY` with an empty parameter (the de facto standard is to treat as a synonym for no parameter, which means "back") (#1996, thanks [@emersion](https://github.com/emersion), [@jwheare](https://github.com/jwheare)!)
* Fixed incorrect handling of some invalid modes in `CS AMODE` (#2002, thanks [@eskimo](https://github.com/eskimo)!)
* Fixed incorrect help text for `NS SAVERIFY` (#2021, thanks [@FiskFan1999](https://github.com/FiskFan1999)!)
### Added
* Added the `draft/persistence` capability and associated `PERSISTENCE` command. This is a first attempt to standardize Ergo's "always-on" functionality so that clients can interact with it programmatically. (#1982)
* Sending `SIGUSR1` to the Ergo process now prints a full goroutine stack dump to stderr, allowing debugging even when the HTTP pprof listener is disabled (#1975)
### Internal

View File

@ -1,7 +1,7 @@
## build ergo binary
FROM golang:1.19-alpine AS build-env
FROM docker.io/golang:1.22-alpine AS build-env
RUN apk add -U --force-refresh --no-cache --purge --clean-protected -l -u make git
RUN apk upgrade -U --force-refresh --no-cache && apk add --no-cache --purge --clean-protected -l -u make git
# copy ergo source
WORKDIR /go/src/github.com/ergochat/ergo
@ -13,10 +13,10 @@ RUN sed -i 's/^\(\s*\)\"127.0.0.1:6667\":.*$/\1":6667":/' /go/src/github.com/erg
sed -i 's/^\s*\"\[::1\]:6667\":.*$//' /go/src/github.com/ergochat/ergo/default.yaml
# compile
RUN make
RUN make install
## build ergo container
FROM alpine:3.13
FROM docker.io/alpine:3.19
# metadata
LABEL maintainer="Daniel Oaks <daniel@danieloaks.net>,Daniel Thamdrup <dallemon@protonmail.com>" \

View File

@ -3,9 +3,13 @@
GIT_COMMIT := $(shell git rev-parse HEAD 2> /dev/null)
GIT_TAG := $(shell git tag --points-at HEAD 2> /dev/null | head -n 1)
# disable linking against native libc / libpthread by default;
# this can be overridden by passing CGO_ENABLED=1 to make
export CGO_ENABLED ?= 0
capdef_file = ./irc/caps/defs.go
all: install
all: build
install:
go install -v -ldflags "-X main.commit=$(GIT_COMMIT) -X main.version=$(GIT_TAG)"
@ -25,13 +29,13 @@ test:
go vet ./...
./.check-gofmt.sh
smoke:
smoke: install
ergo mkcerts --conf ./default.yaml || true
ergo run --conf ./default.yaml --smoke
gofmt:
./.check-gofmt.sh --fix
irctest:
irctest: install
git submodule update --init
cd irctest && make ergo

6
README
View File

@ -33,15 +33,15 @@ Modify the config file as needed (the recommendations at the top may be helpful)
To generate passwords for opers and connect passwords, you can use this command:
$ ergo genpasswd
$ ./ergo genpasswd
If you need to generate self-signed TLS certificates, use this command:
$ ergo mkcerts
$ ./ergo mkcerts
You are now ready to start Ergo!
$ ergo run
$ ./ergo run
For further instructions, consult the manual. A copy of the manual should be
included in your release under `docs/MANUAL.md`. Or you can view it on the

View File

@ -54,9 +54,9 @@ Extract it into a folder, then run the following commands:
```sh
cp default.yaml ircd.yaml
vim ircd.yaml # modify the config file to your liking
ergo mkcerts
ergo run # server should be ready to go!
vim ircd.yaml # modify the config file to your liking
./ergo mkcerts
./ergo run # server should be ready to go!
```
**Note:** See the [productionizing guide in our manual](https://github.com/ergochat/ergo/blob/stable/docs/MANUAL.md#productionizing-with-systemd) for recommendations on how to run a production network, including obtaining valid TLS certificates.
@ -84,7 +84,7 @@ For information on contributing to Ergo, see [DEVELOPING.md](https://github.com/
#### Building
You'll need an [up-to-date distribution of the Go language for your OS and architecture](https://golang.org/dl/). Once that's installed (check the output of `go version`), just check out your desired branch or tag and run `make build`. This will produce an executable binary named `ergo` in the base directory of the project. (Ergo vendors all its dependencies, so you will not need to fetch any dependencies remotely.)
You'll need an [up-to-date distribution of the Go language for your OS and architecture](https://golang.org/dl/). Once that's installed (check the output of `go version`), just check out your desired branch or tag and run `make`. This will produce an executable binary named `ergo` in the base directory of the project. (Ergo vendors all its dependencies, so you will not need to fetch any dependencies remotely.)
## Configuration

View File

@ -134,9 +134,10 @@ server:
# the recommended default is 'ascii' (traditional ASCII-only identifiers).
# the other options are 'precis', which allows UTF8 identifiers that are "sane"
# (according to UFC 8265), with additional mitigations for homoglyph attacks,
# and 'permissive', which allows identifiers containing unusual characters like
# 'permissive', which allows identifiers containing unusual characters like
# emoji, at the cost of increased vulnerability to homoglyph attacks and potential
# client compatibility problems. we recommend leaving this value at its default;
# client compatibility problems, and the legacy mappings 'rfc1459' and
# 'rfc1459-strict'. we recommend leaving this value at its default;
# however, note that changing it once the network is already up and running is
# problematic.
casemapping: "ascii"
@ -164,7 +165,10 @@ server:
# the value must begin with a '~' character. comment out / omit to disable:
coerce-ident: '~u'
# password to login to the server, generated using `ergo genpasswd`:
# 'password' allows you to require a global, shared password (the IRC `PASS` command)
# to connect to the server. for operator passwords, see the `opers` section of the
# config. for a more secure way to create a private server, see the `require-sasl`
# section. you must hash the password with `ergo genpasswd`, then enter the hash here:
#password: "$2a$04$0123456789abcdef0123456789abcdef0123456789abcdef01234"
# motd filename
@ -215,6 +219,10 @@ server:
# - "192.168.1.1"
# - "192.168.10.1/24"
# whether to accept the hostname parameter on the WEBIRC line as the IRC hostname
# (the default/recommended Ergo configuration will use cloaks instead)
accept-hostname: false
# maximum length of clients' sendQ in bytes
# this should be big enough to hold bursts of channel/direct messages
max-sendq: 96k
@ -361,7 +369,7 @@ server:
# in a "closed-loop" system where you control the server and all the clients,
# you may want to increase the maximum (non-tag) length of an IRC line from
# the default value of 512. DO NOT change this on a public server:
# max-line-len: 512
#max-line-len: 512
# send all 0's as the LUSERS (user counts) output to non-operators; potentially useful
# if you don't want to publicize how popular the server is
@ -402,6 +410,10 @@ accounts:
sender: "admin@my.network"
require-tls: true
helo-domain: "my.network" # defaults to server name if unset
# set to `tcp4` to force sending over IPv4, `tcp6` to force IPv6:
# protocol: "tcp4"
# set to force a specific source/local IPv4 or IPv6 address:
# local-address: "1.2.3.4"
# options to enable DKIM signing of outgoing emails (recommended, but
# requires creating a DNS entry for the public key):
# dkim:
@ -414,8 +426,15 @@ accounts:
# port: 25
# username: "admin"
# password: "hunter2"
blacklist-regexes:
# - ".*@mailinator.com"
# implicit-tls: false # TLS from the first byte, typically on port 465
# addresses that are not accepted for registration:
address-blacklist:
# - "*@mailinator.com"
address-blacklist-syntax: "glob" # change to "regex" for regular expressions
# file of newline-delimited address blacklist entries (no enclosing quotes)
# in the above syntax (i.e. either globs or regexes). supersedes
# address-blacklist if set:
# address-blacklist-file: "/path/to/address-blacklist-file"
timeout: 60s
# email-based password reset:
password-reset:
@ -447,6 +466,10 @@ accounts:
# this is useful for compatibility with old clients that don't support SASL
login-via-pass-command: true
# advertise the SCRAM-SHA-256 authentication method. set to false in case of
# compatibility issues with certain clients:
advertise-scram: true
# require-sasl controls whether clients are required to have accounts
# (and sign into them using SASL) to connect to the server
require-sasl:
@ -572,6 +595,40 @@ accounts:
# how many scripts are allowed to run at once? 0 for no limit:
max-concurrency: 64
# support for login via OAuth2 bearer tokens
oauth2:
enabled: false
# should we automatically create users on presentation of a valid token?
autocreate: true
# enable this to use auth-script for validation:
auth-script: false
introspection-url: "https://example.com/api/oidc/introspection"
introspection-timeout: 10s
# omit for auth method `none`; required for auth method `client_secret_basic`:
client-id: "ergo"
client-secret: "4TA0I7mJ3fUUcW05KJiODg"
# support for login via JWT bearer tokens
jwt-auth:
enabled: false
# should we automatically create users on presentation of a valid token?
autocreate: true
# any of these token definitions can be accepted, allowing for key rotation
tokens:
-
algorithm: "hmac" # either 'hmac', 'rsa', or 'eddsa' (ed25519)
# hmac takes a symmetric key, rsa and eddsa take PEM-encoded public keys;
# either way, the key can be specified either as a YAML string:
key: "nANiZ1De4v6WnltCHN2H7Q"
# or as a path to the file containing the key:
#key-file: "jwt_pubkey.pem"
# list of JWT claim names to search for the user's account name (make sure the format
# is what you expect, especially if using "sub"):
account-claims: ["preferred_username"]
# if a claim is formatted as an email address, require it to have the following domain,
# and then strip off the domain and use the local-part as the account name:
#strip-domain: "example.com"
# channel options
channels:
# modes that are set when new channels are created
@ -607,6 +664,12 @@ channels:
# (0 or omit for no expiration):
invite-expiration: 24h
# channels that new clients will automatically join. this should be used with
# caution, since traditional IRC users will likely view it as an antifeature.
# it may be useful in small community networks that have a single "primary" channel:
#auto-join:
# - "#lounge"
# operator classes:
# an operator has a single "class" (defining a privilege level), which can include
# multiple "capabilities" (defining privileged actions they can take). all
@ -800,6 +863,9 @@ limits:
# identlen is the max ident length allowed
identlen: 20
# realnamelen is the maximum realname length allowed
realnamelen: 150
# channellen is the max channel length allowed
channellen: 64
@ -974,7 +1040,8 @@ history:
# options to control how messages are stored and deleted:
retention:
# allow users to delete their own messages from history?
# allow users to delete their own messages from history,
# and channel operators to delete messages in their channel?
allow-individual-delete: false
# if persistent history is enabled, create additional index tables,

34
distrib/apparmor/ergo Normal file
View File

@ -0,0 +1,34 @@
include <tunables/global>
# Georg Pfuetzenreuter <georg+ergo@lysergic.dev>
# AppArmor confinement for ergo and ergo-ldap
profile ergo /usr/bin/ergo {
include <abstractions/base>
include <abstractions/consoles>
include <abstractions/nameservice>
/etc/ergo/ircd.{motd,yaml} r,
/etc/ssl/irc/{crt,key} r,
/etc/ssl/ergo/{crt,key} r,
/usr/bin/ergo mr,
/proc/sys/net/core/somaxconn r,
/sys/kernel/mm/transparent_hugepage/hpage_pmd_size r,
/usr/share/ergo/languages/{,*.lang.json,*.yaml} r,
owner /run/ergo/ircd.lock rwk,
owner /var/lib/ergo/ircd.db rw,
include if exists <local/ergo>
}
profile ergo-ldap /usr/bin/ergo-ldap {
include <abstractions/openssl>
include <abstractions/ssl_certs>
/usr/bin/ergo-ldap rm,
/etc/ergo/ldap.yaml r,
include if exists <local/ergo-ldap>
}

29
distrib/bsd-rc/README.md Normal file
View File

@ -0,0 +1,29 @@
Ergo init script for bsd-rc
===
Written for and tested using FreeBSD.
## Installation
Copy the `ergo` file from this folder to `/etc/rc.d/ergo`,
permissions should be `555`.
You should create a system user for Ergo.
This script defaults to running Ergo as a user named `ergo`,
but that can be changed using `/etc/rc.conf`.
Here are all `rc.conf` variables and their defaults:
- `ergo_enable`, defaults to `NO`. Whether to run `ergo` at system start.
- `ergo_user`, defaults to `ergo`. Run using this user.
- `ergo_group`, defaults to `ergo`. Run using this group.
- `ergo_chdir`, defaults to `/var/db/ergo`. Path to the working directory for the server. Should be writable for `ergo_user`.
- `ergo_conf`, defaults to `/usr/local/etc/ergo/ircd.yaml`. Config file path. Make sure `ergo_user` can read it.
This script assumes ergo to be installed at `/usr/local/bin/ergo`.
## Usage
```shell
/etc/rc.d/ergo <command>
```
In addition to the obvious `start` and `stop` commands, this
script also has a `reload` command that sends `SIGHUP` to the Ergo process.

45
distrib/bsd-rc/ergo Normal file
View File

@ -0,0 +1,45 @@
#!/bin/sh
# PROVIDE: ergo
# REQUIRE: DAEMON
# KEYWORD: shutdown
#
# Add the following lines to /etc/rc.conf to enable Ergo
#
# ergo_enable (bool): Set to YES to enable ergo.
# Default is "NO".
# ergo_user (user): Set user to run ergo.
# Default is "ergo".
# ergo_group (group): Set group to run ergo.
# Default is "ergo".
# ergo_config (file): Set ergo config file path.
# Default is "/usr/local/etc/ergo/config.yaml".
# ergo_chdir (dir): Set ergo working directory
# Default is "/var/db/ergo".
. /etc/rc.subr
name=ergo
rcvar=ergo_enable
desc="Ergo IRCv3 server"
load_rc_config "$name"
: ${ergo_enable:=NO}
: ${ergo_user:=ergo}
: ${ergo_group:=ergo}
: ${ergo_chdir:=/var/db/ergo}
: ${ergo_conf:=/usr/local/etc/ergo/ircd.yaml}
# If you don't define a custom reload function,
# rc automagically sends SIGHUP to the process on reload.
# But you have to list reload as an extra_command for that.
extra_commands="reload"
procname="/usr/local/bin/${name}"
command=/usr/sbin/daemon
command_args="-S -T ${name} ${procname} run --conf ${ergo_conf}"
run_rc_command "$1"

View File

@ -18,7 +18,7 @@ certificates. To get a working ircd, all you need to do is run the image and
expose the ports:
```shell
docker run --name ergo -d -p 6667:6667 -p 6697:6697 ghcr.io/ergochat/ergo:stable
docker run --init --name ergo -d -p 6667:6667 -p 6697:6697 ghcr.io/ergochat/ergo:stable
```
This will start Ergo and listen on ports 6667 (plain text) and 6697 (TLS).
@ -38,6 +38,11 @@ You should see a line similar to:
Oper username:password is admin:cnn2tm9TP3GeI4vLaEMS
```
We recommend the use of `--init` (`init: true` in docker-compose) to solve an
edge case involving unreaped zombie processes when Ergo's script API is used
for authentication or IP validation. For more details, see
[krallin/tini#8](https://github.com/krallin/tini/issues/8).
## Persisting data
Ergo has a persistent data store, used to keep account details, channel
@ -48,14 +53,14 @@ For example, to create a new docker volume and then mount it:
```shell
docker volume create ergo-data
docker run -d -v ergo-data:/ircd -p 6667:6667 -p 6697:6697 ghcr.io/ergochat/ergo:stable
docker run --init -d -v ergo-data:/ircd -p 6667:6667 -p 6697:6697 ghcr.io/ergochat/ergo:stable
```
Or to mount a folder from your host machine:
```shell
mkdir ergo-data
docker run -d -v $(PWD)/ergo-data:/ircd -p 6667:6667 -p 6697:6697 ghcr.io/ergochat/ergo:stable
docker run --init -d -v $(PWD)/ergo-data:/ircd -p 6667:6667 -p 6697:6697 ghcr.io/ergochat/ergo:stable
```
## Customising the config

View File

@ -2,6 +2,7 @@ version: "3.8"
services:
ergo:
init: true
image: ghcr.io/ergochat/ergo:stable
ports:
- "6667:6667/tcp"

View File

@ -60,6 +60,7 @@ _Copyright © Daniel Oaks <daniel@danieloaks.net>, Shivaram Lingamneni <slingamn
- [Migrating from Anope or Atheme](#migrating-from-anope-or-atheme)
- [HOPM](#hopm)
- [Tor](#tor)
- [I2P](#i2p)
- [ZNC](#znc)
- [External authentication systems](#external-authentication-systems)
- [DNSBLs and other IP checking systems](#dnsbls-and-other-ip-checking-systems)
@ -182,8 +183,8 @@ The recommended way to operate ergo as a service on Linux is via systemd. This p
The only major distribution that currently packages Ergo is Arch Linux; the aforementioned AUR package includes a systemd unit file. However, it should be fairly straightforward to set up a productionized Ergo on any Linux distribution. Here's a quickstart guide for Debian/Ubuntu:
1. Create a dedicated, unprivileged role user who will own the ergo process and all its associated files: `adduser --system --group ergo`. This user now has a home directory at `/home/ergo`. To prevent other users from viewing Ergo's configuration file, database, and certificates, restrict the permissions on the home directory: `chmod 0700 /home/ergo`.
1. Copy the executable binary `ergo`, the config file `ircd.yaml`, the database `ircd.db`, and the self-signed TLS certificate (`fullchain.pem` and `privkey.pem`) to `/home/ergo`. (If you don't have an `ircd.db`, it will be auto-created as `/home/ergo/ircd.db` on first launch.) Ensure that they are all owned by the new ergo role user: `sudo chown ergo:ergo /home/ergo/*`. Ensure that the configuration file logs to stderr.
1. Create a dedicated, unprivileged role user who will own the ergo process and all its associated files: `adduser --system --group --home=/home/ergo ergo`. This user now has a home directory at `/home/ergo`. To prevent other users from viewing Ergo's configuration file, database, and certificates, restrict the permissions on the home directory: `chmod 0700 /home/ergo`.
1. Copy the executable binary `ergo`, the config file `ircd.yaml`, the database `ircd.db`, and the self-signed TLS certificate (`fullchain.pem` and `privkey.pem`) to `/home/ergo`. (If you don't have an `ircd.db`, it will be auto-created as `/home/ergo/ircd.db` on first launch.) Ensure that they are all owned by the new ergo role user: `sudo chown -R ergo:ergo /home/ergo`. Ensure that the configuration file logs to stderr.
1. Install our example [ergo.service](https://github.com/ergochat/ergo/blob/stable/distrib/systemd/ergo.service) file to `/etc/systemd/system/ergo.service`.
1. Enable and start the new service with the following commands:
1. `systemctl daemon-reload`
@ -623,6 +624,8 @@ Many clients do not have this support. However, you can designate port 6667 as a
Ergo supports the use of reverse proxies (such as nginx, or a Kubernetes [LoadBalancer](https://kubernetes.io/docs/concepts/services-networking/service/#loadbalancer)) that sit between it and the client. In these deployments, the [PROXY protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt) is used to pass the end user's IP through to Ergo. These proxies can be used to terminate TLS externally to Ergo, e.g., if you need to support versions of the TLS protocol that are not implemented natively by Go, or if you want to consolidate your certificate management into a single nginx instance.
### IRC Sockets
The first step is to add the reverse proxy's IP to `proxy-allowed-from` and `ip-limits.exempted`. (Use `localhost` to exempt all loopback IPs and Unix domain sockets.)
After that, there are two possibilities:
@ -638,6 +641,10 @@ After that, there are two possibilities:
proxy: true
```
### Websockets through HTTP reverse proxies
Ergo will honor the `X-Forwarded-For` headers on incoming websocket connections, if the peer IP address appears in `proxy-allowed-from`. For these connections, set `proxy: false`, or omit the `proxy` option.
## Client certificates
@ -1017,6 +1024,24 @@ or with Gamja, create a new `config.json` (in the base directory of the Gamja in
}
```
On Apache 2.4.47 or higher, websocket proxying can be configured with:
```
RequestHeader setifempty X-Forwarded-Proto https
ProxyPreserveHost On
ProxyPass /webirc http://127.0.0.1:8067 upgrade=websocket
ProxyPassReverse /webirc http://127.0.0.1:8067
```
On Caddy, websocket proxying can be configured with:
```
handle_path /webirc {
reverse_proxy 127.0.0.1:8067
}
```
## Migrating from Anope or Atheme
You can import user and channel registrations from an Anope or Atheme database into a new Ergo database (not all features are supported). Use the following steps:
@ -1118,6 +1143,16 @@ Instructions on how client software should connect to an .onion address are outs
1. [Hexchat](https://hexchat.github.io/) is known to support .onion addresses, once it has been configured to use a local Tor daemon as a SOCKS proxy (Settings -> Preferences -> Network Setup -> Proxy Server).
1. Pidgin should work with [torsocks](https://trac.torproject.org/projects/tor/wiki/doc/torsocks).
## I2P
I2P is an anonymizing overlay network similar to Tor. The recommended configuration for I2P is to treat it similarly to Tor: have the i2pd reverse proxy its connections to an Ergo listener configured with `tor: true`. See the [i2pd configuration guide](https://i2pd.readthedocs.io/en/latest/tutorials/irc/#running-anonymous-irc-server) for more details; note that the instructions to separate I2P traffic from other localhost traffic are unnecessary for a `tor: true` listener.
I2P can additionally expose an opaque client identifier (the user's "b32 address"). Exposing this identifier via Ergo is not recommended, but if you wish to do so, you can use the following procedure:
1. Enable WEBIRC support in the i2pd configuration by adding the `webircpassword` key to the [i2pd server block](https://i2pd.readthedocs.io/en/latest/tutorials/irc/#running-anonymous-irc-server)
1. Remove `tor: true` from the relevant Ergo listener config
1. Enable WEBIRC support in Ergo (starting from the default/recommended configuration, find the existing webirc block, delete the `certfp` configuration, change `password` to use the output of `ergo genpasswd` on the password you configured i2pd to send, and set `accept-hostname: true`)
1. To prevent Ergo from overwriting the hostname as passed from i2pd, set the following options: `server.ip-cloaking.enabled: false` and `server.lookup-hostnames: false`. (There is currently no support for applying cloaks to regular IP traffic but displaying the b32 address for I2P traffic).
## ZNC

41
ergo.go
View File

@ -7,6 +7,7 @@ package main
import (
"bufio"
_ "embed"
"fmt"
"log"
"os"
@ -14,7 +15,7 @@ import (
"syscall"
"golang.org/x/crypto/bcrypt"
"golang.org/x/crypto/ssh/terminal"
"golang.org/x/term"
"github.com/docopt/docopt-go"
"github.com/ergochat/ergo/irc"
@ -26,19 +27,16 @@ import (
var commit = "" // git hash
var version = "" // tagged version
//go:embed default.yaml
var defaultConfig string
// get a password from stdin from the user
func getPassword() string {
fd := int(os.Stdin.Fd())
if terminal.IsTerminal(fd) {
bytePassword, err := terminal.ReadPassword(int(syscall.Stdin))
if err != nil {
log.Fatal("Error reading password:", err.Error())
}
return string(bytePassword)
func getPasswordFromTerminal() string {
bytePassword, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
log.Fatal("Error reading password:", err.Error())
}
reader := bufio.NewReader(os.Stdin)
text, _ := reader.ReadString('\n')
return strings.TrimSpace(text)
return string(bytePassword)
}
func fileDoesNotExist(file string) bool {
@ -100,6 +98,7 @@ Usage:
ergo importdb <database.json> [--conf <filename>] [--quiet]
ergo genpasswd [--conf <filename>] [--quiet]
ergo mkcerts [--conf <filename>] [--quiet]
ergo defaultconfig
ergo run [--conf <filename>] [--quiet] [--smoke]
ergo -h | --help
ergo --version
@ -114,19 +113,20 @@ Options:
// don't require a config file for genpasswd
if arguments["genpasswd"].(bool) {
var password string
fd := int(os.Stdin.Fd())
if terminal.IsTerminal(fd) {
if term.IsTerminal(int(syscall.Stdin)) {
fmt.Print("Enter Password: ")
password = getPassword()
password = getPasswordFromTerminal()
fmt.Print("\n")
fmt.Print("Reenter Password: ")
confirm := getPassword()
confirm := getPasswordFromTerminal()
fmt.Print("\n")
if confirm != password {
log.Fatal("passwords do not match")
}
} else {
password = getPassword()
reader := bufio.NewReader(os.Stdin)
text, _ := reader.ReadString('\n')
password = strings.TrimSpace(text)
}
if err := irc.ValidatePassphrase(password); err != nil {
log.Printf("WARNING: this password contains characters that may cause problems with your IRC client software.\n")
@ -136,10 +136,7 @@ Options:
if err != nil {
log.Fatal("encoding error:", err.Error())
}
fmt.Print(string(hash))
if terminal.IsTerminal(fd) {
fmt.Println()
}
fmt.Println(string(hash))
return
} else if arguments["mkcerts"].(bool) {
doMkcerts(arguments["--conf"].(string), arguments["--quiet"].(bool))
@ -181,6 +178,8 @@ Options:
if err != nil {
log.Fatal("Error while importing db:", err.Error())
}
} else if arguments["defaultconfig"].(bool) {
fmt.Print(defaultConfig)
} else if arguments["run"].(bool) {
if !arguments["--quiet"].(bool) {
logman.Info("server", fmt.Sprintf("%s starting", irc.Ver))

View File

@ -87,6 +87,12 @@ CAPDEFS = [
url="https://gist.github.com/DanielOaks/8126122f74b26012a3de37db80e4e0c6",
standard="proposed IRCv3",
),
CapDef(
identifier="MessageRedaction",
name="draft/message-redaction",
url="https://github.com/progval/ircv3-specifications/blob/redaction/extensions/message-redaction.md",
standard="proposed IRCv3",
),
CapDef(
identifier="MessageTags",
name="message-tags",
@ -195,6 +201,24 @@ CAPDEFS = [
url="https://github.com/ircv3/ircv3-specifications/pull/503",
standard="proposed IRCv3",
),
CapDef(
identifier="Preaway",
name="draft/pre-away",
url="https://github.com/ircv3/ircv3-specifications/pull/514",
standard="proposed IRCv3",
),
CapDef(
identifier="StandardReplies",
name="standard-replies",
url="https://github.com/ircv3/ircv3-specifications/pull/506",
standard="IRCv3",
),
CapDef(
identifier="NoImplicitNames",
name="draft/no-implicit-names",
url="https://github.com/ircv3/ircv3-specifications/pull/527",
standard="proposed IRCv3",
),
]
def validate_defs():
@ -230,7 +254,7 @@ package caps
const (
// number of recognized capabilities:
numCapabs = %d
// length of the uint64 array that represents the bitset:
// length of the uint32 array that represents the bitset:
bitsetLen = %d
)
""" % (numCapabs, bitsetLen), file=output)

26
go.mod
View File

@ -1,43 +1,43 @@
module github.com/ergochat/ergo
go 1.19
go 1.22
require (
code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815
github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1
github.com/ergochat/go-ident v0.0.0-20200511222032-830550b1d775
github.com/ergochat/irc-go v0.1.0
github.com/go-sql-driver/mysql v1.6.0
github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881
github.com/ergochat/irc-go v0.5.0-rc1
github.com/go-sql-driver/mysql v1.7.0
github.com/go-test/deep v1.0.6 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/gofrs/flock v0.8.1
github.com/gorilla/websocket v1.4.2
github.com/okzk/sdnotify v0.0.0-20180710141335-d9becc38acbd
github.com/onsi/ginkgo v1.12.0 // indirect
github.com/onsi/gomega v1.9.0 // indirect
github.com/stretchr/testify v1.4.0 // indirect
github.com/tidwall/buntdb v1.2.9
github.com/tidwall/buntdb v1.2.10
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208
github.com/xdg-go/scram v1.0.2
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e
golang.org/x/text v0.3.7
golang.org/x/crypto v0.17.0
golang.org/x/term v0.15.0
golang.org/x/text v0.14.0
gopkg.in/yaml.v2 v2.4.0
)
require github.com/gofrs/flock v0.8.1
require github.com/golang-jwt/jwt/v5 v5.2.0
require (
github.com/tidwall/btree v1.1.0 // indirect
github.com/tidwall/gjson v1.12.1 // indirect
github.com/tidwall/btree v1.4.2 // indirect
github.com/tidwall/gjson v1.14.3 // indirect
github.com/tidwall/grect v0.1.4 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/rtred v0.1.2 // indirect
github.com/tidwall/tinyqueue v0.1.1 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 // indirect
golang.org/x/sys v0.15.0 // indirect
)
replace github.com/gorilla/websocket => github.com/ergochat/websocket v1.4.2-oragono1

64
go.sum
View File

@ -8,25 +8,25 @@ github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1 h1:WLHTOodthVyv5NvYLIvWl112kSFv5IInKKrRN2qpons=
github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1/go.mod h1:mov+uh1DPWsltdQnOdzn08UO9GsJ3MEvhtu0Ci37fdk=
github.com/ergochat/go-ident v0.0.0-20200511222032-830550b1d775 h1:QSJIdpr3HOzJDPwxT7hp7WbjoZcS+5GqVvsBscqChk0=
github.com/ergochat/go-ident v0.0.0-20200511222032-830550b1d775/go.mod h1:d2qvgjD0TvGNSvUs+mZgX090RiJlrzUYW6vtANGOy3A=
github.com/ergochat/irc-go v0.0.0-20210617222258-256f1601d3ce h1:RfyjeynouKZjmnN8WGzCSrtuHGZ9dwfSYBq405FPoqs=
github.com/ergochat/irc-go v0.0.0-20210617222258-256f1601d3ce/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
github.com/ergochat/irc-go v0.1.0 h1:jBHUayERH9SiPOWe4ePDWRztBjIQsU/jwLbbGUuiOWM=
github.com/ergochat/irc-go v0.1.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881 h1:+J5m88nvybxB5AnBVGzTXM/yHVytt48rXBGcJGzSbms=
github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881/go.mod h1:ASYJtQujNitna6cVHsNQTGrfWvMPJ5Sa2lZlmsH65uM=
github.com/ergochat/irc-go v0.4.0 h1:0YibCKfAAtwxQdNjLQd9xpIEPisLcJ45f8FNsMHAuZc=
github.com/ergochat/irc-go v0.4.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
github.com/ergochat/irc-go v0.5.0-rc1 h1:kFoIHExoNFQ2CV+iShAVna/H4xrXQB4t4jK5Sep2j9k=
github.com/ergochat/irc-go v0.5.0-rc1/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
github.com/ergochat/scram v1.0.2-ergo1 h1:2bYXiRFQH636pT0msOG39fmEYl4Eq+OuutcyDsCix/g=
github.com/ergochat/scram v1.0.2-ergo1/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
github.com/ergochat/websocket v1.4.2-oragono1 h1:plMUunFBM6UoSCIYCKKclTdy/TkkHfUslhOfJQzfueM=
github.com/ergochat/websocket v1.4.2-oragono1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-test/deep v1.0.6 h1:UHSEyLZUwX9Qoi99vVwvewiMC8mM2bf7XEM2nqvzEn8=
github.com/go-test/deep v1.0.6/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
@ -45,20 +45,13 @@ github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJy
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/tidwall/assert v0.1.0 h1:aWcKyRBUAdLoVebxo95N7+YZVTFF/ASTr7BN4sLP6XI=
github.com/tidwall/assert v0.1.0/go.mod h1:QLYtGyeqse53vuELQheYl9dngGCJQ+mTtlxcktb+Kj8=
github.com/tidwall/btree v0.6.1 h1:75VVgBeviiDO+3g4U+7+BaNBNhNINxB0ULPT3fs9pMY=
github.com/tidwall/btree v0.6.1/go.mod h1:TzIRzen6yHbibdSfK6t8QimqbUnoxUSrZfeW7Uob0q4=
github.com/tidwall/btree v1.1.0 h1:5P+9WU8ui5uhmcg3SoPyTwoI0mVyZ1nps7YQzTZFkYM=
github.com/tidwall/btree v1.1.0/go.mod h1:TzIRzen6yHbibdSfK6t8QimqbUnoxUSrZfeW7Uob0q4=
github.com/tidwall/buntdb v1.2.7 h1:SIyObKAymzLyGhDeIhVk2Yc1/EwfCC75Uyu77CHlVoA=
github.com/tidwall/buntdb v1.2.7/go.mod h1:b6KvZM27x/8JLI5hgRhRu60pa3q0Tz9c50TyD46OHUM=
github.com/tidwall/buntdb v1.2.9 h1:XVz684P7X6HCTrdr385yDZWB1zt/n20ZNG3M1iGyFm4=
github.com/tidwall/buntdb v1.2.9/go.mod h1:IwyGSvvDg6hnKSIhtdZ0AqhCZGH8ukdtCAzaP8fI1X4=
github.com/tidwall/gjson v1.10.2 h1:APbLGOM0rrEkd8WBw9C24nllro4ajFuJu0Sc9hRz8Bo=
github.com/tidwall/gjson v1.10.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.12.1 h1:ikuZsLdhr8Ws0IdROXUS1Gi4v9Z4pGqpX/CvJkxvfpo=
github.com/tidwall/btree v1.4.2 h1:PpkaieETJMUxYNADsjgtNRcERX7mGc/GP2zp/r5FM3g=
github.com/tidwall/btree v1.4.2/go.mod h1:LGm8L/DZjPLmeWGjv5kFrY8dL4uVhMmzmmLYmsObdKE=
github.com/tidwall/buntdb v1.2.10 h1:U/ebfkmYPBnyiNZIirUiWFcxA/mgzjbKlyPynFsPtyM=
github.com/tidwall/buntdb v1.2.10/go.mod h1:lZZrZUWzlyDJKlLQ6DKAy53LnG7m5kHyrEHvvcDmBpU=
github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/grect v0.1.3 h1:z9YwQAMUxVSBde3b7Sl8Da37rffgNfZ6Fq6h9t6KdXE=
github.com/tidwall/grect v0.1.3/go.mod h1:8GMjwh3gPZVpLBI/jDz9uslCe0dpxRpWDdtN0lWAS/E=
github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw=
github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/grect v0.1.4 h1:dA3oIgNgWdSspFzn1kS4S/RDpZFLrIxAZOdJKjYapOg=
github.com/tidwall/grect v0.1.4/go.mod h1:9FBsaYRaR0Tcy4UwefBX/UDcDcDy9V5jUcxHzv2jd5Q=
github.com/tidwall/lotsa v1.0.2 h1:dNVBH5MErdaQ/xd9s769R31/n2dXavsQ0Yf4TMEHHw8=
@ -77,27 +70,22 @@ github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/stringprep v1.0.2 h1:6iq84/ryjjeRmMJwxutI51F2GIPlP5BfTvXHeYjyhBc=
github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM=
golang.org/x/crypto v0.0.0-20211115234514-b4de73f9ece8 h1:5QRxNnVsaJP6NAse0UdkRgL3zHMvCRRkrDVLNdNpdy4=
golang.org/x/crypto v0.0.0-20211115234514-b4de73f9ece8/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e h1:T8NU3HyQ8ClP4SEE+KbFlg6n0NhuTsN4MyznaarGsZM=
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@ -4,6 +4,7 @@
package irc
import (
"context"
"crypto/rand"
"crypto/x509"
"encoding/json"
@ -23,6 +24,7 @@ import (
"github.com/ergochat/ergo/irc/email"
"github.com/ergochat/ergo/irc/migrations"
"github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/oauth2"
"github.com/ergochat/ergo/irc/passwd"
"github.com/ergochat/ergo/irc/utils"
)
@ -39,7 +41,6 @@ const (
keyAccountSettings = "account.settings %s"
keyAccountVHost = "account.vhost %s"
keyCertToAccount = "account.creds.certfp %s"
keyAccountChannels = "account.channels %s" // channels registered to the account
keyAccountLastSeen = "account.lastseen %s"
keyAccountReadMarkers = "account.readmarkers %s"
keyAccountModes = "account.modes %s" // user modes for the always-on client as a string
@ -1428,6 +1429,74 @@ func (am *AccountManager) AuthenticateByPassphrase(client *Client, accountName s
return err
}
func (am *AccountManager) AuthenticateByBearerToken(client *Client, tokenType, token string) (err error) {
switch tokenType {
case "oauth2":
return am.AuthenticateByOAuthBearer(client, oauth2.OAuthBearerOptions{Token: token})
case "jwt":
return am.AuthenticateByJWT(client, token)
default:
return errInvalidBearerTokenType
}
}
func (am *AccountManager) AuthenticateByOAuthBearer(client *Client, opts oauth2.OAuthBearerOptions) (err error) {
config := am.server.Config()
if !config.Accounts.OAuth2.Enabled {
return errFeatureDisabled
}
if throttled, remainingTime := client.checkLoginThrottle(); throttled {
return &ThrottleError{remainingTime}
}
var username string
if config.Accounts.AuthScript.Enabled && config.Accounts.OAuth2.AuthScript {
username, err = am.authenticateByOAuthBearerScript(client, config, opts)
} else {
username, err = config.Accounts.OAuth2.Introspect(context.Background(), opts.Token)
}
if err != nil {
return err
}
account, err := am.loadWithAutocreation(username, config.Accounts.OAuth2.Autocreate)
if err == nil {
am.Login(client, account)
}
return err
}
func (am *AccountManager) AuthenticateByJWT(client *Client, token string) (err error) {
config := am.server.Config()
// enabled check is encapsulated here:
accountName, err := config.Accounts.JWTAuth.Validate(token)
if err != nil {
am.server.logger.Debug("accounts", "invalid JWT token", err.Error())
return errAccountInvalidCredentials
}
account, err := am.loadWithAutocreation(accountName, config.Accounts.JWTAuth.Autocreate)
if err == nil {
am.Login(client, account)
}
return err
}
func (am *AccountManager) authenticateByOAuthBearerScript(client *Client, config *Config, opts oauth2.OAuthBearerOptions) (username string, err error) {
output, err := CheckAuthScript(am.server.semaphores.AuthScript, config.Accounts.AuthScript.ScriptConfig,
AuthScriptInput{OAuthBearer: &opts, IP: client.IP().String()})
if err != nil {
am.server.logger.Error("internal", "failed shell auth invocation", err.Error())
return "", oauth2.ErrInvalidToken
} else if output.Success {
return output.AccountName, nil
} else {
return "", oauth2.ErrInvalidToken
}
}
// AllNicks returns the uncasefolded nicknames for all accounts, including additional (grouped) nicks.
func (am *AccountManager) AllNicks() (result []string) {
accountNamePrefix := fmt.Sprintf(keyAccountName, "")
@ -1765,7 +1834,6 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
nicksKey := fmt.Sprintf(keyAccountAdditionalNicks, casefoldedAccount)
settingsKey := fmt.Sprintf(keyAccountSettings, casefoldedAccount)
vhostKey := fmt.Sprintf(keyAccountVHost, casefoldedAccount)
channelsKey := fmt.Sprintf(keyAccountChannels, casefoldedAccount)
joinedChannelsKey := fmt.Sprintf(keyAccountChannelToModes, casefoldedAccount)
lastSeenKey := fmt.Sprintf(keyAccountLastSeen, casefoldedAccount)
readMarkersKey := fmt.Sprintf(keyAccountReadMarkers, casefoldedAccount)
@ -1781,10 +1849,9 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
am.killClients(clients)
}()
var registeredChannels []string
// on our way out, unregister all the account's channels and delete them from the db
defer func() {
for _, channelName := range registeredChannels {
for _, channelName := range am.server.channels.ChannelsForAccount(casefoldedAccount) {
err := am.server.channels.SetUnregistered(channelName, casefoldedAccount)
if err != nil {
am.server.logger.Error("internal", "couldn't unregister channel", channelName, err.Error())
@ -1799,7 +1866,6 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
defer am.serialCacheUpdateMutex.Unlock()
var accountName string
var channelsStr string
keepProtections := false
am.server.store.Update(func(tx *buntdb.Tx) error {
// get the unfolded account name; for an active account, this is
@ -1827,8 +1893,6 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
credText, err = tx.Get(credentialsKey)
tx.Delete(credentialsKey)
tx.Delete(vhostKey)
channelsStr, _ = tx.Get(channelsKey)
tx.Delete(channelsKey)
tx.Delete(joinedChannelsKey)
tx.Delete(lastSeenKey)
tx.Delete(readMarkersKey)
@ -1858,7 +1922,6 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
skeleton, _ := Skeleton(accountName)
additionalNicks := unmarshalReservedNicks(rawNicks)
registeredChannels = unmarshalRegisteredChannels(channelsStr)
am.Lock()
defer am.Unlock()
@ -1890,21 +1953,6 @@ func unmarshalRegisteredChannels(channelsStr string) (result []string) {
return
}
func (am *AccountManager) ChannelsForAccount(account string) (channels []string) {
cfaccount, err := CasefoldName(account)
if err != nil {
return
}
var channelStr string
key := fmt.Sprintf(keyAccountChannels, cfaccount)
am.server.store.View(func(tx *buntdb.Tx) error {
channelStr, _ = tx.Get(key)
return nil
})
return unmarshalRegisteredChannels(channelStr)
}
func (am *AccountManager) AuthenticateByCertificate(client *Client, certfp string, peerCerts []*x509.Certificate, authzid string) (err error) {
if certfp == "" {
return errAccountInvalidCredentials
@ -1961,8 +2009,10 @@ func (am *AccountManager) AuthenticateByCertificate(client *Client, certfp strin
return err
}
if authzid != "" && authzid != account {
return errAuthzidAuthcidMismatch
if authzid != "" {
if cfAuthzid, err := CasefoldName(authzid); err != nil || cfAuthzid != account {
return errAuthzidAuthcidMismatch
}
}
// ok, we found an account corresponding to their certificate
@ -2167,6 +2217,8 @@ var (
"PLAIN": authPlainHandler,
"EXTERNAL": authExternalHandler,
"SCRAM-SHA-256": authScramHandler,
"OAUTHBEARER": authOauthBearerHandler,
"IRCV3BEARER": authIRCv3BearerHandler,
}
)

View File

@ -10,6 +10,7 @@ import (
"fmt"
"net"
"github.com/ergochat/ergo/irc/oauth2"
"github.com/ergochat/ergo/irc/utils"
)
@ -20,7 +21,8 @@ type AuthScriptInput struct {
Certfp string `json:"certfp,omitempty"`
PeerCerts []string `json:"peerCerts,omitempty"`
peerCerts []*x509.Certificate
IP string `json:"ip,omitempty"`
IP string `json:"ip,omitempty"`
OAuthBearer *oauth2.OAuthBearerOptions `json:"oauth2,omitempty"`
}
type AuthScriptOutput struct {

106
irc/bunt/bunt_datastore.go Normal file
View File

@ -0,0 +1,106 @@
// Copyright (c) 2022 Shivaram Lingamneni
// released under the MIT license
package bunt
import (
"fmt"
"strings"
"time"
"github.com/tidwall/buntdb"
"github.com/ergochat/ergo/irc/datastore"
"github.com/ergochat/ergo/irc/logger"
"github.com/ergochat/ergo/irc/utils"
)
// BuntKey yields a string key corresponding to a (table, UUID) pair.
// Ideally this would not be public, but some of the migration code
// needs it.
func BuntKey(table datastore.Table, uuid utils.UUID) string {
return fmt.Sprintf("%x %s", table, uuid.String())
}
// buntdbDatastore implements datastore.Datastore using a buntdb.
type buntdbDatastore struct {
db *buntdb.DB
logger *logger.Manager
}
// NewBuntdbDatastore returns a datastore.Datastore backed by buntdb.
func NewBuntdbDatastore(db *buntdb.DB, logger *logger.Manager) datastore.Datastore {
return &buntdbDatastore{
db: db,
logger: logger,
}
}
func (b *buntdbDatastore) Backoff() time.Duration {
return 0
}
func (b *buntdbDatastore) GetAll(table datastore.Table) (result []datastore.KV, err error) {
tablePrefix := fmt.Sprintf("%x ", table)
err = b.db.View(func(tx *buntdb.Tx) error {
err := tx.AscendGreaterOrEqual("", tablePrefix, func(key, value string) bool {
if !strings.HasPrefix(key, tablePrefix) {
return false
}
uuid, err := utils.DecodeUUID(strings.TrimPrefix(key, tablePrefix))
if err == nil {
result = append(result, datastore.KV{UUID: uuid, Value: []byte(value)})
} else {
b.logger.Error("datastore", "invalid uuid", key)
}
return true
})
return err
})
return
}
func (b *buntdbDatastore) Get(table datastore.Table, uuid utils.UUID) (value []byte, err error) {
buntKey := BuntKey(table, uuid)
var result string
err = b.db.View(func(tx *buntdb.Tx) error {
result, err = tx.Get(buntKey)
return err
})
return []byte(result), err
}
func (b *buntdbDatastore) Set(table datastore.Table, uuid utils.UUID, value []byte, expiration time.Time) (err error) {
buntKey := BuntKey(table, uuid)
var setOptions *buntdb.SetOptions
if !expiration.IsZero() {
ttl := time.Until(expiration)
if ttl > 0 {
setOptions = &buntdb.SetOptions{Expires: true, TTL: ttl}
} else {
return nil // it already expired, i guess?
}
}
strVal := string(value)
err = b.db.Update(func(tx *buntdb.Tx) error {
_, _, err := tx.Set(buntKey, strVal, setOptions)
return err
})
return
}
func (b *buntdbDatastore) Delete(table datastore.Table, key utils.UUID) (err error) {
buntKey := BuntKey(table, key)
err = b.db.Update(func(tx *buntdb.Tx) error {
_, err := tx.Delete(buntKey)
return err
})
// deleting a nonexistent key is not considered an error
switch err {
case buntdb.ErrNotFound:
return nil
default:
return err
}
}

View File

@ -62,6 +62,8 @@ const (
RelaymsgTagName = "draft/relaymsg"
// BOT mode: https://ircv3.net/specs/extensions/bot-mode
BotTagName = "bot"
// https://ircv3.net/specs/extensions/chathistory
ChathistoryTargetsBatchType = "draft/chathistory-targets"
)
func init() {

View File

@ -7,9 +7,9 @@ package caps
const (
// number of recognized capabilities:
numCapabs = 30
// length of the uint64 array that represents the bitset:
bitsetLen = 1
numCapabs = 34
// length of the uint32 array that represents the bitset:
bitsetLen = 2
)
const (
@ -57,14 +57,26 @@ const (
// https://gist.github.com/DanielOaks/8126122f74b26012a3de37db80e4e0c6
Languages Capability = iota
// MessageRedaction is the proposed IRCv3 capability named "draft/message-redaction":
// https://github.com/progval/ircv3-specifications/blob/redaction/extensions/message-redaction.md
MessageRedaction Capability = iota
// Multiline is the proposed IRCv3 capability named "draft/multiline":
// https://github.com/ircv3/ircv3-specifications/pull/398
Multiline Capability = iota
// NoImplicitNames is the proposed IRCv3 capability named "draft/no-implicit-names":
// https://github.com/ircv3/ircv3-specifications/pull/527
NoImplicitNames Capability = iota
// Persistence is the proposed IRCv3 capability named "draft/persistence":
// https://github.com/ircv3/ircv3-specifications/pull/503
Persistence Capability = iota
// Preaway is the proposed IRCv3 capability named "draft/pre-away":
// https://github.com/ircv3/ircv3-specifications/pull/514
Preaway Capability = iota
// ReadMarker is the draft IRCv3 capability named "draft/read-marker":
// https://github.com/ircv3/ircv3-specifications/pull/489
ReadMarker Capability = iota
@ -117,6 +129,10 @@ const (
// https://ircv3.net/specs/extensions/setname.html
SetName Capability = iota
// StandardReplies is the IRCv3 capability named "standard-replies":
// https://github.com/ircv3/ircv3-specifications/pull/506
StandardReplies Capability = iota
// STS is the IRCv3 capability named "sts":
// https://ircv3.net/specs/extensions/sts.html
STS Capability = iota
@ -148,8 +164,11 @@ var (
"draft/chathistory",
"draft/event-playback",
"draft/languages",
"draft/message-redaction",
"draft/multiline",
"draft/no-implicit-names",
"draft/persistence",
"draft/pre-away",
"draft/read-marker",
"draft/relaymsg",
"echo-message",
@ -163,6 +182,7 @@ var (
"sasl",
"server-time",
"setname",
"standard-replies",
"sts",
"userhost-in-names",
"znc.in/playback",

View File

@ -102,6 +102,13 @@ func (s *Set) Strings(version Version, values Values, maxLen int) (result []stri
var capab Capability
asSlice := s[:]
for capab = 0; capab < numCapabs; capab++ {
// XXX clients that only support CAP LS 301 cannot handle multiline
// responses. omit some CAPs in this case, forcing the response to fit on
// a single line. this is technically buggy for CAP LIST (as opposed to LS)
// but it shouldn't matter
if version < Cap302 && !isAllowed301(capab) {
continue
}
// skip any capabilities that are not enabled
if !utils.BitsetGet(asSlice, uint(capab)) {
continue
@ -122,3 +129,15 @@ func (s *Set) Strings(version Version, values Values, maxLen int) (result []stri
}
return
}
// this is a fixed whitelist of caps that are eligible for display in CAP LS 301
func isAllowed301(capab Capability) bool {
switch capab {
case AccountNotify, AccountTag, AwayNotify, Batch, ChgHost, Chathistory, EventPlayback,
Relaymsg, EchoMessage, Nope, ExtendedJoin, InviteNotify, LabeledResponse, MessageTags,
MultiPrefix, SASL, ServerTime, SetName, STS, UserhostInNames, ZNCSelfMessage, ZNCPlayback:
return true
default:
return false
}
}

View File

@ -3,8 +3,11 @@
package caps
import "testing"
import "reflect"
import (
"fmt"
"reflect"
"testing"
)
func TestSets(t *testing.T) {
s1 := NewSet()
@ -60,6 +63,19 @@ func TestSets(t *testing.T) {
}
}
func assertEqual(found, expected interface{}) {
if !reflect.DeepEqual(found, expected) {
panic(fmt.Sprintf("found %#v, expected %#v", found, expected))
}
}
func Test301WhitelistNotRespectedFor302(t *testing.T) {
s1 := NewSet()
s1.Enable(AccountTag, EchoMessage, StandardReplies)
assertEqual(s1.Strings(Cap301, nil, 0), []string{"account-tag echo-message"})
assertEqual(s1.Strings(Cap302, nil, 0), []string{"account-tag echo-message standard-replies"})
}
func TestSubtract(t *testing.T) {
s1 := NewSet(AccountTag, EchoMessage, UserhostInNames, ServerTime)

View File

@ -7,15 +7,17 @@ package irc
import (
"fmt"
"maps"
"strconv"
"strings"
"time"
"sync"
"github.com/ergochat/irc-go/ircutils"
"github.com/ergochat/irc-go/ircmsg"
"github.com/ergochat/ergo/irc/caps"
"github.com/ergochat/ergo/irc/datastore"
"github.com/ergochat/ergo/irc/history"
"github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/utils"
@ -33,7 +35,6 @@ type Channel struct {
key string
forward string
members MemberSet
membersCache []*Client // allow iteration over channel members without holding the lock
name string
nameCasefolded string
server *Server
@ -50,14 +51,17 @@ type Channel struct {
stateMutex sync.RWMutex // tier 1
writebackLock sync.Mutex // tier 1.5
joinPartMutex sync.Mutex // tier 3
ensureLoaded utils.Once // manages loading stored registration info from the database
dirtyBits uint
settings ChannelSettings
uuid utils.UUID
// these caches are paired to allow iteration over channel members without holding the lock
membersCache []*Client
memberDataCache []*memberData
}
// NewChannel creates a new channel from a `Server` and a `name`
// string, which must be unique on the server.
func NewChannel(s *Server, name, casefoldedName string, registered bool) *Channel {
func NewChannel(s *Server, name, casefoldedName string, registered bool, regInfo RegisteredChannel) *Channel {
config := s.Config()
channel := &Channel{
@ -71,14 +75,15 @@ func NewChannel(s *Server, name, casefoldedName string, registered bool) *Channe
channel.initializeLists()
channel.history.Initialize(0, 0)
if !registered {
if registered {
channel.applyRegInfo(regInfo)
} else {
channel.resizeHistory(config)
for _, mode := range config.Channels.defaultModes {
channel.flags.SetMode(mode, true)
}
// no loading to do, so "mark" the load operation as "done":
channel.ensureLoaded.Do(func() {})
} // else: modes will be loaded before first join
channel.uuid = utils.GenerateUUIDv4()
}
return channel
}
@ -92,24 +97,6 @@ func (channel *Channel) initializeLists() {
channel.accountToUMode = make(map[string]modes.Mode)
}
// EnsureLoaded blocks until the channel's registration info has been loaded
// from the database.
func (channel *Channel) EnsureLoaded() {
channel.ensureLoaded.Do(func() {
nmc := channel.NameCasefolded()
info, err := channel.server.channelRegistry.LoadChannel(nmc)
if err == nil {
channel.applyRegInfo(info)
} else {
channel.server.logger.Error("internal", "couldn't load channel", nmc, err.Error())
}
})
}
func (channel *Channel) IsLoaded() bool {
return channel.ensureLoaded.Done()
}
func (channel *Channel) resizeHistory(config *Config) {
status, _, _ := channel.historyStatus(config)
if status == HistoryEphemeral {
@ -126,6 +113,7 @@ func (channel *Channel) applyRegInfo(chanReg RegisteredChannel) {
channel.stateMutex.Lock()
defer channel.stateMutex.Unlock()
channel.uuid = chanReg.UUID
channel.registeredFounder = chanReg.Founder
channel.registeredTime = chanReg.RegisteredAt
channel.topic = chanReg.Topic
@ -150,38 +138,41 @@ func (channel *Channel) applyRegInfo(chanReg RegisteredChannel) {
}
// obtain a consistent snapshot of the channel state that can be persisted to the DB
func (channel *Channel) ExportRegistration(includeFlags uint) (info RegisteredChannel) {
func (channel *Channel) ExportRegistration() (info RegisteredChannel) {
channel.stateMutex.RLock()
defer channel.stateMutex.RUnlock()
info.Name = channel.name
info.NameCasefolded = channel.nameCasefolded
info.UUID = channel.uuid
info.Founder = channel.registeredFounder
info.RegisteredAt = channel.registeredTime
if includeFlags&IncludeTopic != 0 {
info.Topic = channel.topic
info.TopicSetBy = channel.topicSetBy
info.TopicSetTime = channel.topicSetTime
}
info.Topic = channel.topic
info.TopicSetBy = channel.topicSetBy
info.TopicSetTime = channel.topicSetTime
if includeFlags&IncludeModes != 0 {
info.Key = channel.key
info.Forward = channel.forward
info.Modes = channel.flags.AllModes()
info.UserLimit = channel.userLimit
}
info.Key = channel.key
info.Forward = channel.forward
info.Modes = channel.flags.AllModes()
info.UserLimit = channel.userLimit
if includeFlags&IncludeLists != 0 {
info.Bans = channel.lists[modes.BanMask].Masks()
info.Invites = channel.lists[modes.InviteMask].Masks()
info.Excepts = channel.lists[modes.ExceptMask].Masks()
info.AccountToUMode = utils.CopyMap(channel.accountToUMode)
}
info.Bans = channel.lists[modes.BanMask].Masks()
info.Invites = channel.lists[modes.InviteMask].Masks()
info.Excepts = channel.lists[modes.ExceptMask].Masks()
info.AccountToUMode = maps.Clone(channel.accountToUMode)
if includeFlags&IncludeSettings != 0 {
info.Settings = channel.settings
}
info.Settings = channel.settings
return
}
func (channel *Channel) exportSummary() (info RegisteredChannel) {
channel.stateMutex.RLock()
defer channel.stateMutex.RUnlock()
info.Name = channel.name
info.Founder = channel.registeredFounder
info.RegisteredAt = channel.registeredTime
return
}
@ -231,6 +222,8 @@ func (channel *Channel) wakeWriter() {
// equivalent of Socket.send()
func (channel *Channel) writeLoop() {
defer channel.server.HandlePanic()
for {
// TODO(#357) check the error value of this and implement timed backoff
channel.performWrite(0)
@ -288,9 +281,19 @@ func (channel *Channel) performWrite(additionalDirtyBits uint) (err error) {
return
}
info := channel.ExportRegistration(dirtyBits)
err = channel.server.channelRegistry.StoreChannel(info, dirtyBits)
if err != nil {
var success bool
info := channel.ExportRegistration()
if b, err := info.Serialize(); err == nil {
if err := channel.server.dstore.Set(datastore.TableChannels, info.UUID, b, time.Time{}); err == nil {
success = true
} else {
channel.server.logger.Error("internal", "couldn't persist channel", info.Name, err.Error())
}
} else {
channel.server.logger.Error("internal", "couldn't serialize channel", info.Name, err.Error())
}
if !success {
channel.stateMutex.Lock()
channel.dirtyBits = channel.dirtyBits | dirtyBits
channel.stateMutex.Unlock()
@ -314,6 +317,7 @@ func (channel *Channel) SetRegistered(founder string) error {
// SetUnregistered deletes the channel's registration information.
func (channel *Channel) SetUnregistered(expectedFounder string) {
uuid := utils.GenerateUUIDv4()
channel.stateMutex.Lock()
defer channel.stateMutex.Unlock()
@ -324,6 +328,9 @@ func (channel *Channel) SetUnregistered(expectedFounder string) {
var zeroTime time.Time
channel.registeredTime = zeroTime
channel.accountToUMode = make(map[string]modes.Mode)
// reset the UUID so that any re-registration will persist under
// a separate key:
channel.uuid = uuid
}
// implements `CHANSERV CLEAR #chan ACCESS` (resets bans, invites, excepts, and amodes)
@ -419,16 +426,19 @@ func (channel *Channel) AcceptTransfer(client *Client) (err error) {
func (channel *Channel) regenerateMembersCache() {
channel.stateMutex.RLock()
result := make([]*Client, len(channel.members))
membersCache := make([]*Client, len(channel.members))
dataCache := make([]*memberData, len(channel.members))
i := 0
for client := range channel.members {
result[i] = client
for client, info := range channel.members {
membersCache[i] = client
dataCache[i] = info
i++
}
channel.stateMutex.RUnlock()
channel.stateMutex.Lock()
channel.membersCache = result
channel.membersCache = membersCache
channel.memberDataCache = dataCache
channel.stateMutex.Unlock()
}
@ -436,59 +446,45 @@ func (channel *Channel) regenerateMembersCache() {
func (channel *Channel) Names(client *Client, rb *ResponseBuffer) {
channel.stateMutex.RLock()
clientData, isJoined := channel.members[client]
chname := channel.name
membersCache, memberDataCache := channel.membersCache, channel.memberDataCache
channel.stateMutex.RUnlock()
symbol := "=" // https://modern.ircdocs.horse/#rplnamreply-353
if channel.flags.HasMode(modes.Secret) {
symbol = "@"
}
isOper := client.HasRoleCapabs("sajoin")
respectAuditorium := channel.flags.HasMode(modes.Auditorium) && !isOper &&
(!isJoined || clientData.modes.HighestChannelUserMode() == modes.Mode(0))
isMultiPrefix := rb.session.capabilities.Has(caps.MultiPrefix)
isUserhostInNames := rb.session.capabilities.Has(caps.UserhostInNames)
maxNamLen := 480 - len(client.server.name) - len(client.Nick())
var namesLines []string
var buffer strings.Builder
maxNamLen := 480 - len(client.server.name) - len(client.Nick()) - len(chname)
var tl utils.TokenLineBuilder
tl.Initialize(maxNamLen, " ")
if isJoined || !channel.flags.HasMode(modes.Secret) || isOper {
for _, target := range channel.Members() {
for i, target := range membersCache {
if !isJoined && target.HasMode(modes.Invisible) && !isOper {
continue
}
var nick string
if isUserhostInNames {
nick = target.NickMaskString()
} else {
nick = target.Nick()
}
channel.stateMutex.RLock()
memberData, _ := channel.members[target]
channel.stateMutex.RUnlock()
modeSet := memberData.modes
if modeSet == nil {
memberData := memberDataCache[i]
if respectAuditorium && memberData.modes.HighestChannelUserMode() == modes.Mode(0) {
continue
}
if !isJoined && target.HasMode(modes.Invisible) && !isOper {
continue
}
if respectAuditorium && modeSet.HighestChannelUserMode() == modes.Mode(0) {
continue
}
prefix := modeSet.Prefixes(isMultiPrefix)
if buffer.Len()+len(nick)+len(prefix)+1 > maxNamLen {
namesLines = append(namesLines, buffer.String())
buffer.Reset()
}
if buffer.Len() > 0 {
buffer.WriteString(" ")
}
buffer.WriteString(prefix)
buffer.WriteString(nick)
}
if buffer.Len() > 0 {
namesLines = append(namesLines, buffer.String())
tl.AddParts(memberData.modes.Prefixes(isMultiPrefix), nick)
}
}
for _, line := range namesLines {
if buffer.Len() > 0 {
rb.Add(nil, client.server.name, RPL_NAMREPLY, client.nick, "=", channel.name, line)
}
for _, line := range tl.Lines() {
rb.Add(nil, client.server.name, RPL_NAMREPLY, client.nick, symbol, chname, line)
}
rb.Add(nil, client.server.name, RPL_ENDOFNAMES, client.nick, channel.name, client.t("End of NAMES list"))
rb.Add(nil, client.server.name, RPL_ENDOFNAMES, client.nick, chname, client.t("End of NAMES list"))
}
// does `clientMode` give you privileges to grant/remove `targetMode` to/from people,
@ -512,7 +508,7 @@ func channelUserModeHasPrivsOver(clientMode modes.Mode, targetMode modes.Mode) b
// ClientIsAtLeast returns whether the client has at least the given channel privilege.
func (channel *Channel) ClientIsAtLeast(client *Client, permission modes.Mode) bool {
channel.stateMutex.RLock()
memberData := channel.members[client]
memberData, present := channel.members[client]
founder := channel.registeredFounder
channel.stateMutex.RUnlock()
@ -520,6 +516,10 @@ func (channel *Channel) ClientIsAtLeast(client *Client, permission modes.Mode) b
return true
}
if !present {
return false
}
for _, mode := range modes.ChannelUserModes {
if memberData.modes.HasMode(mode) {
return true
@ -551,11 +551,14 @@ func (channel *Channel) ClientStatus(client *Client) (present bool, joinTimeSecs
// helper for persisting channel-user modes for always-on clients;
// return the channel name and all channel-user modes for a client
func (channel *Channel) alwaysOnStatus(client *Client) (chname string, status alwaysOnChannelStatus) {
func (channel *Channel) alwaysOnStatus(client *Client) (ok bool, chname string, status alwaysOnChannelStatus) {
channel.stateMutex.RLock()
defer channel.stateMutex.RUnlock()
chname = channel.name
data := channel.members[client]
data, ok := channel.members[client]
if !ok {
return
}
status.Modes = data.modes.String()
status.JoinTime = data.joinTime
return
@ -569,20 +572,20 @@ func (channel *Channel) setMemberStatus(client *Client, status alwaysOnChannelSt
}
channel.stateMutex.Lock()
defer channel.stateMutex.Unlock()
if _, ok := channel.members[client]; !ok {
return
if mData, ok := channel.members[client]; ok {
mData.modes.Clear()
for _, mode := range status.Modes {
mData.modes.SetMode(modes.Mode(mode), true)
}
mData.joinTime = status.JoinTime
}
memberData := channel.members[client]
memberData.modes = newModes
memberData.joinTime = status.JoinTime
channel.members[client] = memberData
}
func (channel *Channel) ClientHasPrivsOver(client *Client, target *Client) bool {
channel.stateMutex.RLock()
founder := channel.registeredFounder
clientModes := channel.members[client].modes
targetModes := channel.members[target].modes
clientData, clientOK := channel.members[client]
targetData, targetOK := channel.members[target]
channel.stateMutex.RUnlock()
if founder != "" {
@ -593,7 +596,11 @@ func (channel *Channel) ClientHasPrivsOver(client *Client, target *Client) bool
}
}
return channelUserModeHasPrivsOver(clientModes.HighestChannelUserMode(), targetModes.HighestChannelUserMode())
return clientOK && targetOK &&
channelUserModeHasPrivsOver(
clientData.modes.HighestChannelUserMode(),
targetData.modes.HighestChannelUserMode(),
)
}
func (channel *Channel) hasClient(client *Client) bool {
@ -887,7 +894,9 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
if rb.session.client == client {
// don't send topic and names for a SAJOIN of a different client
channel.SendTopic(client, rb, false)
channel.Names(client, rb)
if !rb.session.capabilities.Has(caps.NoImplicitNames) {
channel.Names(client, rb)
}
} else {
// ensure that SAJOIN sends a MODE line to the originating client, if applicable
if givenMode != 0 {
@ -978,7 +987,9 @@ func (channel *Channel) playJoinForSession(session *Session) {
sessionRb.Add(nil, client.server.name, "MARKREAD", chname, client.GetReadMarker(chcfname))
}
channel.SendTopic(client, sessionRb, false)
channel.Names(client, sessionRb)
if !session.capabilities.Has(caps.NoImplicitNames) {
channel.Names(client, sessionRb)
}
sessionRb.Send(false)
}
@ -1062,7 +1073,7 @@ func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.I
}
}
batchID := rb.StartNestedHistoryBatch(chname)
batchID := rb.StartNestedBatch("chathistory", chname)
defer rb.EndNestedBatch(batchID)
for _, item := range items {
@ -1194,7 +1205,7 @@ func (channel *Channel) SetTopic(client *Client, topic string, rb *ResponseBuffe
return
}
topic = ircutils.TruncateUTF8Safe(topic, client.server.Config().Limits.TopicLen)
topic = ircmsg.TruncateUTF8Safe(topic, client.server.Config().Limits.TopicLen)
channel.stateMutex.Lock()
chname := channel.name
@ -1231,20 +1242,26 @@ func (channel *Channel) CanSpeak(client *Client) (bool, modes.Mode) {
channel.stateMutex.RLock()
memberData, hasClient := channel.members[client]
channel.stateMutex.RUnlock()
clientModes := memberData.modes
highestMode := func() modes.Mode {
if !hasClient {
return modes.Mode(0)
}
return memberData.modes.HighestChannelUserMode()
}
if !hasClient && channel.flags.HasMode(modes.NoOutside) {
// TODO: enforce regular +b bans on -n channels?
return false, modes.NoOutside
}
if channel.isMuted(client) && clientModes.HighestChannelUserMode() == modes.Mode(0) {
if channel.isMuted(client) && highestMode() == modes.Mode(0) {
return false, modes.BanMask
}
if channel.flags.HasMode(modes.Moderated) && clientModes.HighestChannelUserMode() == modes.Mode(0) {
if channel.flags.HasMode(modes.Moderated) && highestMode() == modes.Mode(0) {
return false, modes.Moderated
}
if channel.flags.HasMode(modes.RegisteredOnlySpeak) && client.Account() == "" &&
clientModes.HighestChannelUserMode() == modes.Mode(0) {
highestMode() == modes.Mode(0) {
return false, modes.RegisteredOnlySpeak
}
return true, modes.Mode('?')
@ -1317,9 +1334,9 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
if channel.flags.HasMode(modes.OpModerated) {
channel.stateMutex.RLock()
cuData := channel.members[client]
cuData, ok := channel.members[client]
channel.stateMutex.RUnlock()
if cuData.modes.HighestChannelUserMode() == modes.Mode(0) {
if !ok || cuData.modes.HighestChannelUserMode() == modes.Mode(0) {
// max(statusmsg_minmode, halfop)
if minPrefixMode == modes.Mode(0) || minPrefixMode == modes.Voice {
minPrefixMode = modes.Halfop
@ -1447,7 +1464,7 @@ func (channel *Channel) Kick(client *Client, target *Client, comment string, rb
return
}
comment = ircutils.TruncateUTF8Safe(comment, channel.server.Config().Limits.KickLen)
comment = ircmsg.TruncateUTF8Safe(comment, channel.server.Config().Limits.KickLen)
message := utils.MakeMessage(comment)
details := client.Details()
@ -1488,6 +1505,7 @@ func (channel *Channel) Purge(source string) {
chname := channel.name
members := channel.membersCache
channel.membersCache = nil
channel.memberDataCache = nil
channel.members = make(MemberSet)
// TODO try to prevent Purge racing against (pending) Join?
channel.stateMutex.Unlock()
@ -1608,6 +1626,26 @@ func (channel *Channel) auditoriumFriends(client *Client) (friends []*Client) {
return
}
// returns whether the client is visible to unprivileged users in the channel
// (i.e., respecting auditorium mode). note that this assumes that the client
// is a member; if the client is not, it may return true anyway
func (channel *Channel) memberIsVisible(client *Client) bool {
// fast path, we assume they're a member so if this isn't an auditorium,
// they're visible:
if !channel.flags.HasMode(modes.Auditorium) {
return true
}
channel.stateMutex.RLock()
defer channel.stateMutex.RUnlock()
clientData, found := channel.members[client]
if !found {
return false
}
return clientData.modes.HighestChannelUserMode() != modes.Mode(0)
}
// data for RPL_LIST
func (channel *Channel) listData() (memberCount int, name, topic string) {
channel.stateMutex.RLock()

View File

@ -6,7 +6,9 @@ package irc
import (
"sort"
"sync"
"time"
"github.com/ergochat/ergo/irc/datastore"
"github.com/ergochat/ergo/irc/utils"
)
@ -25,85 +27,75 @@ type channelManagerEntry struct {
type ChannelManager struct {
sync.RWMutex // tier 2
// chans is the main data structure, mapping casefolded name -> *Channel
chans map[string]*channelManagerEntry
chansSkeletons utils.HashSet[string] // skeletons of *unregistered* chans
registeredChannels utils.HashSet[string] // casefolds of registered chans
registeredSkeletons utils.HashSet[string] // skeletons of registered chans
purgedChannels utils.HashSet[string] // casefolds of purged chans
server *Server
chans map[string]*channelManagerEntry
chansSkeletons utils.HashSet[string]
purgedChannels map[string]ChannelPurgeRecord // casefolded name to purge record
server *Server
}
// NewChannelManager returns a new ChannelManager.
func (cm *ChannelManager) Initialize(server *Server) {
func (cm *ChannelManager) Initialize(server *Server, config *Config) (err error) {
cm.chans = make(map[string]*channelManagerEntry)
cm.chansSkeletons = make(utils.HashSet[string])
cm.server = server
// purging should work even if registration is disabled
cm.purgedChannels = cm.server.channelRegistry.PurgedChannels()
cm.loadRegisteredChannels(server.Config())
return cm.loadRegisteredChannels(config)
}
func (cm *ChannelManager) loadRegisteredChannels(config *Config) {
if !config.Channels.Registration.Enabled {
func (cm *ChannelManager) loadRegisteredChannels(config *Config) (err error) {
allChannels, err := FetchAndDeserializeAll[RegisteredChannel](datastore.TableChannels, cm.server.dstore, cm.server.logger)
if err != nil {
return
}
allPurgeRecords, err := FetchAndDeserializeAll[ChannelPurgeRecord](datastore.TableChannelPurges, cm.server.dstore, cm.server.logger)
if err != nil {
return
}
var newChannels []*Channel
var collisions []string
defer func() {
for _, ch := range newChannels {
ch.EnsureLoaded()
cm.server.logger.Debug("channels", "initialized registered channel", ch.Name())
}
for _, collision := range collisions {
cm.server.logger.Warning("channels", "registered channel collides with existing channel", collision)
}
}()
rawNames := cm.server.channelRegistry.AllChannels()
cm.Lock()
defer cm.Unlock()
cm.registeredChannels = make(utils.HashSet[string], len(rawNames))
cm.registeredSkeletons = make(utils.HashSet[string], len(rawNames))
for _, name := range rawNames {
cfname, err := CasefoldChannel(name)
if err == nil {
cm.registeredChannels.Add(cfname)
cm.purgedChannels = make(map[string]ChannelPurgeRecord, len(allPurgeRecords))
for _, purge := range allPurgeRecords {
cm.purgedChannels[purge.NameCasefolded] = purge
}
for _, regInfo := range allChannels {
cfname, err := CasefoldChannel(regInfo.Name)
if err != nil {
cm.server.logger.Error("channels", "couldn't casefold registered channel, skipping", regInfo.Name, err.Error())
continue
} else {
cm.server.logger.Debug("channels", "initializing registered channel", regInfo.Name)
}
skeleton, err := Skeleton(name)
skeleton, err := Skeleton(regInfo.Name)
if err == nil {
cm.registeredSkeletons.Add(skeleton)
cm.chansSkeletons.Add(skeleton)
}
if !cm.purgedChannels.Has(cfname) {
if _, ok := cm.chans[cfname]; !ok {
ch := NewChannel(cm.server, name, cfname, true)
cm.chans[cfname] = &channelManagerEntry{
channel: ch,
pendingJoins: 0,
}
newChannels = append(newChannels, ch)
} else {
collisions = append(collisions, name)
if _, ok := cm.purgedChannels[cfname]; !ok {
ch := NewChannel(cm.server, regInfo.Name, cfname, true, regInfo)
cm.chans[cfname] = &channelManagerEntry{
channel: ch,
pendingJoins: 0,
skeleton: skeleton,
}
}
}
return nil
}
// Get returns an existing channel with name equivalent to `name`, or nil
func (cm *ChannelManager) Get(name string) (channel *Channel) {
name, err := CasefoldChannel(name)
if err == nil {
cm.RLock()
defer cm.RUnlock()
entry := cm.chans[name]
// if the channel is still loading, pretend we don't have it
if entry != nil && entry.channel.IsLoaded() {
return entry.channel
}
if err != nil {
return nil
}
cm.RLock()
defer cm.RUnlock()
entry := cm.chans[name]
if entry != nil {
return entry.channel
}
return nil
}
@ -122,33 +114,26 @@ func (cm *ChannelManager) Join(client *Client, name string, key string, isSajoin
cm.Lock()
defer cm.Unlock()
if cm.purgedChannels.Has(casefoldedName) {
// check purges first; a registered purged channel will still be present in `chans`
if _, ok := cm.purgedChannels[casefoldedName]; ok {
return nil, errChannelPurged, false
}
entry := cm.chans[casefoldedName]
if entry == nil {
registered := cm.registeredChannels.Has(casefoldedName)
// enforce OpOnlyCreation
if !registered && server.Config().Channels.OpOnlyCreation &&
if server.Config().Channels.OpOnlyCreation &&
!(isSajoin || client.HasRoleCapabs("chanreg")) {
return nil, errInsufficientPrivs, false
}
// enforce confusables
if !registered && (cm.chansSkeletons.Has(skeleton) || cm.registeredSkeletons.Has(skeleton)) {
if cm.chansSkeletons.Has(skeleton) {
return nil, errConfusableIdentifier, false
}
entry = &channelManagerEntry{
channel: NewChannel(server, name, casefoldedName, registered),
channel: NewChannel(server, name, casefoldedName, false, RegisteredChannel{}),
pendingJoins: 0,
}
if !registered {
// for an unregistered channel, we already have the correct unfolded name
// and therefore the final skeleton. for a registered channel, we don't have
// the unfolded name yet (it needs to be loaded from the db), but we already
// have the final skeleton in `registeredSkeletons` so we don't need to track it
cm.chansSkeletons.Add(skeleton)
entry.skeleton = skeleton
}
cm.chansSkeletons.Add(skeleton)
entry.skeleton = skeleton
cm.chans[casefoldedName] = entry
newChannel = true
}
@ -160,7 +145,6 @@ func (cm *ChannelManager) Join(client *Client, name string, key string, isSajoin
return err, ""
}
channel.EnsureLoaded()
err, forward = channel.Join(client, key, isSajoin || newChannel, rb)
cm.maybeCleanup(channel, true)
@ -252,13 +236,6 @@ func (cm *ChannelManager) SetRegistered(channelName string, account string) (err
if err != nil {
return err
}
// transfer the skeleton from chansSkeletons to registeredSkeletons
skeleton := entry.skeleton
delete(cm.chansSkeletons, skeleton)
entry.skeleton = ""
cm.chans[cfname] = entry
cm.registeredChannels.Add(cfname)
cm.registeredSkeletons.Add(skeleton)
return nil
}
@ -268,17 +245,13 @@ func (cm *ChannelManager) SetUnregistered(channelName string, account string) (e
return err
}
info, err := cm.server.channelRegistry.LoadChannel(cfname)
if err != nil {
return err
}
if info.Founder != account {
return errChannelNotOwnedByAccount
}
var uuid utils.UUID
defer func() {
if err == nil {
err = cm.server.channelRegistry.Delete(info)
if delErr := cm.server.dstore.Delete(datastore.TableChannels, uuid); delErr != nil {
cm.server.logger.Error("datastore", "couldn't delete channel registration", cfname, delErr.Error())
}
}
}()
@ -286,15 +259,11 @@ func (cm *ChannelManager) SetUnregistered(channelName string, account string) (e
defer cm.Unlock()
entry := cm.chans[cfname]
if entry != nil {
entry.channel.SetUnregistered(account)
delete(cm.registeredChannels, cfname)
// transfer the skeleton from registeredSkeletons to chansSkeletons
if skel, err := Skeleton(entry.channel.Name()); err == nil {
delete(cm.registeredSkeletons, skel)
cm.chansSkeletons.Add(skel)
entry.skeleton = skel
cm.chans[cfname] = entry
if entry.channel.Founder() != account {
return errChannelNotOwnedByAccount
}
uuid = entry.channel.UUID()
entry.channel.SetUnregistered(account) // changes the UUID
// #1619: if the channel has 0 members and was only being retained
// because it was registered, clean it up:
cm.maybeCleanupInternal(cfname, entry, false)
@ -322,12 +291,11 @@ func (cm *ChannelManager) Rename(name string, newName string) (err error) {
var info RegisteredChannel
defer func() {
if channel != nil && info.Founder != "" {
channel.Store(IncludeAllAttrs)
if oldCfname != newCfname {
// we just flushed the channel under its new name, therefore this delete
// cannot be overwritten by a write to the old name:
cm.server.channelRegistry.Delete(info)
}
channel.MarkDirty(IncludeAllAttrs)
}
// always-on clients need to update their saved channel memberships
for _, member := range channel.Members() {
member.markDirty(IncludeChannels)
}
}()
@ -335,11 +303,11 @@ func (cm *ChannelManager) Rename(name string, newName string) (err error) {
defer cm.Unlock()
entry := cm.chans[oldCfname]
if entry == nil || !entry.channel.IsLoaded() {
if entry == nil {
return errNoSuchChannel
}
channel = entry.channel
info = channel.ExportRegistration(IncludeInitial)
info = channel.ExportRegistration()
registered := info.Founder != ""
oldSkeleton, err := Skeleton(info.Name)
@ -348,13 +316,13 @@ func (cm *ChannelManager) Rename(name string, newName string) (err error) {
}
if newCfname != oldCfname {
if cm.chans[newCfname] != nil || cm.registeredChannels.Has(newCfname) {
if cm.chans[newCfname] != nil {
return errChannelNameInUse
}
}
if oldSkeleton != newSkeleton {
if cm.chansSkeletons.Has(newSkeleton) || cm.registeredSkeletons.Has(newSkeleton) {
if cm.chansSkeletons.Has(newSkeleton) {
return errConfusableIdentifier
}
}
@ -364,15 +332,8 @@ func (cm *ChannelManager) Rename(name string, newName string) (err error) {
entry.skeleton = newSkeleton
}
cm.chans[newCfname] = entry
if registered {
delete(cm.registeredChannels, oldCfname)
cm.registeredChannels.Add(newCfname)
delete(cm.registeredSkeletons, oldSkeleton)
cm.registeredSkeletons.Add(newSkeleton)
} else {
delete(cm.chansSkeletons, oldSkeleton)
cm.chansSkeletons.Add(newSkeleton)
}
delete(cm.chansSkeletons, oldSkeleton)
cm.chansSkeletons.Add(newSkeleton)
entry.channel.Rename(newName, newCfname)
return nil
}
@ -390,7 +351,18 @@ func (cm *ChannelManager) Channels() (result []*Channel) {
defer cm.RUnlock()
result = make([]*Channel, 0, len(cm.chans))
for _, entry := range cm.chans {
if entry.channel.IsLoaded() {
result = append(result, entry.channel)
}
return
}
// ListableChannels returns a slice of all non-purged channels.
func (cm *ChannelManager) ListableChannels() (result []*Channel) {
cm.RLock()
defer cm.RUnlock()
result = make([]*Channel, 0, len(cm.chans))
for cfname, entry := range cm.chans {
if _, ok := cm.purgedChannels[cfname]; !ok {
result = append(result, entry.channel)
}
}
@ -403,29 +375,46 @@ func (cm *ChannelManager) Purge(chname string, record ChannelPurgeRecord) (err e
if err != nil {
return errInvalidChannelName
}
skel, err := Skeleton(chname)
if err != nil {
return errInvalidChannelName
}
cm.Lock()
cm.purgedChannels.Add(chname)
entry := cm.chans[chname]
if entry != nil {
delete(cm.chans, chname)
if entry.channel.Founder() != "" {
delete(cm.registeredSkeletons, skel)
} else {
delete(cm.chansSkeletons, skel)
record.NameCasefolded = chname
record.UUID = utils.GenerateUUIDv4()
channel, err := func() (channel *Channel, err error) {
cm.Lock()
defer cm.Unlock()
if _, ok := cm.purgedChannels[chname]; ok {
return nil, errChannelPurgedAlready
}
}
cm.Unlock()
cm.server.channelRegistry.PurgeChannel(chname, record)
if entry != nil {
entry.channel.Purge("")
entry := cm.chans[chname]
// atomically prevent anyone from rejoining
cm.purgedChannels[chname] = record
if entry != nil {
channel = entry.channel
}
return
}()
if err != nil {
return err
}
return nil
if channel != nil {
// actually kick everyone off the channel
channel.Purge("")
}
var purgeBytes []byte
if purgeBytes, err = record.Serialize(); err != nil {
cm.server.logger.Error("internal", "couldn't serialize purge record", channel.Name(), err.Error())
}
// TODO we need a better story about error handling for later
if err = cm.server.dstore.Set(datastore.TableChannelPurges, record.UUID, purgeBytes, time.Time{}); err != nil {
cm.server.logger.Error("datastore", "couldn't store purge record", chname, err.Error())
}
return
}
// IsPurged queries whether a channel is purged.
@ -436,7 +425,7 @@ func (cm *ChannelManager) IsPurged(chname string) (result bool) {
}
cm.RLock()
result = cm.purgedChannels.Has(chname)
_, result = cm.purgedChannels[chname]
cm.RUnlock()
return
}
@ -449,14 +438,16 @@ func (cm *ChannelManager) Unpurge(chname string) (err error) {
}
cm.Lock()
found := cm.purgedChannels.Has(chname)
record, found := cm.purgedChannels[chname]
delete(cm.purgedChannels, chname)
cm.Unlock()
cm.server.channelRegistry.UnpurgeChannel(chname)
if !found {
return errNoSuchChannel
}
if err := cm.server.dstore.Delete(datastore.TableChannelPurges, record.UUID); err != nil {
cm.server.logger.Error("datastore", "couldn't delete purge record", chname, err.Error())
}
return nil
}
@ -475,8 +466,46 @@ func (cm *ChannelManager) UnfoldName(cfname string) (result string) {
cm.RLock()
entry := cm.chans[cfname]
cm.RUnlock()
if entry != nil && entry.channel.IsLoaded() {
if entry != nil {
return entry.channel.Name()
}
return cfname
}
func (cm *ChannelManager) LoadPurgeRecord(cfchname string) (record ChannelPurgeRecord, err error) {
cm.RLock()
defer cm.RUnlock()
if record, ok := cm.purgedChannels[cfchname]; ok {
return record, nil
} else {
return record, errNoSuchChannel
}
}
func (cm *ChannelManager) ChannelsForAccount(account string) (channels []string) {
cm.RLock()
defer cm.RUnlock()
for cfname, entry := range cm.chans {
if entry.channel.Founder() == account {
channels = append(channels, cfname)
}
}
return
}
// AllChannels returns the uncasefolded names of all registered channels.
func (cm *ChannelManager) AllRegisteredChannels() (result []string) {
cm.RLock()
defer cm.RUnlock()
for cfname, entry := range cm.chans {
if entry.channel.Founder() != "" {
result = append(result, cfname)
}
}
return
}

View File

@ -5,13 +5,8 @@ package irc
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/tidwall/buntdb"
"github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/utils"
)
@ -19,48 +14,6 @@ import (
// this is exclusively the *persistence* layer for channel registration;
// channel creation/tracking/destruction is in channelmanager.go
const (
keyChannelExists = "channel.exists %s"
keyChannelName = "channel.name %s" // stores the 'preferred name' of the channel, not casemapped
keyChannelRegTime = "channel.registered.time %s"
keyChannelFounder = "channel.founder %s"
keyChannelTopic = "channel.topic %s"
keyChannelTopicSetBy = "channel.topic.setby %s"
keyChannelTopicSetTime = "channel.topic.settime %s"
keyChannelBanlist = "channel.banlist %s"
keyChannelExceptlist = "channel.exceptlist %s"
keyChannelInvitelist = "channel.invitelist %s"
keyChannelPassword = "channel.key %s"
keyChannelModes = "channel.modes %s"
keyChannelAccountToUMode = "channel.accounttoumode %s"
keyChannelUserLimit = "channel.userlimit %s"
keyChannelSettings = "channel.settings %s"
keyChannelForward = "channel.forward %s"
keyChannelPurged = "channel.purged %s"
)
var (
channelKeyStrings = []string{
keyChannelExists,
keyChannelName,
keyChannelRegTime,
keyChannelFounder,
keyChannelTopic,
keyChannelTopicSetBy,
keyChannelTopicSetTime,
keyChannelBanlist,
keyChannelExceptlist,
keyChannelInvitelist,
keyChannelPassword,
keyChannelModes,
keyChannelAccountToUMode,
keyChannelUserLimit,
keyChannelSettings,
keyChannelForward,
}
)
// these are bit flags indicating what part of the channel status is "dirty"
// and needs to be read from memory and written to the db
const (
@ -80,8 +33,8 @@ const (
type RegisteredChannel struct {
// Name of the channel.
Name string
// Casefolded name of the channel.
NameCasefolded string
// UUID for the datastore.
UUID utils.UUID
// RegisteredAt represents the time that the channel was registered.
RegisteredAt time.Time
// Founder indicates the founder of the channel.
@ -112,322 +65,26 @@ type RegisteredChannel struct {
Settings ChannelSettings
}
func (r *RegisteredChannel) Serialize() ([]byte, error) {
return json.Marshal(r)
}
func (r *RegisteredChannel) Deserialize(b []byte) (err error) {
return json.Unmarshal(b, r)
}
type ChannelPurgeRecord struct {
Oper string
PurgedAt time.Time
Reason string
NameCasefolded string `json:"Name"`
UUID utils.UUID
Oper string
PurgedAt time.Time
Reason string
}
// ChannelRegistry manages registered channels.
type ChannelRegistry struct {
server *Server
func (c *ChannelPurgeRecord) Serialize() ([]byte, error) {
return json.Marshal(c)
}
// NewChannelRegistry returns a new ChannelRegistry.
func (reg *ChannelRegistry) Initialize(server *Server) {
reg.server = server
}
// AllChannels returns the uncasefolded names of all registered channels.
func (reg *ChannelRegistry) AllChannels() (result []string) {
prefix := fmt.Sprintf(keyChannelName, "")
reg.server.store.View(func(tx *buntdb.Tx) error {
return tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool {
if !strings.HasPrefix(key, prefix) {
return false
}
result = append(result, value)
return true
})
})
return
}
// PurgedChannels returns the set of all casefolded channel names that have been purged
func (reg *ChannelRegistry) PurgedChannels() (result utils.HashSet[string]) {
result = make(utils.HashSet[string])
prefix := fmt.Sprintf(keyChannelPurged, "")
reg.server.store.View(func(tx *buntdb.Tx) error {
return tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool {
if !strings.HasPrefix(key, prefix) {
return false
}
channel := strings.TrimPrefix(key, prefix)
result.Add(channel)
return true
})
})
return
}
// StoreChannel obtains a consistent view of a channel, then persists it to the store.
func (reg *ChannelRegistry) StoreChannel(info RegisteredChannel, includeFlags uint) (err error) {
if !reg.server.ChannelRegistrationEnabled() {
return
}
if info.Founder == "" {
// sanity check, don't try to store an unregistered channel
return
}
reg.server.store.Update(func(tx *buntdb.Tx) error {
reg.saveChannel(tx, info, includeFlags)
return nil
})
return nil
}
// LoadChannel loads a channel from the store.
func (reg *ChannelRegistry) LoadChannel(nameCasefolded string) (info RegisteredChannel, err error) {
if !reg.server.ChannelRegistrationEnabled() {
err = errFeatureDisabled
return
}
channelKey := nameCasefolded
// nice to have: do all JSON (de)serialization outside of the buntdb transaction
err = reg.server.store.View(func(tx *buntdb.Tx) error {
_, dberr := tx.Get(fmt.Sprintf(keyChannelExists, channelKey))
if dberr == buntdb.ErrNotFound {
// chan does not already exist, return
return errNoSuchChannel
}
// channel exists, load it
name, _ := tx.Get(fmt.Sprintf(keyChannelName, channelKey))
regTime, _ := tx.Get(fmt.Sprintf(keyChannelRegTime, channelKey))
regTimeInt, _ := strconv.ParseInt(regTime, 10, 64)
founder, _ := tx.Get(fmt.Sprintf(keyChannelFounder, channelKey))
topic, _ := tx.Get(fmt.Sprintf(keyChannelTopic, channelKey))
topicSetBy, _ := tx.Get(fmt.Sprintf(keyChannelTopicSetBy, channelKey))
var topicSetTime time.Time
topicSetTimeStr, _ := tx.Get(fmt.Sprintf(keyChannelTopicSetTime, channelKey))
if topicSetTimeInt, topicSetTimeErr := strconv.ParseInt(topicSetTimeStr, 10, 64); topicSetTimeErr == nil {
topicSetTime = time.Unix(0, topicSetTimeInt).UTC()
}
password, _ := tx.Get(fmt.Sprintf(keyChannelPassword, channelKey))
modeString, _ := tx.Get(fmt.Sprintf(keyChannelModes, channelKey))
userLimitString, _ := tx.Get(fmt.Sprintf(keyChannelUserLimit, channelKey))
forward, _ := tx.Get(fmt.Sprintf(keyChannelForward, channelKey))
banlistString, _ := tx.Get(fmt.Sprintf(keyChannelBanlist, channelKey))
exceptlistString, _ := tx.Get(fmt.Sprintf(keyChannelExceptlist, channelKey))
invitelistString, _ := tx.Get(fmt.Sprintf(keyChannelInvitelist, channelKey))
accountToUModeString, _ := tx.Get(fmt.Sprintf(keyChannelAccountToUMode, channelKey))
settingsString, _ := tx.Get(fmt.Sprintf(keyChannelSettings, channelKey))
modeSlice := make([]modes.Mode, len(modeString))
for i, mode := range modeString {
modeSlice[i] = modes.Mode(mode)
}
userLimit, _ := strconv.Atoi(userLimitString)
var banlist map[string]MaskInfo
_ = json.Unmarshal([]byte(banlistString), &banlist)
var exceptlist map[string]MaskInfo
_ = json.Unmarshal([]byte(exceptlistString), &exceptlist)
var invitelist map[string]MaskInfo
_ = json.Unmarshal([]byte(invitelistString), &invitelist)
accountToUMode := make(map[string]modes.Mode)
_ = json.Unmarshal([]byte(accountToUModeString), &accountToUMode)
var settings ChannelSettings
_ = json.Unmarshal([]byte(settingsString), &settings)
info = RegisteredChannel{
Name: name,
NameCasefolded: nameCasefolded,
RegisteredAt: time.Unix(0, regTimeInt).UTC(),
Founder: founder,
Topic: topic,
TopicSetBy: topicSetBy,
TopicSetTime: topicSetTime,
Key: password,
Modes: modeSlice,
Bans: banlist,
Excepts: exceptlist,
Invites: invitelist,
AccountToUMode: accountToUMode,
UserLimit: int(userLimit),
Settings: settings,
Forward: forward,
}
return nil
})
return
}
// Delete deletes a channel corresponding to `info`. If no such channel
// is present in the database, no error is returned.
func (reg *ChannelRegistry) Delete(info RegisteredChannel) (err error) {
if !reg.server.ChannelRegistrationEnabled() {
return
}
reg.server.store.Update(func(tx *buntdb.Tx) error {
reg.deleteChannel(tx, info.NameCasefolded, info)
return nil
})
return nil
}
// delete a channel, unless it was overwritten by another registration of the same channel
func (reg *ChannelRegistry) deleteChannel(tx *buntdb.Tx, key string, info RegisteredChannel) {
_, err := tx.Get(fmt.Sprintf(keyChannelExists, key))
if err == nil {
regTime, _ := tx.Get(fmt.Sprintf(keyChannelRegTime, key))
regTimeInt, _ := strconv.ParseInt(regTime, 10, 64)
registeredAt := time.Unix(0, regTimeInt).UTC()
founder, _ := tx.Get(fmt.Sprintf(keyChannelFounder, key))
// to see if we're deleting the right channel, confirm the founder and the registration time
if founder == info.Founder && registeredAt.Equal(info.RegisteredAt) {
for _, keyFmt := range channelKeyStrings {
tx.Delete(fmt.Sprintf(keyFmt, key))
}
// remove this channel from the client's list of registered channels
channelsKey := fmt.Sprintf(keyAccountChannels, info.Founder)
channelsStr, err := tx.Get(channelsKey)
if err == buntdb.ErrNotFound {
return
}
registeredChannels := unmarshalRegisteredChannels(channelsStr)
var nowRegisteredChannels []string
for _, channel := range registeredChannels {
if channel != key {
nowRegisteredChannels = append(nowRegisteredChannels, channel)
}
}
tx.Set(channelsKey, strings.Join(nowRegisteredChannels, ","), nil)
}
}
}
func (reg *ChannelRegistry) updateAccountToChannelMapping(tx *buntdb.Tx, channelInfo RegisteredChannel) {
channelKey := channelInfo.NameCasefolded
chanFounderKey := fmt.Sprintf(keyChannelFounder, channelKey)
founder, existsErr := tx.Get(chanFounderKey)
if existsErr == buntdb.ErrNotFound || founder != channelInfo.Founder {
// add to new founder's list
accountChannelsKey := fmt.Sprintf(keyAccountChannels, channelInfo.Founder)
alreadyChannels, _ := tx.Get(accountChannelsKey)
newChannels := channelKey // this is the casefolded channel name
if alreadyChannels != "" {
newChannels = fmt.Sprintf("%s,%s", alreadyChannels, newChannels)
}
tx.Set(accountChannelsKey, newChannels, nil)
}
if existsErr == nil && founder != channelInfo.Founder {
// remove from old founder's list
accountChannelsKey := fmt.Sprintf(keyAccountChannels, founder)
alreadyChannelsRaw, _ := tx.Get(accountChannelsKey)
var newChannels []string
if alreadyChannelsRaw != "" {
for _, chname := range strings.Split(alreadyChannelsRaw, ",") {
if chname != channelInfo.NameCasefolded {
newChannels = append(newChannels, chname)
}
}
}
tx.Set(accountChannelsKey, strings.Join(newChannels, ","), nil)
}
}
// saveChannel saves a channel to the store.
func (reg *ChannelRegistry) saveChannel(tx *buntdb.Tx, channelInfo RegisteredChannel, includeFlags uint) {
channelKey := channelInfo.NameCasefolded
// maintain the mapping of account -> registered channels
reg.updateAccountToChannelMapping(tx, channelInfo)
if includeFlags&IncludeInitial != 0 {
tx.Set(fmt.Sprintf(keyChannelExists, channelKey), "1", nil)
tx.Set(fmt.Sprintf(keyChannelName, channelKey), channelInfo.Name, nil)
tx.Set(fmt.Sprintf(keyChannelRegTime, channelKey), strconv.FormatInt(channelInfo.RegisteredAt.UnixNano(), 10), nil)
tx.Set(fmt.Sprintf(keyChannelFounder, channelKey), channelInfo.Founder, nil)
}
if includeFlags&IncludeTopic != 0 {
tx.Set(fmt.Sprintf(keyChannelTopic, channelKey), channelInfo.Topic, nil)
var topicSetTimeStr string
if !channelInfo.TopicSetTime.IsZero() {
topicSetTimeStr = strconv.FormatInt(channelInfo.TopicSetTime.UnixNano(), 10)
}
tx.Set(fmt.Sprintf(keyChannelTopicSetTime, channelKey), topicSetTimeStr, nil)
tx.Set(fmt.Sprintf(keyChannelTopicSetBy, channelKey), channelInfo.TopicSetBy, nil)
}
if includeFlags&IncludeModes != 0 {
tx.Set(fmt.Sprintf(keyChannelPassword, channelKey), channelInfo.Key, nil)
modeString := modes.Modes(channelInfo.Modes).String()
tx.Set(fmt.Sprintf(keyChannelModes, channelKey), modeString, nil)
tx.Set(fmt.Sprintf(keyChannelUserLimit, channelKey), strconv.Itoa(channelInfo.UserLimit), nil)
tx.Set(fmt.Sprintf(keyChannelForward, channelKey), channelInfo.Forward, nil)
}
if includeFlags&IncludeLists != 0 {
banlistString, _ := json.Marshal(channelInfo.Bans)
tx.Set(fmt.Sprintf(keyChannelBanlist, channelKey), string(banlistString), nil)
exceptlistString, _ := json.Marshal(channelInfo.Excepts)
tx.Set(fmt.Sprintf(keyChannelExceptlist, channelKey), string(exceptlistString), nil)
invitelistString, _ := json.Marshal(channelInfo.Invites)
tx.Set(fmt.Sprintf(keyChannelInvitelist, channelKey), string(invitelistString), nil)
accountToUModeString, _ := json.Marshal(channelInfo.AccountToUMode)
tx.Set(fmt.Sprintf(keyChannelAccountToUMode, channelKey), string(accountToUModeString), nil)
}
if includeFlags&IncludeSettings != 0 {
settingsString, _ := json.Marshal(channelInfo.Settings)
tx.Set(fmt.Sprintf(keyChannelSettings, channelKey), string(settingsString), nil)
}
}
// PurgeChannel records a channel purge.
func (reg *ChannelRegistry) PurgeChannel(chname string, record ChannelPurgeRecord) (err error) {
serialized, err := json.Marshal(record)
if err != nil {
return err
}
serializedStr := string(serialized)
key := fmt.Sprintf(keyChannelPurged, chname)
return reg.server.store.Update(func(tx *buntdb.Tx) error {
tx.Set(key, serializedStr, nil)
return nil
})
}
// LoadPurgeRecord retrieves information about whether and how a channel was purged.
func (reg *ChannelRegistry) LoadPurgeRecord(chname string) (record ChannelPurgeRecord, err error) {
var rawRecord string
key := fmt.Sprintf(keyChannelPurged, chname)
reg.server.store.View(func(tx *buntdb.Tx) error {
rawRecord, _ = tx.Get(key)
return nil
})
if rawRecord == "" {
err = errNoSuchChannel
return
}
err = json.Unmarshal([]byte(rawRecord), &record)
if err != nil {
reg.server.logger.Error("internal", "corrupt purge record", chname, err.Error())
err = errNoSuchChannel
return
}
return
}
// UnpurgeChannel deletes the record of a channel purge.
func (reg *ChannelRegistry) UnpurgeChannel(chname string) (err error) {
key := fmt.Sprintf(keyChannelPurged, chname)
return reg.server.store.Update(func(tx *buntdb.Tx) error {
tx.Delete(key)
return nil
})
func (c *ChannelPurgeRecord) Deserialize(b []byte) error {
return json.Unmarshal(b, c)
}

View File

@ -6,6 +6,7 @@ package irc
import (
"fmt"
"regexp"
"slices"
"sort"
"strings"
"time"
@ -218,7 +219,7 @@ func csAmodeHandler(service *ircService, server *Server, client *Client, command
// check for anything valid as a channel mode change that is not valid
// as an AMODE change
for _, modeChange := range modeChanges {
if !utils.SliceContains(modes.ChannelUserModes, modeChange.Mode) {
if !slices.Contains(modes.ChannelUserModes, modeChange.Mode) {
invalid = true
}
}
@ -459,7 +460,7 @@ func csRegisterHandler(service *ircService, server *Server, client *Client, comm
// check whether a client has already registered too many channels
func checkChanLimit(service *ircService, client *Client, rb *ResponseBuffer) (ok bool) {
account := client.Account()
channelsAlreadyRegistered := client.server.accounts.ChannelsForAccount(account)
channelsAlreadyRegistered := client.server.channels.ChannelsForAccount(account)
ok = len(channelsAlreadyRegistered) < client.server.Config().Channels.Registration.MaxChannelsPerAccount || client.HasRoleCapabs("chanreg")
if !ok {
service.Notice(rb, client.t("You have already registered the maximum number of channels; try dropping some with /CS UNREGISTER"))
@ -496,8 +497,8 @@ func csUnregisterHandler(service *ircService, server *Server, client *Client, co
return
}
info := channel.ExportRegistration(0)
channelKey := info.NameCasefolded
info := channel.exportSummary()
channelKey := channel.NameCasefolded()
if !csPrivsCheck(service, info, client, rb) {
return
}
@ -519,7 +520,7 @@ func csClearHandler(service *ircService, server *Server, client *Client, command
service.Notice(rb, client.t("Channel does not exist"))
return
}
if !csPrivsCheck(service, channel.ExportRegistration(0), client, rb) {
if !csPrivsCheck(service, channel.exportSummary(), client, rb) {
return
}
@ -550,7 +551,7 @@ func csTransferHandler(service *ircService, server *Server, client *Client, comm
service.Notice(rb, client.t("Channel does not exist"))
return
}
regInfo := channel.ExportRegistration(0)
regInfo := channel.exportSummary()
chname = regInfo.Name
account := client.Account()
isFounder := account != "" && account == regInfo.Founder
@ -729,11 +730,6 @@ func csPurgeListHandler(service *ircService, client *Client, rb *ResponseBuffer)
}
func csListHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
if !client.HasRoleCapabs("chanreg") {
service.Notice(rb, client.t("Insufficient privileges"))
return
}
var searchRegex *regexp.Regexp
if len(params) > 0 {
var err error
@ -746,7 +742,7 @@ func csListHandler(service *ircService, server *Server, client *Client, command
service.Notice(rb, ircfmt.Unescape(client.t("*** $bChanServ LIST$b ***")))
channels := server.channelRegistry.AllChannels()
channels := server.channels.AllRegisteredChannels()
for _, channel := range channels {
if searchRegex == nil || searchRegex.MatchString(channel) {
service.Notice(rb, fmt.Sprintf(" %s", channel))
@ -771,7 +767,7 @@ func csInfoHandler(service *ircService, server *Server, client *Client, command
// purge status
if client.HasRoleCapabs("chanreg") {
purgeRecord, err := server.channelRegistry.LoadPurgeRecord(chname)
purgeRecord, err := server.channels.LoadPurgeRecord(chname)
if err == nil {
service.Notice(rb, fmt.Sprintf(client.t("Channel %s was purged by the server operators and cannot be used"), chname))
service.Notice(rb, fmt.Sprintf(client.t("Purged by operator: %s"), purgeRecord.Oper))
@ -789,13 +785,7 @@ func csInfoHandler(service *ircService, server *Server, client *Client, command
var chinfo RegisteredChannel
channel := server.channels.Get(params[0])
if channel != nil {
chinfo = channel.ExportRegistration(0)
} else {
chinfo, err = server.channelRegistry.LoadChannel(chname)
if err != nil && !(err == errNoSuchChannel || err == errFeatureDisabled) {
service.Notice(rb, client.t("An error occurred"))
return
}
chinfo = channel.exportSummary()
}
// channel exists but is unregistered, or doesn't exist:
@ -835,12 +825,12 @@ func csGetHandler(service *ircService, server *Server, client *Client, command s
service.Notice(rb, client.t("No such channel"))
return
}
info := channel.ExportRegistration(IncludeSettings)
info := channel.exportSummary()
if !csPrivsCheck(service, info, client, rb) {
return
}
displayChannelSetting(service, setting, info.Settings, client, rb)
displayChannelSetting(service, setting, channel.Settings(), client, rb)
}
func csSetHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
@ -850,12 +840,12 @@ func csSetHandler(service *ircService, server *Server, client *Client, command s
service.Notice(rb, client.t("No such channel"))
return
}
info := channel.ExportRegistration(IncludeSettings)
settings := info.Settings
info := channel.exportSummary()
if !csPrivsCheck(service, info, client, rb) {
return
}
settings := channel.Settings()
var err error
switch strings.ToLower(setting) {
case "history":

View File

@ -8,6 +8,7 @@ package irc
import (
"crypto/x509"
"fmt"
"maps"
"net"
"runtime/debug"
"strconv"
@ -20,6 +21,7 @@ import (
"github.com/ergochat/irc-go/ircfmt"
"github.com/ergochat/irc-go/ircmsg"
"github.com/ergochat/irc-go/ircreader"
"github.com/ergochat/irc-go/ircutils"
"github.com/xdg-go/scram"
"github.com/ergochat/ergo/irc/caps"
@ -27,6 +29,7 @@ import (
"github.com/ergochat/ergo/irc/flatip"
"github.com/ergochat/ergo/irc/history"
"github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/oauth2"
"github.com/ergochat/ergo/irc/sno"
"github.com/ergochat/ergo/irc/utils"
)
@ -118,12 +121,20 @@ type Client struct {
type saslStatus struct {
mechanism string
value string
value ircutils.SASLBuffer
scramConv *scram.ServerConversation
oauthConv *oauth2.OAuthBearerServer
}
func (s *saslStatus) Initialize() {
s.value.Initialize(saslMaxResponseLength)
}
func (s *saslStatus) Clear() {
*s = saslStatus{}
s.mechanism = ""
s.value.Clear()
s.scramConv = nil
s.oauthConv = nil
}
// what stage the client is at w.r.t. the PASS command:
@ -149,13 +160,14 @@ type Session struct {
idleTimer *time.Timer
pingSent bool // we sent PING to a putatively idle connection and we're waiting for PONG
sessionID int64
socket *Socket
realIP net.IP
proxiedIP net.IP
rawHostname string
isTor bool
hideSTS bool
sessionID int64
socket *Socket
realIP net.IP
proxiedIP net.IP
rawHostname string
hostnameFinalized bool
isTor bool
hideSTS bool
fakelag Fakelag
deferredFakelagCount int
@ -295,7 +307,7 @@ func (server *Server) RunClient(conn IRCConn) {
var banMsg string
realIP := utils.AddrToIP(wConn.RemoteAddr())
var proxiedIP net.IP
if wConn.Config.Tor {
if wConn.Tor {
// cover up details of the tor proxying infrastructure (not a user privacy concern,
// but a hardening measure):
proxiedIP = utils.IPv4LoopbackAddress
@ -329,7 +341,7 @@ func (server *Server) RunClient(conn IRCConn) {
lastActive: now,
channels: make(ChannelSet),
ctime: now,
isSTSOnly: wConn.Config.STSOnly,
isSTSOnly: wConn.STSOnly,
languages: server.Languages().Default(),
loginThrottle: connection_limits.GenericThrottle{
Duration: config.Accounts.LoginThrottling.Duration,
@ -358,9 +370,10 @@ func (server *Server) RunClient(conn IRCConn) {
lastActive: now,
realIP: realIP,
proxiedIP: proxiedIP,
isTor: wConn.Config.Tor,
hideSTS: wConn.Config.Tor || wConn.Config.HideSTS,
isTor: wConn.Tor,
hideSTS: wConn.Tor || wConn.HideSTS,
}
session.sasl.Initialize()
client.sessions = []*Session{session}
session.resetFakelag()
@ -369,7 +382,7 @@ func (server *Server) RunClient(conn IRCConn) {
client.SetMode(modes.TLS, true)
}
if wConn.Config.TLSConfig != nil {
if wConn.TLS {
// error is not useful to us here anyways so we can ignore it
session.certfp, session.peerCerts, _ = utils.GetCertFP(wConn.Conn, RegisterTimeout)
}
@ -476,12 +489,21 @@ func (client *Client) resizeHistory(config *Config) {
}
}
// resolve an IP to an IRC-ready hostname, using reverse DNS, forward-confirming if necessary,
// and sending appropriate notices to the client
func (client *Client) lookupHostname(session *Session, overwrite bool) {
// once we have the final IP address (from the connection itself or from proxy data),
// compute the various possibilities for the hostname:
// * In the default/recommended configuration, via the cloak algorithm
// * If hostname lookup is enabled, via (forward-confirmed) reverse DNS
// * If WEBIRC was used, possibly via the hostname passed on the WEBIRC line
func (client *Client) finalizeHostname(session *Session) {
// only allow this once, since registration can fail (e.g. if the nickname is in use)
if session.hostnameFinalized {
return
}
session.hostnameFinalized = true
if session.isTor {
return
} // else: even if cloaking is enabled, look up the real hostname to show to operators
}
config := client.server.Config()
ip := session.realIP
@ -489,30 +511,27 @@ func (client *Client) lookupHostname(session *Session, overwrite bool) {
ip = session.proxiedIP
}
var hostname string
lookupSuccessful := false
if config.Server.lookupHostnames {
session.Notice("*** Looking up your hostname...")
hostname, lookupSuccessful = utils.LookupHostname(ip, config.Server.ForwardConfirmHostnames)
if lookupSuccessful {
session.Notice("*** Found your hostname")
// even if cloaking is enabled, we may want to look up the real hostname to show to operators:
if session.rawHostname == "" {
var hostname string
lookupSuccessful := false
if config.Server.lookupHostnames {
session.Notice("*** Looking up your hostname...")
hostname, lookupSuccessful = utils.LookupHostname(ip, config.Server.ForwardConfirmHostnames)
if lookupSuccessful {
session.Notice("*** Found your hostname")
} else {
session.Notice("*** Couldn't look up your hostname")
}
} else {
session.Notice("*** Couldn't look up your hostname")
hostname = utils.IPStringToHostname(ip.String())
}
} else {
hostname = utils.IPStringToHostname(ip.String())
session.rawHostname = hostname
}
session.rawHostname = hostname
cloakedHostname := config.Server.Cloaks.ComputeCloak(ip)
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
// update the hostname if this is a new connection, but not if it's a reattach
if overwrite || client.rawHostname == "" {
client.rawHostname = hostname
client.cloakedHostname = cloakedHostname
client.updateNickMaskNoMutex()
}
// these will be discarded if this is actually a reattach:
client.rawHostname = session.rawHostname
client.cloakedHostname = config.Server.Cloaks.ComputeCloak(ip)
}
func (client *Client) doIdentLookup(conn net.Conn) {
@ -850,7 +869,7 @@ func (client *Client) replayPrivmsgHistory(rb *ResponseBuffer, items []history.I
if target == "" {
target = nick
}
batchID = rb.StartNestedHistoryBatch(target)
batchID = rb.StartNestedBatch("chathistory", target)
isSelfMessage := func(item *history.Item) bool {
// XXX: Params[0] is the message target. if the source of this message is an in-memory
@ -1222,14 +1241,11 @@ func (client *Client) destroy(session *Session) {
client.destroyed = true
}
becameAutoAway := false
var awayMessage string
if alwaysOn && persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway) {
wasAway := client.awayMessage != ""
wasAway := client.awayMessage
if client.autoAwayEnabledNoMutex(config) {
client.setAutoAwayNoMutex(config)
awayMessage = client.awayMessage
becameAutoAway = !wasAway && awayMessage != ""
}
nowAway := client.awayMessage
if client.registrationTimer != nil {
// unconditionally stop; if the client is still unregistered it must be destroyed
@ -1279,8 +1295,8 @@ func (client *Client) destroy(session *Session) {
client.server.stats.Remove(registered, invisible, operator)
}
if becameAutoAway {
dispatchAwayNotify(client, true, awayMessage)
if !shouldDestroy && wasAway != nowAway {
dispatchAwayNotify(client, nowAway)
}
if !shouldDestroy {
@ -1288,10 +1304,10 @@ func (client *Client) destroy(session *Session) {
}
var quitItem history.Item
var channels []*Channel
var quitHistoryChannels []*Channel
// use a defer here to avoid writing to mysql while holding the destroy semaphore:
defer func() {
for _, channel := range channels {
for _, channel := range quitHistoryChannels {
channel.AddHistoryItem(quitItem, details.account)
}
}()
@ -1313,8 +1329,11 @@ func (client *Client) destroy(session *Session) {
// clean up channels
// (note that if this is a reattach, client has no channels and therefore no friends)
friends := make(ClientSet)
channels = client.Channels()
channels := client.Channels()
for _, channel := range channels {
if channel.memberIsVisible(client) {
quitHistoryChannels = append(quitHistoryChannels, channel)
}
for _, member := range channel.auditoriumFriends(client) {
friends.Add(member)
}
@ -1428,27 +1447,27 @@ func composeMultilineBatch(batchID, fromNickMask, fromAccount string, isBot bool
}
var (
// these are all the output commands that MUST have their last param be a trailing.
// this is needed because dumb clients like to treat trailing params separately from the
// other params in messages.
commandsThatMustUseTrailing = map[string]bool{
"PRIVMSG": true,
"NOTICE": true,
RPL_WHOISCHANNELS: true,
RPL_USERHOST: true,
// in practice, many clients require that the final parameter be a trailing
// (prefixed with `:`) even when this is not syntactically necessary.
// by default, force the following commands to use a trailing:
commandsThatMustUseTrailing = utils.SetLiteral(
"PRIVMSG",
"NOTICE",
RPL_WHOISCHANNELS,
RPL_USERHOST,
// mirc's handling of RPL_NAMREPLY is broken:
// https://forums.mirc.com/ubbthreads.php/topics/266939/re-nick-list
RPL_NAMREPLY: true,
}
RPL_NAMREPLY,
)
)
func forceTrailing(config *Config, command string) bool {
return config.Server.Compatibility.forceTrailing && commandsThatMustUseTrailing.Has(command)
}
// SendRawMessage sends a raw message to the client.
func (session *Session) SendRawMessage(message ircmsg.Message, blocking bool) error {
// use dumb hack to force the last param to be a trailing param if required
config := session.client.server.Config()
if config.Server.Compatibility.forceTrailing && commandsThatMustUseTrailing[message.Command] {
if forceTrailing(session.client.server.Config(), message.Command) {
message.ForceTrailing()
}
@ -1746,7 +1765,7 @@ func (client *Client) handleRegisterTimeout() {
func (client *Client) copyLastSeen() (result map[string]time.Time) {
client.stateMutex.RLock()
defer client.stateMutex.RUnlock()
return utils.CopyMap(client.lastSeen)
return maps.Clone(client.lastSeen)
}
// these are bit flags indicating what part of the client status is "dirty"
@ -1775,6 +1794,8 @@ func (client *Client) wakeWriter() {
}
func (client *Client) writeLoop() {
defer client.server.HandlePanic()
for {
client.performWrite(0)
client.writebackLock.Unlock()
@ -1805,7 +1826,11 @@ func (client *Client) performWrite(additionalDirtyBits uint) {
channels := client.Channels()
channelToModes := make(map[string]alwaysOnChannelStatus, len(channels))
for _, channel := range channels {
chname, status := channel.alwaysOnStatus(client)
ok, chname, status := channel.alwaysOnStatus(client)
if !ok {
client.server.logger.Error("internal", "client and channel membership out of sync", chname, client.Nick())
continue
}
channelToModes[chname] = status
}
client.server.accounts.saveChannels(account, channelToModes)

View File

@ -84,7 +84,7 @@ func (clients *ClientManager) Remove(client *Client) error {
// SetNick sets a client's nickname, validating it against nicknames in use
// XXX: dryRun validates a client's ability to claim a nick, without
// actually claiming it
func (clients *ClientManager) SetNick(client *Client, session *Session, newNick string, dryRun bool) (setNick string, err error, returnedFromAway bool) {
func (clients *ClientManager) SetNick(client *Client, session *Session, newNick string, dryRun bool) (setNick string, err error, awayChanged bool) {
config := client.server.Config()
var newCfNick, newSkeleton string
@ -116,6 +116,8 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick
useAccountName = alwaysOn || config.Accounts.NickReservation.ForceNickEqualsAccount
}
nickIsReserved := false
if useAccountName {
if registered && newNick != accountName {
return "", errNickAccountMismatch, false
@ -167,7 +169,9 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick
reservedAccount, method := client.server.accounts.EnforcementStatus(newCfNick, newSkeleton)
if method == NickEnforcementStrict && reservedAccount != "" && reservedAccount != account {
return "", errNicknameReserved, false
// see #2135: we want to enter the critical section, see if the nick is actually in use,
// and return errNicknameInUse in that case
nickIsReserved = true
}
}
@ -195,16 +199,7 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick
dryRun || session == nil {
return "", errNicknameInUse, false
}
// check TLS modes
if client.HasMode(modes.TLS) != currentClient.HasMode(modes.TLS) {
if useAccountName {
// #955: this is fatal because they can't fix it by trying a different nick
return "", errInsecureReattach, false
} else {
return "", errNicknameInUse, false
}
}
reattachSuccessful, numSessions, lastSeen, back := currentClient.AddSession(session)
reattachSuccessful, numSessions, lastSeen, wasAway, nowAway := currentClient.AddSession(session)
if !reattachSuccessful {
return "", errNicknameInUse, false
}
@ -219,7 +214,7 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick
currentClient.SetRealname(realname)
}
// successful reattach!
return newNick, nil, back
return newNick, nil, wasAway != nowAway
} else if currentClient == client && currentClient.Nick() == newNick {
return "", errNoop, false
}
@ -228,6 +223,9 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick
if skeletonHolder != nil && skeletonHolder != client {
return "", errNicknameInUse, false
}
if nickIsReserved {
return "", errNicknameReserved, false
}
if dryRun {
return "", nil, false

View File

@ -4,8 +4,10 @@
package irc
import (
"fmt"
"testing"
"github.com/ergochat/ergo/irc/languages"
"github.com/ergochat/ergo/irc/utils"
)
@ -30,6 +32,47 @@ func BenchmarkGenerateBatchID(b *testing.B) {
}
}
func BenchmarkNames(b *testing.B) {
channelSize := 1024
server := &Server{
name: "ergo.test",
}
lm, err := languages.NewManager(false, "", "")
if err != nil {
b.Fatal(err)
}
server.config.Store(&Config{
languageManager: lm,
})
for i := 0; i < b.N; i++ {
channel := &Channel{
name: "#test",
nameCasefolded: "#test",
server: server,
members: make(MemberSet),
}
for j := 0; j < channelSize; j++ {
nick := fmt.Sprintf("client_%d", j)
client := &Client{
server: server,
nick: nick,
nickCasefolded: nick,
}
channel.members.Add(client)
channel.regenerateMembersCache()
session := &Session{
client: client,
}
rb := NewResponseBuffer(session)
channel.Names(client, rb)
if len(rb.messages) < 2 {
b.Fatalf("not enough messages: %d", len(rb.messages))
}
// to inspect the messages: line, _ := rb.messages[0].Line()
}
}
}
func TestUserMasks(t *testing.T) {
var um UserMaskSet

View File

@ -89,8 +89,9 @@ func init() {
minParams: 1,
},
"AWAY": {
handler: awayHandler,
minParams: 0,
handler: awayHandler,
usablePreReg: true,
minParams: 0,
},
"BATCH": {
handler: batchHandler,
@ -300,6 +301,10 @@ func init() {
usablePreReg: true,
minParams: 0,
},
"REDACT": {
handler: redactHandler,
minParams: 2,
},
"REHASH": {
handler: rehashHandler,
minParams: 0,

View File

@ -38,6 +38,7 @@ import (
"github.com/ergochat/ergo/irc/logger"
"github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/mysql"
"github.com/ergochat/ergo/irc/oauth2"
"github.com/ergochat/ergo/irc/passwd"
"github.com/ergochat/ergo/irc/utils"
)
@ -303,7 +304,7 @@ func (t *ThrottleConfig) UnmarshalYAML(unmarshal func(interface{}) error) (err e
type AccountConfig struct {
Registration AccountRegistrationConfig
AuthenticationEnabled bool `yaml:"authentication-enabled"`
AdvertiseSCRAM bool `yaml:"advertise-scram"` // undocumented, see #1782
AdvertiseSCRAM bool `yaml:"advertise-scram"`
RequireSasl struct {
Enabled bool
Exempted []string
@ -331,7 +332,9 @@ type AccountConfig struct {
Multiclient MulticlientConfig
Bouncer *MulticlientConfig // # handle old name for 'multiclient'
VHosts VHostConfig
AuthScript AuthScriptConfig `yaml:"auth-script"`
AuthScript AuthScriptConfig `yaml:"auth-script"`
OAuth2 oauth2.OAuth2BearerConfig `yaml:"oauth2"`
JWTAuth jwt.JWTAuthConfig `yaml:"jwt-auth"`
}
type ScriptConfig struct {
@ -450,6 +453,10 @@ func (cm *Casemapping) UnmarshalYAML(unmarshal func(interface{}) error) (err err
result = CasemappingPRECIS
case "permissive", "fun":
result = CasemappingPermissive
case "rfc1459":
result = CasemappingRFC1459
case "rfc1459-strict":
result = CasemappingRFC1459Strict
default:
return fmt.Errorf("invalid casemapping value: %s", orig)
}
@ -484,6 +491,7 @@ type Limits struct {
ChanListModes int `yaml:"chan-list-modes"`
ChannelLen int `yaml:"channellen"`
IdentLen int `yaml:"identlen"`
RealnameLen int `yaml:"realnamelen"`
KickLen int `yaml:"kicklen"`
MonitorEntries int `yaml:"monitor-entries"`
NickLen int `yaml:"nicklen"`
@ -644,6 +652,7 @@ type Config struct {
}
ListDelay time.Duration `yaml:"list-delay"`
InviteExpiration custime.Duration `yaml:"invite-expiration"`
AutoJoin []string `yaml:"auto-join"`
}
OperClasses map[string]*OperClassConfig `yaml:"oper-classes"`
@ -1038,7 +1047,7 @@ func (ce *configPathError) Error() string {
return fmt.Sprintf("Couldn't apply config override `%s`: %s", ce.name, ce.desc)
}
func mungeFromEnvironment(config *Config, envPair string) (applied bool, err *configPathError) {
func mungeFromEnvironment(config *Config, envPair string) (applied bool, name string, err *configPathError) {
equalIdx := strings.IndexByte(envPair, '=')
name, value := envPair[:equalIdx], envPair[equalIdx+1:]
if strings.HasPrefix(name, "ERGO__") {
@ -1046,7 +1055,7 @@ func mungeFromEnvironment(config *Config, envPair string) (applied bool, err *co
} else if strings.HasPrefix(name, "ORAGONO__") {
name = strings.TrimPrefix(name, "ORAGONO__")
} else {
return false, nil
return false, "", nil
}
pathComponents := strings.Split(name, "__")
for i, pathComponent := range pathComponents {
@ -1057,10 +1066,10 @@ func mungeFromEnvironment(config *Config, envPair string) (applied bool, err *co
t := v.Type()
for _, component := range pathComponents {
if component == "" {
return false, &configPathError{name, "invalid", nil}
return false, "", &configPathError{name, "invalid", nil}
}
if v.Kind() != reflect.Struct {
return false, &configPathError{name, "index into non-struct", nil}
return false, "", &configPathError{name, "index into non-struct", nil}
}
var nextField reflect.StructField
success := false
@ -1086,7 +1095,7 @@ func mungeFromEnvironment(config *Config, envPair string) (applied bool, err *co
}
}
if !success {
return false, &configPathError{name, fmt.Sprintf("couldn't resolve path component: `%s`", component), nil}
return false, "", &configPathError{name, fmt.Sprintf("couldn't resolve path component: `%s`", component), nil}
}
v = v.FieldByName(nextField.Name)
// dereference pointer field if necessary, initialize new value if necessary
@ -1100,9 +1109,9 @@ func mungeFromEnvironment(config *Config, envPair string) (applied bool, err *co
}
yamlErr := yaml.Unmarshal([]byte(value), v.Addr().Interface())
if yamlErr != nil {
return false, &configPathError{name, "couldn't deserialize YAML", yamlErr}
return false, "", &configPathError{name, "couldn't deserialize YAML", yamlErr}
}
return true, nil
return true, name, nil
}
// LoadConfig loads the given YAML configuration file.
@ -1114,7 +1123,7 @@ func LoadConfig(filename string) (config *Config, err error) {
if config.AllowEnvironmentOverrides {
for _, envPair := range os.Environ() {
applied, envErr := mungeFromEnvironment(config, envPair)
applied, name, envErr := mungeFromEnvironment(config, envPair)
if envErr != nil {
if envErr.fatalErr != nil {
return nil, envErr
@ -1122,7 +1131,7 @@ func LoadConfig(filename string) (config *Config, err error) {
log.Println(envErr.Error())
}
} else if applied {
log.Printf("applied environment override: %s\n", envPair)
log.Printf("applied environment override: %s\n", name)
}
}
}
@ -1389,16 +1398,34 @@ func LoadConfig(filename string) (config *Config, err error) {
config.Accounts.VHosts.validRegexp = defaultValidVhostRegex
}
saslCapValue := "PLAIN,EXTERNAL,SCRAM-SHA-256"
// TODO(#1782) clean this up:
if !config.Accounts.AdvertiseSCRAM {
saslCapValue = "PLAIN,EXTERNAL"
}
config.Server.capValues[caps.SASL] = saslCapValue
if !config.Accounts.AuthenticationEnabled {
if config.Accounts.AuthenticationEnabled {
saslCapValues := []string{"PLAIN", "EXTERNAL"}
if config.Accounts.AdvertiseSCRAM {
saslCapValues = append(saslCapValues, "SCRAM-SHA-256")
}
if config.Accounts.OAuth2.Enabled {
saslCapValues = append(saslCapValues, "OAUTHBEARER")
}
if config.Accounts.OAuth2.Enabled || config.Accounts.JWTAuth.Enabled {
saslCapValues = append(saslCapValues, "IRCV3BEARER")
}
config.Server.capValues[caps.SASL] = strings.Join(saslCapValues, ",")
} else {
config.Server.supportedCaps.Disable(caps.SASL)
}
if err := config.Accounts.OAuth2.Postprocess(); err != nil {
return nil, err
}
if err := config.Accounts.JWTAuth.Postprocess(); err != nil {
return nil, err
}
if config.Accounts.OAuth2.Enabled && config.Accounts.OAuth2.AuthScript && !config.Accounts.AuthScript.Enabled {
return nil, fmt.Errorf("oauth2 is enabled with auth-script, but no auth-script is enabled")
}
if !config.Accounts.Registration.Enabled {
config.Server.supportedCaps.Disable(caps.AccountRegistration)
} else {
@ -1568,6 +1595,10 @@ func (config *Config) isRelaymsgIdentifier(nick string) bool {
return false
}
if strings.HasPrefix(nick, "#") {
return false // #2114
}
for _, char := range config.Server.Relaymsg.Separators {
if strings.ContainsRune(nick, char) {
return true
@ -1585,7 +1616,16 @@ func (config *Config) generateISupport() (err error) {
isupport.Initialize()
isupport.Add("AWAYLEN", strconv.Itoa(config.Limits.AwayLen))
isupport.Add("BOT", "B")
isupport.Add("CASEMAPPING", "ascii")
var casemappingToken string
switch config.Server.Casemapping {
default:
casemappingToken = "ascii" // this is published for ascii, precis, or permissive
case CasemappingRFC1459:
casemappingToken = "rfc1459"
case CasemappingRFC1459Strict:
casemappingToken = "rfc1459-strict"
}
isupport.Add("CASEMAPPING", casemappingToken)
isupport.Add("CHANLIMIT", fmt.Sprintf("%s:%d", chanTypes, config.Channels.MaxChannelsPerClient))
isupport.Add("CHANMODES", chanmodesToken)
if config.History.Enabled && config.History.ChathistoryMax > 0 {
@ -1606,6 +1646,7 @@ func (config *Config) generateISupport() (err error) {
isupport.Add("KICKLEN", strconv.Itoa(config.Limits.KickLen))
isupport.Add("MAXLIST", fmt.Sprintf("beI:%s", strconv.Itoa(config.Limits.ChanListModes)))
isupport.Add("MAXTARGETS", maxTargetsString)
isupport.Add("MSGREFTYPES", "msgid,timestamp")
isupport.Add("MODES", "")
isupport.Add("MONITOR", strconv.Itoa(config.Limits.MonitorEntries))
isupport.Add("NETWORK", config.Network.Name)

View File

@ -28,7 +28,7 @@ func TestEnvironmentOverrides(t *testing.T) {
`ORAGONO__SERVER__IP_CLOAKING={"enabled": true, "enabled-for-always-on": true, "netname": "irc", "cidr-len-ipv4": 32, "cidr-len-ipv6": 64, "num-bits": 64}`,
}
for _, envPair := range env {
_, err := mungeFromEnvironment(&config, envPair)
_, _, err := mungeFromEnvironment(&config, envPair)
if err != nil {
t.Errorf("couldn't apply override `%s`: %v", envPair, err)
}
@ -93,7 +93,7 @@ func TestEnvironmentOverrideErrors(t *testing.T) {
}
for _, env := range invalidEnvs {
success, err := mungeFromEnvironment(&config, env)
success, _, err := mungeFromEnvironment(&config, env)
if err == nil || success {
t.Errorf("accepted invalid env override `%s`", env)
}

View File

@ -14,6 +14,8 @@ import (
"strings"
"time"
"github.com/ergochat/ergo/irc/bunt"
"github.com/ergochat/ergo/irc/datastore"
"github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/utils"
@ -21,12 +23,19 @@ import (
)
const (
// 'version' of the database schema
keySchemaVersion = "db.version"
// latest schema of the db
latestDbSchema = 22
// TODO migrate metadata keys as well
keyCloakSecret = "crypto.cloak_secret"
// 'version' of the database schema
// latest schema of the db
latestDbSchema = 23
)
var (
schemaVersionUUID = utils.UUID{0, 255, 85, 13, 212, 10, 191, 121, 245, 152, 142, 89, 97, 141, 219, 87} // AP9VDdQKv3n1mI5ZYY3bVw
cloakSecretUUID = utils.UUID{170, 214, 184, 208, 116, 181, 67, 75, 161, 23, 233, 16, 113, 251, 94, 229} // qta40HS1Q0uhF-kQcfte5Q
keySchemaVersion = bunt.BuntKey(datastore.TableMetadata, schemaVersionUUID)
keyCloakSecret = bunt.BuntKey(datastore.TableMetadata, cloakSecretUUID)
)
type SchemaChanger func(*Config, *buntdb.Tx) error
@ -99,10 +108,7 @@ func openDatabaseInternal(config *Config, allowAutoupgrade bool) (db *buntdb.DB,
// read the current version string
var version int
err = db.View(func(tx *buntdb.Tx) (err error) {
vStr, err := tx.Get(keySchemaVersion)
if err == nil {
version, err = strconv.Atoi(vStr)
}
version, err = retrieveSchemaVersion(tx)
return err
})
if err != nil {
@ -130,10 +136,21 @@ func openDatabaseInternal(config *Config, allowAutoupgrade bool) (db *buntdb.DB,
}
}
func retrieveSchemaVersion(tx *buntdb.Tx) (version int, err error) {
if val, err := tx.Get(keySchemaVersion); err == nil {
return strconv.Atoi(val)
}
// legacy key:
if val, err := tx.Get("db.version"); err == nil {
return strconv.Atoi(val)
}
return 0, buntdb.ErrNotFound
}
func performAutoUpgrade(currentVersion int, config *Config) (err error) {
path := config.Datastore.Path
log.Printf("attempting to auto-upgrade schema from version %d to %d\n", currentVersion, latestDbSchema)
timestamp := time.Now().UTC().Format("2006-01-02-15:04:05.000Z")
timestamp := time.Now().UTC().Format("2006-01-02-15.04.05.000Z")
backupPath := fmt.Sprintf("%s.v%d.%s.bak", path, currentVersion, timestamp)
log.Printf("making a backup of current database at %s\n", backupPath)
err = utils.CopyFile(path, backupPath)
@ -167,8 +184,12 @@ func UpgradeDB(config *Config) (err error) {
var version int
err = store.Update(func(tx *buntdb.Tx) error {
for {
vStr, _ := tx.Get(keySchemaVersion)
version, _ = strconv.Atoi(vStr)
if version == 0 {
version, err = retrieveSchemaVersion(tx)
if err != nil {
return err
}
}
if version == latestDbSchema {
// success!
break
@ -183,11 +204,12 @@ func UpgradeDB(config *Config) (err error) {
if err != nil {
return err
}
_, _, err = tx.Set(keySchemaVersion, strconv.Itoa(change.TargetVersion), nil)
version = change.TargetVersion
_, _, err = tx.Set(keySchemaVersion, strconv.Itoa(version), nil)
if err != nil {
return err
}
log.Printf("successfully updated schema to version %d\n", change.TargetVersion)
log.Printf("successfully updated schema to version %d\n", version)
}
return nil
})
@ -198,19 +220,17 @@ func UpgradeDB(config *Config) (err error) {
return err
}
func LoadCloakSecret(db *buntdb.DB) (result string) {
db.View(func(tx *buntdb.Tx) error {
result, _ = tx.Get(keyCloakSecret)
return nil
})
return
func LoadCloakSecret(dstore datastore.Datastore) (result string, err error) {
val, err := dstore.Get(datastore.TableMetadata, cloakSecretUUID)
if err != nil {
return
}
return string(val), nil
}
func StoreCloakSecret(db *buntdb.DB, secret string) {
db.Update(func(tx *buntdb.Tx) error {
tx.Set(keyCloakSecret, secret, nil)
return nil
})
func StoreCloakSecret(dstore datastore.Datastore, secret string) {
// TODO error checking
dstore.Set(datastore.TableMetadata, cloakSecretUUID, []byte(secret), time.Time{})
}
func schemaChangeV1toV2(config *Config, tx *buntdb.Tx) error {
@ -1112,6 +1132,92 @@ func schemaChangeV21To22(config *Config, tx *buntdb.Tx) error {
return nil
}
// first phase of document-oriented database refactor: channels
func schemaChangeV22ToV23(config *Config, tx *buntdb.Tx) error {
keyChannelExists := "channel.exists "
var channelNames []string
tx.AscendGreaterOrEqual("", keyChannelExists, func(key, value string) bool {
if !strings.HasPrefix(key, keyChannelExists) {
return false
}
channelNames = append(channelNames, strings.TrimPrefix(key, keyChannelExists))
return true
})
for _, channelName := range channelNames {
channel, err := loadLegacyChannel(tx, channelName)
if err != nil {
log.Printf("error loading legacy channel %s: %v", channelName, err)
continue
}
channel.UUID = utils.GenerateUUIDv4()
newKey := bunt.BuntKey(datastore.TableChannels, channel.UUID)
j, err := json.Marshal(channel)
if err != nil {
log.Printf("error marshaling channel %s: %v", channelName, err)
continue
}
tx.Set(newKey, string(j), nil)
deleteLegacyChannel(tx, channelName)
}
// purges
keyChannelPurged := "channel.purged "
var purgeKeys []string
var channelPurges []ChannelPurgeRecord
tx.AscendGreaterOrEqual("", keyChannelPurged, func(key, value string) bool {
if !strings.HasPrefix(key, keyChannelPurged) {
return false
}
purgeKeys = append(purgeKeys, key)
cfname := strings.TrimPrefix(key, keyChannelPurged)
var record ChannelPurgeRecord
err := json.Unmarshal([]byte(value), &record)
if err != nil {
log.Printf("error unmarshaling channel purge for %s: %v", cfname, err)
return true
}
record.NameCasefolded = cfname
record.UUID = utils.GenerateUUIDv4()
channelPurges = append(channelPurges, record)
return true
})
for _, record := range channelPurges {
newKey := bunt.BuntKey(datastore.TableChannelPurges, record.UUID)
j, err := json.Marshal(record)
if err != nil {
log.Printf("error marshaling channel purge %s: %v", record.NameCasefolded, err)
continue
}
tx.Set(newKey, string(j), nil)
}
for _, purgeKey := range purgeKeys {
tx.Delete(purgeKey)
}
// clean up denormalized account-to-channels mapping
keyAccountChannels := "account.channels "
var accountToChannels []string
tx.AscendGreaterOrEqual("", keyAccountChannels, func(key, value string) bool {
if !strings.HasPrefix(key, keyAccountChannels) {
return false
}
accountToChannels = append(accountToChannels, key)
return true
})
for _, key := range accountToChannels {
tx.Delete(key)
}
// migrate cloak secret
val, _ := tx.Get("crypto.cloak_secret")
tx.Set(keyCloakSecret, val, nil)
// bump the legacy version key to mark the database as downgrade-incompatible
tx.Set("db.version", "23", nil)
return nil
}
func getSchemaChange(initialVersion int) (result SchemaChange, ok bool) {
for _, change := range allChanges {
if initialVersion == change.InitialVersion {
@ -1227,4 +1333,9 @@ var allChanges = []SchemaChange{
TargetVersion: 22,
Changer: schemaChangeV21To22,
},
{
InitialVersion: 22,
TargetVersion: 23,
Changer: schemaChangeV22ToV23,
},
}

View File

@ -0,0 +1,45 @@
// Copyright (c) 2022 Shivaram Lingamneni <slingamn@cs.stanford.edu>
// released under the MIT license
package datastore
import (
"time"
"github.com/ergochat/ergo/irc/utils"
)
type Table uint16
// XXX these are persisted and must remain stable;
// do not reorder, when deleting use _ to ensure that the deleted value is skipped
const (
TableMetadata Table = iota
TableChannels
TableChannelPurges
)
type KV struct {
UUID utils.UUID
Value []byte
}
// A Datastore provides the following abstraction:
// 1. Tables, each keyed on a UUID (the implementation is free to merge
// the table name and the UUID into a single key as long as the rest of
// the contract can be satisfied). Table names are [a-z0-9_]+
// 2. The ability to efficiently enumerate all uuid-value pairs in a table
// 3. Gets, sets, and deletes for individual (table, uuid) keys
type Datastore interface {
Backoff() time.Duration
GetAll(table Table) ([]KV, error)
// This is rarely used because it would typically lead to TOCTOU races
Get(table Table, key utils.UUID) (value []byte, err error)
Set(table Table, key utils.UUID, value []byte, expiration time.Time) error
// Note that deleting a nonexistent key is not considered an error
Delete(table Table, key utils.UUID) error
}

View File

@ -4,10 +4,13 @@
package email
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"net"
"os"
"regexp"
"strings"
"time"
@ -23,35 +26,111 @@ var (
ErrNoMXRecord = errors.New("Couldn't resolve MX record")
)
type BlacklistSyntax uint
const (
BlacklistSyntaxGlob BlacklistSyntax = iota
BlacklistSyntaxRegexp
)
func blacklistSyntaxFromString(status string) (BlacklistSyntax, error) {
switch strings.ToLower(status) {
case "glob", "":
return BlacklistSyntaxGlob, nil
case "re", "regex", "regexp":
return BlacklistSyntaxRegexp, nil
default:
return BlacklistSyntaxRegexp, fmt.Errorf("Unknown blacklist syntax type `%s`", status)
}
}
func (bs *BlacklistSyntax) UnmarshalYAML(unmarshal func(interface{}) error) error {
var orig string
var err error
if err = unmarshal(&orig); err != nil {
return err
}
if result, err := blacklistSyntaxFromString(orig); err == nil {
*bs = result
return nil
} else {
return err
}
}
type MTAConfig struct {
Server string
Port int
Username string
Password string
Server string
Port int
Username string
Password string
ImplicitTLS bool `yaml:"implicit-tls"`
}
type MailtoConfig struct {
// legacy config format assumed the use of an MTA/smarthost,
// so server, port, etc. appear directly at top level
// XXX: see https://github.com/go-yaml/yaml/issues/63
MTAConfig `yaml:",inline"`
Enabled bool
Sender string
HeloDomain string `yaml:"helo-domain"`
RequireTLS bool `yaml:"require-tls"`
VerifyMessageSubject string `yaml:"verify-message-subject"`
DKIM DKIMConfig
MTAReal MTAConfig `yaml:"mta"`
BlacklistRegexes []string `yaml:"blacklist-regexes"`
blacklistRegexes []*regexp.Regexp
Timeout time.Duration
PasswordReset struct {
MTAConfig `yaml:",inline"`
Enabled bool
Sender string
HeloDomain string `yaml:"helo-domain"`
RequireTLS bool `yaml:"require-tls"`
Protocol string `yaml:"protocol"`
LocalAddress string `yaml:"local-address"`
localAddress net.Addr
VerifyMessageSubject string `yaml:"verify-message-subject"`
DKIM DKIMConfig
MTAReal MTAConfig `yaml:"mta"`
AddressBlacklist []string `yaml:"address-blacklist"`
AddressBlacklistSyntax BlacklistSyntax `yaml:"address-blacklist-syntax"`
AddressBlacklistFile string `yaml:"address-blacklist-file"`
blacklistRegexes []*regexp.Regexp
Timeout time.Duration
PasswordReset struct {
Enabled bool
Cooldown custime.Duration
Timeout custime.Duration
} `yaml:"password-reset"`
}
func (config *MailtoConfig) compileBlacklistEntry(source string) (re *regexp.Regexp, err error) {
if config.AddressBlacklistSyntax == BlacklistSyntaxGlob {
return utils.CompileGlob(source, false)
} else {
return regexp.Compile(fmt.Sprintf("^%s$", source))
}
}
func (config *MailtoConfig) processBlacklistFile(filename string) (result []*regexp.Regexp, err error) {
f, err := os.Open(filename)
if err != nil {
return
}
defer f.Close()
reader := bufio.NewReader(f)
lineNo := 0
for {
line, err := reader.ReadString('\n')
lineNo++
line = strings.TrimSpace(line)
if line != "" && line[0] != '#' {
if compiled, compileErr := config.compileBlacklistEntry(line); compileErr == nil {
result = append(result, compiled)
} else {
return result, fmt.Errorf("Failed to compile line %d of blacklist-regex-file `%s`: %w", lineNo, line, compileErr)
}
}
switch err {
case io.EOF:
return result, nil
case nil:
continue
default:
return result, err
}
}
}
func (config *MailtoConfig) Postprocess(heloDomain string) (err error) {
if config.Sender == "" {
return errors.New("Invalid mailto sender address")
@ -67,12 +146,39 @@ func (config *MailtoConfig) Postprocess(heloDomain string) (err error) {
config.HeloDomain = heloDomain
}
for _, reg := range config.BlacklistRegexes {
compiled, err := regexp.Compile(fmt.Sprintf("^%s$", reg))
if config.AddressBlacklistFile != "" {
config.blacklistRegexes, err = config.processBlacklistFile(config.AddressBlacklistFile)
if err != nil {
return err
}
config.blacklistRegexes = append(config.blacklistRegexes, compiled)
} else if len(config.AddressBlacklist) != 0 {
config.blacklistRegexes = make([]*regexp.Regexp, 0, len(config.AddressBlacklist))
for _, reg := range config.AddressBlacklist {
compiled, err := config.compileBlacklistEntry(reg)
if err != nil {
return err
}
config.blacklistRegexes = append(config.blacklistRegexes, compiled)
}
}
config.Protocol = strings.ToLower(config.Protocol)
if config.Protocol == "" {
config.Protocol = "tcp"
}
if !(config.Protocol == "tcp" || config.Protocol == "tcp4" || config.Protocol == "tcp6") {
return fmt.Errorf("Invalid protocol for email sending: `%s`", config.Protocol)
}
if config.LocalAddress != "" {
ipAddr := net.ParseIP(config.LocalAddress)
if ipAddr == nil {
return fmt.Errorf("Could not parse local-address for email sending: `%s`", config.LocalAddress)
}
config.localAddress = &net.TCPAddr{
IP: ipAddr,
Port: 0,
}
}
if config.MTAConfig.Server != "" {
@ -109,6 +215,9 @@ func ComposeMail(config MailtoConfig, recipient, subject string) (message bytes.
dkimDomain := config.DKIM.Domain
if dkimDomain != "" {
fmt.Fprintf(&message, "Message-ID: <%s@%s>\r\n", utils.GenerateSecretKey(), dkimDomain)
} else {
// #2108: send Message-ID even if dkim is not enabled
fmt.Fprintf(&message, "Message-ID: <%s-%s>\r\n", utils.GenerateSecretKey(), config.Sender)
}
fmt.Fprintf(&message, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
fmt.Fprintf(&message, "Subject: %s\r\n", subject)
@ -117,8 +226,9 @@ func ComposeMail(config MailtoConfig, recipient, subject string) (message bytes.
}
func SendMail(config MailtoConfig, recipient string, msg []byte) (err error) {
recipientLower := strings.ToLower(recipient)
for _, reg := range config.blacklistRegexes {
if reg.MatchString(recipient) {
if reg.MatchString(recipientLower) {
return ErrBlacklistedAddress
}
}
@ -132,11 +242,13 @@ func SendMail(config MailtoConfig, recipient string, msg []byte) (err error) {
var addr string
var auth smtp.Auth
var implicitTLS bool
if !config.DirectSendingEnabled() {
addr = fmt.Sprintf("%s:%d", config.MTAReal.Server, config.MTAReal.Port)
if config.MTAReal.Username != "" && config.MTAReal.Password != "" {
auth = smtp.PlainAuth("", config.MTAReal.Username, config.MTAReal.Password, config.MTAReal.Server)
}
implicitTLS = config.MTAReal.ImplicitTLS
} else {
idx := strings.IndexByte(recipient, '@')
if idx == -1 {
@ -149,5 +261,8 @@ func SendMail(config MailtoConfig, recipient string, msg []byte) (err error) {
addr = fmt.Sprintf("%s:smtp", mx)
}
return smtp.SendMail(addr, auth, config.HeloDomain, config.Sender, []string{recipient}, msg, config.RequireTLS, config.Timeout)
return smtp.SendMail(
addr, auth, config.HeloDomain, config.Sender, []string{recipient}, msg,
config.RequireTLS, implicitTLS, config.Protocol, config.localAddress, config.Timeout,
)
}

View File

@ -51,6 +51,7 @@ var (
errNoExistingBan = errors.New("Ban does not exist")
errNoSuchChannel = errors.New(`No such channel`)
errChannelPurged = errors.New(`This channel was purged by the server operators and cannot be used`)
errChannelPurgedAlready = errors.New(`This channel was already purged and cannot be purged again`)
errConfusableIdentifier = errors.New("This identifier is confusable with one already in use")
errInsufficientPrivs = errors.New("Insufficient privileges")
errInvalidUsername = errors.New("Invalid username")
@ -75,6 +76,7 @@ var (
errValidEmailRequired = errors.New("A valid email address is required for account registration")
errInvalidAccountRename = errors.New("Account renames can only change the casefolding of the account name")
errNameReserved = errors.New(`Name reserved due to a prior registration`)
errInvalidBearerTokenType = errors.New("invalid bearer token type")
)
// String Errors
@ -97,5 +99,5 @@ type ThrottleError struct {
}
func (te *ThrottleError) Error() string {
return fmt.Sprintf(`Please wait at least %v and try again`, te.Duration)
return fmt.Sprintf(`Please wait at least %v and try again`, te.Duration.Round(time.Millisecond))
}

View File

@ -4,9 +4,8 @@
package irc
import (
"maps"
"time"
"github.com/ergochat/ergo/irc/utils"
)
// fakelag is a system for artificially delaying commands when a user issues
@ -40,7 +39,7 @@ func (fl *Fakelag) Initialize(config FakelagConfig) {
fl.config = config
// XXX don't share mutable member CommandBudgets:
if config.CommandBudgets != nil {
fl.config.CommandBudgets = utils.CopyMap(config.CommandBudgets)
fl.config.CommandBudgets = maps.Clone(config.CommandBudgets)
}
fl.nowFunc = time.Now
fl.sleepFunc = time.Sleep

View File

@ -32,6 +32,7 @@ type webircConfig struct {
Fingerprint *string // legacy name for certfp, #1050
Certfp string
Hosts []string
AcceptHostname bool `yaml:"accept-hostname"`
allowedNets []net.IPNet
}

View File

@ -5,6 +5,7 @@ package irc
import (
"fmt"
"maps"
"net"
"time"
@ -18,10 +19,6 @@ func (server *Server) Config() (config *Config) {
return server.config.Load()
}
func (server *Server) ChannelRegistrationEnabled() bool {
return server.Config().Channels.Registration.Enabled
}
func (server *Server) GetOperator(name string) (oper *Oper) {
name, err := CasefoldName(name)
if err != nil {
@ -92,7 +89,7 @@ func (client *Client) AllSessionData(currentSession *Session, hasPrivs bool) (da
return
}
func (client *Client) AddSession(session *Session) (success bool, numSessions int, lastSeen time.Time, back bool) {
func (client *Client) AddSession(session *Session) (success bool, numSessions int, lastSeen time.Time, wasAway, nowAway string) {
config := client.server.Config()
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
@ -113,14 +110,22 @@ func (client *Client) AddSession(session *Session) (success bool, numSessions in
client.setLastSeen(time.Now().UTC(), session.deviceID)
}
client.sessions = newSessions
// TODO(#1551) there should be a cap to opt out of this behavior on a session
if persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway) {
client.awayMessage = ""
if len(client.sessions) == 1 {
back = true
wasAway = client.awayMessage
if client.autoAwayEnabledNoMutex(config) {
client.setAutoAwayNoMutex(config)
} else {
if session.awayMessage != "" && session.awayMessage != "*" {
// set the away message
client.awayMessage = session.awayMessage
} else if session.awayMessage == "" && !session.awayAt.IsZero() {
// weird edge case: explicit `AWAY` or `AWAY :` during pre-registration makes the client back
client.awayMessage = ""
}
// else: the client sent no AWAY command at all, no-op
// or: the client sent `AWAY *`, which should not modify the publicly visible away state
}
return true, len(client.sessions), lastSeen, back
nowAway = client.awayMessage
return true, len(client.sessions), lastSeen, wasAway, nowAway
}
func (client *Client) removeSession(session *Session) (success bool, length int) {
@ -195,7 +200,7 @@ func (client *Client) Away() (result bool, message string) {
return
}
func (session *Session) SetAway(awayMessage string) {
func (session *Session) SetAway(awayMessage string) (wasAway, nowAway string) {
client := session.client
config := client.server.Config()
@ -205,15 +210,21 @@ func (session *Session) SetAway(awayMessage string) {
session.awayMessage = awayMessage
session.awayAt = time.Now().UTC()
autoAway := client.registered && client.alwaysOn && persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway)
if autoAway {
wasAway = client.awayMessage
if client.autoAwayEnabledNoMutex(config) {
client.setAutoAwayNoMutex(config)
} else {
} else if awayMessage != "*" {
client.awayMessage = awayMessage
}
} // else: `AWAY *`, should not modify publicly visible away state
nowAway = client.awayMessage
return
}
func (client *Client) autoAwayEnabledNoMutex(config *Config) bool {
return client.registered && client.alwaysOn &&
persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway)
}
func (client *Client) setAutoAwayNoMutex(config *Config) {
// aggregate the away statuses of the individual sessions:
var globalAwayState string
@ -223,8 +234,8 @@ func (client *Client) setAutoAwayNoMutex(config *Config) {
// a session is active, we are not auto-away
client.awayMessage = ""
return
} else if cSession.awayAt.After(awaySetAt) {
// choose the latest available away message from any session
} else if cSession.awayAt.After(awaySetAt) && cSession.awayMessage != "*" {
// choose the latest valid away message from any session
globalAwayState = cSession.awayMessage
awaySetAt = cSession.awayAt
}
@ -501,7 +512,7 @@ func (client *Client) GetReadMarker(cfname string) (result string) {
func (client *Client) copyReadMarkers() (result map[string]time.Time) {
client.stateMutex.RLock()
defer client.stateMutex.RUnlock()
return utils.CopyMap(client.readMarkers)
return maps.Clone(client.readMarkers)
}
func (client *Client) SetReadMarker(cfname string, now time.Time) (result time.Time) {
@ -601,9 +612,11 @@ func (channel *Channel) Founder() string {
func (channel *Channel) HighestUserMode(client *Client) (result modes.Mode) {
channel.stateMutex.RLock()
clientModes := channel.members[client].modes
channel.stateMutex.RUnlock()
return clientModes.HighestChannelUserMode()
defer channel.stateMutex.RUnlock()
if clientData, ok := channel.members[client]; ok {
return clientData.modes.HighestChannelUserMode()
}
return
}
func (channel *Channel) Settings() (result ChannelSettings) {
@ -638,3 +651,9 @@ func (channel *Channel) getAmode(cfaccount string) (result modes.Mode) {
defer channel.stateMutex.RUnlock()
return channel.accountToUMode[cfaccount]
}
func (channel *Channel) UUID() utils.UUID {
channel.stateMutex.RLock()
defer channel.stateMutex.RUnlock()
return channel.uuid
}

View File

@ -8,7 +8,6 @@ package irc
import (
"bytes"
"encoding/base64"
"fmt"
"net"
"os"
@ -31,6 +30,7 @@ import (
"github.com/ergochat/ergo/irc/history"
"github.com/ergochat/ergo/irc/jwt"
"github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/oauth2"
"github.com/ergochat/ergo/irc/sno"
"github.com/ergochat/ergo/irc/utils"
)
@ -90,8 +90,6 @@ func sendSuccessfulRegResponse(service *ircService, client *Client, rb *Response
details := client.Details()
if service != nil {
service.Notice(rb, client.t("Account created"))
} 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]] from IP %s"), details.nickMask, details.accountName, rb.session.IP().String()))
sendSuccessfulAccountAuth(service, client, rb, false)
@ -180,6 +178,10 @@ func acceptHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo
return false
}
const (
saslMaxResponseLength = 8192 // implementation-defined sanity check, long enough for bearer tokens
)
// AUTHENTICATE [<mechanism>|<data>|*]
func authenticateHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
session := rb.session
@ -203,17 +205,21 @@ func authenticateHandler(server *Server, client *Client, msg ircmsg.Message, rb
return false
}
// start new sasl session
// start new sasl session: parameter is the authentication mechanism
if session.sasl.mechanism == "" {
throttled, remainingTime := client.loginThrottle.Touch()
if throttled {
rb.Add(nil, server.name, ERR_SASLFAIL, client.Nick(), fmt.Sprintf(client.t("Please wait at least %v and try again"), remainingTime))
return false
}
mechanism := strings.ToUpper(msg.Params[0])
_, mechanismIsEnabled := EnabledSaslMechanisms[mechanism]
// The spec says: "The AUTHENTICATE command MUST be used before registration
// is complete and with the sasl capability enabled." Enforcing this universally
// would simplify the implementation somewhat, but we've never enforced it before
// and I don't want to break working clients that use PLAIN or EXTERNAL
// and violate this MUST (e.g. by sending CAP END too early).
if client.registered && !(mechanism == "PLAIN" || mechanism == "EXTERNAL") {
rb.Add(nil, server.name, ERR_SASLFAIL, details.nick, client.t("SASL is only allowed before connection registration"))
return false
}
if mechanismIsEnabled {
session.sasl.mechanism = mechanism
if !config.Server.Compatibility.SendUnprefixedSasl {
@ -231,46 +237,28 @@ func authenticateHandler(server *Server, client *Client, msg ircmsg.Message, rb
return false
}
// continue existing sasl session
rawData := msg.Params[0]
// https://ircv3.net/specs/extensions/sasl-3.1:
// "The response is encoded in Base64 (RFC 4648), then split to 400-byte chunks,
// and each chunk is sent as a separate AUTHENTICATE command."
saslMaxArgLength := 400
if len(rawData) > saslMaxArgLength {
// continue existing sasl session: parameter is a message chunk
done, value, err := session.sasl.value.Add(msg.Params[0])
if err == nil {
if done {
// call actual handler
handler := EnabledSaslMechanisms[session.sasl.mechanism]
return handler(server, client, session, value, rb)
} else {
return false // wait for continuation line
}
}
// else: error handling
switch err {
case ircutils.ErrSASLTooLong:
rb.Add(nil, server.name, ERR_SASLTOOLONG, details.nick, client.t("SASL message too long"))
session.sasl.Clear()
return false
} else if len(rawData) == saslMaxArgLength {
// allow 4 'continuation' lines before rejecting for length
if len(session.sasl.value) >= saslMaxArgLength*4 {
rb.Add(nil, server.name, ERR_SASLFAIL, details.nick, client.t("SASL authentication failed: Passphrase too long"))
session.sasl.Clear()
return false
}
session.sasl.value += rawData
return false
case ircutils.ErrSASLLimitExceeded:
rb.Add(nil, server.name, ERR_SASLFAIL, details.nick, client.t("SASL authentication failed: Passphrase too long"))
default:
rb.Add(nil, server.name, ERR_SASLFAIL, details.nick, client.t("SASL authentication failed: Invalid b64 encoding"))
}
if rawData != "+" {
session.sasl.value += rawData
}
var data []byte
var err error
if session.sasl.value != "+" {
data, err = base64.StdEncoding.DecodeString(session.sasl.value)
session.sasl.value = ""
if err != nil {
rb.Add(nil, server.name, ERR_SASLFAIL, details.nick, client.t("SASL authentication failed: Invalid b64 encoding"))
session.sasl.Clear()
return false
}
}
// call actual handler
handler := EnabledSaslMechanisms[session.sasl.mechanism]
return handler(server, client, session, data, rb)
session.sasl.Clear()
return false
}
// AUTHENTICATE PLAIN
@ -318,6 +306,27 @@ func authPlainHandler(server *Server, client *Client, session *Session, value []
return false
}
// AUTHENTICATE IRCV3BEARER
func authIRCv3BearerHandler(server *Server, client *Client, session *Session, value []byte, rb *ResponseBuffer) bool {
defer session.sasl.Clear()
// <authzid> \x00 <type> \x00 <token>
splitValue := bytes.SplitN(value, []byte{'\000'}, 3)
if len(splitValue) != 3 {
rb.Add(nil, server.name, ERR_SASLFAIL, client.Nick(), client.t("SASL authentication failed: Invalid auth blob"))
return false
}
err := server.accounts.AuthenticateByBearerToken(client, string(splitValue[1]), string(splitValue[2]))
if err != nil {
sendAuthErrorResponse(client, rb, err)
return false
}
sendSuccessfulAccountAuth(nil, client, rb, true)
return false
}
func sendAuthErrorResponse(client *Client, rb *ResponseBuffer, err error) {
msg := authErrorToMessage(client.server, err)
rb.Add(nil, client.server.name, ERR_SASLFAIL, client.nick, fmt.Sprintf("%s: %s", client.t("SASL authentication failed"), client.t(msg)))
@ -332,7 +341,7 @@ func authErrorToMessage(server *Server, err error) (msg string) {
}
switch err {
case errAccountDoesNotExist, errAccountUnverified, errAccountInvalidCredentials, errAuthzidAuthcidMismatch, errNickAccountMismatch, errAccountSuspended:
case errAccountDoesNotExist, errAccountUnverified, errAccountInvalidCredentials, errAuthzidAuthcidMismatch, errNickAccountMismatch, errAccountSuspended, oauth2.ErrInvalidToken:
return err.Error()
default:
// don't expose arbitrary error messages to the user
@ -352,28 +361,18 @@ func authExternalHandler(server *Server, client *Client, session *Session, value
// EXTERNAL doesn't carry an authentication ID (this is determined from the
// certificate), but does carry an optional authorization ID.
var authzid string
authzid := string(value)
var deviceID string
var err error
if len(value) != 0 {
authzid, err = CasefoldName(string(value))
if err != nil {
err = errAuthzidAuthcidMismatch
}
// see #843: strip the device ID for the benefit of clients that don't
// distinguish user/ident from account name
if strudelIndex := strings.IndexByte(authzid, '@'); strudelIndex != -1 {
authzid, deviceID = authzid[:strudelIndex], authzid[strudelIndex+1:]
}
if err == nil {
// 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.AuthenticateByCertificate(client, rb.session.certfp, rb.session.peerCerts, authzid)
}
if err != nil {
sendAuthErrorResponse(client, rb, err)
return false
@ -382,6 +381,9 @@ func authExternalHandler(server *Server, client *Client, session *Session, value
}
sendSuccessfulAccountAuth(nil, client, rb, true)
if !client.registered && deviceID != "" {
rb.session.deviceID = deviceID
}
return false
}
@ -396,6 +398,12 @@ func authScramHandler(server *Server, client *Client, session *Session, value []
// first message? if so, initialize the SCRAM conversation
if session.sasl.scramConv == nil {
if throttled, remainingTime := client.checkLoginThrottle(); throttled {
rb.Add(nil, server.name, ERR_SASLFAIL, client.Nick(),
fmt.Sprintf(client.t("Please wait at least %v and try again"), remainingTime.Round(time.Millisecond)))
continueAuth = false
return false
}
session.sasl.scramConv = server.accounts.NewScramConversation()
}
@ -419,9 +427,8 @@ func authScramHandler(server *Server, client *Client, session *Session, value []
account, err := server.accounts.LoadAccount(authcid)
if err == nil {
server.accounts.Login(client, account)
if fixupNickEqualsAccount(client, rb, server.Config(), "") {
sendSuccessfulAccountAuth(nil, client, rb, true)
}
// fixupNickEqualsAccount is not needed for unregistered clients
sendSuccessfulAccountAuth(nil, client, rb, true)
} else {
server.logger.Error("internal", "SCRAM succeeded but couldn't load account", authcid, err.Error())
rb.Add(nil, server.name, ERR_SASLFAIL, client.nick, client.t("SASL authentication failed"))
@ -434,7 +441,7 @@ func authScramHandler(server *Server, client *Client, session *Session, value []
response, err := session.sasl.scramConv.Step(string(value))
if err == nil {
rb.Add(nil, server.name, "AUTHENTICATE", base64.StdEncoding.EncodeToString([]byte(response)))
sendSASLChallenge(server, rb, []byte(response))
} else {
continueAuth = false
rb.Add(nil, server.name, ERR_SASLFAIL, client.Nick(), err.Error())
@ -444,34 +451,88 @@ func authScramHandler(server *Server, client *Client, session *Session, value []
return false
}
// AUTHENTICATE OAUTHBEARER
func authOauthBearerHandler(server *Server, client *Client, session *Session, value []byte, rb *ResponseBuffer) bool {
if !server.Config().Accounts.OAuth2.Enabled {
rb.Add(nil, server.name, ERR_SASLFAIL, client.Nick(), "SASL authentication failed: mechanism not enabled")
return false
}
if session.sasl.oauthConv == nil {
session.sasl.oauthConv = oauth2.NewOAuthBearerServer(
func(opts oauth2.OAuthBearerOptions) *oauth2.OAuthBearerError {
err := server.accounts.AuthenticateByOAuthBearer(client, opts)
switch err {
case nil:
return nil
case oauth2.ErrInvalidToken:
return &oauth2.OAuthBearerError{Status: "invalid_token", Schemes: "bearer"}
case errFeatureDisabled:
return &oauth2.OAuthBearerError{Status: "invalid_request", Schemes: "bearer"}
default:
// this is probably a misconfiguration or infrastructure error so we should log it
server.logger.Error("internal", "failed to validate OAUTHBEARER token", err.Error())
// tell the client it was their fault even though it probably wasn't:
return &oauth2.OAuthBearerError{Status: "invalid_request", Schemes: "bearer"}
}
},
)
}
challenge, done, err := session.sasl.oauthConv.Next(value)
if done {
if err == nil {
sendSuccessfulAccountAuth(nil, client, rb, true)
} else {
rb.Add(nil, server.name, ERR_SASLFAIL, client.Nick(), ircutils.SanitizeText(err.Error(), 350))
}
session.sasl.Clear()
} else {
// ignore `err`, we need to relay the challenge (which may contain a JSON-encoded error)
// to the client
sendSASLChallenge(server, rb, challenge)
}
return false
}
// helper to b64 a sasl response and chunk it into 400-byte lines
// as per https://ircv3.net/specs/extensions/sasl-3.1
func sendSASLChallenge(server *Server, rb *ResponseBuffer, challenge []byte) {
for _, chunk := range ircutils.EncodeSASLResponse(challenge) {
rb.Add(nil, server.name, "AUTHENTICATE", chunk)
}
}
// AWAY [<message>]
func awayHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
var isAway bool
// #1996: `AWAY :` is treated the same as `AWAY`
var awayMessage string
if len(msg.Params) > 0 {
awayMessage = msg.Params[0]
awayMessage = ircutils.TruncateUTF8Safe(awayMessage, server.Config().Limits.AwayLen)
awayMessage = ircmsg.TruncateUTF8Safe(awayMessage, server.Config().Limits.AwayLen)
}
isAway = (awayMessage != "") // #1996
rb.session.SetAway(awayMessage)
wasAway, nowAway := rb.session.SetAway(awayMessage)
if isAway {
if nowAway != "" {
rb.Add(nil, server.name, RPL_NOWAWAY, client.nick, client.t("You have been marked as being away"))
} else {
rb.Add(nil, server.name, RPL_UNAWAY, client.nick, client.t("You are no longer marked as being away"))
}
dispatchAwayNotify(client, isAway, awayMessage)
if client.registered && wasAway != nowAway {
dispatchAwayNotify(client, nowAway)
} // else: we'll send it (if applicable) after reattach
return false
}
func dispatchAwayNotify(client *Client, isAway bool, awayMessage string) {
func dispatchAwayNotify(client *Client, awayMessage string) {
// dispatch away-notify
details := client.Details()
isBot := client.HasMode(modes.Bot)
for session := range client.FriendsMonitors(caps.AwayNotify) {
if isAway {
if awayMessage != "" {
session.sendFromClientInternal(false, time.Time{}, "", details.nickMask, details.accountName, isBot, nil, "AWAY", awayMessage)
} else {
session.sendFromClientInternal(false, time.Time{}, "", details.nickMask, details.accountName, isBot, nil, "AWAY")
@ -652,7 +713,7 @@ func chathistoryHandler(server *Server, client *Client, msg ircmsg.Message, rb *
} else {
// successful responses are sent as a chathistory or history batch
if listTargets {
batchID := rb.StartNestedBatch("draft/chathistory-targets")
batchID := rb.StartNestedBatch(caps.ChathistoryTargetsBatchType)
defer rb.EndNestedBatch(batchID)
for _, target := range targets {
name := server.UnfoldName(target.CfName)
@ -1442,7 +1503,7 @@ func kickHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons
// KILL <nickname> <comment>
func killHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
nickname := msg.Params[0]
comment := "<no reason supplied>"
var comment string
if len(msg.Params) > 1 {
comment = msg.Params[1]
}
@ -1452,12 +1513,21 @@ func killHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons
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()))
rb.Add(nil, client.server.name, ERR_UNKNOWNERROR, client.Nick(), "KILL", fmt.Sprintf(client.t("Client %s is always-on and cannot be fully removed by /KILL; consider /UBAN ADD instead"), target.Nick()))
}
quitMsg := fmt.Sprintf("Killed (%s (%s))", client.nick, comment)
quitMsg := "Killed"
if comment != "" {
quitMsg = fmt.Sprintf("Killed by %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))
var snoLine string
if comment == "" {
snoLine = fmt.Sprintf(ircfmt.Unescape("%s was killed by %s"), target.Nick(), client.Nick())
} else {
snoLine = fmt.Sprintf(ircfmt.Unescape("%s was killed by %s $c[grey][$r%s$c[grey]]"), target.Nick(), client.Nick(), comment)
}
server.snomasks.Send(sno.LocalKills, snoLine)
target.Quit(quitMsg, nil)
target.destroy(nil)
@ -1666,7 +1736,7 @@ func listHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons
config := server.Config()
if time.Since(client.ctime) < config.Channels.ListDelay && client.Account() == "" && !client.HasMode(modes.Operator) {
remaining := time.Until(client.ctime.Add(config.Channels.ListDelay))
rb.Notice(fmt.Sprintf(client.t("This server requires that you wait %v after connecting before you can use /LIST. You have %v left."), config.Channels.ListDelay, remaining))
rb.Notice(fmt.Sprintf(client.t("This server requires that you wait %v after connecting before you can use /LIST. You have %v left."), config.Channels.ListDelay, remaining.Round(time.Millisecond)))
rb.Add(nil, server.name, RPL_LISTEND, client.Nick(), client.t("End of LIST"))
return false
}
@ -1718,7 +1788,7 @@ func listHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons
clientIsOp := client.HasRoleCapabs("sajoin")
if len(channels) == 0 {
for _, channel := range server.channels.Channels() {
for _, channel := range server.channels.ListableChannels() {
if !clientIsOp && channel.flags.HasMode(modes.Secret) && !channel.hasClient(client) {
continue
}
@ -2048,8 +2118,6 @@ func namesHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon
channels = strings.Split(msg.Params[0], ",")
}
// TODO: in a post-federation world, process `target` (server to forward request to)
// implement the modern behavior: https://modern.ircdocs.horse/#names-message
// "Servers MAY only return information about the first <channel> and silently ignore the others."
// "If no parameter is given for this command, servers SHOULD return one RPL_ENDOFNAMES numeric
@ -2653,6 +2721,97 @@ fail:
return false
}
// REDACT <target> <targetmsgid> [:<reason>]
func redactHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
target := msg.Params[0]
targetmsgid := msg.Params[1]
//clientOnlyTags := msg.ClientOnlyTags()
var reason string
if len(msg.Params) > 2 {
reason = msg.Params[2]
}
var members []*Client // members of a channel, or both parties of a PM
var canDelete CanDelete
msgid := utils.GenerateSecretToken()
time := time.Now().UTC().Round(0)
details := client.Details()
isBot := client.HasMode(modes.Bot)
if target[0] == '#' {
channel := server.channels.Get(target)
if channel == nil {
rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), utils.SafeErrorParam(target), client.t("No such channel"))
return false
}
members = channel.Members()
canDelete = deletionPolicy(server, client, target)
} else {
targetClient := server.clients.Get(target)
if targetClient == nil {
rb.Add(nil, server.name, ERR_NOSUCHNICK, client.Nick(), target, "No such nick")
return false
}
members = []*Client{client, targetClient}
canDelete = canDeleteSelf
}
if canDelete == canDeleteNone {
rb.Add(nil, server.name, "FAIL", "REDACT", "REDACT_FORBIDDEN", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), client.t("You are not authorized to delete messages"))
return false
}
accountName := "*"
if canDelete == canDeleteSelf {
accountName = client.AccountName()
if accountName == "*" {
rb.Add(nil, server.name, "FAIL", "REDACT", "REDACT_FORBIDDEN", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), client.t("You are not authorized to delete this message"))
return false
}
}
err := server.DeleteMessage(target, targetmsgid, accountName)
if err == errNoop {
rb.Add(nil, server.name, "FAIL", "REDACT", "UNKNOWN_MSGID", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), client.t("This message does not exist or is too old"))
return false
} else if err != nil {
isOper := client.HasRoleCapabs("history")
if isOper {
rb.Add(nil, server.name, "FAIL", "REDACT", "REDACT_FORBIDDEN", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), fmt.Sprintf(client.t("Error deleting message: %v"), err))
} else {
rb.Add(nil, server.name, "FAIL", "REDACT", "REDACT_FORBIDDEN", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), client.t("Could not delete message"))
}
return false
}
if target[0] != '#' {
// If this is a PM, we just removed the message from the buffer of the other party;
// now we have to remove it from the buffer of the client who sent the REDACT command
err := server.DeleteMessage(client.Nick(), targetmsgid, accountName)
if err != nil {
client.server.logger.Error("internal", fmt.Sprintf("Private message %s is not deletable by %s from their own buffer's even though we just deleted it from %s's. This is a bug, please report it in details.", targetmsgid, client.Nick(), target), client.Nick())
isOper := client.HasRoleCapabs("history")
if isOper {
rb.Add(nil, server.name, "FAIL", "REDACT", "REDACT_FORBIDDEN", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), fmt.Sprintf(client.t("Error deleting message: %v"), err))
} else {
rb.Add(nil, server.name, "FAIL", "REDACT", "REDACT_FORBIDDEN", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), client.t("Error deleting message"))
}
}
}
for _, member := range members {
for _, session := range member.Sessions() {
if session.capabilities.Has(caps.MessageRedaction) {
session.sendFromClientInternal(false, time, msgid, details.nickMask, details.accountName, isBot, nil, "REDACT", target, targetmsgid, reason)
} else {
// If we wanted to send a fallback to clients which do not support
// draft/message-redaction, we would do it from here.
}
}
}
return false
}
func reportPersistenceStatus(client *Client, rb *ResponseBuffer, broadcast bool) {
settings := client.AccountSettings()
serverSetting := client.server.Config().Accounts.Multiclient.AlwaysOn
@ -2717,7 +2876,7 @@ func registerHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res
case "*", accountName:
// ok
default:
rb.Add(nil, server.name, "FAIL", "REGISTER", "ACCOUNTNAME_MUST_BE_NICK", utils.SafeErrorParam(msg.Params[0]), client.t("You may only register your nickname as your account name"))
rb.Add(nil, server.name, "FAIL", "REGISTER", "ACCOUNT_NAME_MUST_BE_NICK", utils.SafeErrorParam(msg.Params[0]), client.t("You may only register your nickname as your account name"))
return
}
@ -2887,7 +3046,7 @@ func rehashHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo
// TODO all operators should get a notice of some kind here
rb.Notice(client.t("Rehash complete"))
} else {
rb.Add(nil, server.name, ERR_UNKNOWNERROR, nick, "REHASH", err.Error())
rb.Add(nil, server.name, ERR_UNKNOWNERROR, nick, "REHASH", ircutils.SanitizeText(err.Error(), 350))
}
return false
}
@ -3068,7 +3227,9 @@ func renameHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo
targetRb.Add(nil, targetPrefix, "JOIN", newName)
}
channel.SendTopic(mcl, targetRb, false)
channel.Names(mcl, targetRb)
if !targetRb.session.capabilities.Has(caps.NoImplicitNames) {
channel.Names(mcl, targetRb)
}
}
if mcl != client {
targetRb.Send(false)
@ -3233,6 +3394,10 @@ func userHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons
rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.Nick(), "USER", client.t("Not enough parameters"))
return false
}
config := server.Config()
if config.Limits.RealnameLen > 0 && len(realname) > config.Limits.RealnameLen {
realname = ircmsg.TruncateUTF8Safe(realname, config.Limits.RealnameLen)
}
// #843: we accept either: `USER user:pass@clientid` or `USER user@clientid`
if strudelIndex := strings.IndexByte(username, '@'); strudelIndex != -1 {
@ -3367,8 +3532,9 @@ func webircHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo
}
}
config := server.Config()
givenPassword := []byte(msg.Params[0])
for _, info := range server.Config().Server.WebIRC {
for _, info := range config.Server.WebIRC {
if utils.IPInNets(client.realIP, info.allowedNets) {
// confirm password and/or fingerprint
if 0 < len(info.Password) && bcrypt.CompareHashAndPassword(info.Password, givenPassword) != nil {
@ -3378,11 +3544,23 @@ func webircHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo
continue
}
err, quitMsg := client.ApplyProxiedIP(rb.session, net.ParseIP(msg.Params[3]), secure)
candidateIP := msg.Params[3]
err, quitMsg := client.ApplyProxiedIP(rb.session, net.ParseIP(candidateIP), secure)
if err != nil {
client.Quit(quitMsg, rb.session)
return true
} else {
if info.AcceptHostname {
candidateHostname := msg.Params[2]
if candidateHostname != candidateIP {
if utils.IsHostname(candidateHostname) {
rb.session.rawHostname = candidateHostname
} else {
// log this at debug level since it may be spammy
server.logger.Debug("internal", "invalid hostname from WEBIRC", candidateHostname)
}
}
}
return false
}
}

View File

@ -435,6 +435,12 @@ Replies to a PING. Used to check link connectivity.`,
text: `PRIVMSG <target>{,<target>} <text to be sent>
Sends the text to the given targets as a PRIVMSG.`,
},
"redact": {
text: `REDACT <target> <targetmsgid> [<reason>]
Removes the message of the target msgid from the chat history of a channel
or target user.`,
},
"relaymsg": {
text: `RELAYMSG <channel> <spoofed nick> :<message>

View File

@ -4,6 +4,7 @@
package history
import (
"slices"
"sync"
"time"
@ -155,7 +156,7 @@ func (list *Buffer) betweenHelper(start, end Selector, cutoff time.Time, pred Pr
defer func() {
if !ascending {
utils.ReverseSlice(results)
slices.Reverse(results)
}
}()
@ -262,7 +263,7 @@ func (list *Buffer) listCorrespondents(start, end Selector, cutoff time.Time, li
}
if !ascending {
utils.ReverseSlice(results)
slices.Reverse(results)
}
return

View File

@ -4,10 +4,9 @@
package history
import (
"slices"
"sort"
"time"
"github.com/ergochat/ergo/irc/utils"
)
type TargetListing struct {
@ -35,8 +34,8 @@ func MergeTargets(base []TargetListing, extra []TargetListing, start, end time.T
results = make([]TargetListing, 0, prealloc)
if !ascending {
utils.ReverseSlice(base)
utils.ReverseSlice(extra)
slices.Reverse(base)
slices.Reverse(extra)
}
for len(results) < limit {
@ -66,7 +65,7 @@ func MergeTargets(base []TargetListing, extra []TargetListing, start, end time.T
}
if !ascending {
utils.ReverseSlice(results)
slices.Reverse(results)
}
return
}

View File

@ -15,6 +15,14 @@ import (
"github.com/ergochat/ergo/irc/utils"
)
type CanDelete uint
const (
canDeleteAny CanDelete = iota // User is allowed to delete any message (for a given channel/PM)
canDeleteSelf // User is allowed to delete their own messages (ditto)
canDeleteNone // User is not allowed to delete any message (ditto)
)
const (
histservHelp = `HistServ provides commands related to history.`
)
@ -92,33 +100,53 @@ func histservForgetHandler(service *ircService, server *Server, client *Client,
service.Notice(rb, fmt.Sprintf(client.t("Enqueued account %s for message deletion"), accountName))
}
func histservDeleteHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
target, msgid := params[0], params[1] // Fix #1881 2 params are required
// operators can delete; if individual delete is allowed, a chanop or
// the message author can delete
accountName := "*"
isChanop := false
// Returns:
//
// 1. `canDeleteAny` if the client allowed to delete other users' messages from the target, ie.:
// - the client is a channel operator, or
// - the client is an operator with "history" capability
//
// 2. `canDeleteSelf` if the client is allowed to delete their own messages from the target
// 3. `canDeleteNone` otherwise
func deletionPolicy(server *Server, client *Client, target string) CanDelete {
isOper := client.HasRoleCapabs("history")
if !isOper {
if isOper {
return canDeleteAny
} else {
if server.Config().History.Retention.AllowIndividualDelete {
channel := server.channels.Get(target)
if channel != nil && channel.ClientIsAtLeast(client, modes.Operator) {
isChanop = true
return canDeleteAny
} else {
accountName = client.AccountName()
return canDeleteSelf
}
} else {
return canDeleteNone
}
}
if !isOper && !isChanop && accountName == "*" {
}
func histservDeleteHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
target, msgid := params[0], params[1] // Fix #1881 2 params are required
canDelete := deletionPolicy(server, client, target)
accountName := "*"
if canDelete == canDeleteNone {
service.Notice(rb, client.t("Insufficient privileges"))
return
} else if canDelete == canDeleteSelf {
accountName = client.AccountName()
if accountName == "*" {
service.Notice(rb, client.t("Insufficient privileges"))
return
}
}
err := server.DeleteMessage(target, msgid, accountName)
if err == nil {
service.Notice(rb, client.t("Successfully deleted message"))
} else {
isOper := client.HasRoleCapabs("history")
if isOper {
service.Notice(rb, fmt.Sprintf(client.t("Error deleting message: %v"), err))
} else {

View File

@ -193,6 +193,6 @@ func hsSetCloakSecretHandler(service *ircService, server *Server, client *Client
service.Notice(rb, fmt.Sprintf(client.t("To confirm, run this command: %s"), fmt.Sprintf("/HS SETCLOAKSECRET %s %s", secret, expectedCode)))
return
}
StoreCloakSecret(server.store, secret)
StoreCloakSecret(server.dstore, secret)
service.Notice(rb, client.t("Rotated the cloak secret; you must rehash or restart the server for it to take effect"))
}

View File

@ -9,9 +9,13 @@ import (
"log"
"os"
"strconv"
"time"
"github.com/tidwall/buntdb"
"github.com/ergochat/ergo/irc/bunt"
"github.com/ergochat/ergo/irc/datastore"
"github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/utils"
)
@ -20,7 +24,7 @@ const (
// XXX instead of referencing, e.g., keyAccountExists, we should write in the string literal
// (to ensure that no matter what code changes happen elsewhere, we're still producing a
// db of the hardcoded version)
importDBSchemaVersion = 22
importDBSchemaVersion = 23
)
type userImport struct {
@ -54,8 +58,8 @@ type databaseImport struct {
Channels map[string]channelImport
}
func serializeAmodes(raw map[string]string, validCfUsernames utils.HashSet[string]) (result []byte, err error) {
processed := make(map[string]int, len(raw))
func convertAmodes(raw map[string]string, validCfUsernames utils.HashSet[string]) (result map[string]modes.Mode, err error) {
result = make(map[string]modes.Mode)
for accountName, mode := range raw {
if len(mode) != 1 {
return nil, fmt.Errorf("invalid mode %s for account %s", mode, accountName)
@ -64,10 +68,9 @@ func serializeAmodes(raw map[string]string, validCfUsernames utils.HashSet[strin
if err != nil || !validCfUsernames.Has(cfname) {
log.Printf("skipping invalid amode recipient %s\n", accountName)
} else {
processed[cfname] = int(mode[0])
result[cfname] = modes.Mode(mode[0])
}
}
result, err = json.Marshal(processed)
return
}
@ -147,8 +150,9 @@ func doImportDBGeneric(config *Config, dbImport databaseImport, credsType Creden
cfUsernames.Add(cfUsername)
}
// TODO fix this:
for chname, chInfo := range dbImport.Channels {
cfchname, err := CasefoldChannel(chname)
_, err := CasefoldChannel(chname)
if err != nil {
log.Printf("invalid channel name %s: %v", chname, err)
continue
@ -158,43 +162,42 @@ func doImportDBGeneric(config *Config, dbImport databaseImport, credsType Creden
log.Printf("invalid founder %s for channel %s: %v", chInfo.Founder, chname, err)
continue
}
tx.Set(fmt.Sprintf(keyChannelExists, cfchname), "1", nil)
tx.Set(fmt.Sprintf(keyChannelName, cfchname), chname, nil)
tx.Set(fmt.Sprintf(keyChannelRegTime, cfchname), strconv.FormatInt(chInfo.RegisteredAt, 10), nil)
tx.Set(fmt.Sprintf(keyChannelFounder, cfchname), cffounder, nil)
accountChannelsKey := fmt.Sprintf(keyAccountChannels, cffounder)
founderChannels, fcErr := tx.Get(accountChannelsKey)
if fcErr != nil || founderChannels == "" {
founderChannels = cfchname
} else {
founderChannels = fmt.Sprintf("%s,%s", founderChannels, cfchname)
}
tx.Set(accountChannelsKey, founderChannels, nil)
var regInfo RegisteredChannel
regInfo.Name = chname
regInfo.UUID = utils.GenerateUUIDv4()
regInfo.Founder = cffounder
regInfo.RegisteredAt = time.Unix(0, chInfo.RegisteredAt).UTC()
if chInfo.Topic != "" {
tx.Set(fmt.Sprintf(keyChannelTopic, cfchname), chInfo.Topic, nil)
tx.Set(fmt.Sprintf(keyChannelTopicSetTime, cfchname), strconv.FormatInt(chInfo.TopicSetAt, 10), nil)
tx.Set(fmt.Sprintf(keyChannelTopicSetBy, cfchname), chInfo.TopicSetBy, nil)
regInfo.Topic = chInfo.Topic
regInfo.TopicSetBy = chInfo.TopicSetBy
regInfo.TopicSetTime = time.Unix(0, chInfo.TopicSetAt).UTC()
}
if len(chInfo.Amode) != 0 {
m, err := serializeAmodes(chInfo.Amode, cfUsernames)
m, err := convertAmodes(chInfo.Amode, cfUsernames)
if err == nil {
tx.Set(fmt.Sprintf(keyChannelAccountToUMode, cfchname), string(m), nil)
regInfo.AccountToUMode = m
} else {
log.Printf("couldn't serialize amodes for %s: %v", chname, err)
log.Printf("couldn't process amodes for %s: %v", chname, err)
}
}
tx.Set(fmt.Sprintf(keyChannelModes, cfchname), chInfo.Modes, nil)
if chInfo.Key != "" {
tx.Set(fmt.Sprintf(keyChannelPassword, cfchname), chInfo.Key, nil)
for _, mode := range chInfo.Modes {
regInfo.Modes = append(regInfo.Modes, modes.Mode(mode))
}
regInfo.Key = chInfo.Key
if chInfo.Limit > 0 {
tx.Set(fmt.Sprintf(keyChannelUserLimit, cfchname), strconv.Itoa(chInfo.Limit), nil)
regInfo.UserLimit = chInfo.Limit
}
if chInfo.Forward != "" {
if _, err := CasefoldChannel(chInfo.Forward); err == nil {
tx.Set(fmt.Sprintf(keyChannelForward, cfchname), chInfo.Forward, nil)
regInfo.Forward = chInfo.Forward
}
}
if j, err := json.Marshal(regInfo); err == nil {
tx.Set(bunt.BuntKey(datastore.TableChannels, regInfo.UUID), string(j), nil)
} else {
log.Printf("couldn't serialize channel %s: %v", chname, err)
}
}
if warnSkeletons {

View File

@ -5,6 +5,7 @@ package irc
import (
"bytes"
"io"
"net"
"unicode/utf8"
@ -93,21 +94,25 @@ func (cc *IRCStreamConn) Close() (err error) {
// IRCWSConn is an IRCConn over a websocket.
type IRCWSConn struct {
conn *websocket.Conn
buf []byte
binary bool
}
func NewIRCWSConn(conn *websocket.Conn) IRCWSConn {
binary := conn.Subprotocol() == "binary.ircv3.net"
return IRCWSConn{conn: conn, binary: binary}
func NewIRCWSConn(conn *websocket.Conn) *IRCWSConn {
return &IRCWSConn{
conn: conn,
binary: conn.Subprotocol() == "binary.ircv3.net",
buf: make([]byte, initialBufferSize),
}
}
func (wc IRCWSConn) UnderlyingConn() *utils.WrappedConn {
func (wc *IRCWSConn) UnderlyingConn() *utils.WrappedConn {
// just assume that the type is OK
wConn, _ := wc.conn.UnderlyingConn().(*utils.WrappedConn)
return wConn
}
func (wc IRCWSConn) WriteLine(buf []byte) (err error) {
func (wc *IRCWSConn) WriteLine(buf []byte) (err error) {
buf = bytes.TrimSuffix(buf, crlf)
// #1483: if we have websockets at all, then we're enforcing utf8
messageType := websocket.TextMessage
@ -117,7 +122,7 @@ func (wc IRCWSConn) WriteLine(buf []byte) (err error) {
return wc.conn.WriteMessage(messageType, buf)
}
func (wc IRCWSConn) WriteLines(buffers [][]byte) (err error) {
func (wc *IRCWSConn) WriteLines(buffers [][]byte) (err error) {
for _, buf := range buffers {
err = wc.WriteLine(buf)
if err != nil {
@ -127,20 +132,47 @@ func (wc IRCWSConn) WriteLines(buffers [][]byte) (err error) {
return
}
func (wc IRCWSConn) ReadLine() (line []byte, err error) {
messageType, line, err := wc.conn.ReadMessage()
if err == nil {
if messageType == websocket.BinaryMessage && !utf8.Valid(line) {
func (wc *IRCWSConn) ReadLine() (line []byte, err error) {
_, reader, err := wc.conn.NextReader()
switch err {
case nil:
// OK
case websocket.ErrReadLimit:
return line, ircreader.ErrReadQ
default:
return line, err
}
line, err = wc.readFull(reader)
switch err {
case io.ErrUnexpectedEOF, io.EOF:
// these are OK. io.ErrUnexpectedEOF is the good case:
// it means we read the full message and it consumed less than the full wc.buf
if !utf8.Valid(line) {
return line, errInvalidUtf8
}
return line, nil
} else if err == websocket.ErrReadLimit {
case nil, websocket.ErrReadLimit:
// nil means we filled wc.buf without exhausting the reader:
return line, ircreader.ErrReadQ
} else {
default:
return line, err
}
}
func (wc IRCWSConn) Close() (err error) {
func (wc *IRCWSConn) readFull(reader io.Reader) (line []byte, err error) {
// XXX this is io.ReadFull with a single attempt to resize upwards
n, err := io.ReadFull(reader, wc.buf)
if err == nil && len(wc.buf) < maxReadQBytes() {
newBuf := make([]byte, maxReadQBytes())
copy(newBuf, wc.buf[:n])
wc.buf = newBuf
n2, err := io.ReadFull(reader, wc.buf[n:])
return wc.buf[:n+n2], err
}
return wc.buf[:n], err
}
func (wc *IRCWSConn) Close() (err error) {
return wc.conn.Close()
}

View File

@ -11,6 +11,12 @@ import (
const (
maxLastArgLength = 400
/* Modern: "As the maximum number of message parameters to any reply is 15,
the maximum number of RPL_ISUPPORT tokens that can be advertised is 13."
<nickname> [up to 13 parameters] <human-readable trailing>
*/
maxParameters = 13
)
// List holds a list of ISUPPORT tokens
@ -95,7 +101,7 @@ func (il *List) GetDifference(newil *List) [][]string {
length += len(token)
}
if len(cache) == 13 || len(token)+length >= maxLastArgLength {
if len(cache) == maxParameters || len(token)+length >= maxLastArgLength {
replies = append(replies, cache)
cache = make([]string, 0)
length = 0
@ -138,7 +144,7 @@ func (il *List) RegenerateCachedReply() (err error) {
length += len(token)
}
if len(cache) == 13 || len(token)+length >= maxLastArgLength {
if len(cache) == maxParameters || len(token)+length >= maxLastArgLength {
il.CachedReply = append(il.CachedReply, cache)
cache = make([]string, 0)
length = 0

158
irc/jwt/bearer.go Normal file
View File

@ -0,0 +1,158 @@
// Copyright (c) 2024 Shivaram Lingamneni <slingamn@cs.stanford.edu>
// released under the MIT license
package jwt
import (
"fmt"
"io"
"os"
"strings"
jwt "github.com/golang-jwt/jwt/v5"
)
var (
ErrAuthDisabled = fmt.Errorf("JWT authentication is disabled")
ErrNoValidAccountClaim = fmt.Errorf("JWT token did not contain an acceptable account name claim")
)
// JWTAuthConfig is the config for Ergo to accept JWTs via draft/bearer
type JWTAuthConfig struct {
Enabled bool `yaml:"enabled"`
Autocreate bool `yaml:"autocreate"`
Tokens []JWTAuthTokenConfig `yaml:"tokens"`
}
type JWTAuthTokenConfig struct {
Algorithm string `yaml:"algorithm"`
KeyString string `yaml:"key"`
KeyFile string `yaml:"key-file"`
key any
parser *jwt.Parser
AccountClaims []string `yaml:"account-claims"`
StripDomain string `yaml:"strip-domain"`
}
func (j *JWTAuthConfig) Postprocess() error {
if !j.Enabled {
return nil
}
if len(j.Tokens) == 0 {
return fmt.Errorf("JWT authentication enabled, but no valid tokens defined")
}
for i := range j.Tokens {
if err := j.Tokens[i].Postprocess(); err != nil {
return err
}
}
return nil
}
func (j *JWTAuthTokenConfig) Postprocess() error {
keyBytes, err := j.keyBytes()
if err != nil {
return err
}
j.Algorithm = strings.ToLower(j.Algorithm)
var methods []string
switch j.Algorithm {
case "hmac":
j.key = keyBytes
methods = []string{"HS256", "HS384", "HS512"}
case "rsa":
rsaKey, err := jwt.ParseRSAPublicKeyFromPEM(keyBytes)
if err != nil {
return err
}
j.key = rsaKey
methods = []string{"RS256", "RS384", "RS512"}
case "eddsa":
eddsaKey, err := jwt.ParseEdPublicKeyFromPEM(keyBytes)
if err != nil {
return err
}
j.key = eddsaKey
methods = []string{"EdDSA"}
default:
return fmt.Errorf("invalid jwt algorithm: %s", j.Algorithm)
}
j.parser = jwt.NewParser(jwt.WithValidMethods(methods))
if len(j.AccountClaims) == 0 {
return fmt.Errorf("JWT auth enabled, but no account-claims specified")
}
j.StripDomain = strings.ToLower(j.StripDomain)
return nil
}
func (j *JWTAuthConfig) Validate(t string) (accountName string, err error) {
if !j.Enabled || len(j.Tokens) == 0 {
return "", ErrAuthDisabled
}
for i := range j.Tokens {
accountName, err = j.Tokens[i].Validate(t)
if err == nil {
return
}
}
return
}
func (j *JWTAuthTokenConfig) keyBytes() (result []byte, err error) {
if j.KeyFile != "" {
o, err := os.Open(j.KeyFile)
if err != nil {
return nil, err
}
defer o.Close()
return io.ReadAll(o)
}
if j.KeyString != "" {
return []byte(j.KeyString), nil
}
return nil, fmt.Errorf("JWT auth enabled, but no JWT key specified")
}
// implements jwt.Keyfunc
func (j *JWTAuthTokenConfig) keyFunc(_ *jwt.Token) (interface{}, error) {
return j.key, nil
}
func (j *JWTAuthTokenConfig) Validate(t string) (accountName string, err error) {
token, err := j.parser.Parse(t, j.keyFunc)
if err != nil {
return "", err
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
// impossible with Parse (as opposed to ParseWithClaims)
return "", fmt.Errorf("unexpected type from parsed token claims: %T", claims)
}
for _, c := range j.AccountClaims {
if v, ok := claims[c]; ok {
if vstr, ok := v.(string); ok {
// validate and strip email addresses:
if idx := strings.IndexByte(vstr, '@'); idx != -1 {
suffix := vstr[idx+1:]
vstr = vstr[:idx]
if strings.ToLower(suffix) != j.StripDomain {
continue
}
}
return vstr, nil // success
}
}
}
return "", ErrNoValidAccountClaim
}

143
irc/jwt/bearer_test.go Normal file
View File

@ -0,0 +1,143 @@
package jwt
import (
"testing"
jwt "github.com/golang-jwt/jwt/v5"
)
const (
rsaTestPubKey = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwhcCcXrfR/GmoPKxBi0H
cUl2pUl4acq2m3abFtMMoYTydJdEhgYWfsXuragyEIVkJU1ZnrgedW0QJUcANRGO
hP/B+MjBevDNsRXQECfhyjfzhz6KWZb4i7C2oImJuAjq/F4qGLdEGQDBpAzof8qv
9Zt5iN3GXY/EQtQVMFyR/7BPcbPLbHlOtzZ6tVEioXuUxQoai7x3Kc0jIcPWuyGa
Q04IvsgdaWO6oH4fhPfyVsmX37rYUn79zcqPHS4ieWM1KN9qc7W+/UJIeiwAStpJ
8gv+OSMrijRZGgQGCeOO5U59GGJC4mqUczB+JFvrlAIv0rggNpl+qalngosNxukB
uQIDAQAB
-----END PUBLIC KEY-----`
rsaTestPrivKey = `-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDCFwJxet9H8aag
8rEGLQdxSXalSXhpyrabdpsW0wyhhPJ0l0SGBhZ+xe6tqDIQhWQlTVmeuB51bRAl
RwA1EY6E/8H4yMF68M2xFdAQJ+HKN/OHPopZlviLsLagiYm4COr8XioYt0QZAMGk
DOh/yq/1m3mI3cZdj8RC1BUwXJH/sE9xs8tseU63Nnq1USKhe5TFChqLvHcpzSMh
w9a7IZpDTgi+yB1pY7qgfh+E9/JWyZffuthSfv3Nyo8dLiJ5YzUo32pztb79Qkh6
LABK2knyC/45IyuKNFkaBAYJ447lTn0YYkLiapRzMH4kW+uUAi/SuCA2mX6pqWeC
iw3G6QG5AgMBAAECggEARaAnejoP2ykvE1G8e3Cv2M33x/eBQMI9m6uCmz9+qnqc
14JkTIfmjffHVXie7RpNAKys16lJE+rZ/eVoh6EStVdiaDLsZYP45evjRcho0Tgd
Hokq7FSiOMpd2V09kE1yrrHA/DjSLv38eTNAPIejc8IgaR7VyD6Is0iNiVnL7iLa
mj1zB6+dSeQ5ICYkrihb1gA+SvECsjLZ/5XESXEdHJvxhC0vLAdHmdQf3BPPlrGg
VHondxL5gt6MFykpOxTFA6f5JkSefhUR/2OcCDpMs6a5GUytjl3rA3aGT6v3CbnR
ykD6PzyC20EUADQYF2pmJfzbxyRqfNdbSJwQv5QQYQKBgQD4rFdvgZC97L7WhZ5T
axW8hRW2dH24GIqFT4ZnCg0suyMNshyGvDMuBfGvokN/yACmvsdE0/f57esar+ye
l9RC+CzGUch08Ke5WdqwACOCNDpx0kJcXKTuLIgkvthdla/oAQQ9T7OgEwDrvaR0
m8s/Z7Hb3hLD3xdOt6Xjrv/6xQKBgQDHzvbcIkhmWdvaPDT9NEu7psR/fxF5UjqU
Cca/bfHhySRQs3A1CF57pfwpUqAcSivNf7O+3NI62AKoyMDYv0ek2h6hGk6g5GJ1
SuXYfjcbkL6SWNV0InsgmzCjvxhyms83xZq7uMClEBvkiKVMdt6zFkwW9eRKtUuZ
pzVK5RfqZQKBgF5SME/xGw+O7su7ntQROAtrh1LPWKgtVs093sLSgzDGQoN9XWiV
lewNASEXMPcUy3pzvm2S4OoBnj1fISb+e9py+7i1aI1CgrvBIzvCsbU/TjPCBr21
vjFA3trhMHw+vJwJVqxSwNUkoCLKqcg5F5yTHllBIGj/A34uFlQIGrvpAoGAextm
d+1bhExbLBQqZdOh0cWHjjKBVqm2U93OKcYY4Q9oI5zbRqGYbUCwo9k3sxZz9JJ4
8eDmWsEaqlm+kA0SnFyTwJkP1wvAKhpykTf6xi4hbNP0+DACgu17Q3iLHJmLkQZc
Nss3TrwlI2KZzgnzXo4fZYotFWasZMhkCngqiw0CgYEAmz2D70RYEauUNE1+zLhS
6Ox5+PF/8Z0rZOlTghMTfqYcDJa+qQe9pJp7RPgilsgemqo0XtgLKz3ATE5FmMa4
HRRGXPkMNu6Hzz4Yk4eM/yJqckoEc8azV25myqQ+7QXTwZEvxVbtUWZtxfImGwq+
s/uzBKNwWf9UPTeIt+4JScg=
-----END PRIVATE KEY-----`
)
func TestJWTBearerAuth(t *testing.T) {
j := JWTAuthConfig{
Enabled: true,
Tokens: []JWTAuthTokenConfig{
{
Algorithm: "rsa",
KeyString: rsaTestPubKey,
AccountClaims: []string{"preferred_username", "email"},
StripDomain: "example.com",
},
},
}
if err := j.Postprocess(); err != nil {
t.Fatal(err)
}
// fixed test vector signed with the RSA privkey:
token := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJwcmVmZXJyZWRfdXNlcm5hbWUiOiJzbGluZ2FtbiJ9.caPZw2Dl4KZN-SErD5-WZB_lPPveHXaMCoUHxNebb94G9w3VaWDIRdngVU99JKx5nE_yRtpewkHHvXsQnNA_M63GBXGK7afXB8e-kV33QF3v9pXALMP5SzRwMgokyxas0RgHu4e4L0d7dn9o_nkdXp34GX3Pn1MVkUGBH6GdlbOdDHrs04pPQ0Qj-O2U0AIpnZq-X_GQs9ECJo4TlPKWR7Jlq5l9bS0dBnohea4FuqJr232je-dlRVkbCa7nrnFmsIsezsgA3Jb_j9Zu_iv460t_d2eaytbVp9P-DOVfzUfkBsKs-81URQEnTjW6ut445AJz2pxjX92X0GdmORpAkQ"
accountName, err := j.Validate(token)
if err != nil {
t.Errorf("could not validate valid token: %v", err)
}
if accountName != "slingamn" {
t.Errorf("incorrect account name for token: `%s`", accountName)
}
// programmatically sign a new token, validate it
privKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(rsaTestPrivKey))
if err != nil {
t.Fatal(err)
}
jTok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(map[string]any{"preferred_username": "slingamn"}))
token, err = jTok.SignedString(privKey)
if err != nil {
t.Fatal(err)
}
accountName, err = j.Validate(token)
if err != nil {
t.Errorf("could not validate valid token: %v", err)
}
if accountName != "slingamn" {
t.Errorf("incorrect account name for token: `%s`", accountName)
}
// test expiration
jTok = jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(map[string]any{"preferred_username": "slingamn", "exp": 1675740865}))
token, err = jTok.SignedString(privKey)
if err != nil {
t.Fatal(err)
}
accountName, err = j.Validate(token)
if err == nil {
t.Errorf("validated expired token")
}
// test for the infamous algorithm confusion bug
jTok = jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(map[string]any{"preferred_username": "slingamn"}))
token, err = jTok.SignedString([]byte(rsaTestPubKey))
if err != nil {
t.Fatal(err)
}
accountName, err = j.Validate(token)
if err == nil {
t.Errorf("validated HS256 token despite RSA being required")
}
// test no valid claims
jTok = jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(map[string]any{"sub": "slingamn"}))
token, err = jTok.SignedString(privKey)
if err != nil {
t.Fatal(err)
}
accountName, err = j.Validate(token)
if err != ErrNoValidAccountClaim {
t.Errorf("expected ErrNoValidAccountClaim, got: %v", err)
}
// test email addresses
jTok = jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(map[string]any{"email": "Slingamn@example.com"}))
token, err = jTok.SignedString(privKey)
if err != nil {
t.Fatal(err)
}
accountName, err = j.Validate(token)
if err != nil {
t.Errorf("could not validate valid token: %v", err)
}
if accountName != "Slingamn" {
t.Errorf("incorrect account name for token: `%s`", accountName)
}
}

View File

@ -6,18 +6,15 @@ package jwt
import (
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
"time"
"github.com/golang-jwt/jwt"
jwt "github.com/golang-jwt/jwt/v5"
)
var (
ErrNoKeys = errors.New("No signing keys are enabled")
ErrNoKeys = errors.New("No EXTJWT signing keys are enabled")
)
type MapClaims jwt.MapClaims
@ -38,22 +35,10 @@ func (t *JwtServiceConfig) Postprocess() (err error) {
if err != nil {
return err
}
d, _ := pem.Decode(keyBytes)
t.rsaPrivateKey, err = jwt.ParseRSAPrivateKeyFromPEM(keyBytes)
if err != nil {
return err
}
t.rsaPrivateKey, err = x509.ParsePKCS1PrivateKey(d.Bytes)
if err != nil {
privateKey, err := x509.ParsePKCS8PrivateKey(d.Bytes)
if err != nil {
return err
}
if rsaPrivateKey, ok := privateKey.(*rsa.PrivateKey); ok {
t.rsaPrivateKey = rsaPrivateKey
} else {
return fmt.Errorf("Non-RSA key type for extjwt: %T", privateKey)
}
}
}
return nil
}

View File

@ -4,7 +4,15 @@ package irc
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strconv"
"time"
"github.com/tidwall/buntdb"
"github.com/ergochat/ergo/irc/modes"
)
var (
@ -25,3 +33,116 @@ func decodeLegacyPasswordHash(hash string) ([]byte, error) {
return nil, errInvalidPasswordHash
}
}
// legacy channel registration code
const (
keyChannelExists = "channel.exists %s"
keyChannelName = "channel.name %s" // stores the 'preferred name' of the channel, not casemapped
keyChannelRegTime = "channel.registered.time %s"
keyChannelFounder = "channel.founder %s"
keyChannelTopic = "channel.topic %s"
keyChannelTopicSetBy = "channel.topic.setby %s"
keyChannelTopicSetTime = "channel.topic.settime %s"
keyChannelBanlist = "channel.banlist %s"
keyChannelExceptlist = "channel.exceptlist %s"
keyChannelInvitelist = "channel.invitelist %s"
keyChannelPassword = "channel.key %s"
keyChannelModes = "channel.modes %s"
keyChannelAccountToUMode = "channel.accounttoumode %s"
keyChannelUserLimit = "channel.userlimit %s"
keyChannelSettings = "channel.settings %s"
keyChannelForward = "channel.forward %s"
keyChannelPurged = "channel.purged %s"
)
func deleteLegacyChannel(tx *buntdb.Tx, nameCasefolded string) {
tx.Delete(fmt.Sprintf(keyChannelExists, nameCasefolded))
tx.Delete(fmt.Sprintf(keyChannelName, nameCasefolded))
tx.Delete(fmt.Sprintf(keyChannelRegTime, nameCasefolded))
tx.Delete(fmt.Sprintf(keyChannelFounder, nameCasefolded))
tx.Delete(fmt.Sprintf(keyChannelTopic, nameCasefolded))
tx.Delete(fmt.Sprintf(keyChannelTopicSetBy, nameCasefolded))
tx.Delete(fmt.Sprintf(keyChannelTopicSetTime, nameCasefolded))
tx.Delete(fmt.Sprintf(keyChannelBanlist, nameCasefolded))
tx.Delete(fmt.Sprintf(keyChannelExceptlist, nameCasefolded))
tx.Delete(fmt.Sprintf(keyChannelInvitelist, nameCasefolded))
tx.Delete(fmt.Sprintf(keyChannelPassword, nameCasefolded))
tx.Delete(fmt.Sprintf(keyChannelModes, nameCasefolded))
tx.Delete(fmt.Sprintf(keyChannelAccountToUMode, nameCasefolded))
tx.Delete(fmt.Sprintf(keyChannelUserLimit, nameCasefolded))
tx.Delete(fmt.Sprintf(keyChannelSettings, nameCasefolded))
tx.Delete(fmt.Sprintf(keyChannelForward, nameCasefolded))
}
func loadLegacyChannel(tx *buntdb.Tx, nameCasefolded string) (info RegisteredChannel, err error) {
channelKey := nameCasefolded
// nice to have: do all JSON (de)serialization outside of the buntdb transaction
_, dberr := tx.Get(fmt.Sprintf(keyChannelExists, channelKey))
if dberr == buntdb.ErrNotFound {
// chan does not already exist, return
err = errNoSuchChannel
return
}
// channel exists, load it
name, _ := tx.Get(fmt.Sprintf(keyChannelName, channelKey))
regTime, _ := tx.Get(fmt.Sprintf(keyChannelRegTime, channelKey))
regTimeInt, _ := strconv.ParseInt(regTime, 10, 64)
founder, _ := tx.Get(fmt.Sprintf(keyChannelFounder, channelKey))
topic, _ := tx.Get(fmt.Sprintf(keyChannelTopic, channelKey))
topicSetBy, _ := tx.Get(fmt.Sprintf(keyChannelTopicSetBy, channelKey))
var topicSetTime time.Time
topicSetTimeStr, _ := tx.Get(fmt.Sprintf(keyChannelTopicSetTime, channelKey))
if topicSetTimeInt, topicSetTimeErr := strconv.ParseInt(topicSetTimeStr, 10, 64); topicSetTimeErr == nil {
topicSetTime = time.Unix(0, topicSetTimeInt).UTC()
}
password, _ := tx.Get(fmt.Sprintf(keyChannelPassword, channelKey))
modeString, _ := tx.Get(fmt.Sprintf(keyChannelModes, channelKey))
userLimitString, _ := tx.Get(fmt.Sprintf(keyChannelUserLimit, channelKey))
forward, _ := tx.Get(fmt.Sprintf(keyChannelForward, channelKey))
banlistString, _ := tx.Get(fmt.Sprintf(keyChannelBanlist, channelKey))
exceptlistString, _ := tx.Get(fmt.Sprintf(keyChannelExceptlist, channelKey))
invitelistString, _ := tx.Get(fmt.Sprintf(keyChannelInvitelist, channelKey))
accountToUModeString, _ := tx.Get(fmt.Sprintf(keyChannelAccountToUMode, channelKey))
settingsString, _ := tx.Get(fmt.Sprintf(keyChannelSettings, channelKey))
modeSlice := make([]modes.Mode, len(modeString))
for i, mode := range modeString {
modeSlice[i] = modes.Mode(mode)
}
userLimit, _ := strconv.Atoi(userLimitString)
var banlist map[string]MaskInfo
_ = json.Unmarshal([]byte(banlistString), &banlist)
var exceptlist map[string]MaskInfo
_ = json.Unmarshal([]byte(exceptlistString), &exceptlist)
var invitelist map[string]MaskInfo
_ = json.Unmarshal([]byte(invitelistString), &invitelist)
accountToUMode := make(map[string]modes.Mode)
_ = json.Unmarshal([]byte(accountToUModeString), &accountToUMode)
var settings ChannelSettings
_ = json.Unmarshal([]byte(settingsString), &settings)
info = RegisteredChannel{
Name: name,
RegisteredAt: time.Unix(0, regTimeInt).UTC(),
Founder: founder,
Topic: topic,
TopicSetBy: topicSetBy,
TopicSetTime: topicSetTime,
Key: password,
Modes: modeSlice,
Bans: banlist,
Excepts: exceptlist,
Invites: invitelist,
AccountToUMode: accountToUMode,
UserLimit: int(userLimit),
Settings: settings,
Forward: forward,
}
return info, nil
}

View File

@ -5,6 +5,7 @@ package irc
import (
"errors"
"io/fs"
"net"
"net/http"
"os"
@ -29,7 +30,7 @@ type IRCListener interface {
// NewListener creates a new listener according to the specifications in the config file
func NewListener(server *Server, addr string, config utils.ListenerConfig, bindMode os.FileMode) (result IRCListener, err error) {
baseListener, err := createBaseListener(addr, bindMode)
baseListener, err := createBaseListener(server, addr, bindMode)
if err != nil {
return
}
@ -43,11 +44,14 @@ func NewListener(server *Server, addr string, config utils.ListenerConfig, bindM
}
}
func createBaseListener(addr string, bindMode os.FileMode) (listener net.Listener, err error) {
func createBaseListener(server *Server, addr string, bindMode os.FileMode) (listener net.Listener, err error) {
addr = strings.TrimPrefix(addr, "unix:")
if strings.HasPrefix(addr, "/") {
// https://stackoverflow.com/a/34881585
os.Remove(addr)
removeErr := os.Remove(addr)
if removeErr != nil && !errors.Is(removeErr, fs.ErrNotExist) {
server.logger.Warning("listeners", "could not delete unix domain listener", addr, removeErr.Error())
}
listener, err = net.Listen("unix", addr)
if err == nil && bindMode != 0 {
os.Chmod(addr, bindMode)
@ -204,10 +208,10 @@ func confirmProxyData(conn *utils.WrappedConn, remoteAddr, xForwardedFor, xForwa
}
}
if conn.Config.TLSConfig != nil || conn.Config.Tor {
if conn.TLS || conn.Tor {
// we terminated our own encryption:
conn.Secure = true
} else if !conn.Config.WebSocket {
} else if !conn.WebSocket {
// plaintext normal connection: loopback and secureNets are secure
realIP := utils.AddrToIP(conn.RemoteAddr())
conn.Secure = realIP.IsLoopback() || utils.IPInNets(realIP, config.Server.secureNets)

View File

@ -79,8 +79,7 @@ func (m *MessageCache) Initialize(server *Server, serverTime time.Time, msgid st
m.params = params
var msg ircmsg.Message
config := server.Config()
if config.Server.Compatibility.forceTrailing && commandsThatMustUseTrailing[command] {
if forceTrailing(server.Config(), command) {
msg.ForceTrailing()
}
msg.Source = nickmask
@ -111,8 +110,7 @@ func (m *MessageCache) InitializeSplitMessage(server *Server, nickmask, accountN
m.target = target
m.splitMessage = message
config := server.Config()
forceTrailing := config.Server.Compatibility.forceTrailing && commandsThatMustUseTrailing[command]
forceTrailing := forceTrailing(server.Config(), command)
if message.Is512() {
isTagmsg := command == "TAGMSG"

View File

@ -345,6 +345,10 @@ func NewModeSet() *ModeSet {
return &set
}
func (set *ModeSet) Clear() {
utils.BitsetClear(set[:])
}
// test whether `mode` is set
func (set *ModeSet) HasMode(mode Mode) bool {
if set == nil {

View File

@ -11,6 +11,7 @@ import (
"fmt"
"io"
"runtime/debug"
"slices"
"strings"
"sync"
"sync/atomic"
@ -917,7 +918,7 @@ func (mysql *MySQL) betweenTimestamps(ctx context.Context, target, correspondent
results, err = mysql.selectItems(ctx, queryBuf.String(), args...)
if err == nil && !ascending {
utils.ReverseSlice(results)
slices.Reverse(results)
}
return
}
@ -965,7 +966,7 @@ func (mysql *MySQL) listCorrespondentsInternal(ctx context.Context, target strin
}
if !ascending {
utils.ReverseSlice(results)
slices.Reverse(results)
}
return

View File

@ -34,7 +34,7 @@ func performNickChange(server *Server, client *Client, target *Client, session *
origNickMask := details.nickMask
isSanick := client != target
assignedNickname, err, back := client.server.clients.SetNick(target, session, nickname, false)
assignedNickname, err, awayChanged := client.server.clients.SetNick(target, session, nickname, false)
if err == errNicknameInUse {
if !isSanick {
rb.Add(nil, server.name, ERR_NICKNAMEINUSE, details.nick, utils.SafeErrorParam(nickname), client.t("Nickname is already in use"))
@ -43,6 +43,8 @@ func performNickChange(server *Server, client *Client, target *Client, session *
}
} else if err == errNicknameReserved {
if !isSanick {
// see #1594 for context: ERR_NICKNAMEINUSE can confuse clients if the nickname is not
// literally in use:
if !client.registered {
rb.Add(nil, server.name, ERR_NICKNAMEINUSE, details.nick, utils.SafeErrorParam(nickname), client.t("Nickname is reserved by a different account"))
}
@ -115,12 +117,14 @@ func performNickChange(server *Server, client *Client, target *Client, session *
}
}
if back {
dispatchAwayNotify(session.client, false, "")
if awayChanged {
dispatchAwayNotify(session.client, session.client.AwayMessage())
}
for _, channel := range target.Channels() {
channel.AddHistoryItem(histItem, details.account)
if channel.memberIsVisible(client) {
channel.AddHistoryItem(histItem, details.account)
}
}
newCfnick := target.NickCasefolded()

View File

@ -174,7 +174,7 @@ an administrator can set use this command to set up user accounts.`,
help: `Syntax: $bSAVERIFY <username>$b
SAVERIFY manually verifies an account that is pending verification.`,
helpShort: `$bSAREGISTER$b registers an account on someone else's behalf.`,
helpShort: `$bSAVERIFY$b manually verifies an account that is pending verification.`,
enabled: servCmdRequiresAuthEnabled, // deliberate
capabs: []string{"accreg"},
minParams: 1,
@ -811,7 +811,7 @@ func nsGroupHandler(service *ircService, server *Server, client *Client, command
func nsLoginThrottleCheck(service *ircService, client *Client, rb *ResponseBuffer) (success bool) {
throttled, remainingTime := client.checkLoginThrottle()
if throttled {
service.Notice(rb, fmt.Sprintf(client.t("Please wait at least %v and try again"), remainingTime))
service.Notice(rb, fmt.Sprintf(client.t("Please wait at least %v and try again"), remainingTime.Round(time.Millisecond)))
}
return !throttled
}
@ -954,9 +954,9 @@ func nsInfoHandler(service *ircService, server *Server, client *Client, command
func listRegisteredChannels(service *ircService, accountName string, rb *ResponseBuffer) {
client := rb.session.client
channels := client.server.accounts.ChannelsForAccount(accountName)
channels := client.server.channels.ChannelsForAccount(accountName)
service.Notice(rb, fmt.Sprintf(client.t("Account %s has %d registered channel(s)."), accountName, len(channels)))
for _, channel := range rb.session.client.server.accounts.ChannelsForAccount(accountName) {
for _, channel := range channels {
service.Notice(rb, fmt.Sprintf(client.t("Registered channel: %s"), channel))
}
}
@ -1398,6 +1398,11 @@ func nsCertHandler(service *ircService, server *Server, client *Client, command
case "add", "del":
if 2 <= len(params) {
target, certfp = params[0], params[1]
if cftarget, err := CasefoldName(target); err == nil && client.Account() == cftarget {
// If the target is equal to the account, then the user accidentally invoked operator
// syntax (cert add mynick <fp>) instead of self syntax (cert add <fp>).
target = ""
}
} else if len(params) == 1 {
certfp = params[0]
} else if len(params) == 0 && verb == "add" && rb.session.certfp != "" {

View File

@ -12,189 +12,186 @@ package irc
// server ecosystem out there. Custom numerics will be marked as such.
const (
RPL_WELCOME = "001"
RPL_YOURHOST = "002"
RPL_CREATED = "003"
RPL_MYINFO = "004"
RPL_ISUPPORT = "005"
RPL_SNOMASKIS = "008"
RPL_BOUNCE = "010"
RPL_TRACELINK = "200"
RPL_TRACECONNECTING = "201"
RPL_TRACEHANDSHAKE = "202"
RPL_TRACEUNKNOWN = "203"
RPL_TRACEOPERATOR = "204"
RPL_TRACEUSER = "205"
RPL_TRACESERVER = "206"
RPL_TRACESERVICE = "207"
RPL_TRACENEWTYPE = "208"
RPL_TRACECLASS = "209"
RPL_TRACERECONNECT = "210"
RPL_STATSLINKINFO = "211"
RPL_STATSCOMMANDS = "212"
RPL_ENDOFSTATS = "219"
RPL_UMODEIS = "221"
RPL_SERVLIST = "234"
RPL_SERVLISTEND = "235"
RPL_STATSUPTIME = "242"
RPL_STATSOLINE = "243"
RPL_LUSERCLIENT = "251"
RPL_LUSEROP = "252"
RPL_LUSERUNKNOWN = "253"
RPL_LUSERCHANNELS = "254"
RPL_LUSERME = "255"
RPL_ADMINME = "256"
RPL_ADMINLOC1 = "257"
RPL_ADMINLOC2 = "258"
RPL_ADMINEMAIL = "259"
RPL_TRACELOG = "261"
RPL_TRACEEND = "262"
RPL_TRYAGAIN = "263"
RPL_LOCALUSERS = "265"
RPL_GLOBALUSERS = "266"
RPL_WHOISCERTFP = "276"
RPL_AWAY = "301"
RPL_USERHOST = "302"
RPL_ISON = "303"
RPL_UNAWAY = "305"
RPL_NOWAWAY = "306"
RPL_WHOISUSER = "311"
RPL_WHOISSERVER = "312"
RPL_WHOISOPERATOR = "313"
RPL_WHOWASUSER = "314"
RPL_ENDOFWHO = "315"
RPL_WHOISIDLE = "317"
RPL_ENDOFWHOIS = "318"
RPL_WHOISCHANNELS = "319"
RPL_LIST = "322"
RPL_LISTEND = "323"
RPL_CHANNELMODEIS = "324"
RPL_UNIQOPIS = "325"
RPL_CREATIONTIME = "329"
RPL_WHOISACCOUNT = "330"
RPL_NOTOPIC = "331"
RPL_TOPIC = "332"
RPL_TOPICTIME = "333"
RPL_WHOISBOT = "335"
RPL_WHOISACTUALLY = "338"
RPL_INVITING = "341"
RPL_SUMMONING = "342"
RPL_INVITELIST = "346"
RPL_ENDOFINVITELIST = "347"
RPL_EXCEPTLIST = "348"
RPL_ENDOFEXCEPTLIST = "349"
RPL_VERSION = "351"
RPL_WHOREPLY = "352"
RPL_NAMREPLY = "353"
RPL_WHOSPCRPL = "354"
RPL_LINKS = "364"
RPL_ENDOFLINKS = "365"
RPL_ENDOFNAMES = "366"
RPL_BANLIST = "367"
RPL_ENDOFBANLIST = "368"
RPL_ENDOFWHOWAS = "369"
RPL_INFO = "371"
RPL_MOTD = "372"
RPL_ENDOFINFO = "374"
RPL_MOTDSTART = "375"
RPL_ENDOFMOTD = "376"
RPL_WHOISMODES = "379"
RPL_YOUREOPER = "381"
RPL_REHASHING = "382"
RPL_YOURESERVICE = "383"
RPL_TIME = "391"
RPL_USERSSTART = "392"
RPL_USERS = "393"
RPL_ENDOFUSERS = "394"
RPL_NOUSERS = "395"
ERR_UNKNOWNERROR = "400"
ERR_NOSUCHNICK = "401"
ERR_NOSUCHSERVER = "402"
ERR_NOSUCHCHANNEL = "403"
ERR_CANNOTSENDTOCHAN = "404"
ERR_TOOMANYCHANNELS = "405"
ERR_WASNOSUCHNICK = "406"
ERR_TOOMANYTARGETS = "407"
ERR_NOSUCHSERVICE = "408"
ERR_NOORIGIN = "409"
ERR_INVALIDCAPCMD = "410"
ERR_NORECIPIENT = "411"
ERR_NOTEXTTOSEND = "412"
ERR_NOTOPLEVEL = "413"
ERR_WILDTOPLEVEL = "414"
ERR_BADMASK = "415"
ERR_INPUTTOOLONG = "417"
ERR_UNKNOWNCOMMAND = "421"
ERR_NOMOTD = "422"
ERR_NOADMININFO = "423"
ERR_FILEERROR = "424"
ERR_NONICKNAMEGIVEN = "431"
ERR_ERRONEUSNICKNAME = "432"
ERR_NICKNAMEINUSE = "433"
ERR_NICKCOLLISION = "436"
ERR_UNAVAILRESOURCE = "437"
ERR_REG_UNAVAILABLE = "440"
ERR_USERNOTINCHANNEL = "441"
ERR_NOTONCHANNEL = "442"
ERR_USERONCHANNEL = "443"
ERR_NOLOGIN = "444"
ERR_SUMMONDISABLED = "445"
ERR_USERSDISABLED = "446"
ERR_NOTREGISTERED = "451"
ERR_NEEDMOREPARAMS = "461"
ERR_ALREADYREGISTRED = "462"
ERR_NOPERMFORHOST = "463"
ERR_PASSWDMISMATCH = "464"
ERR_YOUREBANNEDCREEP = "465"
ERR_YOUWILLBEBANNED = "466"
ERR_KEYSET = "467"
ERR_INVALIDUSERNAME = "468"
ERR_LINKCHANNEL = "470"
ERR_CHANNELISFULL = "471"
ERR_UNKNOWNMODE = "472"
ERR_INVITEONLYCHAN = "473"
ERR_BANNEDFROMCHAN = "474"
ERR_BADCHANNELKEY = "475"
ERR_BADCHANMASK = "476"
ERR_NEEDREGGEDNICK = "477" // conflicted with ERR_NOCHANMODES; see #936
ERR_BANLISTFULL = "478"
ERR_NOPRIVILEGES = "481"
ERR_CHANOPRIVSNEEDED = "482"
ERR_CANTKILLSERVER = "483"
ERR_RESTRICTED = "484"
ERR_UNIQOPPRIVSNEEDED = "485"
ERR_NOOPERHOST = "491"
ERR_UMODEUNKNOWNFLAG = "501"
ERR_USERSDONTMATCH = "502"
ERR_HELPNOTFOUND = "524"
ERR_CANNOTSENDRP = "573"
RPL_WHOWASIP = "652"
RPL_WHOISSECURE = "671"
RPL_YOURLANGUAGESARE = "687"
ERR_INVALIDMODEPARAM = "696"
ERR_LISTMODEALREADYSET = "697"
ERR_LISTMODENOTSET = "698"
RPL_HELPSTART = "704"
RPL_HELPTXT = "705"
RPL_ENDOFHELP = "706"
ERR_NOPRIVS = "723"
RPL_MONONLINE = "730"
RPL_MONOFFLINE = "731"
RPL_MONLIST = "732"
RPL_ENDOFMONLIST = "733"
ERR_MONLISTFULL = "734"
RPL_LOGGEDIN = "900"
RPL_LOGGEDOUT = "901"
ERR_NICKLOCKED = "902"
RPL_SASLSUCCESS = "903"
ERR_SASLFAIL = "904"
ERR_SASLTOOLONG = "905"
ERR_SASLABORTED = "906"
ERR_SASLALREADY = "907"
RPL_SASLMECHS = "908"
RPL_REG_SUCCESS = "920"
RPL_VERIFY_SUCCESS = "923"
RPL_REG_VERIFICATION_REQUIRED = "927"
ERR_TOOMANYLANGUAGES = "981"
ERR_NOLANGUAGE = "982"
RPL_WELCOME = "001"
RPL_YOURHOST = "002"
RPL_CREATED = "003"
RPL_MYINFO = "004"
RPL_ISUPPORT = "005"
RPL_SNOMASKIS = "008"
RPL_BOUNCE = "010"
RPL_TRACELINK = "200"
RPL_TRACECONNECTING = "201"
RPL_TRACEHANDSHAKE = "202"
RPL_TRACEUNKNOWN = "203"
RPL_TRACEOPERATOR = "204"
RPL_TRACEUSER = "205"
RPL_TRACESERVER = "206"
RPL_TRACESERVICE = "207"
RPL_TRACENEWTYPE = "208"
RPL_TRACECLASS = "209"
RPL_TRACERECONNECT = "210"
RPL_STATSLINKINFO = "211"
RPL_STATSCOMMANDS = "212"
RPL_ENDOFSTATS = "219"
RPL_UMODEIS = "221"
RPL_SERVLIST = "234"
RPL_SERVLISTEND = "235"
RPL_STATSUPTIME = "242"
RPL_STATSOLINE = "243"
RPL_LUSERCLIENT = "251"
RPL_LUSEROP = "252"
RPL_LUSERUNKNOWN = "253"
RPL_LUSERCHANNELS = "254"
RPL_LUSERME = "255"
RPL_ADMINME = "256"
RPL_ADMINLOC1 = "257"
RPL_ADMINLOC2 = "258"
RPL_ADMINEMAIL = "259"
RPL_TRACELOG = "261"
RPL_TRACEEND = "262"
RPL_TRYAGAIN = "263"
RPL_LOCALUSERS = "265"
RPL_GLOBALUSERS = "266"
RPL_WHOISCERTFP = "276"
RPL_AWAY = "301"
RPL_USERHOST = "302"
RPL_ISON = "303"
RPL_UNAWAY = "305"
RPL_NOWAWAY = "306"
RPL_WHOISUSER = "311"
RPL_WHOISSERVER = "312"
RPL_WHOISOPERATOR = "313"
RPL_WHOWASUSER = "314"
RPL_ENDOFWHO = "315"
RPL_WHOISIDLE = "317"
RPL_ENDOFWHOIS = "318"
RPL_WHOISCHANNELS = "319"
RPL_LIST = "322"
RPL_LISTEND = "323"
RPL_CHANNELMODEIS = "324"
RPL_UNIQOPIS = "325"
RPL_CREATIONTIME = "329"
RPL_WHOISACCOUNT = "330"
RPL_NOTOPIC = "331"
RPL_TOPIC = "332"
RPL_TOPICTIME = "333"
RPL_WHOISBOT = "335"
RPL_WHOISACTUALLY = "338"
RPL_INVITING = "341"
RPL_SUMMONING = "342"
RPL_INVITELIST = "346"
RPL_ENDOFINVITELIST = "347"
RPL_EXCEPTLIST = "348"
RPL_ENDOFEXCEPTLIST = "349"
RPL_VERSION = "351"
RPL_WHOREPLY = "352"
RPL_NAMREPLY = "353"
RPL_WHOSPCRPL = "354"
RPL_LINKS = "364"
RPL_ENDOFLINKS = "365"
RPL_ENDOFNAMES = "366"
RPL_BANLIST = "367"
RPL_ENDOFBANLIST = "368"
RPL_ENDOFWHOWAS = "369"
RPL_INFO = "371"
RPL_MOTD = "372"
RPL_ENDOFINFO = "374"
RPL_MOTDSTART = "375"
RPL_ENDOFMOTD = "376"
RPL_WHOISMODES = "379"
RPL_YOUREOPER = "381"
RPL_REHASHING = "382"
RPL_YOURESERVICE = "383"
RPL_TIME = "391"
RPL_USERSSTART = "392"
RPL_USERS = "393"
RPL_ENDOFUSERS = "394"
RPL_NOUSERS = "395"
ERR_UNKNOWNERROR = "400"
ERR_NOSUCHNICK = "401"
ERR_NOSUCHSERVER = "402"
ERR_NOSUCHCHANNEL = "403"
ERR_CANNOTSENDTOCHAN = "404"
ERR_TOOMANYCHANNELS = "405"
ERR_WASNOSUCHNICK = "406"
ERR_TOOMANYTARGETS = "407"
ERR_NOSUCHSERVICE = "408"
ERR_NOORIGIN = "409"
ERR_INVALIDCAPCMD = "410"
ERR_NORECIPIENT = "411"
ERR_NOTEXTTOSEND = "412"
ERR_NOTOPLEVEL = "413"
ERR_WILDTOPLEVEL = "414"
ERR_BADMASK = "415"
ERR_INPUTTOOLONG = "417"
ERR_UNKNOWNCOMMAND = "421"
ERR_NOMOTD = "422"
ERR_NOADMININFO = "423"
ERR_FILEERROR = "424"
ERR_NONICKNAMEGIVEN = "431"
ERR_ERRONEUSNICKNAME = "432"
ERR_NICKNAMEINUSE = "433"
ERR_NICKCOLLISION = "436"
ERR_UNAVAILRESOURCE = "437"
ERR_REG_UNAVAILABLE = "440"
ERR_USERNOTINCHANNEL = "441"
ERR_NOTONCHANNEL = "442"
ERR_USERONCHANNEL = "443"
ERR_NOLOGIN = "444"
ERR_SUMMONDISABLED = "445"
ERR_USERSDISABLED = "446"
ERR_NOTREGISTERED = "451"
ERR_NEEDMOREPARAMS = "461"
ERR_ALREADYREGISTRED = "462"
ERR_NOPERMFORHOST = "463"
ERR_PASSWDMISMATCH = "464"
ERR_YOUREBANNEDCREEP = "465"
ERR_YOUWILLBEBANNED = "466"
ERR_KEYSET = "467"
ERR_INVALIDUSERNAME = "468"
ERR_LINKCHANNEL = "470"
ERR_CHANNELISFULL = "471"
ERR_UNKNOWNMODE = "472"
ERR_INVITEONLYCHAN = "473"
ERR_BANNEDFROMCHAN = "474"
ERR_BADCHANNELKEY = "475"
ERR_BADCHANMASK = "476"
ERR_NEEDREGGEDNICK = "477" // conflicted with ERR_NOCHANMODES; see #936
ERR_BANLISTFULL = "478"
ERR_NOPRIVILEGES = "481"
ERR_CHANOPRIVSNEEDED = "482"
ERR_CANTKILLSERVER = "483"
ERR_RESTRICTED = "484"
ERR_UNIQOPPRIVSNEEDED = "485"
ERR_NOOPERHOST = "491"
ERR_UMODEUNKNOWNFLAG = "501"
ERR_USERSDONTMATCH = "502"
ERR_HELPNOTFOUND = "524"
ERR_CANNOTSENDRP = "573"
RPL_WHOWASIP = "652"
RPL_WHOISSECURE = "671"
RPL_YOURLANGUAGESARE = "687"
ERR_INVALIDMODEPARAM = "696"
ERR_LISTMODEALREADYSET = "697"
ERR_LISTMODENOTSET = "698"
RPL_HELPSTART = "704"
RPL_HELPTXT = "705"
RPL_ENDOFHELP = "706"
ERR_NOPRIVS = "723"
RPL_MONONLINE = "730"
RPL_MONOFFLINE = "731"
RPL_MONLIST = "732"
RPL_ENDOFMONLIST = "733"
ERR_MONLISTFULL = "734"
RPL_LOGGEDIN = "900"
RPL_LOGGEDOUT = "901"
ERR_NICKLOCKED = "902"
RPL_SASLSUCCESS = "903"
ERR_SASLFAIL = "904"
ERR_SASLTOOLONG = "905"
ERR_SASLABORTED = "906"
ERR_SASLALREADY = "907"
RPL_SASLMECHS = "908"
ERR_TOOMANYLANGUAGES = "981"
ERR_NOLANGUAGE = "982"
)

108
irc/oauth2/oauth2.go Normal file
View File

@ -0,0 +1,108 @@
// Copyright 2022-2023 Simon Ser <contact@emersion.fr>
// Derived from https://git.sr.ht/~emersion/soju/tree/36d6cb19a4f90d217d55afb0b15318321baaad09/item/auth/oauth2.go
// Originally released under the AGPLv3, relicensed to the Ergo project under the MIT license
// Modifications copyright 2024 Shivaram Lingamneni <slingamn@cs.stanford.edu>
// Released under the MIT license
package oauth2
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
var (
ErrAuthDisabled = fmt.Errorf("OAuth 2.0 authentication is disabled")
// all cases where the infrastructure is working correctly, but we determined
// that the user supplied an invalid token
ErrInvalidToken = fmt.Errorf("OAuth 2.0 bearer token invalid")
)
type OAuth2BearerConfig struct {
Enabled bool `yaml:"enabled"`
Autocreate bool `yaml:"autocreate"`
AuthScript bool `yaml:"auth-script"`
IntrospectionURL string `yaml:"introspection-url"`
IntrospectionTimeout time.Duration `yaml:"introspection-timeout"`
// omit for `none`, required for `client_secret_basic`
ClientID string `yaml:"client-id"`
ClientSecret string `yaml:"client-secret"`
}
func (o *OAuth2BearerConfig) Postprocess() error {
if !o.Enabled {
return nil
}
if o.IntrospectionTimeout == 0 {
return fmt.Errorf("a nonzero oauthbearer introspection timeout is required (try 10s)")
}
if _, err := url.Parse(o.IntrospectionURL); err != nil {
return fmt.Errorf("invalid introspection-url: %w", err)
}
return nil
}
func (o *OAuth2BearerConfig) Introspect(ctx context.Context, token string) (username string, err error) {
if !o.Enabled {
return "", ErrAuthDisabled
}
ctx, cancel := context.WithTimeout(ctx, o.IntrospectionTimeout)
defer cancel()
reqValues := make(url.Values)
reqValues.Set("token", token)
reqBody := strings.NewReader(reqValues.Encode())
req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.IntrospectionURL, reqBody)
if err != nil {
return "", fmt.Errorf("failed to create OAuth 2.0 introspection request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
if o.ClientID != "" {
req.SetBasicAuth(url.QueryEscape(o.ClientID), url.QueryEscape(o.ClientSecret))
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to send OAuth 2.0 introspection request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("OAuth 2.0 introspection error: %v", resp.Status)
}
var data oauth2Introspection
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return "", fmt.Errorf("failed to decode OAuth 2.0 introspection response: %v", err)
}
if !data.Active {
return "", ErrInvalidToken
}
if data.Username == "" {
// We really need the username here, otherwise an OAuth 2.0 user can
// impersonate any other user.
return "", fmt.Errorf("missing username in OAuth 2.0 introspection response")
}
return data.Username, nil
}
type oauth2Introspection struct {
Active bool `json:"active"`
Username string `json:"username"`
}

172
irc/oauth2/sasl.go Normal file
View File

@ -0,0 +1,172 @@
package oauth2
/*
https://github.com/emersion/go-sasl/blob/e73c9f7bad438a9bf3f5b28e661b74d752ecafdd/oauthbearer.go
Copyright 2019-2022 Simon Ser, Frode Aannevik, Max Mazurov
Released under the MIT license
*/
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
)
var (
ErrUnexpectedClientResponse = errors.New("unexpected client response")
)
// The OAUTHBEARER mechanism name.
const OAuthBearer = "OAUTHBEARER"
type OAuthBearerError struct {
Status string `json:"status"`
Schemes string `json:"schemes"`
Scope string `json:"scope"`
}
type OAuthBearerOptions struct {
Username string `json:"username,omitempty"`
Token string `json:"token,omitempty"`
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
}
func (err *OAuthBearerError) Error() string {
return fmt.Sprintf("OAUTHBEARER authentication error (%v)", err.Status)
}
type OAuthBearerAuthenticator func(opts OAuthBearerOptions) *OAuthBearerError
type OAuthBearerServer struct {
done bool
failErr error
authenticate OAuthBearerAuthenticator
}
func (a *OAuthBearerServer) fail(descr string) ([]byte, bool, error) {
blob, err := json.Marshal(OAuthBearerError{
Status: "invalid_request",
Schemes: "bearer",
})
if err != nil {
panic(err) // wtf
}
a.failErr = errors.New(descr)
return blob, false, nil
}
func (a *OAuthBearerServer) Next(response []byte) (challenge []byte, done bool, err error) {
// Per RFC, we cannot just send an error, we need to return JSON-structured
// value as a challenge and then after getting dummy response from the
// client stop the exchange.
if a.failErr != nil {
// Server libraries (go-smtp, go-imap) will not call Next on
// protocol-specific SASL cancel response ('*'). However, GS2 (and
// indirectly OAUTHBEARER) defines a protocol-independent way to do so
// using 0x01.
if len(response) != 1 && response[0] != 0x01 {
return nil, true, errors.New("unexpected response")
}
return nil, true, a.failErr
}
if a.done {
err = ErrUnexpectedClientResponse
return
}
// Generate empty challenge.
if response == nil {
return []byte{}, false, nil
}
a.done = true
// Cut n,a=username,\x01host=...\x01auth=...
// into
// n
// a=username
// \x01host=...\x01auth=...\x01\x01
parts := bytes.SplitN(response, []byte{','}, 3)
if len(parts) != 3 {
return a.fail("Invalid response")
}
flag := parts[0]
authzid := parts[1]
if !bytes.Equal(flag, []byte{'n'}) {
return a.fail("Invalid response, missing 'n' in gs2-cb-flag")
}
opts := OAuthBearerOptions{}
if len(authzid) > 0 {
if !bytes.HasPrefix(authzid, []byte("a=")) {
return a.fail("Invalid response, missing 'a=' in gs2-authzid")
}
opts.Username = string(bytes.TrimPrefix(authzid, []byte("a=")))
}
// Cut \x01host=...\x01auth=...\x01\x01
// into
// *empty*
// host=...
// auth=...
// *empty*
//
// Note that this code does not do a lot of checks to make sure the input
// follows the exact format specified by RFC.
params := bytes.Split(parts[2], []byte{0x01})
for _, p := range params {
// Skip empty fields (one at start and end).
if len(p) == 0 {
continue
}
pParts := bytes.SplitN(p, []byte{'='}, 2)
if len(pParts) != 2 {
return a.fail("Invalid response, missing '='")
}
switch string(pParts[0]) {
case "host":
opts.Host = string(pParts[1])
case "port":
port, err := strconv.ParseUint(string(pParts[1]), 10, 16)
if err != nil {
return a.fail("Invalid response, malformed 'port' value")
}
opts.Port = int(port)
case "auth":
const prefix = "bearer "
strValue := string(pParts[1])
// Token type is case-insensitive.
if !strings.HasPrefix(strings.ToLower(strValue), prefix) {
return a.fail("Unsupported token type")
}
opts.Token = strValue[len(prefix):]
default:
return a.fail("Invalid response, unknown parameter: " + string(pParts[0]))
}
}
authzErr := a.authenticate(opts)
if authzErr != nil {
blob, err := json.Marshal(authzErr)
if err != nil {
panic(err) // wtf
}
a.failErr = authzErr
return blob, false, nil
}
return nil, true, nil
}
func NewOAuthBearerServer(auth OAuthBearerAuthenticator) *OAuthBearerServer {
return &OAuthBearerServer{
authenticate: auth,
}
}

View File

@ -22,6 +22,16 @@ func TestBasic(t *testing.T) {
}
}
func TestVector(t *testing.T) {
// sanity check for persisted hashes
if CompareHashAndPassword(
[]byte("$2a$12$sJokyLJ5px3Nb51DEDhsQ.wh8nfwEYuMbVYrpqO5v9Ylyj0YyVWj."),
[]byte("this is my passphrase"),
) != nil {
t.Errorf("hash comparison failed unexpectedly")
}
}
func TestLongPassphrases(t *testing.T) {
longPassphrase := make([]byte, 168)
for i := range longPassphrase {

View File

@ -193,6 +193,9 @@ func (rb *ResponseBuffer) sendBatchEnd(blocking bool) {
// Starts a nested batch (see the ResponseBuffer struct definition for a description of
// how this works)
func (rb *ResponseBuffer) StartNestedBatch(batchType string, params ...string) (batchID string) {
if !rb.session.capabilities.Has(caps.Batch) {
return
}
batchID = rb.session.generateBatchID()
msgParams := make([]string, len(params)+2)
msgParams[0] = "+" + batchID
@ -219,19 +222,6 @@ func (rb *ResponseBuffer) EndNestedBatch(batchID string) {
rb.AddMessage(ircmsg.MakeMessage(nil, rb.target.server.name, "BATCH", "-"+batchID))
}
// Convenience to start a nested batch for history lines, at the highest level
// supported by the client (`history`, `chathistory`, or no batch, in descending order).
func (rb *ResponseBuffer) StartNestedHistoryBatch(params ...string) (batchID string) {
var batchType string
if rb.session.capabilities.Has(caps.Batch) {
batchType = "chathistory"
}
if batchType != "" {
batchID = rb.StartNestedBatch(batchType, params...)
}
return
}
// Send sends all messages in the buffer to the client.
// Afterwards, the buffer is in an undefined state and MUST NOT be used further.
// If `blocking` is true you MUST be sending to the client from its own goroutine.

37
irc/serde.go Normal file
View File

@ -0,0 +1,37 @@
// Copyright (c) 2022 Shivaram Lingamneni
// released under the MIT license
package irc
import (
"strconv"
"github.com/ergochat/ergo/irc/datastore"
"github.com/ergochat/ergo/irc/logger"
)
type Serializable interface {
Serialize() ([]byte, error)
Deserialize([]byte) error
}
func FetchAndDeserializeAll[T any, C interface {
*T
Serializable
}](table datastore.Table, dstore datastore.Datastore, log *logger.Manager) (result []T, err error) {
rawRecords, err := dstore.GetAll(table)
if err != nil {
return
}
result = make([]T, len(rawRecords))
pos := 0
for _, record := range rawRecords {
err := C(&result[pos]).Deserialize(record.Value)
if err != nil {
log.Error("internal", "deserialization error", strconv.Itoa(int(table)), record.UUID.String(), err.Error())
continue
}
pos++
}
return result[:pos], nil
}

View File

@ -22,9 +22,12 @@ import (
"github.com/ergochat/irc-go/ircfmt"
"github.com/okzk/sdnotify"
"github.com/tidwall/buntdb"
"github.com/ergochat/ergo/irc/bunt"
"github.com/ergochat/ergo/irc/caps"
"github.com/ergochat/ergo/irc/connection_limits"
"github.com/ergochat/ergo/irc/datastore"
"github.com/ergochat/ergo/irc/flatip"
"github.com/ergochat/ergo/irc/flock"
"github.com/ergochat/ergo/irc/history"
@ -33,7 +36,6 @@ import (
"github.com/ergochat/ergo/irc/mysql"
"github.com/ergochat/ergo/irc/sno"
"github.com/ergochat/ergo/irc/utils"
"github.com/tidwall/buntdb"
)
const (
@ -66,7 +68,6 @@ type Server struct {
accepts AcceptManager
accounts AccountManager
channels ChannelManager
channelRegistry ChannelRegistry
clients ClientManager
config atomic.Pointer[Config]
configFilename string
@ -87,6 +88,7 @@ type Server struct {
tracebackSignal chan os.Signal
snomasks SnoManager
store *buntdb.DB
dstore datastore.Datastore
historyDB mysql.MySQL
torLimiter connection_limits.TorLimiter
whoWas WhoWasList
@ -98,6 +100,10 @@ type Server struct {
// NewServer returns a new Oragono server.
func NewServer(config *Config, logger *logger.Manager) (*Server, error) {
// sanity check that kernel randomness is available; on modern Linux,
// this will block until it is, on other platforms it may panic:
utils.GenerateUUIDv4()
// initialize data structures
server := &Server{
ctime: time.Now().UTC(),
@ -308,9 +314,7 @@ func (server *Server) checkBanScriptExemptSASL(config *Config, session *Session)
func (server *Server) tryRegister(c *Client, session *Session) (exiting bool) {
// XXX PROXY or WEBIRC MUST be sent as the first line of the session;
// if we are here at all that means we have the final value of the IP
if session.rawHostname == "" {
session.client.lookupHostname(session, false)
}
c.finalizeHostname(session)
// try to complete registration normally
// XXX(#1057) username can be filled in by an ident query without the client
@ -353,10 +357,7 @@ func (server *Server) tryRegister(c *Client, session *Session) (exiting bool) {
rb := NewResponseBuffer(session)
nickError := performNickChange(server, c, c, session, c.preregNick, rb)
rb.Send(true)
if nickError == errInsecureReattach {
c.Quit(c.t("You can't mix secure and insecure connections to this account"), nil)
return true
} else if nickError != nil {
if nickError != nil {
c.preregNick = ""
return false
}
@ -391,6 +392,12 @@ func (server *Server) tryRegister(c *Client, session *Session) (exiting bool) {
}
server.playRegistrationBurst(session)
if len(config.Channels.AutoJoin) > 0 {
// only applicable to new clients, not reattaches:
server.handleAutojoins(session, config.Channels.AutoJoin)
}
return false
}
@ -499,6 +506,14 @@ func (server *Server) MOTD(client *Client, rb *ResponseBuffer) {
rb.Add(nil, server.name, RPL_ENDOFMOTD, client.nick, client.t("End of MOTD command"))
}
func (server *Server) handleAutojoins(session *Session, channelNames []string) {
rb := NewResponseBuffer(session)
for _, chname := range channelNames {
server.channels.Join(session.client, chname, "", false, rb)
}
rb.Send(true)
}
func (client *Client) whoisChannelsNames(target *Client, multiPrefix bool, hasPrivs bool) []string {
var chstrs []string
targetInvis := target.HasMode(modes.Invisible)
@ -685,9 +700,6 @@ func (server *Server) applyConfig(config *Config) (err error) {
if !oldConfig.Accounts.NickReservation.Enabled {
server.accounts.buildNickToAccountIndex(config)
}
if !oldConfig.Channels.Registration.Enabled {
server.channels.loadRegisteredChannels(config)
}
// resize history buffers as needed
if config.historyChangedFrom(oldConfig) {
for _, channel := range server.channels.Channels() {
@ -716,7 +728,11 @@ func (server *Server) applyConfig(config *Config) (err error) {
// now that the datastore is initialized, we can load the cloak secret from it
// XXX this modifies config after the initial load, which is naughty,
// but there's no data race because we haven't done SetConfig yet
config.Server.Cloaks.SetSecret(LoadCloakSecret(server.store))
cloakSecret, err := LoadCloakSecret(server.dstore)
if err != nil {
return fmt.Errorf("Could not load cloak secret: %w", err)
}
config.Server.Cloaks.SetSecret(cloakSecret)
// activate the new config
server.config.Store(config)
@ -837,6 +853,7 @@ func (server *Server) loadDatastore(config *Config) error {
db, err := OpenDatabase(config)
if err == nil {
server.store = db
server.dstore = bunt.NewBuntdbDatastore(db, server.logger)
return nil
} else {
return fmt.Errorf("Failed to open datastore: %s", err.Error())
@ -849,8 +866,7 @@ func (server *Server) loadFromDatastore(config *Config) (err error) {
server.loadDLines()
server.loadKLines()
server.channelRegistry.Initialize(server)
server.channels.Initialize(server)
server.channels.Initialize(server, config)
server.accounts.Initialize(server)
if config.Datastore.MySQL.Enabled {

View File

@ -55,14 +55,18 @@ type Client struct {
// Dial returns a new Client connected to an SMTP server at addr.
// The addr must include a port, as in "mail.example.com:smtp".
func Dial(addr string, timeout time.Duration) (*Client, error) {
func Dial(protocol, addr string, localAddress net.Addr, timeout time.Duration, implicitTLS bool) (*Client, error) {
var conn net.Conn
var err error
dialer := net.Dialer{
Timeout: timeout,
LocalAddr: localAddress,
}
start := time.Now()
if timeout == 0 {
conn, err = net.Dial("tcp", addr)
if !implicitTLS {
conn, err = dialer.Dial(protocol, addr)
} else {
conn, err = net.DialTimeout("tcp", addr, timeout)
conn, err = tls.DialWithDialer(&dialer, protocol, addr, nil)
}
if err != nil {
return nil, err
@ -338,7 +342,7 @@ var testHookStartTLS func(*tls.Config) // nil, except for tests
// functionality. Higher-level packages exist outside of the standard
// library.
// XXX: modified in Ergo to add `requireTLS`, `heloDomain`, and `timeout` arguments
func SendMail(addr string, a Auth, heloDomain string, from string, to []string, msg []byte, requireTLS bool, timeout time.Duration) error {
func SendMail(addr string, a Auth, heloDomain string, from string, to []string, msg []byte, requireTLS, implicitTLS bool, protocol string, localAddress net.Addr, timeout time.Duration) error {
if err := validateLine(from); err != nil {
return err
}
@ -347,7 +351,7 @@ func SendMail(addr string, a Auth, heloDomain string, from string, to []string,
return err
}
}
c, err := Dial(addr, timeout)
c, err := Dial(protocol, addr, localAddress, timeout, implicitTLS)
if err != nil {
return err
}
@ -355,23 +359,25 @@ func SendMail(addr string, a Auth, heloDomain string, from string, to []string,
if err = c.Hello(heloDomain); err != nil {
return err
}
if ok, _ := c.Extension("STARTTLS"); ok {
var config *tls.Config
if requireTLS {
config = &tls.Config{ServerName: c.serverName}
} else {
// if TLS isn't a hard requirement, don't verify the certificate either,
// since a MITM attacker could just remove the STARTTLS advertisement
config = &tls.Config{InsecureSkipVerify: true}
if !implicitTLS {
if ok, _ := c.Extension("STARTTLS"); ok {
var config *tls.Config
if requireTLS {
config = &tls.Config{ServerName: c.serverName}
} else {
// if TLS isn't a hard requirement, don't verify the certificate either,
// since a MITM attacker could just remove the STARTTLS advertisement
config = &tls.Config{InsecureSkipVerify: true}
}
if testHookStartTLS != nil {
testHookStartTLS(config)
}
if err = c.StartTLS(config); err != nil {
return err
}
} else if requireTLS {
return errors.New("TLS required, but not negotiated")
}
if testHookStartTLS != nil {
testHookStartTLS(config)
}
if err = c.StartTLS(config); err != nil {
return err
}
} else if requireTLS {
return errors.New("TLS required, but not negotiated")
}
if a != nil && c.ext != nil {
if _, ok := c.ext["AUTH"]; !ok {

View File

@ -60,6 +60,10 @@ const (
// confusables detection: standard skeleton algorithm (which may be ineffective
// over the larger set of permitted identifiers)
CasemappingPermissive
// rfc1459 is a legacy mapping as defined here: https://modern.ircdocs.horse/#casemapping-parameter
CasemappingRFC1459
// rfc1459-strict is a legacy mapping as defined here: https://modern.ircdocs.horse/#casemapping-parameter
CasemappingRFC1459Strict
)
// XXX this is a global variable without explicit synchronization.
@ -110,6 +114,10 @@ func casefoldWithSetting(str string, setting Casemapping) (string, error) {
return foldASCII(str)
case CasemappingPermissive:
return foldPermissive(str)
case CasemappingRFC1459:
return foldRFC1459(str, false)
case CasemappingRFC1459Strict:
return foldRFC1459(str, true)
}
}
@ -214,7 +222,7 @@ func Skeleton(name string) (string, error) {
switch globalCasemappingSetting {
default:
return realSkeleton(name)
case CasemappingASCII:
case CasemappingASCII, CasemappingRFC1459, CasemappingRFC1459Strict:
// identity function is fine because we independently case-normalize in Casefold
return name, nil
}
@ -302,6 +310,23 @@ func foldASCII(str string) (result string, err error) {
return strings.ToLower(str), nil
}
var (
rfc1459Replacer = strings.NewReplacer("[", "{", "]", "}", "\\", "|", "~", "^")
rfc1459StrictReplacer = strings.NewReplacer("[", "{", "]", "}", "\\", "|")
)
func foldRFC1459(str string, strict bool) (result string, err error) {
asciiFold, err := foldASCII(str)
if err != nil {
return "", err
}
replacer := rfc1459Replacer
if strict {
replacer = rfc1459StrictReplacer
}
return replacer.Replace(asciiFold), nil
}
func IsPrintableASCII(str string) bool {
for i := 0; i < len(str); i++ {
// allow space here because it's technically printable;

View File

@ -279,3 +279,31 @@ func TestFoldASCIIInvalid(t *testing.T) {
t.Errorf("control characters should be invalid in identifiers")
}
}
func TestFoldRFC1459(t *testing.T) {
folder := func(str string) (string, error) {
return foldRFC1459(str, false)
}
tester := func(first, second string, equal bool) {
validFoldTester(first, second, equal, folder, t)
}
tester("shivaram", "SHIVARAM", true)
tester("shivaram[a]", "shivaram{a}", true)
tester("shivaram\\a]", "shivaram{a}", false)
tester("shivaram\\a]", "shivaram|a}", true)
tester("shivaram~a]", "shivaram^a}", true)
}
func TestFoldRFC1459Strict(t *testing.T) {
folder := func(str string) (string, error) {
return foldRFC1459(str, true)
}
tester := func(first, second string, equal bool) {
validFoldTester(first, second, equal, folder, t)
}
tester("shivaram", "SHIVARAM", true)
tester("shivaram[a]", "shivaram{a}", true)
tester("shivaram\\a]", "shivaram{a}", false)
tester("shivaram\\a]", "shivaram|a}", true)
tester("shivaram~a]", "shivaram^a}", false)
}

View File

@ -16,17 +16,16 @@ import (
type ClientSet = utils.HashSet[*Client]
type memberData struct {
modes *modes.ModeSet
modes modes.ModeSet
joinTime int64
}
// MemberSet is a set of members with modes.
type MemberSet map[*Client]memberData
type MemberSet map[*Client]*memberData
// Add adds the given client to this set.
func (members MemberSet) Add(member *Client) {
members[member] = memberData{
modes: modes.NewModeSet(),
members[member] = &memberData{
joinTime: time.Now().UnixNano(),
}
}

View File

@ -48,6 +48,13 @@ func BitsetSet(set []uint32, position uint, on bool) (changed bool) {
}
}
// BitsetClear clears the bitset in-place.
func BitsetClear(set []uint32) {
for i := 0; i < len(set); i++ {
atomic.StoreUint32(&set[i], 0)
}
}
// BitsetEmpty returns whether the bitset is empty.
// This has false positives under concurrent modification (i.e., it can return true
// even though w.r.t. the sequence of atomic modifications, there was no point at

View File

@ -9,7 +9,7 @@ import (
"io"
"net"
"strings"
"sync"
"sync/atomic"
"time"
)
@ -209,7 +209,11 @@ func parseProxyLineV2(line []byte) (ip net.IP, err error) {
type WrappedConn struct {
net.Conn
ProxiedIP net.IP
Config ListenerConfig
TLS bool
Tor bool
STSOnly bool
WebSocket bool
HideSTS bool
// Secure indicates whether we believe the connection between us and the client
// was secure against interception and modification (including all proxies):
Secure bool
@ -218,35 +222,30 @@ type WrappedConn struct {
// ReloadableListener is a wrapper for net.Listener that allows reloading
// of config data for postprocessing connections (TLS, PROXY protocol, etc.)
type ReloadableListener struct {
// TODO: make this lock-free
sync.Mutex
realListener net.Listener
config ListenerConfig
isClosed bool
// nil means the listener is closed:
config atomic.Pointer[ListenerConfig]
}
func NewReloadableListener(realListener net.Listener, config ListenerConfig) *ReloadableListener {
return &ReloadableListener{
result := &ReloadableListener{
realListener: realListener,
config: config,
}
result.config.Store(&config) // heap escape
return result
}
func (rl *ReloadableListener) Reload(config ListenerConfig) {
rl.Lock()
rl.config = config
rl.Unlock()
rl.config.Store(&config)
}
func (rl *ReloadableListener) Accept() (conn net.Conn, err error) {
conn, err = rl.realListener.Accept()
rl.Lock()
config := rl.config
isClosed := rl.isClosed
rl.Unlock()
config := rl.config.Load()
if isClosed {
if config == nil {
// Close() was called
if err == nil {
conn.Close()
}
@ -279,14 +278,17 @@ func (rl *ReloadableListener) Accept() (conn net.Conn, err error) {
return &WrappedConn{
Conn: conn,
ProxiedIP: proxiedIP,
Config: config,
TLS: config.TLSConfig != nil,
Tor: config.Tor,
STSOnly: config.STSOnly,
WebSocket: config.WebSocket,
HideSTS: config.HideSTS,
// Secure will be set later by client code
}, nil
}
func (rl *ReloadableListener) Close() error {
rl.Lock()
rl.isClosed = true
rl.Unlock()
rl.config.Store(nil)
return rl.realListener.Close()
}

View File

@ -125,6 +125,28 @@ func (t *TokenLineBuilder) Add(token string) {
t.buf.WriteString(token)
}
// AddParts concatenates `parts` into a token and adds it to the line,
// creating a new line if necessary.
func (t *TokenLineBuilder) AddParts(parts ...string) {
var tokenLen int
for _, part := range parts {
tokenLen += len(part)
}
if t.buf.Len() != 0 {
tokenLen += len(t.delim)
}
if t.lineLen < t.buf.Len()+tokenLen {
t.result = append(t.result, t.buf.String())
t.buf.Reset()
}
if t.buf.Len() != 0 {
t.buf.WriteString(t.delim)
}
for _, part := range parts {
t.buf.WriteString(part)
}
}
// Lines terminates the line-building and returns all the lines.
func (t *TokenLineBuilder) Lines() (result []string) {
result = t.result

View File

@ -43,3 +43,26 @@ func TestBuildTokenLines(t *testing.T) {
val = BuildTokenLines(10, []string{"abcd", "efgh", "ijkl"}, ",")
assertEqual(val, []string{"abcd,efgh", "ijkl"}, t)
}
func TestTLBuilderAddParts(t *testing.T) {
var tl TokenLineBuilder
tl.Initialize(20, " ")
tl.Add("bob")
tl.AddParts("@", "alice")
tl.AddParts("@", "ErgoBot__")
assertEqual(tl.Lines(), []string{"bob @alice", "@ErgoBot__"}, t)
}
func BenchmarkTokenLines(b *testing.B) {
tokens := strings.Fields(monteCristo)
b.ResetTimer()
for i := 0; i < b.N; i++ {
var tl TokenLineBuilder
tl.Initialize(400, " ")
for _, tok := range tokens {
tl.Add(tok)
}
tl.Lines()
}
}

View File

@ -20,26 +20,10 @@ func (s HashSet[T]) Remove(elem T) {
delete(s, elem)
}
func CopyMap[K comparable, V any](input map[K]V) (result map[K]V) {
result = make(map[K]V, len(input))
for key, value := range input {
result[key] = value
func SetLiteral[T comparable](elems ...T) HashSet[T] {
result := make(HashSet[T], len(elems))
for _, elem := range elems {
result.Add(elem)
}
return
}
// reverse the order of a slice in place
func ReverseSlice[T any](results []T) {
for i, j := 0, len(results)-1; i < j; i, j = i+1, j-1 {
results[i], results[j] = results[j], results[i]
}
}
func SliceContains[T comparable](slice []T, elem T) (result bool) {
for _, t := range slice {
if elem == t {
return true
}
}
return false
return result
}

56
irc/utils/uuid.go Normal file
View File

@ -0,0 +1,56 @@
// Copyright (c) 2022 Shivaram Lingamneni <slingamn@cs.stanford.edu>
// released under the MIT license
package utils
import (
"crypto/rand"
"encoding/base64"
"errors"
)
var (
ErrInvalidUUID = errors.New("Invalid uuid")
)
// Technically a UUIDv4 has version bits set, but this doesn't matter in practice
type UUID [16]byte
func (u UUID) MarshalJSON() (b []byte, err error) {
b = make([]byte, 24)
b[0] = '"'
base64.RawURLEncoding.Encode(b[1:], u[:])
b[23] = '"'
return
}
func (u *UUID) UnmarshalJSON(b []byte) (err error) {
if len(b) != 24 {
return ErrInvalidUUID
}
readLen, err := base64.RawURLEncoding.Decode(u[:], b[1:23])
if err != nil || readLen != 16 {
return ErrInvalidUUID
}
return nil
}
func (u *UUID) String() string {
return base64.RawURLEncoding.EncodeToString(u[:])
}
func GenerateUUIDv4() (result UUID) {
_, err := rand.Read(result[:])
if err != nil {
panic(err)
}
return
}
func DecodeUUID(ustr string) (result UUID, err error) {
length, err := base64.RawURLEncoding.Decode(result[:], []byte(ustr))
if err == nil && length != 16 {
err = ErrInvalidUUID
}
return
}

View File

@ -7,7 +7,7 @@ import "fmt"
const (
// SemVer is the semantic version of Ergo.
SemVer = "2.11.0-rc1"
SemVer = "2.14.0-unreleased"
)
var (

@ -1 +1 @@
Subproject commit 35d342a478f8ddc7d6b9ba7b2e55f769c60478d1
Subproject commit 723991c7ec02e471d8d11d53edd8249baa1655db

View File

@ -108,9 +108,10 @@ server:
# the recommended default is 'ascii' (traditional ASCII-only identifiers).
# the other options are 'precis', which allows UTF8 identifiers that are "sane"
# (according to UFC 8265), with additional mitigations for homoglyph attacks,
# and 'permissive', which allows identifiers containing unusual characters like
# 'permissive', which allows identifiers containing unusual characters like
# emoji, at the cost of increased vulnerability to homoglyph attacks and potential
# client compatibility problems. we recommend leaving this value at its default;
# client compatibility problems, and the legacy mappings 'rfc1459' and
# 'rfc1459-strict'. we recommend leaving this value at its default;
# however, note that changing it once the network is already up and running is
# problematic.
casemapping: "ascii"
@ -138,7 +139,10 @@ server:
# the value must begin with a '~' character. comment out / omit to disable:
#coerce-ident: '~u'
# password to login to the server, generated using `ergo genpasswd`:
# 'password' allows you to require a global, shared password (the IRC `PASS` command)
# to connect to the server. for operator passwords, see the `opers` section of the
# config. for a more secure way to create a private server, see the `require-sasl`
# section. you must hash the password with `ergo genpasswd`, then enter the hash here:
#password: "$2a$04$0123456789abcdef0123456789abcdef0123456789abcdef01234"
# motd filename
@ -189,6 +193,9 @@ server:
# - "192.168.1.1"
# - "192.168.10.1/24"
# whether to accept the hostname parameter on the WEBIRC line as the IRC hostname
accept-hostname: true
# maximum length of clients' sendQ in bytes
# this should be big enough to hold bursts of channel/direct messages
max-sendq: 96k
@ -334,7 +341,7 @@ server:
# in a "closed-loop" system where you control the server and all the clients,
# you may want to increase the maximum (non-tag) length of an IRC line from
# the default value of 512. DO NOT change this on a public server:
# max-line-len: 512
#max-line-len: 512
# send all 0's as the LUSERS (user counts) output to non-operators; potentially useful
# if you don't want to publicize how popular the server is
@ -375,6 +382,10 @@ accounts:
sender: "admin@my.network"
require-tls: true
helo-domain: "my.network" # defaults to server name if unset
# set to `tcp4` to force sending over IPv4, `tcp6` to force IPv6:
# protocol: "tcp4"
# set to force a specific source/local IPv4 or IPv6 address:
# local-address: "1.2.3.4"
# options to enable DKIM signing of outgoing emails (recommended, but
# requires creating a DNS entry for the public key):
# dkim:
@ -387,8 +398,15 @@ accounts:
# port: 25
# username: "admin"
# password: "hunter2"
blacklist-regexes:
# - ".*@mailinator.com"
# implicit-tls: false # TLS from the first byte, typically on port 465
# addresses that are not accepted for registration:
address-blacklist:
# - "*@mailinator.com"
address-blacklist-syntax: "glob" # change to "regex" for regular expressions
# file of newline-delimited address blacklist entries (no enclosing quotes)
# in the above syntax (i.e. either globs or regexes). supersedes
# address-blacklist if set:
# address-blacklist-file: "/path/to/address-blacklist-file"
timeout: 60s
# email-based password reset:
password-reset:
@ -420,6 +438,10 @@ accounts:
# this is useful for compatibility with old clients that don't support SASL
login-via-pass-command: false
# advertise the SCRAM-SHA-256 authentication method. set to false in case of
# compatibility issues with certain clients:
advertise-scram: true
# require-sasl controls whether clients are required to have accounts
# (and sign into them using SASL) to connect to the server
require-sasl:
@ -545,6 +567,40 @@ accounts:
# how many scripts are allowed to run at once? 0 for no limit:
max-concurrency: 64
# support for login via OAuth2 bearer tokens
oauth2:
enabled: false
# should we automatically create users on presentation of a valid token?
autocreate: true
# enable this to use auth-script for validation:
auth-script: false
introspection-url: "https://example.com/api/oidc/introspection"
introspection-timeout: 10s
# omit for auth method `none`; required for auth method `client_secret_basic`:
client-id: "ergo"
client-secret: "4TA0I7mJ3fUUcW05KJiODg"
# support for login via JWT bearer tokens
jwt-auth:
enabled: false
# should we automatically create users on presentation of a valid token?
autocreate: true
# any of these token definitions can be accepted, allowing for key rotation
tokens:
-
algorithm: "hmac" # either 'hmac', 'rsa', or 'eddsa' (ed25519)
# hmac takes a symmetric key, rsa and eddsa take PEM-encoded public keys;
# either way, the key can be specified either as a YAML string:
key: "nANiZ1De4v6WnltCHN2H7Q"
# or as a path to the file containing the key:
#key-file: "jwt_pubkey.pem"
# list of JWT claim names to search for the user's account name (make sure the format
# is what you expect, especially if using "sub"):
account-claims: ["preferred_username"]
# if a claim is formatted as an email address, require it to have the following domain,
# and then strip off the domain and use the local-part as the account name:
#strip-domain: "example.com"
# channel options
channels:
# modes that are set when new channels are created
@ -579,6 +635,12 @@ channels:
# (0 or omit for no expiration):
invite-expiration: 24h
# channels that new clients will automatically join. this should be used with
# caution, since traditional IRC users will likely view it as an antifeature.
# it may be useful in small community networks that have a single "primary" channel:
#auto-join:
# - "#lounge"
# operator classes:
# an operator has a single "class" (defining a privilege level), which can include
# multiple "capabilities" (defining privileged actions they can take). all
@ -772,6 +834,9 @@ limits:
# identlen is the max ident length allowed
identlen: 20
# realnamelen is the maximum realname length allowed
realnamelen: 150
# channellen is the max channel length allowed
channellen: 64
@ -946,7 +1011,8 @@ history:
# options to control how messages are stored and deleted:
retention:
# allow users to delete their own messages from history?
# allow users to delete their own messages from history,
# and channel operators to delete messages in their channel?
allow-individual-delete: false
# if persistent history is enabled, create additional index tables,

View File

@ -38,6 +38,12 @@ func (e ProtocolError) Error() string {
// Query makes an Ident query, if timeout is >0 the query is timed out after that many seconds.
func Query(ip string, portOnServer, portOnClient int, timeout time.Duration) (response Response, err error) {
// if a timeout is set, respect it from the beginning of the query, including the dial time
var deadline time.Time
if timeout > 0 {
deadline = time.Now().Add(timeout)
}
var conn net.Conn
if timeout > 0 {
conn, err = net.DialTimeout("tcp", net.JoinHostPort(ip, "113"), timeout)
@ -47,13 +53,12 @@ func Query(ip string, portOnServer, portOnClient int, timeout time.Duration) (re
if err != nil {
return
}
defer conn.Close()
// stop the ident read after <timeout> seconds
if timeout > 0 {
conn.SetDeadline(time.Now().Add(timeout))
}
// if timeout is 0, `deadline` is the empty time.Time{} which means no deadline:
conn.SetDeadline(deadline)
_, err = conn.Write([]byte(fmt.Sprintf("%d, %d", portOnClient, portOnServer) + "\r\n"))
_, err = conn.Write([]byte(fmt.Sprintf("%d, %d\r\n", portOnClient, portOnServer)))
if err != nil {
return
}

View File

@ -5,6 +5,7 @@ package ircfmt
import (
"regexp"
"strconv"
"strings"
)
@ -19,24 +20,126 @@ const (
underline string = "\x1f"
reset string = "\x0f"
runecolour rune = '\x03'
runebold rune = '\x02'
runemonospace rune = '\x11'
runereverseColour rune = '\x16'
runeitalic rune = '\x1d'
runestrikethrough rune = '\x1e'
runereset rune = '\x0f'
runeunderline rune = '\x1f'
// valid characters in a colour code character, for speed
colours1 string = "0123456789"
metacharacters = (bold + colour + monospace + reverseColour + italic + strikethrough + underline + reset)
)
// ColorCode is a normalized representation of an IRC color code,
// as per this de facto specification: https://modern.ircdocs.horse/formatting.html#color
// The zero value of the type represents a default or unset color,
// whereas ColorCode{true, 0} represents the color white.
type ColorCode struct {
IsSet bool
Value uint8
}
// ParseColor converts a string representation of an IRC color code, e.g. "04",
// into a normalized ColorCode, e.g. ColorCode{true, 4}.
func ParseColor(str string) (color ColorCode) {
// "99 - Default Foreground/Background - Not universally supported."
// normalize 99 to ColorCode{} meaning "unset":
if code, err := strconv.ParseUint(str, 10, 8); err == nil && code < 99 {
color.IsSet = true
color.Value = uint8(code)
}
return
}
// FormattedSubstring represents a section of an IRC message with associated
// formatting data.
type FormattedSubstring struct {
Content string
ForegroundColor ColorCode
BackgroundColor ColorCode
Bold bool
Monospace bool
Strikethrough bool
Underline bool
Italic bool
ReverseColor bool
}
// IsFormatted returns whether the section has any formatting flags switched on.
func (f *FormattedSubstring) IsFormatted() bool {
// could rely on value receiver but if this is to be a public API,
// let's make it a pointer receiver
g := *f
g.Content = ""
return g != FormattedSubstring{}
}
var (
// "If there are two ASCII digits available where a <COLOR> is allowed,
// then two characters MUST always be read for it and displayed as described below."
// we rely on greedy matching to implement this for both forms:
// (\x03)00,01
colorForeBackRe = regexp.MustCompile(`^([0-9]{1,2}),([0-9]{1,2})`)
// (\x03)00
colorForeRe = regexp.MustCompile(`^([0-9]{1,2})`)
)
// Split takes an IRC message (typically a PRIVMSG or NOTICE final parameter)
// containing IRC formatting control codes, and splits it into substrings with
// associated formatting information.
func Split(raw string) (result []FormattedSubstring) {
var chunk FormattedSubstring
for {
// skip to the next metacharacter, or the end of the string
if idx := strings.IndexAny(raw, metacharacters); idx != 0 {
if idx == -1 {
idx = len(raw)
}
chunk.Content = raw[:idx]
if len(chunk.Content) != 0 {
result = append(result, chunk)
}
raw = raw[idx:]
}
if len(raw) == 0 {
return
}
// we're at a metacharacter. by default, all previous formatting carries over
metacharacter := raw[0]
raw = raw[1:]
switch metacharacter {
case bold[0]:
chunk.Bold = !chunk.Bold
case monospace[0]:
chunk.Monospace = !chunk.Monospace
case strikethrough[0]:
chunk.Strikethrough = !chunk.Strikethrough
case underline[0]:
chunk.Underline = !chunk.Underline
case italic[0]:
chunk.Italic = !chunk.Italic
case reverseColour[0]:
chunk.ReverseColor = !chunk.ReverseColor
case reset[0]:
chunk = FormattedSubstring{}
case colour[0]:
// preferentially match the "\x0399,01" form, then "\x0399";
// if neither of those matches, then it's a reset
if matches := colorForeBackRe.FindStringSubmatch(raw); len(matches) != 0 {
chunk.ForegroundColor = ParseColor(matches[1])
chunk.BackgroundColor = ParseColor(matches[2])
raw = raw[len(matches[0]):]
} else if matches := colorForeRe.FindStringSubmatch(raw); len(matches) != 0 {
chunk.ForegroundColor = ParseColor(matches[1])
raw = raw[len(matches[0]):]
} else {
chunk.ForegroundColor = ColorCode{}
chunk.BackgroundColor = ColorCode{}
}
default:
// should be impossible, but just ignore it
}
}
}
var (
// valtoescape replaces most of IRC characters with our escapes.
valtoescape = strings.NewReplacer("$", "$$", colour, "$c", reverseColour, "$v", bold, "$b", italic, "$i", strikethrough, "$s", underline, "$u", monospace, "$m", reset, "$r")
// valToStrip replaces most of the IRC characters with nothing
valToStrip = strings.NewReplacer(colour, "$c", reverseColour, "", bold, "", italic, "", strikethrough, "", underline, "", monospace, "", reset, "")
// escapetoval contains most of our escapes and how they map to real IRC characters.
// intentionally skips colour, since that's handled elsewhere.
@ -98,7 +201,9 @@ var (
"light blue": "12",
"pink": "13",
"grey": "14",
"gray": "14",
"light grey": "15",
"light gray": "15",
"default": "99",
}
@ -123,7 +228,7 @@ func Escape(in string) string {
out.WriteString("$c")
inRunes = inRunes[2:] // strip colour code chars
if len(inRunes) < 1 || !strings.Contains(colours1, string(inRunes[0])) {
if len(inRunes) < 1 || !isDigit(inRunes[0]) {
out.WriteString("[]")
continue
}
@ -131,14 +236,14 @@ func Escape(in string) string {
var foreBuffer, backBuffer string
foreBuffer += string(inRunes[0])
inRunes = inRunes[1:]
if 0 < len(inRunes) && strings.Contains(colours1, string(inRunes[0])) {
if 0 < len(inRunes) && isDigit(inRunes[0]) {
foreBuffer += string(inRunes[0])
inRunes = inRunes[1:]
}
if 1 < len(inRunes) && inRunes[0] == ',' && strings.Contains(colours1, string(inRunes[1])) {
if 1 < len(inRunes) && inRunes[0] == ',' && isDigit(inRunes[1]) {
backBuffer += string(inRunes[1])
inRunes = inRunes[2:]
if 0 < len(inRunes) && strings.Contains(colours1, string(inRunes[0])) {
if 0 < len(inRunes) && isDigit(inRunes[1]) {
backBuffer += string(inRunes[0])
inRunes = inRunes[1:]
}
@ -178,52 +283,27 @@ func Escape(in string) string {
return out.String()
}
func isDigit(r rune) bool {
return '0' <= r && r <= '9' // don't use unicode.IsDigit, it includes non-ASCII numerals
}
// Strip takes a raw IRC string and removes it with all formatting codes removed
// IE, it turns this: "This is a \x02cool\x02, \x034red\x0f message!"
// into: "This is a cool, red message!"
func Strip(in string) string {
out := strings.Builder{}
runes := []rune(in)
if out.Len() < len(runes) { // Reduce allocations where needed
out.Grow(len(in) - out.Len())
}
for len(runes) > 0 {
switch runes[0] {
case runebold, runemonospace, runereverseColour, runeitalic, runestrikethrough, runeunderline, runereset:
runes = runes[1:]
case runecolour:
runes = removeColour(runes)
default:
out.WriteRune(runes[0])
runes = runes[1:]
}
}
return out.String()
}
func removeNumber(runes []rune) []rune {
if len(runes) > 0 && runes[0] >= '0' && runes[0] <= '9' {
runes = runes[1:]
}
return runes
}
func removeColour(runes []rune) []rune {
if runes[0] != runecolour {
return runes
}
runes = runes[1:]
runes = removeNumber(runes)
runes = removeNumber(runes)
if len(runes) > 1 && runes[0] == ',' && runes[1] >= '0' && runes[1] <= '9' {
runes = runes[2:]
splitChunks := Split(in)
if len(splitChunks) == 0 {
return ""
} else if len(splitChunks) == 1 {
return splitChunks[0].Content
} else {
return runes // Nothing else because we dont have a comma
var buf strings.Builder
buf.Grow(len(in))
for _, chunk := range splitChunks {
buf.WriteString(chunk.Content)
}
return buf.String()
}
runes = removeNumber(runes)
return runes
}
// resolve "light blue" to "12", "12" to "12", "asdf" to "", etc.

View File

@ -238,7 +238,7 @@ func parseLine(line string, maxTagDataLength int, truncateLen int) (ircmsg Messa
// truncate if desired
if truncateLen != 0 && truncateLen < len(line) {
err = ErrorBodyTooLong
line = line[:truncateLen]
line = TruncateUTF8Safe(line, truncateLen)
}
// modern: "These message parts, and parameters themselves, are separated

29
vendor/github.com/ergochat/irc-go/ircmsg/unicode.go generated vendored Normal file
View File

@ -0,0 +1,29 @@
// Copyright (c) 2021 Shivaram Lingamneni
// Released under the MIT License
package ircmsg
import (
"unicode/utf8"
)
// TruncateUTF8Safe truncates a message, respecting UTF8 boundaries. If a message
// was originally valid UTF8, TruncateUTF8Safe will not make it invalid; instead
// it will truncate additional bytes as needed, back to the last valid
// UTF8-encoded codepoint. If a message is not UTF8, TruncateUTF8Safe will truncate
// at most 3 additional bytes before giving up.
func TruncateUTF8Safe(message string, byteLimit int) (result string) {
if len(message) <= byteLimit {
return message
}
message = message[:byteLimit]
for i := 0; i < (utf8.UTFMax - 1); i++ {
r, n := utf8.DecodeLastRuneInString(message)
if r == utf8.RuneError && n <= 1 {
message = message[:len(message)-1]
} else {
break
}
}
return message
}

105
vendor/github.com/ergochat/irc-go/ircutils/sasl.go generated vendored Normal file
View File

@ -0,0 +1,105 @@
package ircutils
import (
"encoding/base64"
"errors"
"strings"
)
var (
ErrSASLLimitExceeded = errors.New("SASL total response size exceeded configured limit")
ErrSASLTooLong = errors.New("SASL response chunk exceeded 400-byte limit")
)
// EncodeSASLResponse encodes a raw SASL response as parameters to successive
// AUTHENTICATE commands, as described in the IRCv3 SASL specification.
func EncodeSASLResponse(raw []byte) (result []string) {
// https://ircv3.net/specs/extensions/sasl-3.1#the-authenticate-command
// "The response is encoded in Base64 (RFC 4648), then split to 400-byte chunks,
// and each chunk is sent as a separate AUTHENTICATE command. Empty (zero-length)
// responses are sent as AUTHENTICATE +. If the last chunk was exactly 400 bytes
// long, it must also be followed by AUTHENTICATE + to signal end of response."
if len(raw) == 0 {
return []string{"+"}
}
response := base64.StdEncoding.EncodeToString(raw)
lastLen := 0
for len(response) > 0 {
// TODO once we require go 1.21, this can be: lastLen = min(len(response), 400)
lastLen = len(response)
if lastLen > 400 {
lastLen = 400
}
result = append(result, response[:lastLen])
response = response[lastLen:]
}
if lastLen == 400 {
result = append(result, "+")
}
return result
}
// SASLBuffer handles buffering and decoding SASL responses sent as parameters
// to AUTHENTICATE commands, as described in the IRCv3 SASL specification.
// Do not copy a SASLBuffer after first use.
type SASLBuffer struct {
maxLength int
buffer strings.Builder
}
// NewSASLBuffer returns a new SASLBuffer. maxLength is the maximum amount of
// base64'ed data to buffer (0 for no limit).
func NewSASLBuffer(maxLength int) *SASLBuffer {
result := new(SASLBuffer)
result.Initialize(maxLength)
return result
}
// Initialize initializes a SASLBuffer in place.
func (b *SASLBuffer) Initialize(maxLength int) {
b.maxLength = maxLength
}
// Add processes an additional SASL response chunk sent via AUTHENTICATE.
// If the response is complete, it resets the buffer and returns the decoded
// response along with any decoding or protocol errors detected.
func (b *SASLBuffer) Add(value string) (done bool, output []byte, err error) {
if value == "+" {
output, err = b.getAndReset()
return true, output, err
}
if len(value) > 400 {
b.buffer.Reset()
return true, nil, ErrSASLTooLong
}
if b.maxLength != 0 && (b.buffer.Len()+len(value)) > b.maxLength {
b.buffer.Reset()
return true, nil, ErrSASLLimitExceeded
}
b.buffer.WriteString(value)
if len(value) < 400 {
output, err = b.getAndReset()
return true, output, err
} else {
// 400 bytes, wait for continuation line or +
return false, nil, nil
}
}
// Clear resets the buffer state.
func (b *SASLBuffer) Clear() {
b.buffer.Reset()
}
func (b *SASLBuffer) getAndReset() (output []byte, err error) {
output, err = base64.StdEncoding.DecodeString(b.buffer.String())
b.buffer.Reset()
return
}

View File

@ -7,24 +7,11 @@ import (
"strings"
"unicode"
"unicode/utf8"
"github.com/ergochat/irc-go/ircmsg"
)
// truncate a message, taking care not to make valid UTF8 into invalid UTF8
func TruncateUTF8Safe(message string, byteLimit int) (result string) {
if len(message) <= byteLimit {
return message
}
message = message[:byteLimit]
for i := 0; i < (utf8.UTFMax - 1); i++ {
r, n := utf8.DecodeLastRuneInString(message)
if r == utf8.RuneError && n <= 1 {
message = message[:len(message)-1]
} else {
break
}
}
return message
}
var TruncateUTF8Safe = ircmsg.TruncateUTF8Safe
// Sanitizes human-readable text to make it safe for IRC;
// assumes UTF-8 and uses the replacement character where

View File

@ -23,6 +23,7 @@ Asta Xie <xiemengjun at gmail.com>
Bulat Gaifullin <gaifullinbf at gmail.com>
Caine Jette <jette at alum.mit.edu>
Carlos Nieto <jose.carlos at menteslibres.net>
Chris Kirkland <chriskirkland at github.com>
Chris Moos <chris at tech9computers.com>
Craig Wilson <craiggwilson at gmail.com>
Daniel Montoya <dsmontoyam at gmail.com>
@ -45,6 +46,7 @@ Ilia Cimpoes <ichimpoesh at gmail.com>
INADA Naoki <songofacandy at gmail.com>
Jacek Szwec <szwec.jacek at gmail.com>
James Harr <james.harr at gmail.com>
Janek Vedock <janekvedock at comcast.net>
Jeff Hodges <jeff at somethingsimilar.com>
Jeffrey Charles <jeffreycharles at gmail.com>
Jerome Meyer <jxmeyer at gmail.com>
@ -59,12 +61,14 @@ Kamil Dziedzic <kamil at klecza.pl>
Kei Kamikawa <x00.x7f.x86 at gmail.com>
Kevin Malachowski <kevin at chowski.com>
Kieron Woodhouse <kieron.woodhouse at infosum.com>
Lance Tian <lance6716 at gmail.com>
Lennart Rudolph <lrudolph at hmc.edu>
Leonardo YongUk Kim <dalinaum at gmail.com>
Linh Tran Tuan <linhduonggnu at gmail.com>
Lion Yang <lion at aosc.xyz>
Luca Looz <luca.looz92 at gmail.com>
Lucas Liu <extrafliu at gmail.com>
Lunny Xiao <xiaolunwen at gmail.com>
Luke Scott <luke at webconnex.com>
Maciej Zimnoch <maciej.zimnoch at codilime.com>
Michael Woolnough <michael.woolnough at gmail.com>
@ -79,6 +83,7 @@ Reed Allman <rdallman10 at gmail.com>
Richard Wilkes <wilkes at me.com>
Robert Russell <robert at rrbrussell.com>
Runrioter Wung <runrioter at gmail.com>
Santhosh Kumar Tekuri <santhosh.tekuri at gmail.com>
Sho Iizuka <sho.i518 at gmail.com>
Sho Ikeda <suicaicoca at gmail.com>
Shuode Li <elemount at qq.com>
@ -99,12 +104,14 @@ Xiuming Chen <cc at cxm.cc>
Xuehong Chan <chanxuehong at gmail.com>
Zhenye Xie <xiezhenye at gmail.com>
Zhixin Wen <john.wenzhixin at gmail.com>
Ziheng Lyu <zihenglv at gmail.com>
# Organizations
Barracuda Networks, Inc.
Counting Ltd.
DigitalOcean Inc.
dyves labs AG
Facebook Inc.
GitHub Inc.
Google Inc.

View File

@ -1,3 +1,24 @@
## Version 1.7 (2022-11-29)
Changes:
- Drop support of Go 1.12 (#1211)
- Refactoring `(*textRows).readRow` in a more clear way (#1230)
- util: Reduce boundary check in escape functions. (#1316)
- enhancement for mysqlConn handleAuthResult (#1250)
New Features:
- support Is comparison on MySQLError (#1210)
- return unsigned in database type name when necessary (#1238)
- Add API to express like a --ssl-mode=PREFERRED MySQL client (#1370)
- Add SQLState to MySQLError (#1321)
Bugfixes:
- Fix parsing 0 year. (#1257)
## Version 1.6 (2021-04-01)
Changes:

View File

@ -40,7 +40,7 @@ A MySQL-Driver for Go's [database/sql](https://golang.org/pkg/database/sql/) pac
* Optional placeholder interpolation
## Requirements
* Go 1.10 or higher. We aim to support the 3 latest versions of Go.
* Go 1.13 or higher. We aim to support the 3 latest versions of Go.
* MySQL (4.1+), MariaDB, Percona Server, Google CloudSQL or Sphinx (2.2.3+)
---------------------------------------
@ -85,7 +85,7 @@ db.SetMaxIdleConns(10)
`db.SetMaxOpenConns()` is highly recommended to limit the number of connection used by the application. There is no recommended limit number because it depends on application and MySQL server.
`db.SetMaxIdleConns()` is recommended to be set same to (or greater than) `db.SetMaxOpenConns()`. When it is smaller than `SetMaxOpenConns()`, connections can be opened and closed very frequently than you expect. Idle connections can be closed by the `db.SetConnMaxLifetime()`. If you want to close idle connections more rapidly, you can use `db.SetConnMaxIdleTime()` since Go 1.15.
`db.SetMaxIdleConns()` is recommended to be set same to `db.SetMaxOpenConns()`. When it is smaller than `SetMaxOpenConns()`, connections can be opened and closed much more frequently than you expect. Idle connections can be closed by the `db.SetConnMaxLifetime()`. If you want to close idle connections more rapidly, you can use `db.SetConnMaxIdleTime()` since Go 1.15.
### DSN (Data Source Name)
@ -157,6 +157,17 @@ Default: false
`allowCleartextPasswords=true` allows using the [cleartext client side plugin](https://dev.mysql.com/doc/en/cleartext-pluggable-authentication.html) if required by an account, such as one defined with the [PAM authentication plugin](http://dev.mysql.com/doc/en/pam-authentication-plugin.html). Sending passwords in clear text may be a security problem in some configurations. To avoid problems if there is any possibility that the password would be intercepted, clients should connect to MySQL Server using a method that protects the password. Possibilities include [TLS / SSL](#tls), IPsec, or a private network.
##### `allowFallbackToPlaintext`
```
Type: bool
Valid Values: true, false
Default: false
```
`allowFallbackToPlaintext=true` acts like a `--ssl-mode=PREFERRED` MySQL client as described in [Command Options for Connecting to the Server](https://dev.mysql.com/doc/refman/5.7/en/connection-options.html#option_general_ssl-mode)
##### `allowNativePasswords`
```
@ -454,7 +465,7 @@ user:password@/
The connection pool is managed by Go's database/sql package. For details on how to configure the size of the pool and how long connections stay in the pool see `*DB.SetMaxOpenConns`, `*DB.SetMaxIdleConns`, and `*DB.SetConnMaxLifetime` in the [database/sql documentation](https://golang.org/pkg/database/sql/). The read, write, and dial timeouts for each individual connection are configured with the DSN parameters [`readTimeout`](#readtimeout), [`writeTimeout`](#writetimeout), and [`timeout`](#timeout), respectively.
## `ColumnType` Support
This driver supports the [`ColumnType` interface](https://golang.org/pkg/database/sql/#ColumnType) introduced in Go 1.8, with the exception of [`ColumnType.Length()`](https://golang.org/pkg/database/sql/#ColumnType.Length), which is currently not supported.
This driver supports the [`ColumnType` interface](https://golang.org/pkg/database/sql/#ColumnType) introduced in Go 1.8, with the exception of [`ColumnType.Length()`](https://golang.org/pkg/database/sql/#ColumnType.Length), which is currently not supported. All Unsigned database type names will be returned `UNSIGNED ` with `INT`, `TINYINT`, `SMALLINT`, `BIGINT`.
## `context.Context` Support
Go 1.8 added `database/sql` support for `context.Context`. This driver supports query timeouts and cancellation via contexts.

19
vendor/github.com/go-sql-driver/mysql/atomic_bool.go generated vendored Normal file
View File

@ -0,0 +1,19 @@
// Go MySQL Driver - A MySQL-Driver for Go's database/sql package.
//
// Copyright 2022 The Go-MySQL-Driver Authors. All rights reserved.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at http://mozilla.org/MPL/2.0/.
//go:build go1.19
// +build go1.19
package mysql
import "sync/atomic"
/******************************************************************************
* Sync utils *
******************************************************************************/
type atomicBool = atomic.Bool

View File

@ -0,0 +1,47 @@
// Go MySQL Driver - A MySQL-Driver for Go's database/sql package.
//
// Copyright 2022 The Go-MySQL-Driver Authors. All rights reserved.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at http://mozilla.org/MPL/2.0/.
//go:build !go1.19
// +build !go1.19
package mysql
import "sync/atomic"
/******************************************************************************
* Sync utils *
******************************************************************************/
// atomicBool is an implementation of atomic.Bool for older version of Go.
// it is a wrapper around uint32 for usage as a boolean value with
// atomic access.
type atomicBool struct {
_ noCopy
value uint32
}
// Load returns whether the current boolean value is true
func (ab *atomicBool) Load() bool {
return atomic.LoadUint32(&ab.value) > 0
}
// Store sets the value of the bool regardless of the previous value
func (ab *atomicBool) Store(value bool) {
if value {
atomic.StoreUint32(&ab.value, 1)
} else {
atomic.StoreUint32(&ab.value, 0)
}
}
// Swap sets the value of the bool and returns the old value.
func (ab *atomicBool) Swap(value bool) bool {
if value {
return atomic.SwapUint32(&ab.value, 1) > 0
}
return atomic.SwapUint32(&ab.value, 0) > 0
}

View File

@ -33,27 +33,26 @@ var (
// Note: The provided rsa.PublicKey instance is exclusively owned by the driver
// after registering it and may not be modified.
//
// data, err := ioutil.ReadFile("mykey.pem")
// if err != nil {
// log.Fatal(err)
// }
// data, err := ioutil.ReadFile("mykey.pem")
// if err != nil {
// log.Fatal(err)
// }
//
// block, _ := pem.Decode(data)
// if block == nil || block.Type != "PUBLIC KEY" {
// log.Fatal("failed to decode PEM block containing public key")
// }
// block, _ := pem.Decode(data)
// if block == nil || block.Type != "PUBLIC KEY" {
// log.Fatal("failed to decode PEM block containing public key")
// }
//
// pub, err := x509.ParsePKIXPublicKey(block.Bytes)
// if err != nil {
// log.Fatal(err)
// }
//
// if rsaPubKey, ok := pub.(*rsa.PublicKey); ok {
// mysql.RegisterServerPubKey("mykey", rsaPubKey)
// } else {
// log.Fatal("not a RSA public key")
// }
// pub, err := x509.ParsePKIXPublicKey(block.Bytes)
// if err != nil {
// log.Fatal(err)
// }
//
// if rsaPubKey, ok := pub.(*rsa.PublicKey); ok {
// mysql.RegisterServerPubKey("mykey", rsaPubKey)
// } else {
// log.Fatal("not a RSA public key")
// }
func RegisterServerPubKey(name string, pubKey *rsa.PublicKey) {
serverPubKeyLock.Lock()
if serverPubKeyRegistry == nil {
@ -274,7 +273,9 @@ func (mc *mysqlConn) auth(authData []byte, plugin string) ([]byte, error) {
if len(mc.cfg.Passwd) == 0 {
return []byte{0}, nil
}
if mc.cfg.tls != nil || mc.cfg.Net == "unix" {
// unlike caching_sha2_password, sha256_password does not accept
// cleartext password on unix transport.
if mc.cfg.TLS != nil {
// write cleartext auth packet
return append([]byte(mc.cfg.Passwd), 0), nil
}
@ -350,7 +351,7 @@ func (mc *mysqlConn) handleAuthResult(oldAuthData []byte, plugin string) error {
}
case cachingSha2PasswordPerformFullAuthentication:
if mc.cfg.tls != nil || mc.cfg.Net == "unix" {
if mc.cfg.TLS != nil || mc.cfg.Net == "unix" {
// write cleartext auth packet
err = mc.writeAuthSwitchPacket(append([]byte(mc.cfg.Passwd), 0))
if err != nil {
@ -365,13 +366,20 @@ func (mc *mysqlConn) handleAuthResult(oldAuthData []byte, plugin string) error {
return err
}
data[4] = cachingSha2PasswordRequestPublicKey
mc.writePacket(data)
err = mc.writePacket(data)
if err != nil {
return err
}
// parse public key
if data, err = mc.readPacket(); err != nil {
return err
}
if data[0] != iAuthMoreData {
return fmt.Errorf("unexpect resp from server for caching_sha2_password perform full authentication")
}
// parse public key
block, rest := pem.Decode(data[1:])
if block == nil {
return fmt.Errorf("No Pem data found, data: %s", rest)
@ -404,6 +412,10 @@ func (mc *mysqlConn) handleAuthResult(oldAuthData []byte, plugin string) error {
return nil // auth successful
default:
block, _ := pem.Decode(authData)
if block == nil {
return fmt.Errorf("no Pem data found, data: %s", authData)
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return err

View File

@ -13,7 +13,8 @@ const binaryCollation = "binary"
// A list of available collations mapped to the internal ID.
// To update this map use the following MySQL query:
// SELECT COLLATION_NAME, ID FROM information_schema.COLLATIONS WHERE ID<256 ORDER BY ID
//
// SELECT COLLATION_NAME, ID FROM information_schema.COLLATIONS WHERE ID<256 ORDER BY ID
//
// Handshake packet have only 1 byte for collation_id. So we can't use collations with ID > 255.
//

View File

@ -6,6 +6,7 @@
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at http://mozilla.org/MPL/2.0/.
//go:build linux || darwin || dragonfly || freebsd || netbsd || openbsd || solaris || illumos
// +build linux darwin dragonfly freebsd netbsd openbsd solaris illumos
package mysql

View File

@ -6,6 +6,7 @@
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at http://mozilla.org/MPL/2.0/.
//go:build !linux && !darwin && !dragonfly && !freebsd && !netbsd && !openbsd && !solaris && !illumos
// +build !linux,!darwin,!dragonfly,!freebsd,!netbsd,!openbsd,!solaris,!illumos
package mysql

View File

@ -104,7 +104,7 @@ func (mc *mysqlConn) Begin() (driver.Tx, error) {
}
func (mc *mysqlConn) begin(readOnly bool) (driver.Tx, error) {
if mc.closed.IsSet() {
if mc.closed.Load() {
errLog.Print(ErrInvalidConn)
return nil, driver.ErrBadConn
}
@ -123,7 +123,7 @@ func (mc *mysqlConn) begin(readOnly bool) (driver.Tx, error) {
func (mc *mysqlConn) Close() (err error) {
// Makes Close idempotent
if !mc.closed.IsSet() {
if !mc.closed.Load() {
err = mc.writeCommandPacket(comQuit)
}
@ -137,7 +137,7 @@ func (mc *mysqlConn) Close() (err error) {
// is called before auth or on auth failure because MySQL will have already
// closed the network connection.
func (mc *mysqlConn) cleanup() {
if !mc.closed.TrySet(true) {
if mc.closed.Swap(true) {
return
}
@ -152,7 +152,7 @@ func (mc *mysqlConn) cleanup() {
}
func (mc *mysqlConn) error() error {
if mc.closed.IsSet() {
if mc.closed.Load() {
if err := mc.canceled.Value(); err != nil {
return err
}
@ -162,7 +162,7 @@ func (mc *mysqlConn) error() error {
}
func (mc *mysqlConn) Prepare(query string) (driver.Stmt, error) {
if mc.closed.IsSet() {
if mc.closed.Load() {
errLog.Print(ErrInvalidConn)
return nil, driver.ErrBadConn
}
@ -295,7 +295,7 @@ func (mc *mysqlConn) interpolateParams(query string, args []driver.Value) (strin
}
func (mc *mysqlConn) Exec(query string, args []driver.Value) (driver.Result, error) {
if mc.closed.IsSet() {
if mc.closed.Load() {
errLog.Print(ErrInvalidConn)
return nil, driver.ErrBadConn
}
@ -356,7 +356,7 @@ func (mc *mysqlConn) Query(query string, args []driver.Value) (driver.Rows, erro
}
func (mc *mysqlConn) query(query string, args []driver.Value) (*textRows, error) {
if mc.closed.IsSet() {
if mc.closed.Load() {
errLog.Print(ErrInvalidConn)
return nil, driver.ErrBadConn
}
@ -450,7 +450,7 @@ func (mc *mysqlConn) finish() {
// Ping implements driver.Pinger interface
func (mc *mysqlConn) Ping(ctx context.Context) (err error) {
if mc.closed.IsSet() {
if mc.closed.Load() {
errLog.Print(ErrInvalidConn)
return driver.ErrBadConn
}
@ -469,7 +469,7 @@ func (mc *mysqlConn) Ping(ctx context.Context) (err error) {
// BeginTx implements driver.ConnBeginTx interface
func (mc *mysqlConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) {
if mc.closed.IsSet() {
if mc.closed.Load() {
return nil, driver.ErrBadConn
}
@ -636,7 +636,7 @@ func (mc *mysqlConn) CheckNamedValue(nv *driver.NamedValue) (err error) {
// ResetSession implements driver.SessionResetter.
// (From Go 1.10)
func (mc *mysqlConn) ResetSession(ctx context.Context) error {
if mc.closed.IsSet() {
if mc.closed.Load() {
return driver.ErrBadConn
}
mc.reset = true
@ -646,5 +646,5 @@ func (mc *mysqlConn) ResetSession(ctx context.Context) error {
// IsValid implements driver.Validator interface
// (From Go 1.15)
func (mc *mysqlConn) IsValid() bool {
return !mc.closed.IsSet()
return !mc.closed.Load()
}

View File

@ -8,10 +8,10 @@
//
// The driver should be used via the database/sql package:
//
// import "database/sql"
// import _ "github.com/go-sql-driver/mysql"
// import "database/sql"
// import _ "github.com/go-sql-driver/mysql"
//
// db, err := sql.Open("mysql", "user:password@/dbname")
// db, err := sql.Open("mysql", "user:password@/dbname")
//
// See https://github.com/go-sql-driver/mysql#usage for details
package mysql

View File

@ -46,22 +46,23 @@ type Config struct {
ServerPubKey string // Server public key name
pubKey *rsa.PublicKey // Server public key
TLSConfig string // TLS configuration name
tls *tls.Config // TLS configuration
TLS *tls.Config // TLS configuration, its priority is higher than TLSConfig
Timeout time.Duration // Dial timeout
ReadTimeout time.Duration // I/O read timeout
WriteTimeout time.Duration // I/O write timeout
AllowAllFiles bool // Allow all files to be used with LOAD DATA LOCAL INFILE
AllowCleartextPasswords bool // Allows the cleartext client side plugin
AllowNativePasswords bool // Allows the native password authentication method
AllowOldPasswords bool // Allows the old insecure password method
CheckConnLiveness bool // Check connections for liveness before using them
ClientFoundRows bool // Return number of matching rows instead of rows changed
ColumnsWithAlias bool // Prepend table alias to column names
InterpolateParams bool // Interpolate placeholders into query string
MultiStatements bool // Allow multiple statements in one query
ParseTime bool // Parse time values to time.Time
RejectReadOnly bool // Reject read-only connections
AllowAllFiles bool // Allow all files to be used with LOAD DATA LOCAL INFILE
AllowCleartextPasswords bool // Allows the cleartext client side plugin
AllowFallbackToPlaintext bool // Allows fallback to unencrypted connection if server does not support TLS
AllowNativePasswords bool // Allows the native password authentication method
AllowOldPasswords bool // Allows the old insecure password method
CheckConnLiveness bool // Check connections for liveness before using them
ClientFoundRows bool // Return number of matching rows instead of rows changed
ColumnsWithAlias bool // Prepend table alias to column names
InterpolateParams bool // Interpolate placeholders into query string
MultiStatements bool // Allow multiple statements in one query
ParseTime bool // Parse time values to time.Time
RejectReadOnly bool // Reject read-only connections
}
// NewConfig creates a new Config and sets default values.
@ -77,8 +78,8 @@ func NewConfig() *Config {
func (cfg *Config) Clone() *Config {
cp := *cfg
if cp.tls != nil {
cp.tls = cfg.tls.Clone()
if cp.TLS != nil {
cp.TLS = cfg.TLS.Clone()
}
if len(cp.Params) > 0 {
cp.Params = make(map[string]string, len(cfg.Params))
@ -119,24 +120,29 @@ func (cfg *Config) normalize() error {
cfg.Addr = ensureHavePort(cfg.Addr)
}
switch cfg.TLSConfig {
case "false", "":
// don't set anything
case "true":
cfg.tls = &tls.Config{}
case "skip-verify", "preferred":
cfg.tls = &tls.Config{InsecureSkipVerify: true}
default:
cfg.tls = getTLSConfigClone(cfg.TLSConfig)
if cfg.tls == nil {
return errors.New("invalid value / unknown config name: " + cfg.TLSConfig)
if cfg.TLS == nil {
switch cfg.TLSConfig {
case "false", "":
// don't set anything
case "true":
cfg.TLS = &tls.Config{}
case "skip-verify":
cfg.TLS = &tls.Config{InsecureSkipVerify: true}
case "preferred":
cfg.TLS = &tls.Config{InsecureSkipVerify: true}
cfg.AllowFallbackToPlaintext = true
default:
cfg.TLS = getTLSConfigClone(cfg.TLSConfig)
if cfg.TLS == nil {
return errors.New("invalid value / unknown config name: " + cfg.TLSConfig)
}
}
}
if cfg.tls != nil && cfg.tls.ServerName == "" && !cfg.tls.InsecureSkipVerify {
if cfg.TLS != nil && cfg.TLS.ServerName == "" && !cfg.TLS.InsecureSkipVerify {
host, _, err := net.SplitHostPort(cfg.Addr)
if err == nil {
cfg.tls.ServerName = host
cfg.TLS.ServerName = host
}
}
@ -204,6 +210,10 @@ func (cfg *Config) FormatDSN() string {
writeDSNParam(&buf, &hasParam, "allowCleartextPasswords", "true")
}
if cfg.AllowFallbackToPlaintext {
writeDSNParam(&buf, &hasParam, "allowFallbackToPlaintext", "true")
}
if !cfg.AllowNativePasswords {
writeDSNParam(&buf, &hasParam, "allowNativePasswords", "false")
}
@ -391,6 +401,14 @@ func parseDSNParams(cfg *Config, params string) (err error) {
return errors.New("invalid bool value: " + value)
}
// Allow fallback to unencrypted connection if server does not support TLS
case "allowFallbackToPlaintext":
var isBool bool
cfg.AllowFallbackToPlaintext, isBool = readBool(value)
if !isBool {
return errors.New("invalid bool value: " + value)
}
// Use native password authentication
case "allowNativePasswords":
var isBool bool
@ -426,7 +444,6 @@ func parseDSNParams(cfg *Config, params string) (err error) {
// Collation
case "collation":
cfg.Collation = value
break
case "columnsWithAlias":
var isBool bool

Some files were not shown because too many files have changed in this diff Show More