1
0
Fork 0
forked from External/ergo

Compare commits

...
Sign in to create a new pull request.

180 commits

Author SHA1 Message Date
CEF Server
bfb027b647 fix voicestate 2025-04-12 03:16:50 +00:00
CEF Server
3b0fecd381 reactions 2025-01-18 22:21:47 +00:00
CEF Server
d73b6bac86 complete database reivision 2024-11-28 02:58:14 +00:00
d26235e9a9 Merge pull request 'sync' (#1) from External/ergo:master into master
Reviewed-on: #1
2024-11-18 11:42:02 -08:00
CEF Server
711af30aa8 channel history modifications 2024-11-18 19:38:49 +00:00
Shivaram Lingamneni
1bdc45ebb4
clarify role of database file (#2190) 2024-11-17 15:21:06 -05:00
Shivaram Lingamneni
eddd4cc723
fix incorrect batch parameter in draft/extended-isupport (#2197) 2024-10-26 22:11:20 -04:00
Shivaram Lingamneni
726d997d07
advertise SAFELIST (#2196)
LIST is implemented via blocking (*ResponseBuffer).Send, so it can never
exceed the sendq limit.
2024-10-06 12:11:34 -04:00
Shivaram Lingamneni
9577e87d9a
bump irc-go to v0.5.0-rc2 (#2194) 2024-09-27 00:42:09 -04:00
Shivaram Lingamneni
7586520032
implement draft/extended-isupport (#2184) 2024-09-27 00:40:56 -04:00
Shivaram Lingamneni
f68d32b4ee
remove GCStats.Pause initialization (#2189)
It's too small anyway so the runtime has to reallocate it.
2024-09-08 01:48:47 -04:00
CEF Server
f4c03b6765 switch to redis pubsub for ipc
adjust commands to utilize channel names
add new config variables
fix mention race condition
2024-08-27 14:49:05 +00:00
Shivaram Lingamneni
796bc198ed
upgrade go to 1.23 (#2187) 2024-08-15 23:50:27 -04:00
CEF Server
64ebb1f480 attempt to fix dockerfile 2024-08-09 10:55:31 +00:00
CEF Server
f9a0be2473 bake in url info 2024-08-09 10:47:52 +00:00
Shivaram Lingamneni
df6aa4c34b
enable building for solaris (#2183) 2024-08-02 15:09:28 -04:00
CEF Server
a529158fe1 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	irc/getters.go
2024-07-29 06:49:40 +00:00
CEF Server
f80854e95a extend setrealname 2024-07-29 06:49:10 +00:00
CEF Server
e5bf5bee6f extend setrealname 2024-07-29 06:42:52 +00:00
CEF Server
13e1315ad9 fix kick
add HTTP auth
add an ipc command to nuke a user
2024-07-29 03:30:44 +00:00
CEF Server
979188cf2a switch CEF listener for docker support 2024-07-27 23:50:11 +00:00
60172a0be4 strip dockerfile 2024-07-24 17:34:23 -07:00
ca62b268b0 import changes 2024-07-24 17:10:22 -07:00
Shivaram Lingamneni
30f47a9b22 bump irctest 2024-07-07 02:34:51 -04:00
Shivaram Lingamneni
92a23229f8
update to goreleaser v2 (#2169)
* update goreleaser config:

* --rm-dist is replaced by --clean
* `replacements` is removed:
   https://goreleaser.com/deprecations/#archivesreplacements

* update to goreleaser v2

* goreleaser version must be specified in .goreleaser.yml
* --skip-publish is replaced by --skip=publish
2024-07-05 17:18:08 -04:00
Shivaram Lingamneni
825b4298b8
Merge pull request #2175 from slingamn/deps.1
upgrade dependencies for new release cycle
2024-07-05 23:15:16 +02:00
Shivaram Lingamneni
eba6d532ea go mod tidy 2024-07-05 16:40:07 -04:00
Shivaram Lingamneni
7d3971835e upgrade x dependencies 2024-07-05 16:39:22 -04:00
Shivaram Lingamneni
99393d49bf upgrade golang-jwt 2024-07-05 16:37:44 -04:00
Shivaram Lingamneni
82c50cc497 upgrade buntdb 2024-07-05 16:36:53 -04:00
Shivaram Lingamneni
ce41f501c9 set up new development version 2024-07-01 01:07:21 -04:00
Shivaram Lingamneni
d25fc2a758 bump version and changelog for v2.14.0 2024-06-30 23:36:28 -04:00
Shivaram Lingamneni
f598da300d
add linux/riscv64 release (#2173)
* add riscv64 release

* undo Alpine upgrade

* exclude *bsd/riscv64 releases

---------

Co-authored-by: Meng Zhuo <mzh@golangcn.org>
2024-06-20 23:55:20 -04:00
Shivaram Lingamneni
bb6c7ee158
Merge pull request #2171 from slingamn/rc2
bump version and changelog for v2.14.0-rc2
2024-06-16 10:51:20 +02:00
Shivaram Lingamneni
958eb43393 bump version and changelog for v2.14.0-rc2 2024-06-16 04:40:00 -04:00
Shivaram Lingamneni
9b8562c211
Merge pull request #2170 from slingamn/validate.1
fix truncation check
2024-06-11 23:17:35 +02:00
Shivaram Lingamneni
2bb0a9c8e3 bump irctest to fork's master 2024-06-11 17:06:58 -04:00
Shivaram Lingamneni
0b333c7e72 fix truncation check
* The message target was not being counted :-(
* The additional character added to the target by STATUSMSG was not counted
2024-06-11 01:42:57 -04:00
Shivaram Lingamneni
2aec5e167c
Merge pull request #2168 from slingamn/fix_defaultconfig
don't require a config file for defaultconfig
2024-06-09 09:25:19 +02:00
Shivaram Lingamneni
3127353b84 don't require a config file for defaultconfig 2024-06-09 03:20:33 -04:00
Shivaram Lingamneni
654381071b
update version and changelog for v2.14.0-rc1 (#2164)
* changelog for v2.14.0-rc1

* bump version string for rc1

* bump irctest
2024-06-09 03:11:33 -04:00
Shivaram Lingamneni
71671405f3
Merge pull request #2167 from slingamn/lowerlimit
lower recommended ban list limit to 100
2024-06-09 06:08:04 +02:00
Shivaram Lingamneni
aa6be594b9 lower recommended ban list limit to 100
Insp and Libera use 100, seems a bit safer
2024-06-09 00:04:54 -04:00
Shivaram Lingamneni
6326982767
Merge pull request #2165 from slingamn/banlimit
fix #2081
2024-06-04 05:53:47 +02:00
Shivaram Lingamneni
0517b5571d fix #2081
Increase default/recommended mask list size limit to 150;
SAMODE overrides enforcement of the limit.
2024-06-03 23:39:08 -04:00
Shivaram Lingamneni
1117680fdd
clean up RPL_CHANNELMODEIS logic (#2163)
Don't send RPL_CHANNELMODEIS for no-op changes to channel-user modes
2024-06-03 23:28:08 -04:00
Shivaram Lingamneni
f44d902ce3
Merge pull request #2162 from slingamn/issue2043
fix #2043
2024-06-03 00:46:57 +02:00
Shivaram Lingamneni
7318e48629 fix #2043
Add human-readable description parameters to multiline fail messages,
since they are technically required by the standard-replies spec
(although the utility of showing them to users is dubious)
2024-06-02 03:34:11 -04:00
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
561 changed files with 39483 additions and 12768 deletions

View file

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

View file

@ -1,5 +1,6 @@
# .goreleaser.yml # .goreleaser.yml
# Build customization # Build customization
version: 2
project_name: ergo project_name: ergo
builds: builds:
- main: ergo.go - main: ergo.go
@ -17,6 +18,7 @@ builds:
- amd64 - amd64
- arm - arm
- arm64 - arm64
- riscv64
goarm: goarm:
- 6 - 6
ignore: ignore:
@ -24,30 +26,41 @@ builds:
goarch: arm goarch: arm
- goos: windows - goos: windows
goarch: arm64 goarch: arm64
- goos: windows
goarch: riscv64
- goos: darwin - goos: darwin
goarch: arm goarch: arm
- goos: darwin
goarch: riscv64
- goos: freebsd - goos: freebsd
goarch: arm goarch: arm
- goos: freebsd - goos: freebsd
goarch: arm64 goarch: arm64
- goos: freebsd
goarch: riscv64
- goos: openbsd - goos: openbsd
goarch: arm goarch: arm
- goos: openbsd - goos: openbsd
goarch: arm64 goarch: arm64
- goos: openbsd
goarch: riscv64
- goos: plan9 - goos: plan9
goarch: arm goarch: arm
- goos: plan9 - goos: plan9
goarch: arm64 goarch: arm64
- goos: plan9
goarch: riscv64
flags: flags:
- -trimpath - -trimpath
archives: archives:
- -
name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" name_template: >-
{{ .ProjectName }}-{{ .Version }}-
{{- if eq .Os "darwin" }}macos{{- else }}{{ .Os }}{{ end -}}-
{{- if eq .Arch "amd64" }}x86_64{{- else }}{{ .Arch }}{{ end -}}
{{ if .Arm }}v{{ .Arm }}{{ end -}}
format: tar.gz format: tar.gz
replacements:
amd64: x86_64
darwin: macos
format_overrides: format_overrides:
- goos: windows - goos: windows
format: zip format: zip

View file

@ -1,6 +1,142 @@
# Changelog # Changelog
All notable changes to Ergo will be documented in this file. All notable changes to Ergo will be documented in this file.
## [2.14.0] - 2024-06-30
We're pleased to be publishing v2.14.0, a new stable release. This release contains primarily bug fixes, with the addition of some new authentication mechanisms for integrating with web clients.
This release includes changes to the config file format, all of which are fully backwards-compatible and do not require updating the file before upgrading. It includes no changes to the database file format.
Many thanks to [@al3xandros](https://github.com/al3xandros), donio, [@eeeeeta](https://github.com/eeeeeta), [@emersion](https://github.com/emersion), [@Eriner](https://github.com/Eriner), [@eskimo](https://github.com/eskimo), [@Herringway](https://github.com/Herringway), [@jwheare](https://github.com/jwheare), [@knolley](https://github.com/knolley), [@mengzhuo](https://github.com/mengzhuo), pathof, [@poVoq](https://github.com/poVoq), [@progval](https://github.com/progval), [@RNDpacman](https://github.com/RNDpacman), and [@xnaas](https://github.com/xnaas) for contributing patches, reporting issues, and helping test.
### Config changes
* Added `accounts.oauth2` and `accounts.jwt-auth` blocks for configuring OAuth2 and JWT authentication (#2004)
* Added `protocol` and `local-address` options to `accounts.registration.email-verification`, to force emails to be sent over IPv4 (or IPv6) or to force the use of a particular source address (#2142)
* Added `limits.realnamelen`, a configurable limit on the length of realnames. If unset, no limit is enforced beyond the IRC protocol line length limits (the previous behavior). (#2123, thanks [@eskimo](https://github.com/eskimo)!)
* Added the `accept-hostname` option to the webirc config block, allowing Ergo to accept hostnames passed from reverse proxies on the `WEBIRC` line. Note that this will have no effect under the default/recommended configuration, in which cloaks are used instead (#1686, #2146, thanks [@RNDpacman](https://github.com/RNDpacman)!)
* The default/recommended value of `limits.chan-list-modes` (the size limit for ban/except/invite lists) was raised to 100 (#2081, #2165, #2167)
### Added
* Added support for the `OAUTHBEARER` SASL mechanism, allowing Ergo to interoperate with Gamja and an OAuth2 provider (#2004, #2122, thanks [@emersion](https://github.com/emersion)!)
* Added support for the [`IRCV3BEARER` SASL mechanism](https://github.com/ircv3/ircv3-specifications/pull/545), allowing Ergo to accept OAuth2 or JWT bearer tokens (#2158)
* Added support for the legacy `rfc1459` and `rfc1459-strict` casemappings (#2099, #2159, thanks [@xnaas](https://github.com/xnaas)!)
* The new `ergo defaultconfig` subcommand prints a copy of the default config file to standard output (#2157, #2160, thanks [@al3xandros](https://github.com/al3xandros)!)
### Fixed
* Even with `allow-truncation: false` (the recommended default), some oversized messages were being accepted and relayed with truncation. These messages will now be rejected with `417 ERR_INPUTTOOLONG` as expected (#2170)
* NICK and QUIT from invisible members of auditorium channels are no longer recorded in history (#2133, #2137, thanks [@knolley](https://github.com/knolley) and [@poVoq](https://github.com/poVoq)!)
* If channel registration was disabled, registered channels could become inaccessible after rehash; this has been fixed (#2130, thanks [@eeeeeta](https://github.com/eeeeeta)!)
* Attempts to use unrecognized SASL mechanisms no longer count against the login throttle, improving compatibility with Pidgin (#2156, thanks donio and pathof!)
* Fixed database autoupgrade on Windows, which was previously broken due to the use of a colon in the backup filename (#2139, #2140, thanks [@Herringway](https://github.com/Herringway)!)
* Fixed handling of `NS CERT ADD <user> <fp>` when an unprivileged user invokes it on themself (#2128, #2098, thanks [@Eriner](https://github.com/Eriner)!)
* Fixed missing human-readable trailing parameters for two multiline `FAIL` messages (#2043, #2162, thanks [@jwheare](https://github.com/jwheare) and [@progval](https://github.com/progval)!)
* Fixed symbol sent by `353 RPL_NAMREPLY` for secret channels (#2144, #2145, thanks savoyard!)
### Changed
* Trying to claim a registered nickname that is also actually in use by another client now produces `433 ERR_NICKNAMEINUSE` as expected (#2135, #2136, thanks savoyard!)
* `SAMODE` now overrides the enforcement of `limits.chan-list-modes` (the size limit for ban/except/invite lists) (#2081, #2165)
* Certain unsuccessful `MODE` changes no longer send `324 RPL_CHANNELMODEIS` and `329 RPL_CREATIONTIME` (#2163)
* Debug logging for environment variable configuration overrides no longer prints the value, only the key (#2129, #2132, thanks [@eeeeeta](https://github.com/eeeeeta)!)
### Internal
* Official release builds use Go 1.22.4
* Added a linux/riscv64 release (#2172, #2173, thanks [@mengzhuo](https://github.com/mengzhuo)!)
## [2.13.1] - 2024-05-06
Ergo 2.13.1 is a bugfix release, fixing an exploitable deadlock that could lead to a denial of service. We regret the oversight.
This release includes no changes to the config file format or database format.
### Security
* Fixed an exploitable deadlock that could lead to a denial of service (#2149)
### Internal
* Official release builds use Go 1.22.2
## [2.13.0] - 2024-01-14
We're pleased to be publishing v2.13.0, a new stable release. This is a bugfix release that fixes some issues, including a crash.
This release includes no changes to the config file format or database format.
Many thanks to [@dallemon](https://github.com/dallemon), [@jwheare](https://github.com/jwheare), [@Mikaela](https://github.com/Mikaela), [@nealey](https://github.com/nealey), and [@Sheikah45](https://github.com/Sheikah45) for contributing patches, reporting issues, and helping test.
### Fixed
* Fixed a (hopefully rare) crash when persisting always-on client statuses (#2113, #2117, thanks [@Sheikah45](https://github.com/Sheikah45)!)
* Fixed not being able to message channels with `/` (or the configured `RELAYMSG` separator) in their names (#2114, thanks [@Mikaela](https://github.com/Mikaela)!)
* Verification emails now always include a `Message-ID` header, improving compatibility with Gmail (#2108, #2110)
* Improved human-readable description of `REDACT_FORBIDDEN` (#2101, thanks [@jwheare](https://github.com/jwheare)!)
### Removed
* Removed numerics associated with the retired ACC spec (#2109, #2111, thanks [@jwheare](https://github.com/jwheare)!)
### Internal
* Upgraded the Docker base image from Alpine 3.13 to 3.19. The resulting images are incompatible with Docker 19.x and lower (all currently non-EOL Docker versions should be supported). (#2103)
* Official release builds use Go 1.21.6
## [2.12.0] - 2023-10-10
We're pleased to be publishing v2.12.0, a new stable release. This is another bugfix release aimed at improving client compatibility and keeping up with the IRCv3 specification process.
This release includes changes to the config file format, one of which is a compatibility break: if you were using `accounts.email-verification.blacklist-regexes`, you can restore the previous functionality by renaming `blacklist-regexes` to `address-blacklist` and setting the additional key `address-blacklist-syntax: regex`. See [default.yaml](https://github.com/ergochat/ergo/blob/e7597876d987a6fc061b768fcf878d0035d1c85a/default.yaml#L422-L424) for an example; for more details, see the "Changed" section below.
This release includes a database change. If you have `datastore.autoupgrade` set to `true` in your configuration, it will be automatically applied when you restart Ergo. Otherwise, you can update the database manually by running `ergo upgradedb` (see the manual for complete instructions).
Many thanks to [@adsr](https://github.com/adsr), [@avollmerhaus](https://github.com/avollmerhaus), [@csmith](https://github.com/csmith), [@EchedeyLR](https://github.com/EchedeyLR), [@emersion](https://github.com/emersion), [@eskimo](https://github.com/eskimo), [@julio-b](https://github.com/julio-b), knolle, [@KoxSosen](https://github.com/KoxSosen), [@Mikaela](https://github.com/Mikaela), [@mogad0n](https://github.com/mogad0n), and [@progval](https://github.com/progval) for contributing patches, reporting issues, and helping test.
### Config changes
* Removed `accounts.email-verification.blacklist-regexes` in favor of `address-blacklist`, `address-blacklist-syntax`, and `address-blacklist-file`. See the "Changed" section below for the semantics of these new keys. (#1997, #2088)
* Added `implicit-tls` (TLS from the first byte) support for MTAs (#2048, #2049, thanks [@EchedeyLR](https://github.com/EchedeyLR)!)
### Fixed
* Fixed an edge case under `allow-truncation: true` (the recommended default is `false`) where Ergo could truncate a message in the middle of a UTF-8 codepoint (#2074)
* Fixed `CHATHISTORY TARGETS` being sent in a batch even without negotiation of the `batch` capability (#2066, thanks [@julio-b](https://github.com/julio-b)!)
* Errors from `/REHASH` are now properly sanitized before being sent to the user, fixing an edge case where they would be dropped (#2031, thanks [@eskimo](https://github.com/eskimo)!
* Fixed some edge cases in auto-away aggregation (#2044)
* Fixed a FAIL code sent by draft/account-registration (#2092, thanks [@progval](https://github.com/progval)!)
* Fixed a socket leak in the ident client (default/recommended configurations of Ergo disable ident and are not affected by this issue) (#2089)
### Changed
* Bouncer reattach from an "insecure" session is no longer disallowed. We continue to recommend that operators preemptively disable all insecure transports, such as plaintext listeners (#2013)
* Email addresses are now converted to lowercase before checking them against the blacklist (#1997, #2088)
* The default syntax for the email address blacklist is now "glob" (expressions with `*` and `?` as wildcard characters), as opposed to the full [Go regular expression syntax](https://github.com/google/re2/wiki/Syntax). To enable full regular expression syntax, set `address-blacklist-syntax: regex`.
* Due to line length limitations, some capabilities are now hidden from clients that only support version 301 CAP negotiation. To the best of our knowledge, all clients that support these capabilities also support version 302 CAP negotiation, rendering this moot (#2068)
* The default/recommended configuration now advertises the SCRAM-SHA-256 SASL method. We still do not recommend using this method in production. (#2032)
* Improved KILL messages (#2053, #2041, thanks [@mogad0n](https://github.com/mogad0n)!)
### Added
* Added support for automatically joining new clients to a channel or channels (#2077, #2079, thanks [@adsr](https://github.com/adsr)!)
* Added implicit TLS (TLS from the first byte) support for MTAs (#2048, #2049, thanks [@EchedeyLR](https://github.com/EchedeyLR)!)
* Added support for [draft/message-redaction](https://github.com/ircv3/ircv3-specifications/pull/524) (#2065, thanks [@progval](https://github.com/progval)!)
* Added support for [draft/pre-away](https://github.com/ircv3/ircv3-specifications/pull/514) (#2044)
* Added support for [draft/no-implicit-names](https://github.com/ircv3/ircv3-specifications/pull/527) (#2083)
* Added support for the [MSGREFTYPES](https://ircv3.net/specs/extensions/chathistory#isupport-tokens) 005 token (#2042)
* Ergo now advertises the [standard-replies](https://ircv3.net/specs/extensions/standard-replies) capability. Requesting this capability does not change Ergo's behavior.
### Internal
* Release builds are now statically linked by default. This should not affect normal chat operations, but may disrupt attempts to connect to external services (e.g. MTAs) that are configured using a hostname that relies on libc's name resolution behavior. To restore the old behavior, build from source with `CGO_ENABLED=1`. (#2023)
* Upgraded to Go 1.21 (#2045, #2084); official release builds use Go 1.21.3, which includes a fix for CVE-2023-44487
* The default `make` target is now `build` (which builds an `ergo` binary in the working directory) instead of `install` (which builds and installs an `ergo` binary to `${GOPATH}/bin/ergo`). Take note if building from source, or testing Ergo in development! (#2047)
* `make irctest` now depends on `make install`, in an attempt to ensure that irctest runs against the intended development version of Ergo (#2047)
## [2.11.1] - 2022-01-22
Ergo 2.11.1 is a bugfix release, fixing a denial-of-service issue in our websocket implementation. We regret the oversight.
This release includes no changes to the config file format or database file format.
### Security
* Fixed a denial-of-service issue affecting websocket clients (#2039)
## [2.11.0] - 2022-12-25 ## [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. 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.

View file

@ -1,7 +1,8 @@
## build ergo binary ## build ergo binary
FROM golang:1.20-alpine AS build-env FROM docker.io/golang:1.23-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
RUN apk add --no-cache --purge --clean-protected -l -u make git
# copy ergo source # copy ergo source
WORKDIR /go/src/github.com/ergochat/ergo WORKDIR /go/src/github.com/ergochat/ergo
@ -16,14 +17,7 @@ RUN sed -i 's/^\(\s*\)\"127.0.0.1:6667\":.*$/\1":6667":/' /go/src/github.com/erg
RUN make install RUN make install
## build ergo container ## 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>" \
description="Ergo is a modern, experimental IRC server written in Go"
# standard ports listened on
EXPOSE 6667/tcp 6697/tcp
# ergo itself # ergo itself
COPY --from=build-env /go/bin/ergo \ COPY --from=build-env /go/bin/ergo \
@ -39,10 +33,4 @@ WORKDIR /ircd
# default motd # default motd
COPY --from=build-env /go/src/github.com/ergochat/ergo/ergo.motd /ircd/ergo.motd COPY --from=build-env /go/src/github.com/ergochat/ergo/ergo.motd /ircd/ergo.motd
# launch
ENTRYPOINT ["/ircd-bin/run.sh"] ENTRYPOINT ["/ircd-bin/run.sh"]
# # uncomment to debug
# RUN apk add --no-cache bash
# RUN apk add --no-cache vim
# CMD /bin/bash

View file

@ -12,13 +12,13 @@ capdef_file = ./irc/caps/defs.go
all: build all: build
install: install:
go install -v -ldflags "-X main.commit=$(GIT_COMMIT) -X main.version=$(GIT_TAG)" go install -mod=mod -v -ldflags "-X main.commit=$(GIT_COMMIT) -X main.version=$(GIT_TAG)"
build: build:
go build -v -ldflags "-X main.commit=$(GIT_COMMIT) -X main.version=$(GIT_TAG)" go build -mod=mod -v -ldflags "-X main.commit=$(GIT_COMMIT) -X main.version=$(GIT_TAG)"
release: release:
goreleaser --skip-publish --rm-dist goreleaser --skip=publish --clean
capdefs: capdefs:
python3 ./gencapdefs.py > ${capdef_file} python3 ./gencapdefs.py > ${capdef_file}

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: 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: If you need to generate self-signed TLS certificates, use this command:
$ ergo mkcerts $ ./ergo mkcerts
You are now ready to start Ergo! You are now ready to start Ergo!
$ ergo run $ ./ergo run
For further instructions, consult the manual. A copy of the manual should be 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 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 ```sh
cp default.yaml ircd.yaml cp default.yaml ircd.yaml
vim ircd.yaml # modify the config file to your liking vim ircd.yaml # modify the config file to your liking
ergo mkcerts ./ergo mkcerts
ergo run # server should be ready to go! ./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. **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.

View file

@ -134,9 +134,10 @@ server:
# the recommended default is 'ascii' (traditional ASCII-only identifiers). # the recommended default is 'ascii' (traditional ASCII-only identifiers).
# the other options are 'precis', which allows UTF8 identifiers that are "sane" # the other options are 'precis', which allows UTF8 identifiers that are "sane"
# (according to UFC 8265), with additional mitigations for homoglyph attacks, # (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 # 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 # however, note that changing it once the network is already up and running is
# problematic. # problematic.
casemapping: "ascii" casemapping: "ascii"
@ -218,6 +219,10 @@ server:
# - "192.168.1.1" # - "192.168.1.1"
# - "192.168.10.1/24" # - "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 # maximum length of clients' sendQ in bytes
# this should be big enough to hold bursts of channel/direct messages # this should be big enough to hold bursts of channel/direct messages
max-sendq: 96k max-sendq: 96k
@ -364,7 +369,7 @@ server:
# in a "closed-loop" system where you control the server and all the clients, # 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 # 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: # the default value of 512. DO NOT change this on a public server:
# max-line-len: 512 max-line-len: 2048
# send all 0's as the LUSERS (user counts) output to non-operators; potentially useful # 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 # if you don't want to publicize how popular the server is
@ -405,6 +410,10 @@ accounts:
sender: "admin@my.network" sender: "admin@my.network"
require-tls: true require-tls: true
helo-domain: "my.network" # defaults to server name if unset 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 # options to enable DKIM signing of outgoing emails (recommended, but
# requires creating a DNS entry for the public key): # requires creating a DNS entry for the public key):
# dkim: # dkim:
@ -418,8 +427,14 @@ accounts:
# username: "admin" # username: "admin"
# password: "hunter2" # password: "hunter2"
# implicit-tls: false # TLS from the first byte, typically on port 465 # implicit-tls: false # TLS from the first byte, typically on port 465
blacklist-regexes: # addresses that are not accepted for registration:
# - ".*@mailinator.com" 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 timeout: 60s
# email-based password reset: # email-based password reset:
password-reset: password-reset:
@ -580,6 +595,40 @@ accounts:
# how many scripts are allowed to run at once? 0 for no limit: # how many scripts are allowed to run at once? 0 for no limit:
max-concurrency: 64 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 # channel options
channels: channels:
# modes that are set when new channels are created # modes that are set when new channels are created
@ -615,6 +664,12 @@ channels:
# (0 or omit for no expiration): # (0 or omit for no expiration):
invite-expiration: 24h 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: # operator classes:
# an operator has a single "class" (defining a privilege level), which can include # an operator has a single "class" (defining a privilege level), which can include
# multiple "capabilities" (defining privileged actions they can take). all # multiple "capabilities" (defining privileged actions they can take). all
@ -765,7 +820,7 @@ lock-file: "ircd.lock"
# datastore configuration # datastore configuration
datastore: datastore:
# path to the datastore # path to the database file (used to store account and channel registrations):
path: ircd.db path: ircd.db
# if the database schema requires an upgrade, `autoupgrade` will attempt to # if the database schema requires an upgrade, `autoupgrade` will attempt to
@ -808,6 +863,9 @@ limits:
# identlen is the max ident length allowed # identlen is the max ident length allowed
identlen: 20 identlen: 20
# realnamelen is the maximum realname length allowed
realnamelen: 150
# channellen is the max channel length allowed # channellen is the max channel length allowed
channellen: 64 channellen: 64
@ -827,7 +885,7 @@ limits:
whowas-entries: 100 whowas-entries: 100
# maximum length of channel lists (beI modes) # maximum length of channel lists (beI modes)
chan-list-modes: 60 chan-list-modes: 100
# maximum number of messages to accept during registration (prevents # maximum number of messages to accept during registration (prevents
# DoS / resource exhaustion attacks): # DoS / resource exhaustion attacks):
@ -982,7 +1040,8 @@ history:
# options to control how messages are stored and deleted: # options to control how messages are stored and deleted:
retention: 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 allow-individual-delete: false
# if persistent history is enabled, create additional index tables, # if persistent history is enabled, create additional index tables,
@ -1008,3 +1067,9 @@ history:
# whether to allow customization of the config at runtime using environment variables, # whether to allow customization of the config at runtime using environment variables,
# e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details. # e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details.
allow-environment-overrides: true allow-environment-overrides: true
cef:
imagor:
url: "https://example.com/embed/"
secret: "secretgoeshere"
redis: "redis://user:password@localhost:6379/0?protocol=3"

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>
}

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: expose the ports:
```shell ```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). 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 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 ## Persisting data
Ergo has a persistent data store, used to keep account details, channel 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 ```shell
docker volume create ergo-data 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: Or to mount a folder from your host machine:
```shell ```shell
mkdir ergo-data 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 ## Customising the config

View file

@ -2,6 +2,7 @@ version: "3.8"
services: services:
ergo: ergo:
init: true
image: ghcr.io/ergochat/ergo:stable image: ghcr.io/ergochat/ergo:stable
ports: ports:
- "6667:6667/tcp" - "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) - [Migrating from Anope or Atheme](#migrating-from-anope-or-atheme)
- [HOPM](#hopm) - [HOPM](#hopm)
- [Tor](#tor) - [Tor](#tor)
- [I2P](#i2p)
- [ZNC](#znc) - [ZNC](#znc)
- [External authentication systems](#external-authentication-systems) - [External authentication systems](#external-authentication-systems)
- [DNSBLs and other IP checking systems](#dnsbls-and-other-ip-checking-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: 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. 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 ergo:ergo /home/ergo/*`. Ensure that the configuration file logs to stderr. 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. 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. Enable and start the new service with the following commands:
1. `systemctl daemon-reload` 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. 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.) 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: After that, there are two possibilities:
@ -638,6 +641,10 @@ After that, there are two possibilities:
proxy: true 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 ## Client certificates
@ -1027,6 +1034,14 @@ ProxyPass /webirc http://127.0.0.1:8067 upgrade=websocket
ProxyPassReverse /webirc http://127.0.0.1:8067 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 ## 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: 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:
@ -1128,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. [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). 1. Pidgin should work with [torsocks](https://trac.torproject.org/projects/tor/wiki/doc/torsocks).
## I2P
I2P is an anonymizing overlay network similar to Tor. The recommended configuration for I2P is to treat it similarly to Tor: have the i2pd reverse proxy its connections to an Ergo listener configured with `tor: true`. See the [i2pd configuration guide](https://i2pd.readthedocs.io/en/latest/tutorials/irc/#running-anonymous-irc-server) for more details; note that the instructions to separate I2P traffic from other localhost traffic are unnecessary for a `tor: true` listener.
I2P can additionally expose an opaque client identifier (the user's "b32 address"). Exposing this identifier via Ergo is not recommended, but if you wish to do so, you can use the following procedure:
1. Enable WEBIRC support in the i2pd configuration by adding the `webircpassword` key to the [i2pd server block](https://i2pd.readthedocs.io/en/latest/tutorials/irc/#running-anonymous-irc-server)
1. Remove `tor: true` from the relevant Ergo listener config
1. Enable WEBIRC support in Ergo (starting from the default/recommended configuration, find the existing webirc block, delete the `certfp` configuration, change `password` to use the output of `ergo genpasswd` on the password you configured i2pd to send, and set `accept-hostname: true`)
1. To prevent Ergo from overwriting the hostname as passed from i2pd, set the following options: `server.ip-cloaking.enabled: false` and `server.lookup-hostnames: false`. (There is currently no support for applying cloaks to regular IP traffic but displaying the b32 address for I2P traffic).
## ZNC ## ZNC

42
ergo.go
View file

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

View file

@ -87,6 +87,12 @@ CAPDEFS = [
url="https://gist.github.com/DanielOaks/8126122f74b26012a3de37db80e4e0c6", url="https://gist.github.com/DanielOaks/8126122f74b26012a3de37db80e4e0c6",
standard="proposed IRCv3", 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( CapDef(
identifier="MessageTags", identifier="MessageTags",
name="message-tags", name="message-tags",
@ -207,6 +213,18 @@ CAPDEFS = [
url="https://github.com/ircv3/ircv3-specifications/pull/506", url="https://github.com/ircv3/ircv3-specifications/pull/506",
standard="IRCv3", standard="IRCv3",
), ),
CapDef(
identifier="NoImplicitNames",
name="draft/no-implicit-names",
url="https://github.com/ircv3/ircv3-specifications/pull/527",
standard="proposed IRCv3",
),
CapDef(
identifier="ExtendedISupport",
name="draft/extended-isupport",
url="https://github.com/ircv3/ircv3-specifications/pull/543",
standard="proposed IRCv3",
),
] ]
def validate_defs(): def validate_defs():

27
go.mod
View file

@ -1,33 +1,39 @@
module github.com/ergochat/ergo module github.com/ergochat/ergo
go 1.20 go 1.23
require ( require (
code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48 code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815
github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1 github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1
github.com/ergochat/go-ident v0.0.0-20200511222032-830550b1d775 github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881
github.com/ergochat/irc-go v0.2.0 github.com/ergochat/irc-go v0.5.0-rc2
github.com/go-sql-driver/mysql v1.7.0 github.com/go-sql-driver/mysql v1.7.0
github.com/go-test/deep v1.0.6 // indirect 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/gorilla/websocket v1.4.2
github.com/okzk/sdnotify v0.0.0-20180710141335-d9becc38acbd github.com/okzk/sdnotify v0.0.0-20180710141335-d9becc38acbd
github.com/onsi/ginkgo v1.12.0 // indirect github.com/onsi/ginkgo v1.12.0 // indirect
github.com/onsi/gomega v1.9.0 // indirect github.com/onsi/gomega v1.9.0 // indirect
github.com/stretchr/testify v1.4.0 // indirect github.com/tidwall/buntdb v1.3.1
github.com/tidwall/buntdb v1.2.10
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208
github.com/xdg-go/scram v1.0.2 github.com/xdg-go/scram v1.0.2
golang.org/x/crypto v0.5.0 golang.org/x/crypto v0.25.0
golang.org/x/text v0.6.0 golang.org/x/term v0.22.0
golang.org/x/text v0.16.0
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
) )
require github.com/gofrs/flock v0.8.1 require (
github.com/cshum/imagor v1.4.13
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/redis/go-redis/v9 v9.6.1
)
require ( require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/tidwall/btree v1.4.2 // indirect github.com/tidwall/btree v1.4.2 // indirect
github.com/tidwall/gjson v1.14.3 // indirect github.com/tidwall/gjson v1.14.3 // indirect
github.com/tidwall/grect v0.1.4 // indirect github.com/tidwall/grect v0.1.4 // indirect
@ -36,8 +42,7 @@ require (
github.com/tidwall/rtred v0.1.2 // indirect github.com/tidwall/rtred v0.1.2 // indirect
github.com/tidwall/tinyqueue v0.1.1 // indirect github.com/tidwall/tinyqueue v0.1.1 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect
golang.org/x/sys v0.4.0 // indirect golang.org/x/sys v0.22.0 // indirect
golang.org/x/term v0.4.0 // indirect
) )
replace github.com/gorilla/websocket => github.com/ergochat/websocket v1.4.2-oragono1 replace github.com/gorilla/websocket => github.com/ergochat/websocket v1.4.2-oragono1

63
go.sum
View file

@ -2,16 +2,28 @@ code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48 h1:/EMHruHCFXR9
code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48/go.mod h1:wN/zk7mhREp/oviagqUXY3EwuHhWyOvAdsn5Y4CzOrc= code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48/go.mod h1:wN/zk7mhREp/oviagqUXY3EwuHhWyOvAdsn5Y4CzOrc=
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 h1:KeNholpO2xKjgaaSyd+DyQRrsQjhbSeS7qe4nEw8aQw= github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 h1:KeNholpO2xKjgaaSyd+DyQRrsQjhbSeS7qe4nEw8aQw=
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962/go.mod h1:kC29dT1vFpj7py2OvG1khBdQpo3kInWP+6QipLbdngo= github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962/go.mod h1:kC29dT1vFpj7py2OvG1khBdQpo3kInWP+6QipLbdngo=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cshum/imagor v1.4.13 h1:BFcSpsTUOJj+Wv5SzDeXa8bhsT/Ehw7EcrFD0UTdpmU=
github.com/cshum/imagor v1.4.13/go.mod h1:LHxXgks6Y06GzEHitnlO8vcD5gznxIHWPdvGsnlGpMo=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ= github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= 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 h1:WLHTOodthVyv5NvYLIvWl112kSFv5IInKKrRN2qpons=
github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1/go.mod h1:mov+uh1DPWsltdQnOdzn08UO9GsJ3MEvhtu0Ci37fdk= 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-20230911071154-8c30606d6881 h1:+J5m88nvybxB5AnBVGzTXM/yHVytt48rXBGcJGzSbms=
github.com/ergochat/go-ident v0.0.0-20200511222032-830550b1d775/go.mod h1:d2qvgjD0TvGNSvUs+mZgX090RiJlrzUYW6vtANGOy3A= github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881/go.mod h1:ASYJtQujNitna6cVHsNQTGrfWvMPJ5Sa2lZlmsH65uM=
github.com/ergochat/irc-go v0.2.0 h1:3vHdy4c56UTY6+/rTBrQc1fmt32N5G8PrEZacJDOr+E= github.com/ergochat/irc-go v0.5.0-rc1 h1:kFoIHExoNFQ2CV+iShAVna/H4xrXQB4t4jK5Sep2j9k=
github.com/ergochat/irc-go v0.2.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0= github.com/ergochat/irc-go v0.5.0-rc1/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
github.com/ergochat/irc-go v0.5.0-rc2 h1:VuSQJF5K4hWvYSzGa4b8vgL6kzw8HF6LSOejE+RWpAo=
github.com/ergochat/irc-go v0.5.0-rc2/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
github.com/ergochat/scram v1.0.2-ergo1 h1:2bYXiRFQH636pT0msOG39fmEYl4Eq+OuutcyDsCix/g= github.com/ergochat/scram v1.0.2-ergo1 h1:2bYXiRFQH636pT0msOG39fmEYl4Eq+OuutcyDsCix/g=
github.com/ergochat/scram v1.0.2-ergo1/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs= 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 h1:plMUunFBM6UoSCIYCKKclTdy/TkkHfUslhOfJQzfueM=
@ -23,8 +35,8 @@ 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/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 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= 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/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/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 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
@ -38,20 +50,23 @@ github.com/onsi/gomega v1.9.0 h1:R1uwffexN6Pr340GtYRIdZmAiN4J+iw6WG4wog1DUXg=
github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA= github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/assert v0.1.0 h1:aWcKyRBUAdLoVebxo95N7+YZVTFF/ASTr7BN4sLP6XI= github.com/tidwall/assert v0.1.0 h1:aWcKyRBUAdLoVebxo95N7+YZVTFF/ASTr7BN4sLP6XI=
github.com/tidwall/assert v0.1.0/go.mod h1:QLYtGyeqse53vuELQheYl9dngGCJQ+mTtlxcktb+Kj8=
github.com/tidwall/btree v1.4.2 h1:PpkaieETJMUxYNADsjgtNRcERX7mGc/GP2zp/r5FM3g= github.com/tidwall/btree v1.4.2 h1:PpkaieETJMUxYNADsjgtNRcERX7mGc/GP2zp/r5FM3g=
github.com/tidwall/btree v1.4.2/go.mod h1:LGm8L/DZjPLmeWGjv5kFrY8dL4uVhMmzmmLYmsObdKE= 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.3.1 h1:HKoDF01/aBhl9RjYtbaLnvX9/OuenwvQiC3OP1CcL4o=
github.com/tidwall/buntdb v1.2.10/go.mod h1:lZZrZUWzlyDJKlLQ6DKAy53LnG7m5kHyrEHvvcDmBpU= github.com/tidwall/buntdb v1.3.1/go.mod h1:lZZrZUWzlyDJKlLQ6DKAy53LnG7m5kHyrEHvvcDmBpU=
github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw= github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw=
github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 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 h1:dA3oIgNgWdSspFzn1kS4S/RDpZFLrIxAZOdJKjYapOg=
github.com/tidwall/grect v0.1.4/go.mod h1:9FBsaYRaR0Tcy4UwefBX/UDcDcDy9V5jUcxHzv2jd5Q= github.com/tidwall/grect v0.1.4/go.mod h1:9FBsaYRaR0Tcy4UwefBX/UDcDcDy9V5jUcxHzv2jd5Q=
github.com/tidwall/lotsa v1.0.2 h1:dNVBH5MErdaQ/xd9s769R31/n2dXavsQ0Yf4TMEHHw8= github.com/tidwall/lotsa v1.0.2 h1:dNVBH5MErdaQ/xd9s769R31/n2dXavsQ0Yf4TMEHHw8=
github.com/tidwall/lotsa v1.0.2/go.mod h1:X6NiU+4yHA3fE3Puvpnn1XMDrFZrE9JO2/w+UMuqgR8=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
@ -66,21 +81,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/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 h1:6iq84/ryjjeRmMJwxutI51F2GIPlP5BfTvXHeYjyhBc=
github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM= github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM=
golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE= golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU= golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw= golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/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-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-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.4.0 h1:O7UWfv5+A2qiuulQk30kVinPoMtoIPeVaKLEgLpVkvg= golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/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 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -90,7 +106,8 @@ gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -4,6 +4,7 @@
package irc package irc
import ( import (
"context"
"crypto/rand" "crypto/rand"
"crypto/x509" "crypto/x509"
"encoding/json" "encoding/json"
@ -23,6 +24,7 @@ import (
"github.com/ergochat/ergo/irc/email" "github.com/ergochat/ergo/irc/email"
"github.com/ergochat/ergo/irc/migrations" "github.com/ergochat/ergo/irc/migrations"
"github.com/ergochat/ergo/irc/modes" "github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/oauth2"
"github.com/ergochat/ergo/irc/passwd" "github.com/ergochat/ergo/irc/passwd"
"github.com/ergochat/ergo/irc/utils" "github.com/ergochat/ergo/irc/utils"
) )
@ -1427,6 +1429,74 @@ func (am *AccountManager) AuthenticateByPassphrase(client *Client, accountName s
return err 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. // AllNicks returns the uncasefolded nicknames for all accounts, including additional (grouped) nicks.
func (am *AccountManager) AllNicks() (result []string) { func (am *AccountManager) AllNicks() (result []string) {
accountNamePrefix := fmt.Sprintf(keyAccountName, "") accountNamePrefix := fmt.Sprintf(keyAccountName, "")
@ -1939,8 +2009,10 @@ func (am *AccountManager) AuthenticateByCertificate(client *Client, certfp strin
return err return err
} }
if authzid != "" && authzid != account { if authzid != "" {
return errAuthzidAuthcidMismatch if cfAuthzid, err := CasefoldName(authzid); err != nil || cfAuthzid != account {
return errAuthzidAuthcidMismatch
}
} }
// ok, we found an account corresponding to their certificate // ok, we found an account corresponding to their certificate
@ -2145,6 +2217,8 @@ var (
"PLAIN": authPlainHandler, "PLAIN": authPlainHandler,
"EXTERNAL": authExternalHandler, "EXTERNAL": authExternalHandler,
"SCRAM-SHA-256": authScramHandler, "SCRAM-SHA-256": authScramHandler,
"OAUTHBEARER": authOauthBearerHandler,
"IRCV3BEARER": authIRCv3BearerHandler,
} }
) )

View file

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

View file

@ -62,10 +62,13 @@ const (
RelaymsgTagName = "draft/relaymsg" RelaymsgTagName = "draft/relaymsg"
// BOT mode: https://ircv3.net/specs/extensions/bot-mode // BOT mode: https://ircv3.net/specs/extensions/bot-mode
BotTagName = "bot" BotTagName = "bot"
// https://ircv3.net/specs/extensions/chathistory
ChathistoryTargetsBatchType = "draft/chathistory-targets"
ExtendedISupportBatchType = "draft/extended-isupport"
) )
func init() { func init() {
nameToCapability = make(map[string]Capability) nameToCapability = make(map[string]Capability, numCapabs)
for capab, name := range capabilityNames { for capab, name := range capabilityNames {
nameToCapability[name] = Capability(capab) nameToCapability[name] = Capability(capab)
} }

View file

@ -7,9 +7,9 @@ package caps
const ( const (
// number of recognized capabilities: // number of recognized capabilities:
numCapabs = 32 numCapabs = 36
// length of the uint32 array that represents the bitset: // length of the uint32 array that represents the bitset:
bitsetLen = 1 bitsetLen = 2
) )
const ( const (
@ -53,14 +53,26 @@ const (
// https://github.com/ircv3/ircv3-specifications/pull/362 // https://github.com/ircv3/ircv3-specifications/pull/362
EventPlayback Capability = iota EventPlayback Capability = iota
// ExtendedISupport is the proposed IRCv3 capability named "draft/extended-isupport":
// https://github.com/ircv3/ircv3-specifications/pull/543
ExtendedISupport Capability = iota
// Languages is the proposed IRCv3 capability named "draft/languages": // Languages is the proposed IRCv3 capability named "draft/languages":
// https://gist.github.com/DanielOaks/8126122f74b26012a3de37db80e4e0c6 // https://gist.github.com/DanielOaks/8126122f74b26012a3de37db80e4e0c6
Languages Capability = iota 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": // Multiline is the proposed IRCv3 capability named "draft/multiline":
// https://github.com/ircv3/ircv3-specifications/pull/398 // https://github.com/ircv3/ircv3-specifications/pull/398
Multiline Capability = iota 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": // Persistence is the proposed IRCv3 capability named "draft/persistence":
// https://github.com/ircv3/ircv3-specifications/pull/503 // https://github.com/ircv3/ircv3-specifications/pull/503
Persistence Capability = iota Persistence Capability = iota
@ -140,6 +152,8 @@ const (
// ZNCSelfMessage is the ZNC vendor capability named "znc.in/self-message": // ZNCSelfMessage is the ZNC vendor capability named "znc.in/self-message":
// https://wiki.znc.in/Query_buffers // https://wiki.znc.in/Query_buffers
ZNCSelfMessage Capability = iota ZNCSelfMessage Capability = iota
ExtendedNames Capability = iota
) )
// `capabilityNames[capab]` is the string name of the capability `capab` // `capabilityNames[capab]` is the string name of the capability `capab`
@ -155,8 +169,11 @@ var (
"draft/channel-rename", "draft/channel-rename",
"draft/chathistory", "draft/chathistory",
"draft/event-playback", "draft/event-playback",
"draft/extended-isupport",
"draft/languages", "draft/languages",
"draft/message-redaction",
"draft/multiline", "draft/multiline",
"draft/no-implicit-names",
"draft/persistence", "draft/persistence",
"draft/pre-away", "draft/pre-away",
"draft/read-marker", "draft/read-marker",
@ -177,5 +194,6 @@ var (
"userhost-in-names", "userhost-in-names",
"znc.in/playback", "znc.in/playback",
"znc.in/self-message", "znc.in/self-message",
"cef/extended-names",
} }
) )

View file

@ -102,6 +102,13 @@ func (s *Set) Strings(version Version, values Values, maxLen int) (result []stri
var capab Capability var capab Capability
asSlice := s[:] asSlice := s[:]
for capab = 0; capab < numCapabs; capab++ { 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 // skip any capabilities that are not enabled
if !utils.BitsetGet(asSlice, uint(capab)) { if !utils.BitsetGet(asSlice, uint(capab)) {
continue continue
@ -122,3 +129,15 @@ func (s *Set) Strings(version Version, values Values, maxLen int) (result []stri
} }
return 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 package caps
import "testing" import (
import "reflect" "fmt"
"reflect"
"testing"
)
func TestSets(t *testing.T) { func TestSets(t *testing.T) {
s1 := NewSet() 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) { func TestSubtract(t *testing.T) {
s1 := NewSet(AccountTag, EchoMessage, UserhostInNames, ServerTime) s1 := NewSet(AccountTag, EchoMessage, UserhostInNames, ServerTime)

194
irc/cef.go Normal file
View file

@ -0,0 +1,194 @@
package irc
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"github.com/cshum/imagor/imagorpath"
"github.com/ergochat/ergo/irc/caps"
"github.com/ergochat/irc-go/ircmsg"
"net/http"
"regexp"
"strings"
"time"
)
var ctx = context.Background()
func (channel *Channel) RedisBroadcast(message ...string) {
if channel.server.redis == nil {
return
}
err := channel.server.redis.Publish(ctx, "channel."+channel.NameCasefolded(), strings.Join(message, " ")).Err()
if err != nil {
fmt.Printf("Channel broadcast error: %v", err)
}
}
func (client *Client) RedisBroadcast(message ...string) {
err := client.server.redis.Publish(ctx, "user."+client.NickCasefolded(), strings.Join(message, " ")).Err()
if err != nil {
fmt.Printf("User broadcast error: %v", err)
}
}
func (channel *Channel) Broadcast(command string, params ...string) {
channel.BroadcastFrom(channel.server.name, command, params...)
}
func (channel *Channel) BroadcastFrom(prefix string, command string, params ...string) {
for _, member := range channel.Members() {
for _, session := range member.Sessions() {
session.Send(nil, prefix, command, params...)
}
}
}
// ChannelSub Actions and info centering around channels
func (server *Server) ChannelSub() {
pubsub := server.redis.PSubscribe(ctx, "channel.*")
defer pubsub.Close()
ch := pubsub.Channel()
for msg := range ch {
server.logger.Info("RedisMessage", msg.Channel, msg.Payload)
line := strings.Split(msg.Payload, " ")
channelName := strings.SplitN(msg.Channel, ".", 2)[1]
channel := server.channels.Get(channelName)
if len(line) == 0 {
println("Empty string dumped into ", msg.Channel, " channel")
}
if channel == nil {
server.logger.Warning("RedisMessage", "Unknown channel")
continue
}
switch line[0] {
case "VOICEPART":
channel.Broadcast("VOICEPART", channelName, line[1])
case "VOICESTATE":
channel.Broadcast("VOICESTATE", channelName, line[1], line[2], line[3])
case "BROADCASTTO":
for _, person := range channel.Members() {
person.Send(nil, server.name, line[1], line[2:]...)
}
}
}
}
// UserSub Handles things pertaining to users
func (server *Server) UserSub() {
pubsub := server.redis.PSubscribe(ctx, "user.*")
defer pubsub.Close()
ch := pubsub.Channel()
for msg := range ch {
server.logger.Info("RedisMessage", msg.Channel, msg.Payload)
line := strings.Split(msg.Payload, " ")
userName := strings.SplitN(msg.Channel, ".", 2)[1]
user := server.clients.Get(userName)
if len(line) == 0 {
println("Empty string dumped into ", msg.Channel, " channel")
}
switch line[0] {
case "FULLYREMOVE":
if user != nil {
user.destroy(nil)
err := server.accounts.Unregister(user.Account(), true)
if err != nil {
return
}
}
break
case "BROADCASTAS":
if user != nil {
// I'm not too sure what the capability bit is, I think it's just ones that match
for friend := range user.Friends(caps.ExtendedNames) {
friend.Send(nil, user.NickMaskString(), line[1], line[2:]...)
}
}
break
}
}
}
func startRedis(server *Server) {
go server.ChannelSub()
go server.UserSub()
}
func (server *Server) GenerateImagorSignaturesFromMessage(message *ircmsg.Message) string {
line, err := message.Line()
if err == nil {
return server.GenerateImagorSignatures(line)
}
return ""
}
func (server *Server) GetUrlMime(url string) string {
config := server.Config()
// hacky, should fix
if !strings.Contains(url, "?") {
url += "?"
}
params := imagorpath.Params{
Image: url,
Meta: true,
}
metaPath := imagorpath.Generate(params, imagorpath.NewHMACSigner(sha256.New, 0, config.Cef.Imagor.Secret))
client := http.Client{
Timeout: 5 * time.Second,
}
resp, err := client.Get(config.Cef.Imagor.Url + metaPath)
if err != nil {
println("Failed on the initial get")
println(err.Error())
return ""
}
defer resp.Body.Close()
var meta map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&meta)
if err != nil {
println("Failed on the JSON decode")
return ""
}
contentType, valid := meta["format"].(string)
if !valid {
println("No content type")
return ""
}
return contentType
}
var urlRegex = regexp.MustCompile("https?:\\/\\/[\\w-]+(\\.[\\w-]+)+([\\w.,@?^=%&amp;:/~+#-]*[\\w@?^=%&amp;/~+#-])?")
// Process a message to add Imagor signatures
func (server *Server) GenerateImagorSignatures(str string) string {
urls := urlRegex.FindAllString(str, -1)
var sigs []string
for _, url := range urls {
params := imagorpath.Params{
Image: url,
FitIn: true,
Width: 600,
Height: 600,
}
path := imagorpath.Generate(params, imagorpath.NewHMACSigner(sha256.New, 0, server.Config().Cef.Imagor.Secret))
signature := path[:strings.IndexByte(path, '/')]
contentType := server.GetUrlMime(url)
if contentType != "" {
sigs = append(sigs, signature+"|"+strings.ReplaceAll(contentType, "/", "_"))
} else {
sigs = append(sigs, signature)
}
}
if len(sigs) > 0 {
return strings.Join(sigs, ",")
}
return ""
}

View file

@ -7,13 +7,14 @@ package irc
import ( import (
"fmt" "fmt"
"maps"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"sync" "sync"
"github.com/ergochat/irc-go/ircutils" "github.com/ergochat/irc-go/ircmsg"
"github.com/ergochat/ergo/irc/caps" "github.com/ergochat/ergo/irc/caps"
"github.com/ergochat/ergo/irc/datastore" "github.com/ergochat/ergo/irc/datastore"
@ -34,7 +35,6 @@ type Channel struct {
key string key string
forward string forward string
members MemberSet members MemberSet
membersCache []*Client // allow iteration over channel members without holding the lock
name string name string
nameCasefolded string nameCasefolded string
server *Server server *Server
@ -54,6 +54,9 @@ type Channel struct {
dirtyBits uint dirtyBits uint
settings ChannelSettings settings ChannelSettings
uuid utils.UUID 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` // NewChannel creates a new channel from a `Server` and a `name`
@ -156,7 +159,7 @@ func (channel *Channel) ExportRegistration() (info RegisteredChannel) {
info.Bans = channel.lists[modes.BanMask].Masks() info.Bans = channel.lists[modes.BanMask].Masks()
info.Invites = channel.lists[modes.InviteMask].Masks() info.Invites = channel.lists[modes.InviteMask].Masks()
info.Excepts = channel.lists[modes.ExceptMask].Masks() info.Excepts = channel.lists[modes.ExceptMask].Masks()
info.AccountToUMode = utils.CopyMap(channel.accountToUMode) info.AccountToUMode = maps.Clone(channel.accountToUMode)
info.Settings = channel.settings info.Settings = channel.settings
@ -219,6 +222,8 @@ func (channel *Channel) wakeWriter() {
// equivalent of Socket.send() // equivalent of Socket.send()
func (channel *Channel) writeLoop() { func (channel *Channel) writeLoop() {
defer channel.server.HandlePanic()
for { for {
// TODO(#357) check the error value of this and implement timed backoff // TODO(#357) check the error value of this and implement timed backoff
channel.performWrite(0) channel.performWrite(0)
@ -421,16 +426,19 @@ func (channel *Channel) AcceptTransfer(client *Client) (err error) {
func (channel *Channel) regenerateMembersCache() { func (channel *Channel) regenerateMembersCache() {
channel.stateMutex.RLock() channel.stateMutex.RLock()
result := make([]*Client, len(channel.members)) membersCache := make([]*Client, len(channel.members))
dataCache := make([]*memberData, len(channel.members))
i := 0 i := 0
for client := range channel.members { for client, info := range channel.members {
result[i] = client membersCache[i] = client
dataCache[i] = info
i++ i++
} }
channel.stateMutex.RUnlock() channel.stateMutex.RUnlock()
channel.stateMutex.Lock() channel.stateMutex.Lock()
channel.membersCache = result channel.membersCache = membersCache
channel.memberDataCache = dataCache
channel.stateMutex.Unlock() channel.stateMutex.Unlock()
} }
@ -438,59 +446,51 @@ func (channel *Channel) regenerateMembersCache() {
func (channel *Channel) Names(client *Client, rb *ResponseBuffer) { func (channel *Channel) Names(client *Client, rb *ResponseBuffer) {
channel.stateMutex.RLock() channel.stateMutex.RLock()
clientData, isJoined := channel.members[client] clientData, isJoined := channel.members[client]
chname := channel.name
membersCache, memberDataCache := channel.membersCache, channel.memberDataCache
channel.stateMutex.RUnlock() channel.stateMutex.RUnlock()
symbol := "=" // https://modern.ircdocs.horse/#rplnamreply-353
if channel.flags.HasMode(modes.Secret) {
symbol = "@"
}
isOper := client.HasRoleCapabs("sajoin") isOper := client.HasRoleCapabs("sajoin")
respectAuditorium := channel.flags.HasMode(modes.Auditorium) && !isOper && respectAuditorium := channel.flags.HasMode(modes.Auditorium) && !isOper &&
(!isJoined || clientData.modes.HighestChannelUserMode() == modes.Mode(0)) (!isJoined || clientData.modes.HighestChannelUserMode() == modes.Mode(0))
isMultiPrefix := rb.session.capabilities.Has(caps.MultiPrefix) isMultiPrefix := rb.session.capabilities.Has(caps.MultiPrefix)
isUserhostInNames := rb.session.capabilities.Has(caps.UserhostInNames) isUserhostInNames := rb.session.capabilities.Has(caps.UserhostInNames)
maxNamLen := 480 - len(client.server.name) - len(client.Nick()) maxNamLen := 480 - len(client.server.name) - len(client.Nick()) - len(chname)
var namesLines []string var tl utils.TokenLineBuilder
var buffer strings.Builder tl.Initialize(maxNamLen, " ")
if isJoined || !channel.flags.HasMode(modes.Secret) || isOper { 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 var nick string
if isUserhostInNames { if isUserhostInNames {
nick = target.NickMaskString() nick = target.NickMaskString()
} else { } else {
nick = target.Nick() nick = target.Nick()
} }
channel.stateMutex.RLock() memberData := memberDataCache[i]
memberData, _ := channel.members[target] if respectAuditorium && memberData.modes.HighestChannelUserMode() == modes.Mode(0) {
channel.stateMutex.RUnlock()
modeSet := memberData.modes
if modeSet == nil {
continue continue
} }
if !isJoined && target.HasMode(modes.Invisible) && !isOper { if rb.session.capabilities.Has(caps.ExtendedNames) {
continue away, _ := target.Away()
if away {
nick = nick + "*"
}
} }
if respectAuditorium && modeSet.HighestChannelUserMode() == modes.Mode(0) { tl.AddParts(memberData.modes.Prefixes(isMultiPrefix), nick)
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())
} }
} }
for _, line := range namesLines { for _, line := range tl.Lines() {
if buffer.Len() > 0 { rb.Add(nil, client.server.name, RPL_NAMREPLY, client.nick, symbol, chname, line)
rb.Add(nil, client.server.name, RPL_NAMREPLY, client.nick, "=", channel.name, 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, // does `clientMode` give you privileges to grant/remove `targetMode` to/from people,
@ -514,7 +514,7 @@ func channelUserModeHasPrivsOver(clientMode modes.Mode, targetMode modes.Mode) b
// ClientIsAtLeast returns whether the client has at least the given channel privilege. // ClientIsAtLeast returns whether the client has at least the given channel privilege.
func (channel *Channel) ClientIsAtLeast(client *Client, permission modes.Mode) bool { func (channel *Channel) ClientIsAtLeast(client *Client, permission modes.Mode) bool {
channel.stateMutex.RLock() channel.stateMutex.RLock()
memberData := channel.members[client] memberData, present := channel.members[client]
founder := channel.registeredFounder founder := channel.registeredFounder
channel.stateMutex.RUnlock() channel.stateMutex.RUnlock()
@ -522,6 +522,10 @@ func (channel *Channel) ClientIsAtLeast(client *Client, permission modes.Mode) b
return true return true
} }
if !present {
return false
}
for _, mode := range modes.ChannelUserModes { for _, mode := range modes.ChannelUserModes {
if memberData.modes.HasMode(mode) { if memberData.modes.HasMode(mode) {
return true return true
@ -553,11 +557,14 @@ func (channel *Channel) ClientStatus(client *Client) (present bool, joinTimeSecs
// helper for persisting channel-user modes for always-on clients; // helper for persisting channel-user modes for always-on clients;
// return the channel name and all channel-user modes for a client // 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() channel.stateMutex.RLock()
defer channel.stateMutex.RUnlock() defer channel.stateMutex.RUnlock()
chname = channel.name chname = channel.name
data := channel.members[client] data, ok := channel.members[client]
if !ok {
return
}
status.Modes = data.modes.String() status.Modes = data.modes.String()
status.JoinTime = data.joinTime status.JoinTime = data.joinTime
return return
@ -571,20 +578,20 @@ func (channel *Channel) setMemberStatus(client *Client, status alwaysOnChannelSt
} }
channel.stateMutex.Lock() channel.stateMutex.Lock()
defer channel.stateMutex.Unlock() defer channel.stateMutex.Unlock()
if _, ok := channel.members[client]; !ok { if mData, ok := channel.members[client]; ok {
return 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 { func (channel *Channel) ClientHasPrivsOver(client *Client, target *Client) bool {
channel.stateMutex.RLock() channel.stateMutex.RLock()
founder := channel.registeredFounder founder := channel.registeredFounder
clientModes := channel.members[client].modes clientData, clientOK := channel.members[client]
targetModes := channel.members[target].modes targetData, targetOK := channel.members[target]
channel.stateMutex.RUnlock() channel.stateMutex.RUnlock()
if founder != "" { if founder != "" {
@ -595,7 +602,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 { func (channel *Channel) hasClient(client *Client) bool {
@ -716,6 +727,9 @@ func (channel *Channel) AddHistoryItem(item history.Item, account string) (err e
if !itemIsStorable(&item, channel.server.Config()) { if !itemIsStorable(&item, channel.server.Config()) {
return return
} }
if item.Target == "" {
item.Target = channel.nameCasefolded
}
status, target, _ := channel.historyStatus(channel.server.Config()) status, target, _ := channel.historyStatus(channel.server.Config())
if status == HistoryPersistent { if status == HistoryPersistent {
@ -790,6 +804,8 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
} }
client.server.logger.Debug("channels", fmt.Sprintf("%s joined channel %s", details.nick, chname)) client.server.logger.Debug("channels", fmt.Sprintf("%s joined channel %s", details.nick, chname))
// I think this is assured to always be a good join point
channel.RedisBroadcast("VOICEPOLL")
givenMode := func() (givenMode modes.Mode) { givenMode := func() (givenMode modes.Mode) {
channel.joinPartMutex.Lock() channel.joinPartMutex.Lock()
@ -823,11 +839,12 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
// no history item for fake persistent joins // no history item for fake persistent joins
if rb != nil && !respectAuditorium { if rb != nil && !respectAuditorium {
histItem := history.Item{ histItem := history.Item{
Type: history.Join, Type: history.Join,
Nick: details.nickMask, Nick: details.nickMask,
AccountName: details.accountName, Account: details.account,
Message: message, Message: message,
IsBot: isBot, Target: channel.NameCasefolded(),
IsBot: isBot,
} }
histItem.Params[0] = details.realname histItem.Params[0] = details.realname
channel.AddHistoryItem(histItem, details.account) channel.AddHistoryItem(histItem, details.account)
@ -889,7 +906,9 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
if rb.session.client == client { if rb.session.client == client {
// don't send topic and names for a SAJOIN of a different client // don't send topic and names for a SAJOIN of a different client
channel.SendTopic(client, rb, false) channel.SendTopic(client, rb, false)
channel.Names(client, rb) if !rb.session.capabilities.Has(caps.NoImplicitNames) {
channel.Names(client, rb)
}
} else { } else {
// ensure that SAJOIN sends a MODE line to the originating client, if applicable // ensure that SAJOIN sends a MODE line to the originating client, if applicable
if givenMode != 0 { if givenMode != 0 {
@ -956,7 +975,7 @@ func (channel *Channel) autoReplayHistory(client *Client, rb *ResponseBuffer, sk
} }
} }
if 0 < numItems { if 0 < numItems {
channel.replayHistoryItems(rb, items, false) channel.replayHistoryItems(rb, items, false, "", "", numItems)
rb.Flush(true) rb.Flush(true)
} }
} }
@ -980,8 +999,11 @@ func (channel *Channel) playJoinForSession(session *Session) {
sessionRb.Add(nil, client.server.name, "MARKREAD", chname, client.GetReadMarker(chcfname)) sessionRb.Add(nil, client.server.name, "MARKREAD", chname, client.GetReadMarker(chcfname))
} }
channel.SendTopic(client, sessionRb, false) channel.SendTopic(client, sessionRb, false)
channel.Names(client, sessionRb) if !session.capabilities.Has(caps.NoImplicitNames) {
channel.Names(client, sessionRb)
}
sessionRb.Send(false) sessionRb.Send(false)
channel.RedisBroadcast("VOICEPOLL")
} }
// Part parts the given client from this channel, with the given message. // Part parts the given client from this channel, with the given message.
@ -1033,18 +1055,19 @@ func (channel *Channel) Part(client *Client, message string, rb *ResponseBuffer)
if !respectAuditorium { if !respectAuditorium {
channel.AddHistoryItem(history.Item{ channel.AddHistoryItem(history.Item{
Type: history.Part, Type: history.Part,
Nick: details.nickMask, Nick: details.nickMask,
AccountName: details.accountName, Account: details.account,
Message: splitMessage, Message: splitMessage,
IsBot: isBot, Target: channel.NameCasefolded(),
IsBot: isBot,
}, details.account) }, details.account)
} }
client.server.logger.Debug("channels", fmt.Sprintf("%s left channel %s", details.nick, chname)) client.server.logger.Debug("channels", fmt.Sprintf("%s left channel %s", details.nick, chname))
} }
func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.Item, chathistoryCommand bool) { func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.Item, chathistoryCommand bool, identifier string, preposition string, limit int) {
// send an empty batch if necessary, as per the CHATHISTORY spec // send an empty batch if necessary, as per the CHATHISTORY spec
chname := channel.Name() chname := channel.Name()
client := rb.target client := rb.target
@ -1064,19 +1087,19 @@ func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.I
} }
} }
batchID := rb.StartNestedHistoryBatch(chname) batchID := rb.StartNestedBatch("chathistory", chname, identifier, preposition, strconv.Itoa(limit))
defer rb.EndNestedBatch(batchID) defer rb.EndNestedBatch(batchID)
for _, item := range items { for _, item := range items {
nick := NUHToNick(item.Nick) nick := NUHToNick(item.Nick)
switch item.Type { switch item.Type {
case history.Privmsg: case history.Privmsg:
rb.AddSplitMessageFromClient(item.Nick, item.AccountName, item.IsBot, item.Tags, "PRIVMSG", chname, item.Message) rb.AddSplitMessageFromClientWithReactions(item.Nick, item.Account, item.IsBot, item.Tags, "PRIVMSG", chname, item.Message, item.Reactions)
case history.Notice: case history.Notice:
rb.AddSplitMessageFromClient(item.Nick, item.AccountName, item.IsBot, item.Tags, "NOTICE", chname, item.Message) rb.AddSplitMessageFromClientWithReactions(item.Nick, item.Account, item.IsBot, item.Tags, "NOTICE", chname, item.Message, item.Reactions)
case history.Tagmsg: case history.Tagmsg:
if eventPlayback { if eventPlayback {
rb.AddSplitMessageFromClient(item.Nick, item.AccountName, item.IsBot, item.Tags, "TAGMSG", chname, item.Message) rb.AddSplitMessageFromClient(item.Nick, item.Account, item.IsBot, item.Tags, "TAGMSG", chname, item.Message)
} else if chathistoryCommand { } else if chathistoryCommand {
// #1676, we have to send something here or else it breaks pagination // #1676, we have to send something here or else it breaks pagination
rb.AddFromClient(item.Message.Time, history.HistservMungeMsgid(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, fmt.Sprintf(client.t("%s sent a TAGMSG"), nick)) rb.AddFromClient(item.Message.Time, history.HistservMungeMsgid(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, fmt.Sprintf(client.t("%s sent a TAGMSG"), nick))
@ -1084,25 +1107,25 @@ func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.I
case history.Join: case history.Join:
if eventPlayback { if eventPlayback {
if extendedJoin { if extendedJoin {
rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, item.IsBot, nil, "JOIN", chname, item.AccountName, item.Params[0]) rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.Account, item.IsBot, nil, "JOIN", chname, item.Account, item.Params[0])
} else { } else {
rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, item.IsBot, nil, "JOIN", chname) rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.Account, item.IsBot, nil, "JOIN", chname)
} }
} else { } else {
if !playJoinsAsPrivmsg { if !playJoinsAsPrivmsg {
continue // #474 continue // #474
} }
var message string var message string
if item.AccountName == "*" { if item.Account == "*" {
message = fmt.Sprintf(client.t("%s joined the channel"), nick) message = fmt.Sprintf(client.t("%s joined the channel"), nick)
} else { } else {
message = fmt.Sprintf(client.t("%[1]s [account: %[2]s] joined the channel"), nick, item.AccountName) message = fmt.Sprintf(client.t("%[1]s [account: %[2]s] joined the channel"), nick, item.Account)
} }
rb.AddFromClient(item.Message.Time, history.HistservMungeMsgid(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, message) rb.AddFromClient(item.Message.Time, history.HistservMungeMsgid(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, message)
} }
case history.Part: case history.Part:
if eventPlayback { if eventPlayback {
rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, item.IsBot, nil, "PART", chname, item.Message.Message) rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.Account, item.IsBot, nil, "PART", chname, item.Message.Message)
} else { } else {
if !playJoinsAsPrivmsg { if !playJoinsAsPrivmsg {
continue // #474 continue // #474
@ -1112,14 +1135,14 @@ func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.I
} }
case history.Kick: case history.Kick:
if eventPlayback { if eventPlayback {
rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, item.IsBot, nil, "KICK", chname, item.Params[0], item.Message.Message) rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.Account, item.IsBot, nil, "KICK", chname, item.Params[0], item.Message.Message)
} else { } else {
message := fmt.Sprintf(client.t("%[1]s kicked %[2]s (%[3]s)"), nick, item.Params[0], item.Message.Message) message := fmt.Sprintf(client.t("%[1]s kicked %[2]s (%[3]s)"), nick, item.Params[0], item.Message.Message)
rb.AddFromClient(item.Message.Time, history.HistservMungeMsgid(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, message) rb.AddFromClient(item.Message.Time, history.HistservMungeMsgid(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, message)
} }
case history.Quit: case history.Quit:
if eventPlayback { if eventPlayback {
rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, item.IsBot, nil, "QUIT", item.Message.Message) rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.Account, item.IsBot, nil, "QUIT", item.Message.Message)
} else { } else {
if !playJoinsAsPrivmsg { if !playJoinsAsPrivmsg {
continue // #474 continue // #474
@ -1129,14 +1152,14 @@ func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.I
} }
case history.Nick: case history.Nick:
if eventPlayback { if eventPlayback {
rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, item.IsBot, nil, "NICK", item.Params[0]) rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.Account, item.IsBot, nil, "NICK", item.Params[0])
} else { } else {
message := fmt.Sprintf(client.t("%[1]s changed nick to %[2]s"), nick, item.Params[0]) message := fmt.Sprintf(client.t("%[1]s changed nick to %[2]s"), nick, item.Params[0])
rb.AddFromClient(item.Message.Time, history.HistservMungeMsgid(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, message) rb.AddFromClient(item.Message.Time, history.HistservMungeMsgid(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, message)
} }
case history.Topic: case history.Topic:
if eventPlayback { if eventPlayback {
rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, item.IsBot, nil, "TOPIC", chname, item.Message.Message) rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.Account, item.IsBot, nil, "TOPIC", chname, item.Message.Message)
} else { } else {
message := fmt.Sprintf(client.t("%[1]s set the channel topic to: %[2]s"), nick, item.Message.Message) message := fmt.Sprintf(client.t("%[1]s set the channel topic to: %[2]s"), nick, item.Message.Message)
rb.AddFromClient(item.Message.Time, history.HistservMungeMsgid(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, message) rb.AddFromClient(item.Message.Time, history.HistservMungeMsgid(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, message)
@ -1148,7 +1171,7 @@ func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.I
params[i+1] = pair.Message params[i+1] = pair.Message
} }
if eventPlayback { if eventPlayback {
rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, item.IsBot, nil, "MODE", params...) rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.Account, item.IsBot, nil, "MODE", params...)
} else { } else {
message := fmt.Sprintf(client.t("%[1]s set channel modes: %[2]s"), nick, strings.Join(params[1:], " ")) message := fmt.Sprintf(client.t("%[1]s set channel modes: %[2]s"), nick, strings.Join(params[1:], " "))
rb.AddFromClient(item.Message.Time, history.HistservMungeMsgid(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, message) rb.AddFromClient(item.Message.Time, history.HistservMungeMsgid(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", chname, message)
@ -1196,7 +1219,7 @@ func (channel *Channel) SetTopic(client *Client, topic string, rb *ResponseBuffe
return return
} }
topic = ircutils.TruncateUTF8Safe(topic, client.server.Config().Limits.TopicLen) topic = ircmsg.TruncateUTF8Safe(topic, client.server.Config().Limits.TopicLen)
channel.stateMutex.Lock() channel.stateMutex.Lock()
chname := channel.name chname := channel.name
@ -1218,11 +1241,12 @@ func (channel *Channel) SetTopic(client *Client, topic string, rb *ResponseBuffe
} }
channel.AddHistoryItem(history.Item{ channel.AddHistoryItem(history.Item{
Type: history.Topic, Type: history.Topic,
Nick: details.nickMask, Nick: details.nickMask,
AccountName: details.accountName, Account: details.account,
Message: message, Message: message,
IsBot: isBot, IsBot: isBot,
Target: channel.NameCasefolded(),
}, details.account) }, details.account)
channel.MarkDirty(IncludeTopic) channel.MarkDirty(IncludeTopic)
@ -1233,20 +1257,26 @@ func (channel *Channel) CanSpeak(client *Client) (bool, modes.Mode) {
channel.stateMutex.RLock() channel.stateMutex.RLock()
memberData, hasClient := channel.members[client] memberData, hasClient := channel.members[client]
channel.stateMutex.RUnlock() 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) { if !hasClient && channel.flags.HasMode(modes.NoOutside) {
// TODO: enforce regular +b bans on -n channels? // TODO: enforce regular +b bans on -n channels?
return false, modes.NoOutside 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 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 return false, modes.Moderated
} }
if channel.flags.HasMode(modes.RegisteredOnlySpeak) && client.Account() == "" && if channel.flags.HasMode(modes.RegisteredOnlySpeak) && client.Account() == "" &&
clientModes.HighestChannelUserMode() == modes.Mode(0) { highestMode() == modes.Mode(0) {
return false, modes.RegisteredOnlySpeak return false, modes.RegisteredOnlySpeak
} }
return true, modes.Mode('?') return true, modes.Mode('?')
@ -1305,6 +1335,11 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
isBot := client.HasMode(modes.Bot) isBot := client.HasMode(modes.Bot)
chname := channel.Name() chname := channel.Name()
// STATUSMSG targets are prefixed with the supplied min-prefix, e.g., @#channel
if minPrefixMode != modes.Mode(0) {
chname = fmt.Sprintf("%s%s", modes.ChannelModePrefixes[minPrefixMode], chname)
}
if !client.server.Config().Server.Compatibility.allowTruncation { if !client.server.Config().Server.Compatibility.allowTruncation {
if !validateSplitMessageLen(histType, details.nickMask, chname, message) { if !validateSplitMessageLen(histType, details.nickMask, chname, message) {
rb.Add(nil, client.server.name, ERR_INPUTTOOLONG, details.nick, client.t("Line too long to be relayed without truncation")) rb.Add(nil, client.server.name, ERR_INPUTTOOLONG, details.nick, client.t("Line too long to be relayed without truncation"))
@ -1312,16 +1347,11 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
} }
} }
// STATUSMSG targets are prefixed with the supplied min-prefix, e.g., @#channel
if minPrefixMode != modes.Mode(0) {
chname = fmt.Sprintf("%s%s", modes.ChannelModePrefixes[minPrefixMode], chname)
}
if channel.flags.HasMode(modes.OpModerated) { if channel.flags.HasMode(modes.OpModerated) {
channel.stateMutex.RLock() channel.stateMutex.RLock()
cuData := channel.members[client] cuData, ok := channel.members[client]
channel.stateMutex.RUnlock() channel.stateMutex.RUnlock()
if cuData.modes.HighestChannelUserMode() == modes.Mode(0) { if !ok || cuData.modes.HighestChannelUserMode() == modes.Mode(0) {
// max(statusmsg_minmode, halfop) // max(statusmsg_minmode, halfop)
if minPrefixMode == modes.Mode(0) || minPrefixMode == modes.Voice { if minPrefixMode == modes.Mode(0) || minPrefixMode == modes.Voice {
minPrefixMode = modes.Halfop minPrefixMode = modes.Halfop
@ -1356,12 +1386,13 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
// #959: don't save STATUSMSG (or OpModerated) // #959: don't save STATUSMSG (or OpModerated)
if minPrefixMode == modes.Mode(0) { if minPrefixMode == modes.Mode(0) {
channel.AddHistoryItem(history.Item{ channel.AddHistoryItem(history.Item{
Type: histType, Type: histType,
Message: message, Message: message,
Nick: details.nickMask, Nick: details.nickMask,
AccountName: details.accountName, Account: details.accountName,
Tags: clientOnlyTags, Tags: clientOnlyTags,
IsBot: isBot, IsBot: isBot,
Target: channel.NameCasefolded(),
}, details.account) }, details.account)
} }
} }
@ -1435,6 +1466,7 @@ func (channel *Channel) Quit(client *Client) {
client.server.channels.Cleanup(channel) client.server.channels.Cleanup(channel)
} }
client.removeChannel(channel) client.removeChannel(channel)
channel.Broadcast("KICK", client.NickCasefolded())
} }
func (channel *Channel) Kick(client *Client, target *Client, comment string, rb *ResponseBuffer, hasPrivs bool) { func (channel *Channel) Kick(client *Client, target *Client, comment string, rb *ResponseBuffer, hasPrivs bool) {
@ -1449,7 +1481,7 @@ func (channel *Channel) Kick(client *Client, target *Client, comment string, rb
return return
} }
comment = ircutils.TruncateUTF8Safe(comment, channel.server.Config().Limits.KickLen) comment = ircmsg.TruncateUTF8Safe(comment, channel.server.Config().Limits.KickLen)
message := utils.MakeMessage(comment) message := utils.MakeMessage(comment)
details := client.Details() details := client.Details()
@ -1467,11 +1499,12 @@ func (channel *Channel) Kick(client *Client, target *Client, comment string, rb
rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, isBot, nil, "KICK", chname, targetNick, comment) rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, isBot, nil, "KICK", chname, targetNick, comment)
histItem := history.Item{ histItem := history.Item{
Type: history.Kick, Type: history.Kick,
Nick: details.nickMask, Nick: details.nickMask,
AccountName: details.accountName, Account: details.account,
Message: message, Message: message,
IsBot: isBot, IsBot: isBot,
Target: channel.NameCasefolded(),
} }
histItem.Params[0] = targetNick histItem.Params[0] = targetNick
channel.AddHistoryItem(histItem, details.account) channel.AddHistoryItem(histItem, details.account)
@ -1490,6 +1523,7 @@ func (channel *Channel) Purge(source string) {
chname := channel.name chname := channel.name
members := channel.membersCache members := channel.membersCache
channel.membersCache = nil channel.membersCache = nil
channel.memberDataCache = nil
channel.members = make(MemberSet) channel.members = make(MemberSet)
// TODO try to prevent Purge racing against (pending) Join? // TODO try to prevent Purge racing against (pending) Join?
channel.stateMutex.Unlock() channel.stateMutex.Unlock()
@ -1497,7 +1531,7 @@ func (channel *Channel) Purge(source string) {
now := time.Now().UTC() now := time.Now().UTC()
for _, member := range members { for _, member := range members {
tnick := member.Nick() tnick := member.Nick()
msgid := utils.GenerateSecretToken() msgid := utils.GenerateMessageIdStr()
for _, session := range member.Sessions() { for _, session := range member.Sessions() {
session.sendFromClientInternal(false, now, msgid, source, "*", false, nil, "KICK", chname, tnick, member.t("This channel has been purged by the server administrators and cannot be used")) session.sendFromClientInternal(false, now, msgid, source, "*", false, nil, "KICK", chname, tnick, member.t("This channel has been purged by the server administrators and cannot be used"))
} }
@ -1547,6 +1581,8 @@ func (channel *Channel) Invite(invitee *Client, inviter *Client, rb *ResponseBuf
item := history.Item{ item := history.Item{
Type: history.Invite, Type: history.Invite,
Message: message, Message: message,
Account: inviter.Account(),
Target: invitee.Account(),
} }
for _, member := range channel.Members() { for _, member := range channel.Members() {
@ -1610,6 +1646,26 @@ func (channel *Channel) auditoriumFriends(client *Client) (friends []*Client) {
return 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 // data for RPL_LIST
func (channel *Channel) listData() (memberCount int, name, topic string) { func (channel *Channel) listData() (memberCount int, name, topic string) {
channel.stateMutex.RLock() channel.stateMutex.RLock()

View file

@ -6,6 +6,7 @@ package irc
import ( import (
"fmt" "fmt"
"regexp" "regexp"
"slices"
"sort" "sort"
"strings" "strings"
"time" "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 // check for anything valid as a channel mode change that is not valid
// as an AMODE change // as an AMODE change
for _, modeChange := range modeChanges { for _, modeChange := range modeChanges {
if !utils.SliceContains(modes.ChannelUserModes, modeChange.Mode) { if !slices.Contains(modes.ChannelUserModes, modeChange.Mode) {
invalid = true invalid = true
} }
} }
@ -752,6 +753,7 @@ func csListHandler(service *ircService, server *Server, client *Client, command
} }
func csInfoHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { func csInfoHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
if len(params) == 0 { if len(params) == 0 {
// #765 // #765
listRegisteredChannels(service, client.Account(), rb) listRegisteredChannels(service, client.Account(), rb)
@ -764,37 +766,41 @@ func csInfoHandler(service *ircService, server *Server, client *Client, command
return return
} }
// purge status
if client.HasRoleCapabs("chanreg") {
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))
service.Notice(rb, fmt.Sprintf(client.t("Purged at: %s"), purgeRecord.PurgedAt.Format(time.RFC1123)))
if purgeRecord.Reason != "" {
service.Notice(rb, fmt.Sprintf(client.t("Purge reason: %s"), purgeRecord.Reason))
}
}
} else {
if server.channels.IsPurged(chname) {
service.Notice(rb, fmt.Sprintf(client.t("Channel %s was purged by the server operators and cannot be used"), chname))
}
}
var chinfo RegisteredChannel var chinfo RegisteredChannel
channel := server.channels.Get(params[0]) channel := server.channels.Get(params[0])
if channel != nil { if channel != nil {
chinfo = channel.exportSummary() chinfo = channel.exportSummary()
} }
tags := map[string]string{
"target": chinfo.Name,
}
// purge status
if client.HasRoleCapabs("chanreg") {
purgeRecord, err := server.channels.LoadPurgeRecord(chname)
if err == nil {
service.TaggedNotice(rb, fmt.Sprintf(client.t("Channel %s was purged by the server operators and cannot be used"), chname), tags)
service.TaggedNotice(rb, fmt.Sprintf(client.t("Purged by operator: %s"), purgeRecord.Oper), tags)
service.TaggedNotice(rb, fmt.Sprintf(client.t("Purged at: %s"), purgeRecord.PurgedAt.Format(time.RFC1123)), tags)
if purgeRecord.Reason != "" {
service.TaggedNotice(rb, fmt.Sprintf(client.t("Purge reason: %s"), purgeRecord.Reason), tags)
}
}
} else {
if server.channels.IsPurged(chname) {
service.TaggedNotice(rb, fmt.Sprintf(client.t("Channel %s was purged by the server operators and cannot be used"), chname), tags)
}
}
// channel exists but is unregistered, or doesn't exist: // channel exists but is unregistered, or doesn't exist:
if chinfo.Founder == "" { if chinfo.Founder == "" {
service.Notice(rb, fmt.Sprintf(client.t("Channel %s is not registered"), chname)) service.TaggedNotice(rb, fmt.Sprintf(client.t("Channel %s is not registered"), chname), tags)
return return
} }
service.Notice(rb, fmt.Sprintf(client.t("Channel %s is registered"), chinfo.Name)) service.TaggedNotice(rb, fmt.Sprintf(client.t("Channel %s is registered"), chinfo.Name), tags)
service.Notice(rb, fmt.Sprintf(client.t("Founder: %s"), chinfo.Founder)) service.TaggedNotice(rb, fmt.Sprintf(client.t("Founder: %s"), chinfo.Founder), tags)
service.Notice(rb, fmt.Sprintf(client.t("Registered at: %s"), chinfo.RegisteredAt.Format(time.RFC1123))) service.TaggedNotice(rb, fmt.Sprintf(client.t("Registered at: %s"), chinfo.RegisteredAt.Format(time.RFC1123)), tags)
} }
func displayChannelSetting(service *ircService, settingName string, settings ChannelSettings, client *Client, rb *ResponseBuffer) { func displayChannelSetting(service *ircService, settingName string, settings ChannelSettings, client *Client, rb *ResponseBuffer) {

View file

@ -8,6 +8,7 @@ package irc
import ( import (
"crypto/x509" "crypto/x509"
"fmt" "fmt"
"maps"
"net" "net"
"runtime/debug" "runtime/debug"
"strconv" "strconv"
@ -20,6 +21,7 @@ import (
"github.com/ergochat/irc-go/ircfmt" "github.com/ergochat/irc-go/ircfmt"
"github.com/ergochat/irc-go/ircmsg" "github.com/ergochat/irc-go/ircmsg"
"github.com/ergochat/irc-go/ircreader" "github.com/ergochat/irc-go/ircreader"
"github.com/ergochat/irc-go/ircutils"
"github.com/xdg-go/scram" "github.com/xdg-go/scram"
"github.com/ergochat/ergo/irc/caps" "github.com/ergochat/ergo/irc/caps"
@ -27,13 +29,14 @@ import (
"github.com/ergochat/ergo/irc/flatip" "github.com/ergochat/ergo/irc/flatip"
"github.com/ergochat/ergo/irc/history" "github.com/ergochat/ergo/irc/history"
"github.com/ergochat/ergo/irc/modes" "github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/oauth2"
"github.com/ergochat/ergo/irc/sno" "github.com/ergochat/ergo/irc/sno"
"github.com/ergochat/ergo/irc/utils" "github.com/ergochat/ergo/irc/utils"
) )
const ( const (
// maximum IRC line length, not including tags // Set to 4096 because CEF doesn't care about compatibility
DefaultMaxLineLen = 512 DefaultMaxLineLen = 4096
// IdentTimeout is how long before our ident (username) check times out. // IdentTimeout is how long before our ident (username) check times out.
IdentTimeout = time.Second + 500*time.Millisecond IdentTimeout = time.Second + 500*time.Millisecond
@ -118,12 +121,20 @@ type Client struct {
type saslStatus struct { type saslStatus struct {
mechanism string mechanism string
value string value ircutils.SASLBuffer
scramConv *scram.ServerConversation scramConv *scram.ServerConversation
oauthConv *oauth2.OAuthBearerServer
}
func (s *saslStatus) Initialize() {
s.value.Initialize(saslMaxResponseLength)
} }
func (s *saslStatus) Clear() { 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: // what stage the client is at w.r.t. the PASS command:
@ -149,13 +160,14 @@ type Session struct {
idleTimer *time.Timer idleTimer *time.Timer
pingSent bool // we sent PING to a putatively idle connection and we're waiting for PONG pingSent bool // we sent PING to a putatively idle connection and we're waiting for PONG
sessionID int64 sessionID int64
socket *Socket socket *Socket
realIP net.IP realIP net.IP
proxiedIP net.IP proxiedIP net.IP
rawHostname string rawHostname string
isTor bool hostnameFinalized bool
hideSTS bool isTor bool
hideSTS bool
fakelag Fakelag fakelag Fakelag
deferredFakelagCount int deferredFakelagCount int
@ -167,6 +179,8 @@ type Session struct {
batchCounter atomic.Uint32 batchCounter atomic.Uint32
isupportSentPrereg bool
quitMessage string quitMessage string
awayMessage string awayMessage string
@ -361,6 +375,7 @@ func (server *Server) RunClient(conn IRCConn) {
isTor: wConn.Tor, isTor: wConn.Tor,
hideSTS: wConn.Tor || wConn.HideSTS, hideSTS: wConn.Tor || wConn.HideSTS,
} }
session.sasl.Initialize()
client.sessions = []*Session{session} client.sessions = []*Session{session}
session.resetFakelag() session.resetFakelag()
@ -476,12 +491,21 @@ func (client *Client) resizeHistory(config *Config) {
} }
} }
// resolve an IP to an IRC-ready hostname, using reverse DNS, forward-confirming if necessary, // once we have the final IP address (from the connection itself or from proxy data),
// and sending appropriate notices to the client // compute the various possibilities for the hostname:
func (client *Client) lookupHostname(session *Session, overwrite bool) { // * 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 { if session.isTor {
return return
} // else: even if cloaking is enabled, look up the real hostname to show to operators }
config := client.server.Config() config := client.server.Config()
ip := session.realIP ip := session.realIP
@ -489,30 +513,27 @@ func (client *Client) lookupHostname(session *Session, overwrite bool) {
ip = session.proxiedIP ip = session.proxiedIP
} }
var hostname string // even if cloaking is enabled, we may want to look up the real hostname to show to operators:
lookupSuccessful := false if session.rawHostname == "" {
if config.Server.lookupHostnames { var hostname string
session.Notice("*** Looking up your hostname...") lookupSuccessful := false
hostname, lookupSuccessful = utils.LookupHostname(ip, config.Server.ForwardConfirmHostnames) if config.Server.lookupHostnames {
if lookupSuccessful { session.Notice("*** Looking up your hostname...")
session.Notice("*** Found 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 { } else {
session.Notice("*** Couldn't look up your hostname") hostname = utils.IPStringToHostname(ip.String())
} }
} else { session.rawHostname = hostname
hostname = utils.IPStringToHostname(ip.String())
} }
session.rawHostname = hostname // these will be discarded if this is actually a reattach:
cloakedHostname := config.Server.Cloaks.ComputeCloak(ip) client.rawHostname = session.rawHostname
client.stateMutex.Lock() client.cloakedHostname = config.Server.Cloaks.ComputeCloak(ip)
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()
}
} }
func (client *Client) doIdentLookup(conn net.Conn) { func (client *Client) doIdentLookup(conn net.Conn) {
@ -630,6 +651,17 @@ func (client *Client) run(session *Session) {
firstLine := !isReattach firstLine := !isReattach
correspondents, _ := client.server.historyDB.GetPMs(client.Account())
// For safety, let's keep this within the 4096 character barrier
var lineBuilder utils.TokenLineBuilder
lineBuilder.Initialize(MaxLineLen, ",")
for username, timestamp := range correspondents {
lineBuilder.Add(fmt.Sprintf("%s %d", client.server.getCurrentNick(username), timestamp))
}
for _, message := range lineBuilder.Lines() {
session.Send(nil, client.server.name, "PMS", message)
}
for { for {
var invalidUtf8 bool var invalidUtf8 bool
line, err := session.socket.Read() line, err := session.socket.Read()
@ -843,14 +875,14 @@ func (session *Session) Ping() {
session.Send(nil, "", "PING", session.client.Nick()) session.Send(nil, "", "PING", session.client.Nick())
} }
func (client *Client) replayPrivmsgHistory(rb *ResponseBuffer, items []history.Item, target string, chathistoryCommand bool) { func (client *Client) replayPrivmsgHistory(rb *ResponseBuffer, items []history.Item, target string, chathistoryCommand bool, identifier string, preposition string, limit int) {
var batchID string var batchID string
details := client.Details() details := client.Details()
nick := details.nick nick := details.nick
if target == "" { if target == "" {
target = nick target = nick
} }
batchID = rb.StartNestedHistoryBatch(target) batchID = rb.StartNestedBatch("chathistory", target, identifier, preposition, strconv.Itoa(limit))
isSelfMessage := func(item *history.Item) bool { isSelfMessage := func(item *history.Item) bool {
// XXX: Params[0] is the message target. if the source of this message is an in-memory // XXX: Params[0] is the message target. if the source of this message is an in-memory
@ -871,7 +903,7 @@ func (client *Client) replayPrivmsgHistory(rb *ResponseBuffer, items []history.I
continue continue
} }
if hasEventPlayback { if hasEventPlayback {
rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, item.IsBot, nil, "INVITE", nick, item.Message.Message) rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.Account, item.IsBot, nil, "INVITE", nick, item.Message.Message)
} else { } else {
rb.AddFromClient(item.Message.Time, history.HistservMungeMsgid(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", fmt.Sprintf(client.t("%[1]s invited you to channel %[2]s"), NUHToNick(item.Nick), item.Message.Message)) rb.AddFromClient(item.Message.Time, history.HistservMungeMsgid(item.Message.Msgid), histservService.prefix, "*", false, nil, "PRIVMSG", fmt.Sprintf(client.t("%[1]s invited you to channel %[2]s"), NUHToNick(item.Nick), item.Message.Message))
} }
@ -899,11 +931,11 @@ func (client *Client) replayPrivmsgHistory(rb *ResponseBuffer, items []history.I
tags = item.Tags tags = item.Tags
} }
if !isSelfMessage(&item) { if !isSelfMessage(&item) {
rb.AddSplitMessageFromClient(item.Nick, item.AccountName, item.IsBot, tags, command, nick, item.Message) rb.AddSplitMessageFromClientWithReactions(item.Nick, item.Account, item.IsBot, tags, command, nick, item.Message, item.Reactions)
} else { } else {
// this message was sent *from* the client to another nick; the target is item.Params[0] // this message was sent *from* the client to another nick; the target is item.Params[0]
// substitute client's current nickmask in case client changed nick // substitute client's current nickmask in case client changed nick
rb.AddSplitMessageFromClient(details.nickMask, item.AccountName, item.IsBot, tags, command, item.Params[0], item.Message) rb.AddSplitMessageFromClientWithReactions(details.nickMask, item.Account, item.IsBot, tags, command, item.Params[0], item.Message, item.Reactions)
} }
} }
@ -1285,10 +1317,10 @@ func (client *Client) destroy(session *Session) {
} }
var quitItem history.Item var quitItem history.Item
var channels []*Channel var quitHistoryChannels []*Channel
// use a defer here to avoid writing to mysql while holding the destroy semaphore: // use a defer here to avoid writing to mysql while holding the destroy semaphore:
defer func() { defer func() {
for _, channel := range channels { for _, channel := range quitHistoryChannels {
channel.AddHistoryItem(quitItem, details.account) channel.AddHistoryItem(quitItem, details.account)
} }
}() }()
@ -1310,8 +1342,11 @@ func (client *Client) destroy(session *Session) {
// clean up channels // clean up channels
// (note that if this is a reattach, client has no channels and therefore no friends) // (note that if this is a reattach, client has no channels and therefore no friends)
friends := make(ClientSet) friends := make(ClientSet)
channels = client.Channels() channels := client.Channels()
for _, channel := range channels { for _, channel := range channels {
if channel.memberIsVisible(client) {
quitHistoryChannels = append(quitHistoryChannels, channel)
}
for _, member := range channel.auditoriumFriends(client) { for _, member := range channel.auditoriumFriends(client) {
friends.Add(member) friends.Add(member)
} }
@ -1330,11 +1365,11 @@ func (client *Client) destroy(session *Session) {
splitQuitMessage := utils.MakeMessage(quitMessage) splitQuitMessage := utils.MakeMessage(quitMessage)
isBot := client.HasMode(modes.Bot) isBot := client.HasMode(modes.Bot)
quitItem = history.Item{ quitItem = history.Item{
Type: history.Quit, Type: history.Quit,
Nick: details.nickMask, Nick: details.nickMask,
AccountName: details.accountName, Account: details.accountName,
Message: splitQuitMessage, Message: splitQuitMessage,
IsBot: isBot, IsBot: isBot,
} }
var cache MessageCache var cache MessageCache
cache.Initialize(client.server, splitQuitMessage.Time, splitQuitMessage.Msgid, details.nickMask, details.accountName, isBot, nil, "QUIT", quitMessage) cache.Initialize(client.server, splitQuitMessage.Time, splitQuitMessage.Msgid, details.nickMask, details.accountName, isBot, nil, "QUIT", quitMessage)
@ -1414,6 +1449,10 @@ func composeMultilineBatch(batchID, fromNickMask, fromAccount string, isBot bool
for _, msg := range message.Split { for _, msg := range message.Split {
message := ircmsg.MakeMessage(nil, fromNickMask, command, target, msg.Message) message := ircmsg.MakeMessage(nil, fromNickMask, command, target, msg.Message)
message.SetTag("batch", batchID) message.SetTag("batch", batchID)
for k, v := range msg.Tags {
message.SetTag(k, v)
}
if msg.Concat { if msg.Concat {
message.SetTag(caps.MultilineConcatTag, "") message.SetTag(caps.MultilineConcatTag, "")
} }
@ -1425,27 +1464,27 @@ func composeMultilineBatch(batchID, fromNickMask, fromAccount string, isBot bool
} }
var ( var (
// these are all the output commands that MUST have their last param be a trailing. // in practice, many clients require that the final parameter be a trailing
// this is needed because dumb clients like to treat trailing params separately from the // (prefixed with `:`) even when this is not syntactically necessary.
// other params in messages. // by default, force the following commands to use a trailing:
commandsThatMustUseTrailing = map[string]bool{ commandsThatMustUseTrailing = utils.SetLiteral(
"PRIVMSG": true, "PRIVMSG",
"NOTICE": true, "NOTICE",
RPL_WHOISCHANNELS,
RPL_WHOISCHANNELS: true, RPL_USERHOST,
RPL_USERHOST: true,
// mirc's handling of RPL_NAMREPLY is broken: // mirc's handling of RPL_NAMREPLY is broken:
// https://forums.mirc.com/ubbthreads.php/topics/266939/re-nick-list // 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. // SendRawMessage sends a raw message to the client.
func (session *Session) SendRawMessage(message ircmsg.Message, blocking bool) error { 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 if forceTrailing(session.client.server.Config(), message.Command) {
config := session.client.server.Config()
if config.Server.Compatibility.forceTrailing && commandsThatMustUseTrailing[message.Command] {
message.ForceTrailing() message.ForceTrailing()
} }
@ -1651,7 +1690,7 @@ func (client *Client) addHistoryItem(target *Client, item history.Item, details,
} }
item.Nick = details.nickMask item.Nick = details.nickMask
item.AccountName = details.accountName item.Account = details.account
targetedItem := item targetedItem := item
targetedItem.Params[0] = tDetails.nick targetedItem.Params[0] = tDetails.nick
@ -1659,15 +1698,15 @@ func (client *Client) addHistoryItem(target *Client, item history.Item, details,
tStatus, _ := target.historyStatus(config) tStatus, _ := target.historyStatus(config)
// add to ephemeral history // add to ephemeral history
if cStatus == HistoryEphemeral { if cStatus == HistoryEphemeral {
targetedItem.CfCorrespondent = tDetails.nickCasefolded targetedItem.Target = tDetails.account
client.history.Add(targetedItem) client.history.Add(targetedItem)
} }
if tStatus == HistoryEphemeral && client != target { if tStatus == HistoryEphemeral && client != target {
item.CfCorrespondent = details.nickCasefolded item.Target = target.Account()
target.history.Add(item) target.history.Add(item)
} }
if cStatus == HistoryPersistent || tStatus == HistoryPersistent { if cStatus == HistoryPersistent || tStatus == HistoryPersistent {
targetedItem.CfCorrespondent = "" targetedItem.Target = target.account
client.server.historyDB.AddDirectMessage(details.nickCasefolded, details.account, tDetails.nickCasefolded, tDetails.account, targetedItem) client.server.historyDB.AddDirectMessage(details.nickCasefolded, details.account, tDetails.nickCasefolded, tDetails.account, targetedItem)
} }
return nil return nil
@ -1743,7 +1782,7 @@ func (client *Client) handleRegisterTimeout() {
func (client *Client) copyLastSeen() (result map[string]time.Time) { func (client *Client) copyLastSeen() (result map[string]time.Time) {
client.stateMutex.RLock() client.stateMutex.RLock()
defer client.stateMutex.RUnlock() 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" // these are bit flags indicating what part of the client status is "dirty"
@ -1772,6 +1811,8 @@ func (client *Client) wakeWriter() {
} }
func (client *Client) writeLoop() { func (client *Client) writeLoop() {
defer client.server.HandlePanic()
for { for {
client.performWrite(0) client.performWrite(0)
client.writebackLock.Unlock() client.writebackLock.Unlock()
@ -1802,7 +1843,11 @@ func (client *Client) performWrite(additionalDirtyBits uint) {
channels := client.Channels() channels := client.Channels()
channelToModes := make(map[string]alwaysOnChannelStatus, len(channels)) channelToModes := make(map[string]alwaysOnChannelStatus, len(channels))
for _, channel := range 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 channelToModes[chname] = status
} }
client.server.accounts.saveChannels(account, channelToModes) client.server.accounts.saveChannels(account, channelToModes)

View file

@ -116,6 +116,8 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick
useAccountName = alwaysOn || config.Accounts.NickReservation.ForceNickEqualsAccount useAccountName = alwaysOn || config.Accounts.NickReservation.ForceNickEqualsAccount
} }
nickIsReserved := false
if useAccountName { if useAccountName {
if registered && newNick != accountName { if registered && newNick != accountName {
return "", errNickAccountMismatch, false return "", errNickAccountMismatch, false
@ -167,7 +169,9 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick
reservedAccount, method := client.server.accounts.EnforcementStatus(newCfNick, newSkeleton) reservedAccount, method := client.server.accounts.EnforcementStatus(newCfNick, newSkeleton)
if method == NickEnforcementStrict && reservedAccount != "" && reservedAccount != account { 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,15 +199,6 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick
dryRun || session == nil { dryRun || session == nil {
return "", errNicknameInUse, false 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, wasAway, nowAway := currentClient.AddSession(session) reattachSuccessful, numSessions, lastSeen, wasAway, nowAway := currentClient.AddSession(session)
if !reattachSuccessful { if !reattachSuccessful {
return "", errNicknameInUse, false return "", errNicknameInUse, false
@ -228,6 +223,9 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick
if skeletonHolder != nil && skeletonHolder != client { if skeletonHolder != nil && skeletonHolder != client {
return "", errNicknameInUse, false return "", errNicknameInUse, false
} }
if nickIsReserved {
return "", errNicknameReserved, false
}
if dryRun { if dryRun {
return "", nil, false return "", nil, false

View file

@ -4,8 +4,10 @@
package irc package irc
import ( import (
"fmt"
"testing" "testing"
"github.com/ergochat/ergo/irc/languages"
"github.com/ergochat/ergo/irc/utils" "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) { func TestUserMasks(t *testing.T) {
var um UserMaskSet var um UserMaskSet

View file

@ -152,6 +152,10 @@ func init() {
handler: isonHandler, handler: isonHandler,
minParams: 1, minParams: 1,
}, },
"ISUPPORT": {
handler: isupportHandler,
usablePreReg: true,
},
"JOIN": { "JOIN": {
handler: joinHandler, handler: joinHandler,
minParams: 1, minParams: 1,
@ -301,6 +305,10 @@ func init() {
usablePreReg: true, usablePreReg: true,
minParams: 0, minParams: 0,
}, },
"REDACT": {
handler: redactHandler,
minParams: 2,
},
"REHASH": { "REHASH": {
handler: rehashHandler, handler: rehashHandler,
minParams: 0, minParams: 0,
@ -375,6 +383,11 @@ func init() {
handler: zncHandler, handler: zncHandler,
minParams: 1, minParams: 1,
}, },
// CEF custom commands
"REACT": {
handler: reactHandler,
minParams: 2,
},
} }
initializeServices() initializeServices()

View file

@ -38,6 +38,7 @@ import (
"github.com/ergochat/ergo/irc/logger" "github.com/ergochat/ergo/irc/logger"
"github.com/ergochat/ergo/irc/modes" "github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/mysql" "github.com/ergochat/ergo/irc/mysql"
"github.com/ergochat/ergo/irc/oauth2"
"github.com/ergochat/ergo/irc/passwd" "github.com/ergochat/ergo/irc/passwd"
"github.com/ergochat/ergo/irc/utils" "github.com/ergochat/ergo/irc/utils"
) )
@ -331,7 +332,9 @@ type AccountConfig struct {
Multiclient MulticlientConfig Multiclient MulticlientConfig
Bouncer *MulticlientConfig // # handle old name for 'multiclient' Bouncer *MulticlientConfig // # handle old name for 'multiclient'
VHosts VHostConfig 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 { type ScriptConfig struct {
@ -450,6 +453,10 @@ func (cm *Casemapping) UnmarshalYAML(unmarshal func(interface{}) error) (err err
result = CasemappingPRECIS result = CasemappingPRECIS
case "permissive", "fun": case "permissive", "fun":
result = CasemappingPermissive result = CasemappingPermissive
case "rfc1459":
result = CasemappingRFC1459
case "rfc1459-strict":
result = CasemappingRFC1459Strict
default: default:
return fmt.Errorf("invalid casemapping value: %s", orig) return fmt.Errorf("invalid casemapping value: %s", orig)
} }
@ -484,6 +491,7 @@ type Limits struct {
ChanListModes int `yaml:"chan-list-modes"` ChanListModes int `yaml:"chan-list-modes"`
ChannelLen int `yaml:"channellen"` ChannelLen int `yaml:"channellen"`
IdentLen int `yaml:"identlen"` IdentLen int `yaml:"identlen"`
RealnameLen int `yaml:"realnamelen"`
KickLen int `yaml:"kicklen"` KickLen int `yaml:"kicklen"`
MonitorEntries int `yaml:"monitor-entries"` MonitorEntries int `yaml:"monitor-entries"`
NickLen int `yaml:"nicklen"` NickLen int `yaml:"nicklen"`
@ -644,6 +652,7 @@ type Config struct {
} }
ListDelay time.Duration `yaml:"list-delay"` ListDelay time.Duration `yaml:"list-delay"`
InviteExpiration custime.Duration `yaml:"invite-expiration"` InviteExpiration custime.Duration `yaml:"invite-expiration"`
AutoJoin []string `yaml:"auto-join"`
} }
OperClasses map[string]*OperClassConfig `yaml:"oper-classes"` OperClasses map[string]*OperClassConfig `yaml:"oper-classes"`
@ -700,6 +709,14 @@ type Config struct {
} }
Filename string Filename string
Cef struct {
Imagor struct {
Url string
Secret string
}
Redis string
}
} }
// OperClass defines an assembled operator class. // OperClass defines an assembled operator class.
@ -1038,7 +1055,7 @@ func (ce *configPathError) Error() string {
return fmt.Sprintf("Couldn't apply config override `%s`: %s", ce.name, ce.desc) 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, '=') equalIdx := strings.IndexByte(envPair, '=')
name, value := envPair[:equalIdx], envPair[equalIdx+1:] name, value := envPair[:equalIdx], envPair[equalIdx+1:]
if strings.HasPrefix(name, "ERGO__") { if strings.HasPrefix(name, "ERGO__") {
@ -1046,7 +1063,7 @@ func mungeFromEnvironment(config *Config, envPair string) (applied bool, err *co
} else if strings.HasPrefix(name, "ORAGONO__") { } else if strings.HasPrefix(name, "ORAGONO__") {
name = strings.TrimPrefix(name, "ORAGONO__") name = strings.TrimPrefix(name, "ORAGONO__")
} else { } else {
return false, nil return false, "", nil
} }
pathComponents := strings.Split(name, "__") pathComponents := strings.Split(name, "__")
for i, pathComponent := range pathComponents { for i, pathComponent := range pathComponents {
@ -1057,10 +1074,10 @@ func mungeFromEnvironment(config *Config, envPair string) (applied bool, err *co
t := v.Type() t := v.Type()
for _, component := range pathComponents { for _, component := range pathComponents {
if component == "" { if component == "" {
return false, &configPathError{name, "invalid", nil} return false, "", &configPathError{name, "invalid", nil}
} }
if v.Kind() != reflect.Struct { 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 var nextField reflect.StructField
success := false success := false
@ -1086,7 +1103,7 @@ func mungeFromEnvironment(config *Config, envPair string) (applied bool, err *co
} }
} }
if !success { 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) v = v.FieldByName(nextField.Name)
// dereference pointer field if necessary, initialize new value if necessary // dereference pointer field if necessary, initialize new value if necessary
@ -1100,9 +1117,9 @@ func mungeFromEnvironment(config *Config, envPair string) (applied bool, err *co
} }
yamlErr := yaml.Unmarshal([]byte(value), v.Addr().Interface()) yamlErr := yaml.Unmarshal([]byte(value), v.Addr().Interface())
if yamlErr != nil { 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. // LoadConfig loads the given YAML configuration file.
@ -1114,7 +1131,7 @@ func LoadConfig(filename string) (config *Config, err error) {
if config.AllowEnvironmentOverrides { if config.AllowEnvironmentOverrides {
for _, envPair := range os.Environ() { for _, envPair := range os.Environ() {
applied, envErr := mungeFromEnvironment(config, envPair) applied, name, envErr := mungeFromEnvironment(config, envPair)
if envErr != nil { if envErr != nil {
if envErr.fatalErr != nil { if envErr.fatalErr != nil {
return nil, envErr return nil, envErr
@ -1122,7 +1139,7 @@ func LoadConfig(filename string) (config *Config, err error) {
log.Println(envErr.Error()) log.Println(envErr.Error())
} }
} else if applied { } else if applied {
log.Printf("applied environment override: %s\n", envPair) log.Printf("applied environment override: %s\n", name)
} }
} }
} }
@ -1389,15 +1406,34 @@ func LoadConfig(filename string) (config *Config, err error) {
config.Accounts.VHosts.validRegexp = defaultValidVhostRegex config.Accounts.VHosts.validRegexp = defaultValidVhostRegex
} }
saslCapValue := "PLAIN,EXTERNAL,SCRAM-SHA-256" if config.Accounts.AuthenticationEnabled {
if !config.Accounts.AdvertiseSCRAM { saslCapValues := []string{"PLAIN", "EXTERNAL"}
saslCapValue = "PLAIN,EXTERNAL" if config.Accounts.AdvertiseSCRAM {
} saslCapValues = append(saslCapValues, "SCRAM-SHA-256")
config.Server.capValues[caps.SASL] = saslCapValue }
if !config.Accounts.AuthenticationEnabled { 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) 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 { if !config.Accounts.Registration.Enabled {
config.Server.supportedCaps.Disable(caps.AccountRegistration) config.Server.supportedCaps.Disable(caps.AccountRegistration)
} else { } else {
@ -1567,6 +1603,10 @@ func (config *Config) isRelaymsgIdentifier(nick string) bool {
return false return false
} }
if strings.HasPrefix(nick, "#") {
return false // #2114
}
for _, char := range config.Server.Relaymsg.Separators { for _, char := range config.Server.Relaymsg.Separators {
if strings.ContainsRune(nick, char) { if strings.ContainsRune(nick, char) {
return true return true
@ -1584,7 +1624,16 @@ func (config *Config) generateISupport() (err error) {
isupport.Initialize() isupport.Initialize()
isupport.Add("AWAYLEN", strconv.Itoa(config.Limits.AwayLen)) isupport.Add("AWAYLEN", strconv.Itoa(config.Limits.AwayLen))
isupport.Add("BOT", "B") 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("CHANLIMIT", fmt.Sprintf("%s:%d", chanTypes, config.Channels.MaxChannelsPerClient))
isupport.Add("CHANMODES", chanmodesToken) isupport.Add("CHANMODES", chanmodesToken)
if config.History.Enabled && config.History.ChathistoryMax > 0 { if config.History.Enabled && config.History.ChathistoryMax > 0 {
@ -1615,6 +1664,7 @@ func (config *Config) generateISupport() (err error) {
isupport.Add("RPCHAN", "E") isupport.Add("RPCHAN", "E")
isupport.Add("RPUSER", "E") isupport.Add("RPUSER", "E")
} }
isupport.Add("SAFELIST", "")
isupport.Add("STATUSMSG", "~&@%+") isupport.Add("STATUSMSG", "~&@%+")
isupport.Add("TARGMAX", fmt.Sprintf("NAMES:1,LIST:1,KICK:,WHOIS:1,USERHOST:10,PRIVMSG:%s,TAGMSG:%s,NOTICE:%s,MONITOR:%d", maxTargetsString, maxTargetsString, maxTargetsString, config.Limits.MonitorEntries)) isupport.Add("TARGMAX", fmt.Sprintf("NAMES:1,LIST:1,KICK:,WHOIS:1,USERHOST:10,PRIVMSG:%s,TAGMSG:%s,NOTICE:%s,MONITOR:%d", maxTargetsString, maxTargetsString, maxTargetsString, config.Limits.MonitorEntries))
isupport.Add("TOPICLEN", strconv.Itoa(config.Limits.TopicLen)) isupport.Add("TOPICLEN", strconv.Itoa(config.Limits.TopicLen))

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}`, `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 { for _, envPair := range env {
_, err := mungeFromEnvironment(&config, envPair) _, _, err := mungeFromEnvironment(&config, envPair)
if err != nil { if err != nil {
t.Errorf("couldn't apply override `%s`: %v", envPair, err) t.Errorf("couldn't apply override `%s`: %v", envPair, err)
} }
@ -93,7 +93,7 @@ func TestEnvironmentOverrideErrors(t *testing.T) {
} }
for _, env := range invalidEnvs { for _, env := range invalidEnvs {
success, err := mungeFromEnvironment(&config, env) success, _, err := mungeFromEnvironment(&config, env)
if err == nil || success { if err == nil || success {
t.Errorf("accepted invalid env override `%s`", env) t.Errorf("accepted invalid env override `%s`", env)
} }

View file

@ -8,7 +8,7 @@ package irc
const ( const (
// maxLastArgLength is used to simply cap off the final argument when creating general messages where we need to select a limit. // maxLastArgLength is used to simply cap off the final argument when creating general messages where we need to select a limit.
// for instance, in MONITOR lists, RPL_ISUPPORT lists, etc. // for instance, in MONITOR lists, RPL_ISUPPORT lists, etc.
maxLastArgLength = 400 maxLastArgLength = 1024
// maxTargets is the maximum number of targets for PRIVMSG and NOTICE. // maxTargets is the maximum number of targets for PRIVMSG and NOTICE.
maxTargets = 4 maxTargets = 4
) )

View file

@ -150,7 +150,7 @@ func retrieveSchemaVersion(tx *buntdb.Tx) (version int, err error) {
func performAutoUpgrade(currentVersion int, config *Config) (err error) { func performAutoUpgrade(currentVersion int, config *Config) (err error) {
path := config.Datastore.Path path := config.Datastore.Path
log.Printf("attempting to auto-upgrade schema from version %d to %d\n", currentVersion, latestDbSchema) 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) backupPath := fmt.Sprintf("%s.v%d.%s.bak", path, currentVersion, timestamp)
log.Printf("making a backup of current database at %s\n", backupPath) log.Printf("making a backup of current database at %s\n", backupPath)
err = utils.CopyFile(path, backupPath) err = utils.CopyFile(path, backupPath)

View file

@ -275,6 +275,6 @@ func (dm *DLineManager) loadFromDatastore() {
}) })
} }
func (s *Server) loadDLines() { func (server *Server) loadDLines() {
s.dlines = NewDLineManager(s) server.dlines = NewDLineManager(server)
} }

View file

@ -4,10 +4,13 @@
package email package email
import ( import (
"bufio"
"bytes" "bytes"
"errors" "errors"
"fmt" "fmt"
"io"
"net" "net"
"os"
"regexp" "regexp"
"strings" "strings"
"time" "time"
@ -23,6 +26,38 @@ var (
ErrNoMXRecord = errors.New("Couldn't resolve MX record") 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 { type MTAConfig struct {
Server string Server string
Port int Port int
@ -35,24 +70,67 @@ type MailtoConfig struct {
// legacy config format assumed the use of an MTA/smarthost, // legacy config format assumed the use of an MTA/smarthost,
// so server, port, etc. appear directly at top level // so server, port, etc. appear directly at top level
// XXX: see https://github.com/go-yaml/yaml/issues/63 // XXX: see https://github.com/go-yaml/yaml/issues/63
MTAConfig `yaml:",inline"` MTAConfig `yaml:",inline"`
Enabled bool Enabled bool
Sender string Sender string
HeloDomain string `yaml:"helo-domain"` HeloDomain string `yaml:"helo-domain"`
RequireTLS bool `yaml:"require-tls"` RequireTLS bool `yaml:"require-tls"`
VerifyMessageSubject string `yaml:"verify-message-subject"` Protocol string `yaml:"protocol"`
DKIM DKIMConfig LocalAddress string `yaml:"local-address"`
MTAReal MTAConfig `yaml:"mta"` localAddress net.Addr
BlacklistRegexes []string `yaml:"blacklist-regexes"` VerifyMessageSubject string `yaml:"verify-message-subject"`
blacklistRegexes []*regexp.Regexp DKIM DKIMConfig
Timeout time.Duration MTAReal MTAConfig `yaml:"mta"`
PasswordReset struct { 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 Enabled bool
Cooldown custime.Duration Cooldown custime.Duration
Timeout custime.Duration Timeout custime.Duration
} `yaml:"password-reset"` } `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) { func (config *MailtoConfig) Postprocess(heloDomain string) (err error) {
if config.Sender == "" { if config.Sender == "" {
return errors.New("Invalid mailto sender address") return errors.New("Invalid mailto sender address")
@ -68,12 +146,39 @@ func (config *MailtoConfig) Postprocess(heloDomain string) (err error) {
config.HeloDomain = heloDomain config.HeloDomain = heloDomain
} }
for _, reg := range config.BlacklistRegexes { if config.AddressBlacklistFile != "" {
compiled, err := regexp.Compile(fmt.Sprintf("^%s$", reg)) config.blacklistRegexes, err = config.processBlacklistFile(config.AddressBlacklistFile)
if err != nil { if err != nil {
return err 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 != "" { if config.MTAConfig.Server != "" {
@ -110,6 +215,9 @@ func ComposeMail(config MailtoConfig, recipient, subject string) (message bytes.
dkimDomain := config.DKIM.Domain dkimDomain := config.DKIM.Domain
if dkimDomain != "" { if dkimDomain != "" {
fmt.Fprintf(&message, "Message-ID: <%s@%s>\r\n", utils.GenerateSecretKey(), 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, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
fmt.Fprintf(&message, "Subject: %s\r\n", subject) fmt.Fprintf(&message, "Subject: %s\r\n", subject)
@ -118,8 +226,9 @@ func ComposeMail(config MailtoConfig, recipient, subject string) (message bytes.
} }
func SendMail(config MailtoConfig, recipient string, msg []byte) (err error) { func SendMail(config MailtoConfig, recipient string, msg []byte) (err error) {
recipientLower := strings.ToLower(recipient)
for _, reg := range config.blacklistRegexes { for _, reg := range config.blacklistRegexes {
if reg.MatchString(recipient) { if reg.MatchString(recipientLower) {
return ErrBlacklistedAddress return ErrBlacklistedAddress
} }
} }
@ -154,6 +263,6 @@ func SendMail(config MailtoConfig, recipient string, msg []byte) (err error) {
return smtp.SendMail( return smtp.SendMail(
addr, auth, config.HeloDomain, config.Sender, []string{recipient}, msg, addr, auth, config.HeloDomain, config.Sender, []string{recipient}, msg,
config.RequireTLS, implicitTLS, config.Timeout, config.RequireTLS, implicitTLS, config.Protocol, config.localAddress, config.Timeout,
) )
} }

View file

@ -76,6 +76,7 @@ var (
errValidEmailRequired = errors.New("A valid email address is required for account registration") 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") errInvalidAccountRename = errors.New("Account renames can only change the casefolding of the account name")
errNameReserved = errors.New(`Name reserved due to a prior registration`) errNameReserved = errors.New(`Name reserved due to a prior registration`)
errInvalidBearerTokenType = errors.New("invalid bearer token type")
) )
// String Errors // String Errors

View file

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

View file

@ -1,4 +1,4 @@
//go:build !plan9 //go:build !(plan9 || solaris)
package flock package flock

View file

@ -1,4 +1,4 @@
//go:build plan9 //go:build plan9 || solaris
package flock package flock

View file

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

View file

@ -5,6 +5,7 @@ package irc
import ( import (
"fmt" "fmt"
"maps"
"net" "net"
"time" "time"
@ -18,10 +19,6 @@ func (server *Server) Config() (config *Config) {
return server.config.Load() return server.config.Load()
} }
func (server *Server) ChannelRegistrationEnabled() bool {
return server.Config().Channels.Registration.Enabled
}
func (server *Server) GetOperator(name string) (oper *Oper) { func (server *Server) GetOperator(name string) (oper *Oper) {
name, err := CasefoldName(name) name, err := CasefoldName(name)
if err != nil { if err != nil {
@ -413,7 +410,11 @@ func (client *Client) SetMode(mode modes.Mode, on bool) bool {
func (client *Client) SetRealname(realname string) { func (client *Client) SetRealname(realname string) {
client.stateMutex.Lock() client.stateMutex.Lock()
// TODO: make this configurable
client.realname = realname client.realname = realname
if len(realname) > 128 {
client.realname = client.realname[:128]
}
alwaysOn := client.registered && client.alwaysOn alwaysOn := client.registered && client.alwaysOn
client.stateMutex.Unlock() client.stateMutex.Unlock()
if alwaysOn { if alwaysOn {
@ -515,7 +516,7 @@ func (client *Client) GetReadMarker(cfname string) (result string) {
func (client *Client) copyReadMarkers() (result map[string]time.Time) { func (client *Client) copyReadMarkers() (result map[string]time.Time) {
client.stateMutex.RLock() client.stateMutex.RLock()
defer client.stateMutex.RUnlock() 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) { func (client *Client) SetReadMarker(cfname string, now time.Time) (result time.Time) {
@ -615,9 +616,11 @@ func (channel *Channel) Founder() string {
func (channel *Channel) HighestUserMode(client *Client) (result modes.Mode) { func (channel *Channel) HighestUserMode(client *Client) (result modes.Mode) {
channel.stateMutex.RLock() channel.stateMutex.RLock()
clientModes := channel.members[client].modes defer channel.stateMutex.RUnlock()
channel.stateMutex.RUnlock() if clientData, ok := channel.members[client]; ok {
return clientModes.HighestChannelUserMode() return clientData.modes.HighestChannelUserMode()
}
return
} }
func (channel *Channel) Settings() (result ChannelSettings) { func (channel *Channel) Settings() (result ChannelSettings) {

View file

@ -8,7 +8,6 @@ package irc
import ( import (
"bytes" "bytes"
"encoding/base64"
"fmt" "fmt"
"net" "net"
"os" "os"
@ -31,6 +30,7 @@ import (
"github.com/ergochat/ergo/irc/history" "github.com/ergochat/ergo/irc/history"
"github.com/ergochat/ergo/irc/jwt" "github.com/ergochat/ergo/irc/jwt"
"github.com/ergochat/ergo/irc/modes" "github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/oauth2"
"github.com/ergochat/ergo/irc/sno" "github.com/ergochat/ergo/irc/sno"
"github.com/ergochat/ergo/irc/utils" "github.com/ergochat/ergo/irc/utils"
) )
@ -90,8 +90,6 @@ func sendSuccessfulRegResponse(service *ircService, client *Client, rb *Response
details := client.Details() details := client.Details()
if service != nil { if service != nil {
service.Notice(rb, client.t("Account created")) 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())) client.server.snomasks.Send(sno.LocalAccounts, fmt.Sprintf(ircfmt.Unescape("Client $c[grey][$r%s$c[grey]] registered account $c[grey][$r%s$c[grey]] from IP %s"), details.nickMask, details.accountName, rb.session.IP().String()))
sendSuccessfulAccountAuth(service, client, rb, false) sendSuccessfulAccountAuth(service, client, rb, false)
@ -180,6 +178,10 @@ func acceptHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo
return false return false
} }
const (
saslMaxResponseLength = 8192 // implementation-defined sanity check, long enough for bearer tokens
)
// AUTHENTICATE [<mechanism>|<data>|*] // AUTHENTICATE [<mechanism>|<data>|*]
func authenticateHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { func authenticateHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
session := rb.session session := rb.session
@ -203,18 +205,21 @@ func authenticateHandler(server *Server, client *Client, msg ircmsg.Message, rb
return false return false
} }
// start new sasl session // start new sasl session: parameter is the authentication mechanism
if session.sasl.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.Round(time.Millisecond)))
return false
}
mechanism := strings.ToUpper(msg.Params[0]) mechanism := strings.ToUpper(msg.Params[0])
_, mechanismIsEnabled := EnabledSaslMechanisms[mechanism] _, 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 { if mechanismIsEnabled {
session.sasl.mechanism = mechanism session.sasl.mechanism = mechanism
if !config.Server.Compatibility.SendUnprefixedSasl { if !config.Server.Compatibility.SendUnprefixedSasl {
@ -232,46 +237,28 @@ func authenticateHandler(server *Server, client *Client, msg ircmsg.Message, rb
return false return false
} }
// continue existing sasl session // continue existing sasl session: parameter is a message chunk
rawData := msg.Params[0] done, value, err := session.sasl.value.Add(msg.Params[0])
if err == nil {
// https://ircv3.net/specs/extensions/sasl-3.1: if done {
// "The response is encoded in Base64 (RFC 4648), then split to 400-byte chunks, // call actual handler
// and each chunk is sent as a separate AUTHENTICATE command." handler := EnabledSaslMechanisms[session.sasl.mechanism]
saslMaxArgLength := 400 return handler(server, client, session, value, rb)
if len(rawData) > saslMaxArgLength { } 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")) rb.Add(nil, server.name, ERR_SASLTOOLONG, details.nick, client.t("SASL message too long"))
session.sasl.Clear() case ircutils.ErrSASLLimitExceeded:
return false rb.Add(nil, server.name, ERR_SASLFAIL, details.nick, client.t("SASL authentication failed: Passphrase too long"))
} else if len(rawData) == saslMaxArgLength { default:
// allow 4 'continuation' lines before rejecting for length rb.Add(nil, server.name, ERR_SASLFAIL, details.nick, client.t("SASL authentication failed: Invalid b64 encoding"))
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
} }
if rawData != "+" { session.sasl.Clear()
session.sasl.value += rawData return false
}
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)
} }
// AUTHENTICATE PLAIN // AUTHENTICATE PLAIN
@ -319,6 +306,27 @@ func authPlainHandler(server *Server, client *Client, session *Session, value []
return false 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) { func sendAuthErrorResponse(client *Client, rb *ResponseBuffer, err error) {
msg := authErrorToMessage(client.server, err) 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))) rb.Add(nil, client.server.name, ERR_SASLFAIL, client.nick, fmt.Sprintf("%s: %s", client.t("SASL authentication failed"), client.t(msg)))
@ -333,7 +341,7 @@ func authErrorToMessage(server *Server, err error) (msg string) {
} }
switch err { switch err {
case errAccountDoesNotExist, errAccountUnverified, errAccountInvalidCredentials, errAuthzidAuthcidMismatch, errNickAccountMismatch, errAccountSuspended: case errAccountDoesNotExist, errAccountUnverified, errAccountInvalidCredentials, errAuthzidAuthcidMismatch, errNickAccountMismatch, errAccountSuspended, oauth2.ErrInvalidToken:
return err.Error() return err.Error()
default: default:
// don't expose arbitrary error messages to the user // don't expose arbitrary error messages to the user
@ -353,28 +361,18 @@ func authExternalHandler(server *Server, client *Client, session *Session, value
// EXTERNAL doesn't carry an authentication ID (this is determined from the // EXTERNAL doesn't carry an authentication ID (this is determined from the
// certificate), but does carry an optional authorization ID. // certificate), but does carry an optional authorization ID.
var authzid string authzid := string(value)
var deviceID string
var err error var err error
if len(value) != 0 { // see #843: strip the device ID for the benefit of clients that don't
authzid, err = CasefoldName(string(value)) // distinguish user/ident from account name
if err != nil { if strudelIndex := strings.IndexByte(authzid, '@'); strudelIndex != -1 {
err = errAuthzidAuthcidMismatch authzid, deviceID = authzid[:strudelIndex], authzid[strudelIndex+1:]
}
} }
if err == nil { 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) err = server.accounts.AuthenticateByCertificate(client, rb.session.certfp, rb.session.peerCerts, authzid)
} }
if err != nil { if err != nil {
sendAuthErrorResponse(client, rb, err) sendAuthErrorResponse(client, rb, err)
return false return false
@ -383,6 +381,9 @@ func authExternalHandler(server *Server, client *Client, session *Session, value
} }
sendSuccessfulAccountAuth(nil, client, rb, true) sendSuccessfulAccountAuth(nil, client, rb, true)
if !client.registered && deviceID != "" {
rb.session.deviceID = deviceID
}
return false return false
} }
@ -397,6 +398,12 @@ func authScramHandler(server *Server, client *Client, session *Session, value []
// first message? if so, initialize the SCRAM conversation // first message? if so, initialize the SCRAM conversation
if session.sasl.scramConv == nil { 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() session.sasl.scramConv = server.accounts.NewScramConversation()
} }
@ -420,9 +427,8 @@ func authScramHandler(server *Server, client *Client, session *Session, value []
account, err := server.accounts.LoadAccount(authcid) account, err := server.accounts.LoadAccount(authcid)
if err == nil { if err == nil {
server.accounts.Login(client, account) server.accounts.Login(client, account)
if fixupNickEqualsAccount(client, rb, server.Config(), "") { // fixupNickEqualsAccount is not needed for unregistered clients
sendSuccessfulAccountAuth(nil, client, rb, true) sendSuccessfulAccountAuth(nil, client, rb, true)
}
} else { } else {
server.logger.Error("internal", "SCRAM succeeded but couldn't load account", authcid, err.Error()) 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")) rb.Add(nil, server.name, ERR_SASLFAIL, client.nick, client.t("SASL authentication failed"))
@ -435,7 +441,7 @@ func authScramHandler(server *Server, client *Client, session *Session, value []
response, err := session.sasl.scramConv.Step(string(value)) response, err := session.sasl.scramConv.Step(string(value))
if err == nil { if err == nil {
rb.Add(nil, server.name, "AUTHENTICATE", base64.StdEncoding.EncodeToString([]byte(response))) sendSASLChallenge(server, rb, []byte(response))
} else { } else {
continueAuth = false continueAuth = false
rb.Add(nil, server.name, ERR_SASLFAIL, client.Nick(), err.Error()) rb.Add(nil, server.name, ERR_SASLFAIL, client.Nick(), err.Error())
@ -445,13 +451,65 @@ func authScramHandler(server *Server, client *Client, session *Session, value []
return false return false
} }
// AUTHENTICATE OAUTHBEARER
func authOauthBearerHandler(server *Server, client *Client, session *Session, value []byte, rb *ResponseBuffer) bool {
if !server.Config().Accounts.OAuth2.Enabled {
rb.Add(nil, server.name, ERR_SASLFAIL, client.Nick(), "SASL authentication failed: mechanism not enabled")
return false
}
if session.sasl.oauthConv == nil {
session.sasl.oauthConv = oauth2.NewOAuthBearerServer(
func(opts oauth2.OAuthBearerOptions) *oauth2.OAuthBearerError {
err := server.accounts.AuthenticateByOAuthBearer(client, opts)
switch err {
case nil:
return nil
case oauth2.ErrInvalidToken:
return &oauth2.OAuthBearerError{Status: "invalid_token", Schemes: "bearer"}
case errFeatureDisabled:
return &oauth2.OAuthBearerError{Status: "invalid_request", Schemes: "bearer"}
default:
// this is probably a misconfiguration or infrastructure error so we should log it
server.logger.Error("internal", "failed to validate OAUTHBEARER token", err.Error())
// tell the client it was their fault even though it probably wasn't:
return &oauth2.OAuthBearerError{Status: "invalid_request", Schemes: "bearer"}
}
},
)
}
challenge, done, err := session.sasl.oauthConv.Next(value)
if done {
if err == nil {
sendSuccessfulAccountAuth(nil, client, rb, true)
} else {
rb.Add(nil, server.name, ERR_SASLFAIL, client.Nick(), ircutils.SanitizeText(err.Error(), 350))
}
session.sasl.Clear()
} else {
// ignore `err`, we need to relay the challenge (which may contain a JSON-encoded error)
// to the client
sendSASLChallenge(server, rb, challenge)
}
return false
}
// helper to b64 a sasl response and chunk it into 400-byte lines
// as per https://ircv3.net/specs/extensions/sasl-3.1
func sendSASLChallenge(server *Server, rb *ResponseBuffer, challenge []byte) {
for _, chunk := range ircutils.EncodeSASLResponse(challenge) {
rb.Add(nil, server.name, "AUTHENTICATE", chunk)
}
}
// AWAY [<message>] // AWAY [<message>]
func awayHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { func awayHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
// #1996: `AWAY :` is treated the same as `AWAY` // #1996: `AWAY :` is treated the same as `AWAY`
var awayMessage string var awayMessage string
if len(msg.Params) > 0 { if len(msg.Params) > 0 {
awayMessage = msg.Params[0] awayMessage = msg.Params[0]
awayMessage = ircutils.TruncateUTF8Safe(awayMessage, server.Config().Limits.AwayLen) awayMessage = ircmsg.TruncateUTF8Safe(awayMessage, server.Config().Limits.AwayLen)
} }
wasAway, nowAway := rb.session.SetAway(awayMessage) wasAway, nowAway := rb.session.SetAway(awayMessage)
@ -512,6 +570,16 @@ func batchHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon
// XXX changing the label inside a handler is a bit dodgy, but it works here // XXX changing the label inside a handler is a bit dodgy, but it works here
// because there's no way we could have triggered a flush up to this point // because there's no way we could have triggered a flush up to this point
rb.Label = batch.responseLabel rb.Label = batch.responseLabel
for _, msg := range batch.message.Split {
signatures := server.GenerateImagorSignatures(msg.Message)
if len(signatures) > 0 {
if msg.Tags == nil {
msg.Tags = make(map[string]string)
}
msg.Tags["signatures"] = signatures
}
}
dispatchMessageToTarget(client, batch.tags, histType, batch.command, batch.target, batch.message, rb) dispatchMessageToTarget(client, batch.tags, histType, batch.command, batch.target, batch.message, rb)
} }
} }
@ -644,6 +712,14 @@ func chathistoryHandler(server *Server, client *Client, msg ircmsg.Message, rb *
var err error var err error
var listTargets bool var listTargets bool
var targets []history.TargetListing var targets []history.TargetListing
var _, batchIdentifier = msg.GetTag("identifier")
var assuredPreposition = "error"
var limit int
if len(batchIdentifier) == 0 {
batchIdentifier = "UNIDENTIFIED"
}
defer func() { defer func() {
// errors are sent either without a batch, or in a draft/labeled-response batch as usual // errors are sent either without a batch, or in a draft/labeled-response batch as usual
if err == utils.ErrInvalidParams { if err == utils.ErrInvalidParams {
@ -655,7 +731,7 @@ func chathistoryHandler(server *Server, client *Client, msg ircmsg.Message, rb *
} else { } else {
// successful responses are sent as a chathistory or history batch // successful responses are sent as a chathistory or history batch
if listTargets { if listTargets {
batchID := rb.StartNestedBatch("draft/chathistory-targets") batchID := rb.StartNestedBatch(caps.ChathistoryTargetsBatchType)
defer rb.EndNestedBatch(batchID) defer rb.EndNestedBatch(batchID)
for _, target := range targets { for _, target := range targets {
name := server.UnfoldName(target.CfName) name := server.UnfoldName(target.CfName)
@ -663,9 +739,9 @@ func chathistoryHandler(server *Server, client *Client, msg ircmsg.Message, rb *
target.Time.Format(IRCv3TimestampFormat)) target.Time.Format(IRCv3TimestampFormat))
} }
} else if channel != nil { } else if channel != nil {
channel.replayHistoryItems(rb, items, true) channel.replayHistoryItems(rb, items, true, batchIdentifier, assuredPreposition, limit)
} else { } else {
client.replayPrivmsgHistory(rb, items, target, true) client.replayPrivmsgHistory(rb, items, target, true, batchIdentifier, assuredPreposition, limit)
} }
} }
}() }()
@ -718,7 +794,6 @@ func chathistoryHandler(server *Server, client *Client, msg ircmsg.Message, rb *
paramPos := 2 paramPos := 2
var start, end history.Selector var start, end history.Selector
var limit int
switch preposition { switch preposition {
case "targets": case "targets":
// use the same selector parsing as BETWEEN, // use the same selector parsing as BETWEEN,
@ -773,6 +848,7 @@ func chathistoryHandler(server *Server, client *Client, msg ircmsg.Message, rb *
err = utils.ErrInvalidParams err = utils.ErrInvalidParams
return return
} }
assuredPreposition = preposition
if listTargets { if listTargets {
targets, err = client.listTargets(start, end, limit) targets, err = client.listTargets(start, end, limit)
@ -797,7 +873,6 @@ func debugHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon
switch param { switch param {
case "GCSTATS": case "GCSTATS":
stats := debug.GCStats{ stats := debug.GCStats{
Pause: make([]time.Duration, 10),
PauseQuantiles: make([]time.Duration, 5), PauseQuantiles: make([]time.Duration, 5),
} }
debug.ReadGCStats(&stats) debug.ReadGCStats(&stats)
@ -1070,6 +1145,12 @@ func extjwtHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo
} }
claims["channel"] = channel.Name() claims["channel"] = channel.Name()
var channelModeStrings []string
for _, mode := range channel.flags.AllModes() {
channelModeStrings = append(channelModeStrings, mode.String())
}
claims["chanModes"] = channelModeStrings
claims["joined"] = 0 claims["joined"] = 0
claims["cmodes"] = []string{} claims["cmodes"] = []string{}
if present, joinTimeSecs, cModes := channel.ClientStatus(client); present { if present, joinTimeSecs, cModes := channel.ClientStatus(client); present {
@ -1153,29 +1234,34 @@ Get an explanation of <argument>, or "index" for a list of help topics.`), rb)
// HISTORY alice 15 // HISTORY alice 15
// HISTORY #darwin 1h // HISTORY #darwin 1h
func historyHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { func historyHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
config := server.Config() rb.Notice(client.t("This command is currently disabled. Please use CHATHISTORY"))
if !config.History.Enabled { /*
rb.Notice(client.t("This command has been disabled by the server administrators")) config := server.Config()
return false if !config.History.Enabled {
} rb.Notice(client.t("This command has been disabled by the server administrators"))
return false
items, channel, err := easySelectHistory(server, client, msg.Params)
if err == errNoSuchChannel {
rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), utils.SafeErrorParam(msg.Params[0]), client.t("No such channel"))
return false
} else if err != nil {
rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.Nick(), msg.Command, client.t("Could not retrieve history"))
return false
}
if len(items) != 0 {
if channel != nil {
channel.replayHistoryItems(rb, items, true)
} else {
client.replayPrivmsgHistory(rb, items, "", true)
} }
}
items, channel, err := easySelectHistory(server, client, msg.Params)
if err == errNoSuchChannel {
rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), utils.SafeErrorParam(msg.Params[0]), client.t("No such channel"))
return false
} else if err != nil {
rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.Nick(), msg.Command, client.t("Could not retrieve history"))
return false
}
var _, batchIdentifier = msg.GetTag("identifier")
if len(items) != 0 {
if channel != nil {
channel.replayHistoryItems(rb, items, true, batchIdentifier)
} else {
client.replayPrivmsgHistory(rb, items, "", true, batchIdentifier)
}
}
*/
return false return false
} }
@ -1263,6 +1349,15 @@ func isonHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons
return false return false
} }
// ISUPPORT
func isupportHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
server.RplISupport(client, rb)
if !client.registered {
rb.session.isupportSentPrereg = true
}
return false
}
// JOIN <channel>{,<channel>} [<key>{,<key>}] // JOIN <channel>{,<channel>} [<key>{,<key>}]
func joinHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { func joinHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
// #1417: allow `JOIN 0` with a confirmation code // #1417: allow `JOIN 0` with a confirmation code
@ -1831,11 +1926,12 @@ func announceCmodeChanges(channel *Channel, applied modes.ModeChanges, source, a
} }
} }
channel.AddHistoryItem(history.Item{ channel.AddHistoryItem(history.Item{
Type: history.Mode, Type: history.Mode,
Nick: source, Nick: source,
AccountName: accountName, Account: accountName,
Message: message, Message: message,
IsBot: isBot, Target: channel.NameCasefolded(),
IsBot: isBot,
}, account) }, account)
} }
} }
@ -2060,8 +2156,6 @@ func namesHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon
channels = strings.Split(msg.Params[0], ",") 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 // implement the modern behavior: https://modern.ircdocs.horse/#names-message
// "Servers MAY only return information about the first <channel> and silently ignore the others." // "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 // "If no parameter is given for this command, servers SHOULD return one RPL_ENDOFNAMES numeric
@ -2128,6 +2222,7 @@ func validateLineLen(msgType history.ItemType, source, target, payload string) (
default: default:
return true return true
} }
limit -= len(target)
limit -= len(payload) limit -= len(payload)
return limit >= 0 return limit >= 0
} }
@ -2148,39 +2243,50 @@ func validateSplitMessageLen(msgType history.ItemType, source, target string, me
// helper to store a batched PRIVMSG in the session object // helper to store a batched PRIVMSG in the session object
func absorbBatchedMessage(server *Server, client *Client, msg ircmsg.Message, batchTag string, histType history.ItemType, rb *ResponseBuffer) { func absorbBatchedMessage(server *Server, client *Client, msg ircmsg.Message, batchTag string, histType history.ItemType, rb *ResponseBuffer) {
var errorCode, errorMessage string var failParams []string
defer func() { defer func() {
if errorCode != "" { if failParams != nil {
if histType != history.Notice { if histType != history.Notice {
rb.Add(nil, server.name, "FAIL", "BATCH", errorCode, errorMessage) params := make([]string, 1+len(failParams))
params[0] = "BATCH"
copy(params[1:], failParams)
rb.Add(nil, server.name, "FAIL", params...)
} }
rb.session.EndMultilineBatch("") rb.session.EndMultilineBatch("")
} }
}() }()
if batchTag != rb.session.batch.label { if batchTag != rb.session.batch.label {
errorCode, errorMessage = "MULTILINE_INVALID", client.t("Incorrect batch tag sent") failParams = []string{"MULTILINE_INVALID", client.t("Incorrect batch tag sent")}
return return
} else if len(msg.Params) < 2 { } else if len(msg.Params) < 2 {
errorCode, errorMessage = "MULTILINE_INVALID", client.t("Invalid multiline batch") failParams = []string{"MULTILINE_INVALID", client.t("Invalid multiline batch")}
return return
} }
rb.session.batch.command = msg.Command rb.session.batch.command = msg.Command
isConcat, _ := msg.GetTag(caps.MultilineConcatTag) isConcat, _ := msg.GetTag(caps.MultilineConcatTag)
if isConcat && len(msg.Params[1]) == 0 { if isConcat && len(msg.Params[1]) == 0 {
errorCode, errorMessage = "MULTILINE_INVALID", client.t("Cannot send a blank line with the multiline concat tag") failParams = []string{"MULTILINE_INVALID", client.t("Cannot send a blank line with the multiline concat tag")}
return return
} }
if !isConcat && len(rb.session.batch.message.Split) != 0 { if !isConcat && len(rb.session.batch.message.Split) != 0 {
rb.session.batch.lenBytes++ // bill for the newline rb.session.batch.lenBytes++ // bill for the newline
} }
rb.session.batch.message.Append(msg.Params[1], isConcat) rb.session.batch.message.Append(msg.Params[1], isConcat, msg.ClientOnlyTags())
rb.session.batch.lenBytes += len(msg.Params[1]) rb.session.batch.lenBytes += len(msg.Params[1])
config := server.Config() config := server.Config()
if config.Limits.Multiline.MaxBytes < rb.session.batch.lenBytes { if config.Limits.Multiline.MaxBytes < rb.session.batch.lenBytes {
errorCode, errorMessage = "MULTILINE_MAX_BYTES", strconv.Itoa(config.Limits.Multiline.MaxBytes) failParams = []string{
"MULTILINE_MAX_BYTES",
strconv.Itoa(config.Limits.Multiline.MaxBytes),
fmt.Sprintf(client.t("Multiline batch byte limit %d exceeded"), config.Limits.Multiline.MaxBytes),
}
} else if config.Limits.Multiline.MaxLines != 0 && config.Limits.Multiline.MaxLines < rb.session.batch.message.LenLines() { } else if config.Limits.Multiline.MaxLines != 0 && config.Limits.Multiline.MaxLines < rb.session.batch.message.LenLines() {
errorCode, errorMessage = "MULTILINE_MAX_LINES", strconv.Itoa(config.Limits.Multiline.MaxLines) failParams = []string{
"MULTILINE_MAX_LINES",
strconv.Itoa(config.Limits.Multiline.MaxLines),
fmt.Sprintf(client.t("Multiline batch line limit %d exceeded"), config.Limits.Multiline.MaxLines),
}
} }
} }
@ -2244,11 +2350,53 @@ func messageHandler(server *Server, client *Client, msg ircmsg.Message, rb *Resp
// each target gets distinct msgids // each target gets distinct msgids
splitMsg := utils.MakeMessage(message) splitMsg := utils.MakeMessage(message)
signatures := server.GenerateImagorSignaturesFromMessage(&msg)
if len(signatures) > 0 {
if clientOnlyTags == nil {
clientOnlyTags = make(map[string]string)
}
clientOnlyTags["signatures"] = signatures
}
dispatchMessageToTarget(client, clientOnlyTags, histType, msg.Command, targetString, splitMsg, rb) dispatchMessageToTarget(client, clientOnlyTags, histType, msg.Command, targetString, splitMsg, rb)
} }
return false return false
} }
// Not really sure how to do this in Go
var endChars = map[int32]bool{
' ': true,
'@': true,
':': true,
'!': true,
'?': true,
}
func detectMentions(message string) (mentions []string) {
buf := ""
mentions = []string{}
working := false
for _, char := range message {
if char == '@' {
working = true
continue
}
if !working {
continue
}
if _, stop := endChars[char]; stop {
working = false
mentions = append(mentions, buf)
buf = ""
} else {
buf += string(char)
}
}
if len(buf) != 0 {
mentions = append(mentions, buf)
}
return
}
func dispatchMessageToTarget(client *Client, tags map[string]string, histType history.ItemType, command, target string, message utils.SplitMessage, rb *ResponseBuffer) { func dispatchMessageToTarget(client *Client, tags map[string]string, histType history.ItemType, command, target string, message utils.SplitMessage, rb *ResponseBuffer) {
server := client.server server := client.server
@ -2265,7 +2413,15 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi
} }
return return
} }
// This likely isn't that great for performance. Should figure out some way to deal with this at some point
mentions := detectMentions(message.Message)
channel.SendSplitMessage(command, lowestPrefix, tags, client, message, rb) channel.SendSplitMessage(command, lowestPrefix, tags, client, message, rb)
for _, mention := range mentions {
user := client.server.clients.Get(mention)
if user != nil {
user.RedisBroadcast("MENTION", channel.Name(), message.Msgid)
}
}
} else if target[0] == '$' && len(target) > 2 && client.Oper().HasRoleCapab("massmessage") { } else if target[0] == '$' && len(target) > 2 && client.Oper().HasRoleCapab("massmessage") {
details := client.Details() details := client.Details()
matcher, err := utils.CompileGlob(target[2:], false) matcher, err := utils.CompileGlob(target[2:], false)
@ -2288,6 +2444,7 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi
} }
} }
} else { } else {
// PMs
lowercaseTarget := strings.ToLower(target) lowercaseTarget := strings.ToLower(target)
service, isService := ErgoServices[lowercaseTarget] service, isService := ErgoServices[lowercaseTarget]
_, isZNC := zncHandlers[lowercaseTarget] _, isZNC := zncHandlers[lowercaseTarget]
@ -2387,8 +2544,13 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi
Type: histType, Type: histType,
Message: message, Message: message,
Tags: tags, Tags: tags,
Target: user.Account(),
Account: client.Account(),
} }
client.addHistoryItem(user, item, &details, &tDetails, config) client.addHistoryItem(user, item, &details, &tDetails, config)
user.RedisBroadcast("MENTION", user.NickCasefolded(), message.Msgid)
} }
} }
@ -2665,6 +2827,97 @@ fail:
return false 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.GenerateMessageIdStr()
time := time.Now().UTC().Round(0)
details := client.Details()
isBot := client.HasMode(modes.Bot)
if target[0] == '#' {
channel := server.channels.Get(target)
if channel == nil {
rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), utils.SafeErrorParam(target), client.t("No such channel"))
return false
}
members = channel.Members()
canDelete = deletionPolicy(server, client, target)
} else {
targetClient := server.clients.Get(target)
if targetClient == nil {
rb.Add(nil, server.name, ERR_NOSUCHNICK, client.Nick(), target, "No such nick")
return false
}
members = []*Client{client, targetClient}
canDelete = canDeleteSelf
}
if canDelete == canDeleteNone {
rb.Add(nil, server.name, "FAIL", "REDACT", "REDACT_FORBIDDEN", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), client.t("You are not authorized to delete messages"))
return false
}
account := "*"
if canDelete == canDeleteSelf {
account = client.account
if account == "*" {
rb.Add(nil, server.name, "FAIL", "REDACT", "REDACT_FORBIDDEN", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), client.t("You are not authorized to delete this message"))
return false
}
}
err := server.DeleteMessage(target, targetmsgid, account)
if err == errNoop {
rb.Add(nil, server.name, "FAIL", "REDACT", "UNKNOWN_MSGID", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), client.t("This message does not exist or is too old"))
return false
} else if err != nil {
isOper := client.HasRoleCapabs("history")
if isOper {
rb.Add(nil, server.name, "FAIL", "REDACT", "REDACT_FORBIDDEN", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), fmt.Sprintf(client.t("Error deleting message: %v"), err))
} else {
rb.Add(nil, server.name, "FAIL", "REDACT", "REDACT_FORBIDDEN", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), client.t("Could not delete message"))
}
return false
}
if target[0] != '#' {
// If this is a PM, we just removed the message from the buffer of the other party;
// now we have to remove it from the buffer of the client who sent the REDACT command
err := server.DeleteMessage(client.Nick(), targetmsgid, account)
if err != nil {
client.server.logger.Error("internal", fmt.Sprintf("Private message %s is not deletable by %s from their own buffer's even though we just deleted it from %s's. This is a bug, please report it in details.", targetmsgid, client.Nick(), target), client.Nick())
isOper := client.HasRoleCapabs("history")
if isOper {
rb.Add(nil, server.name, "FAIL", "REDACT", "REDACT_FORBIDDEN", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), fmt.Sprintf(client.t("Error deleting message: %v"), err))
} else {
rb.Add(nil, server.name, "FAIL", "REDACT", "REDACT_FORBIDDEN", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), client.t("Error deleting message"))
}
}
}
for _, member := range members {
for _, session := range member.Sessions() {
if session.capabilities.Has(caps.MessageRedaction) {
session.sendFromClientInternal(false, time, msgid, details.nickMask, details.accountName, isBot, nil, "REDACT", target, targetmsgid, reason)
} else {
// If we wanted to send a fallback to clients which do not support
// draft/message-redaction, we would do it from here.
}
}
}
return false
}
func reportPersistenceStatus(client *Client, rb *ResponseBuffer, broadcast bool) { func reportPersistenceStatus(client *Client, rb *ResponseBuffer, broadcast bool) {
settings := client.AccountSettings() settings := client.AccountSettings()
serverSetting := client.server.Config().Accounts.Multiclient.AlwaysOn serverSetting := client.server.Config().Accounts.Multiclient.AlwaysOn
@ -2729,7 +2982,7 @@ func registerHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res
case "*", accountName: case "*", accountName:
// ok // ok
default: 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 return
} }
@ -2965,6 +3218,8 @@ func relaymsgHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res
Type: history.Privmsg, Type: history.Privmsg,
Message: message, Message: message,
Nick: nuh, Nick: nuh,
Target: channel.NameCasefolded(),
Account: "$RELAYMSG",
}, "") }, "")
// 3 possibilities for tags: // 3 possibilities for tags:
@ -3080,7 +3335,9 @@ func renameHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo
targetRb.Add(nil, targetPrefix, "JOIN", newName) targetRb.Add(nil, targetPrefix, "JOIN", newName)
} }
channel.SendTopic(mcl, targetRb, false) channel.SendTopic(mcl, targetRb, false)
channel.Names(mcl, targetRb) if !targetRb.session.capabilities.Has(caps.NoImplicitNames) {
channel.Names(mcl, targetRb)
}
} }
if mcl != client { if mcl != client {
targetRb.Send(false) targetRb.Send(false)
@ -3245,6 +3502,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")) rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.Nick(), "USER", client.t("Not enough parameters"))
return false 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` // #843: we accept either: `USER user:pass@clientid` or `USER user@clientid`
if strudelIndex := strings.IndexByte(username, '@'); strudelIndex != -1 { if strudelIndex := strings.IndexByte(username, '@'); strudelIndex != -1 {
@ -3379,8 +3640,9 @@ func webircHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo
} }
} }
config := server.Config()
givenPassword := []byte(msg.Params[0]) 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) { if utils.IPInNets(client.realIP, info.allowedNets) {
// confirm password and/or fingerprint // confirm password and/or fingerprint
if 0 < len(info.Password) && bcrypt.CompareHashAndPassword(info.Password, givenPassword) != nil { if 0 < len(info.Password) && bcrypt.CompareHashAndPassword(info.Password, givenPassword) != nil {
@ -3390,11 +3652,23 @@ func webircHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo
continue 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 { if err != nil {
client.Quit(quitMsg, rb.session) client.Quit(quitMsg, rb.session)
return true return true
} else { } 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 return false
} }
} }
@ -3811,6 +4085,33 @@ func zncHandler(server *Server, client *Client, msg ircmsg.Message, rb *Response
return false return false
} }
// REACT <msgid> :<reaction>
func reactHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
// This directly uses SQL stuff, since it's targeted at CEF, which requires a DB.
_, _, target, sender, _, pm, err := server.historyDB.GetMessage(msg.Params[0])
if err != nil {
return false
}
var operation string
if server.historyDB.HasReactionFromUser(msg.Params[0], client.AccountName(), msg.Params[1]) {
server.historyDB.DeleteReaction(msg.Params[0], client.AccountName(), msg.Params[1])
operation = "DEL"
} else {
server.historyDB.AddReaction(msg.Params[0], client.AccountName(), msg.Params[1])
operation = "ADD"
}
if pm {
server.clients.Get(target).Send(nil, client.NickMaskString(), "REACT", operation, msg.Params[0], msg.Params[1])
server.clients.Get(sender).Send(nil, client.NickMaskString(), "REACT", operation, msg.Params[0], msg.Params[1])
} else {
server.channels.Get(target).BroadcastFrom(client.NickMaskString(), "REACT", operation, msg.Params[0], msg.Params[1])
}
return false
}
// fake handler for unknown commands // fake handler for unknown commands
func unknownCommandHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { func unknownCommandHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
var message string var message string

View file

@ -259,6 +259,11 @@ appropriate channel privs.`,
text: `ISON <nickname>{ <nickname>} text: `ISON <nickname>{ <nickname>}
Returns whether the given nicks exist on the network.`, Returns whether the given nicks exist on the network.`,
},
"isupport": {
text: `ISUPPORT
Returns RPL_ISUPPORT lines describing the server's capabilities.`,
}, },
"join": { "join": {
text: `JOIN <channel>{,<channel>} [<key>{,<key>}] text: `JOIN <channel>{,<channel>} [<key>{,<key>}]
@ -435,6 +440,12 @@ Replies to a PING. Used to check link connectivity.`,
text: `PRIVMSG <target>{,<target>} <text to be sent> text: `PRIVMSG <target>{,<target>} <text to be sent>
Sends the text to the given targets as a PRIVMSG.`, 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": { "relaymsg": {
text: `RELAYMSG <channel> <spoofed nick> :<message> text: `RELAYMSG <channel> <spoofed nick> :<message>
@ -623,6 +634,12 @@ for direct use by end users.`,
duplicate: true, duplicate: true,
}, },
"react": {
text: `REACT <msgid> <reaction>
Toggles a reaction to a message. CEF-specific`,
},
// Informational // Informational
"modes": { "modes": {
textGenerator: modesTextGenerator, textGenerator: modesTextGenerator,

View file

@ -4,6 +4,7 @@
package history package history
import ( import (
"slices"
"sync" "sync"
"time" "time"
@ -31,13 +32,20 @@ const (
initialAutoSize = 32 initialAutoSize = 32
) )
type Reaction struct {
Name string
Total int
SampleUsers []string
}
// Item represents an event (e.g., a PRIVMSG or a JOIN) and its associated data // Item represents an event (e.g., a PRIVMSG or a JOIN) and its associated data
type Item struct { type Item struct {
Type ItemType Type ItemType
Nick string Nick string
// this is the uncasefolded account name, if there's no account it should be set to "*" // this is the uncasefolded account name, if there's no account it should be set to "*"
AccountName string // in cef, this is always set, at least in theory. cant wait for bugs toc rop up
Account string
// for non-privmsg items, we may stuff some other data in here // for non-privmsg items, we may stuff some other data in here
Message utils.SplitMessage Message utils.SplitMessage
Tags map[string]string Tags map[string]string
@ -45,8 +53,10 @@ type Item struct {
// for a DM, this is the casefolded nickname of the other party (whether this is // for a DM, this is the casefolded nickname of the other party (whether this is
// an incoming or outgoing message). this lets us emulate the "query buffer" functionality // an incoming or outgoing message). this lets us emulate the "query buffer" functionality
// required by CHATHISTORY: // required by CHATHISTORY:
CfCorrespondent string `json:"CfCorrespondent,omitempty"` Target string `json:"Target"`
IsBot bool `json:"IsBot,omitempty"` IsBot bool `json:"IsBot,omitempty"`
Reactions []Reaction
} }
// HasMsgid tests whether a message has the message id `msgid`. // HasMsgid tests whether a message has the message id `msgid`.
@ -155,7 +165,7 @@ func (list *Buffer) betweenHelper(start, end Selector, cutoff time.Time, pred Pr
defer func() { defer func() {
if !ascending { if !ascending {
utils.ReverseSlice(results) slices.Reverse(results)
} }
}() }()
@ -212,10 +222,10 @@ func (list *Buffer) allCorrespondents() (results []TargetListing) {
stop := list.start stop := list.start
for { for {
if !seen.Has(list.buffer[pos].CfCorrespondent) { if !seen.Has(list.buffer[pos].Target) {
seen.Add(list.buffer[pos].CfCorrespondent) seen.Add(list.buffer[pos].Target)
results = append(results, TargetListing{ results = append(results, TargetListing{
CfName: list.buffer[pos].CfCorrespondent, CfName: list.buffer[pos].Target,
Time: list.buffer[pos].Message.Time, Time: list.buffer[pos].Message.Time,
}) })
} }
@ -262,7 +272,7 @@ func (list *Buffer) listCorrespondents(start, end Selector, cutoff time.Time, li
} }
if !ascending { if !ascending {
utils.ReverseSlice(results) slices.Reverse(results)
} }
return return
@ -280,7 +290,7 @@ func (list *Buffer) MakeSequence(correspondent string, cutoff time.Time) Sequenc
var pred Predicate var pred Predicate
if correspondent != "" { if correspondent != "" {
pred = func(item *Item) bool { pred = func(item *Item) bool {
return item.CfCorrespondent == correspondent return item.Target == correspondent
} }
} }
return &bufferSequence{ return &bufferSequence{

View file

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

View file

@ -15,6 +15,14 @@ import (
"github.com/ergochat/ergo/irc/utils" "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 ( const (
histservHelp = `HistServ provides commands related to history.` 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)) 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) { // Returns:
target, msgid := params[0], params[1] // Fix #1881 2 params are required //
// 1. `canDeleteAny` if the client allowed to delete other users' messages from the target, ie.:
// operators can delete; if individual delete is allowed, a chanop or // - the client is a channel operator, or
// the message author can delete // - the client is an operator with "history" capability
accountName := "*" //
isChanop := false // 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") isOper := client.HasRoleCapabs("history")
if !isOper { if isOper {
return canDeleteAny
} else {
if server.Config().History.Retention.AllowIndividualDelete { if server.Config().History.Retention.AllowIndividualDelete {
channel := server.channels.Get(target) channel := server.channels.Get(target)
if channel != nil && channel.ClientIsAtLeast(client, modes.Operator) { if channel != nil && channel.ClientIsAtLeast(client, modes.Operator) {
isChanop = true return canDeleteAny
} else { } 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")) service.Notice(rb, client.t("Insufficient privileges"))
return return
} else if canDelete == canDeleteSelf {
accountName = client.AccountName()
if accountName == "*" {
service.Notice(rb, client.t("Insufficient privileges"))
return
}
} }
err := server.DeleteMessage(target, msgid, accountName) err := server.DeleteMessage(target, msgid, accountName)
if err == nil { if err == nil {
service.Notice(rb, client.t("Successfully deleted message")) service.Notice(rb, client.t("Successfully deleted message"))
} else { } else {
isOper := client.HasRoleCapabs("history")
if isOper { if isOper {
service.Notice(rb, fmt.Sprintf(client.t("Error deleting message: %v"), err)) service.Notice(rb, fmt.Sprintf(client.t("Error deleting message: %v"), err))
} else { } else {

View file

@ -11,6 +11,12 @@ import (
const ( const (
maxLastArgLength = 400 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 // List holds a list of ISUPPORT tokens
@ -95,7 +101,7 @@ func (il *List) GetDifference(newil *List) [][]string {
length += len(token) length += len(token)
} }
if len(cache) == 13 || len(token)+length >= maxLastArgLength { if len(cache) == maxParameters || len(token)+length >= maxLastArgLength {
replies = append(replies, cache) replies = append(replies, cache)
cache = make([]string, 0) cache = make([]string, 0)
length = 0 length = 0
@ -138,7 +144,7 @@ func (il *List) RegenerateCachedReply() (err error) {
length += len(token) 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) il.CachedReply = append(il.CachedReply, cache)
cache = make([]string, 0) cache = make([]string, 0)
length = 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 ( import (
"crypto/rsa" "crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors" "errors"
"fmt"
"os" "os"
"time" "time"
"github.com/golang-jwt/jwt" jwt "github.com/golang-jwt/jwt/v5"
) )
var ( var (
ErrNoKeys = errors.New("No signing keys are enabled") ErrNoKeys = errors.New("No EXTJWT signing keys are enabled")
) )
type MapClaims jwt.MapClaims type MapClaims jwt.MapClaims
@ -38,22 +35,10 @@ func (t *JwtServiceConfig) Postprocess() (err error) {
if err != nil { if err != nil {
return err return err
} }
d, _ := pem.Decode(keyBytes) t.rsaPrivateKey, err = jwt.ParseRSAPrivateKeyFromPEM(keyBytes)
if err != nil { if err != nil {
return err 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 return nil
} }
@ -64,7 +49,7 @@ func (t *JwtServiceConfig) Enabled() bool {
func (t *JwtServiceConfig) Sign(claims MapClaims) (result string, err error) { func (t *JwtServiceConfig) Sign(claims MapClaims) (result string, err error) {
claims["exp"] = time.Now().Unix() + int64(t.Expiration/time.Second) claims["exp"] = time.Now().Unix() + int64(t.Expiration/time.Second)
claims["now"] = time.Now().Unix()
if t.rsaPrivateKey != nil { if t.rsaPrivateKey != nil {
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(claims)) token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(claims))
return token.SignedString(t.rsaPrivateKey) return token.SignedString(t.rsaPrivateKey)

View file

@ -253,6 +253,6 @@ func (km *KLineManager) loadFromDatastore() {
} }
func (s *Server) loadKLines() { func (server *Server) loadKLines() {
s.klines = NewKLineManager(s) server.klines = NewKLineManager(server)
} }

View file

@ -5,6 +5,7 @@ package irc
import ( import (
"errors" "errors"
"io/fs"
"net" "net"
"net/http" "net/http"
"os" "os"
@ -29,7 +30,7 @@ type IRCListener interface {
// NewListener creates a new listener according to the specifications in the config file // 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) { 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 { if err != nil {
return 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:") addr = strings.TrimPrefix(addr, "unix:")
if strings.HasPrefix(addr, "/") { if strings.HasPrefix(addr, "/") {
// https://stackoverflow.com/a/34881585 // 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) listener, err = net.Listen("unix", addr)
if err == nil && bindMode != 0 { if err == nil && bindMode != 0 {
os.Chmod(addr, bindMode) os.Chmod(addr, bindMode)

View file

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

View file

@ -158,7 +158,6 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c
var alreadySentPrivError bool var alreadySentPrivError bool
maskOpCount := 0
chname := channel.Name() chname := channel.Name()
details := client.Details() details := client.Details()
@ -192,6 +191,11 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c
} }
} }
// should we send 324 RPL_CHANNELMODEIS? standard behavior is to send it for
// `MODE #channel`, i.e., an empty list of intended changes, but Ergo will
// also send it for no-op changes to zero-argument modes like +i
shouldSendModeIsLine := len(changes) == 0
for _, change := range changes { for _, change := range changes {
if !hasPrivs(change) { if !hasPrivs(change) {
if !alreadySentPrivError { if !alreadySentPrivError {
@ -203,7 +207,6 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c
switch change.Mode { switch change.Mode {
case modes.BanMask, modes.ExceptMask, modes.InviteMask: case modes.BanMask, modes.ExceptMask, modes.InviteMask:
maskOpCount += 1
if change.Op == modes.List { if change.Op == modes.List {
channel.ShowMaskList(client, change.Mode, rb) channel.ShowMaskList(client, change.Mode, rb)
continue continue
@ -212,7 +215,7 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c
mask := change.Arg mask := change.Arg
switch change.Op { switch change.Op {
case modes.Add: case modes.Add:
if channel.lists[change.Mode].Length() >= client.server.Config().Limits.ChanListModes { if !isSamode && channel.lists[change.Mode].Length() >= client.server.Config().Limits.ChanListModes {
if !listFullWarned[change.Mode] { if !listFullWarned[change.Mode] {
rb.Add(nil, client.server.name, ERR_BANLISTFULL, details.nick, chname, change.Mode.String(), client.t("Channel list is full")) rb.Add(nil, client.server.name, ERR_BANLISTFULL, details.nick, chname, change.Mode.String(), client.t("Channel list is full"))
listFullWarned[change.Mode] = true listFullWarned[change.Mode] = true
@ -313,11 +316,14 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c
default: default:
// all channel modes with no args, e.g., InviteOnly, Secret // all channel modes with no args, e.g., InviteOnly, Secret
if change.Op == modes.List { if change.Op == modes.List {
shouldSendModeIsLine = true
continue continue
} }
if channel.flags.SetMode(change.Mode, change.Op == modes.Add) { if channel.flags.SetMode(change.Mode, change.Op == modes.Add) {
applied = append(applied, change) applied = append(applied, change)
} else {
shouldSendModeIsLine = true
} }
} }
} }
@ -337,8 +343,7 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c
channel.MarkDirty(includeFlags) channel.MarkDirty(includeFlags)
} }
// #649: don't send 324 RPL_CHANNELMODEIS if we were only working with mask lists if len(applied) == 0 && !alreadySentPrivError && shouldSendModeIsLine {
if len(applied) == 0 && !alreadySentPrivError && (maskOpCount == 0 || maskOpCount < len(changes)) {
args := append([]string{details.nick, chname}, channel.modeStrings(client)...) args := append([]string{details.nick, chname}, channel.modeStrings(client)...)
rb.Add(nil, client.server.name, RPL_CHANNELMODEIS, args...) rb.Add(nil, client.server.name, RPL_CHANNELMODEIS, args...)
rb.Add(nil, client.server.name, RPL_CREATIONTIME, details.nick, chname, strconv.FormatInt(channel.createdTime.Unix(), 10)) rb.Add(nil, client.server.name, RPL_CREATIONTIME, details.nick, chname, strconv.FormatInt(channel.createdTime.Unix(), 10))

View file

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

View file

@ -11,6 +11,7 @@ import (
"fmt" "fmt"
"io" "io"
"runtime/debug" "runtime/debug"
"slices"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
@ -29,17 +30,9 @@ var (
const ( const (
// maximum length in bytes of any message target (nickname or channel name) in its // maximum length in bytes of any message target (nickname or channel name) in its
// canonicalized (i.e., casefolded) state: // canonicalized (i.e., casefolded) state:
MaxTargetLength = 64 MaxTargetLength = 64
cleanupRowLimit = 50
// latest schema of the db cleanupPauseTime = 10 * time.Minute
latestDbSchema = "2"
keySchemaVersion = "db.version"
// minor version indicates rollback-safe upgrades, i.e.,
// you can downgrade oragono and everything will work
latestDbMinorVersion = "2"
keySchemaMinorVersion = "db.minorversion"
cleanupRowLimit = 50
cleanupPauseTime = 10 * time.Minute
) )
type e struct{} type e struct{}
@ -49,11 +42,16 @@ type MySQL struct {
logger *logger.Manager logger *logger.Manager
insertHistory *sql.Stmt insertHistory *sql.Stmt
insertSequence *sql.Stmt
insertConversation *sql.Stmt insertConversation *sql.Stmt
insertCorrespondent *sql.Stmt
insertAccountMessage *sql.Stmt insertAccountMessage *sql.Stmt
getReactionsQuery *sql.Stmt
getSingleReaction *sql.Stmt
addReaction *sql.Stmt
deleteReaction *sql.Stmt
getMessageById *sql.Stmt
stateMutex sync.Mutex stateMutex sync.Mutex
config Config config Config
@ -88,197 +86,55 @@ func (mysql *MySQL) getExpireTime() (expireTime time.Duration) {
return return
} }
func (m *MySQL) Open() (err error) { func (mysql *MySQL) Open() (err error) {
var address string var address string
if m.config.SocketPath != "" { if mysql.config.SocketPath != "" {
address = fmt.Sprintf("unix(%s)", m.config.SocketPath) address = fmt.Sprintf("unix(%s)", mysql.config.SocketPath)
} else if m.config.Port != 0 { } else if mysql.config.Port != 0 {
address = fmt.Sprintf("tcp(%s:%d)", m.config.Host, m.config.Port) address = fmt.Sprintf("tcp(%s:%d)", mysql.config.Host, mysql.config.Port)
} }
m.db, err = sql.Open("mysql", fmt.Sprintf("%s:%s@%s/%s", m.config.User, m.config.Password, address, m.config.HistoryDatabase)) mysql.db, err = sql.Open("mysql", fmt.Sprintf("%s:%s@%s/%s", mysql.config.User, mysql.config.Password, address, mysql.config.HistoryDatabase))
if err != nil { if err != nil {
return err return err
} }
if m.config.MaxConns != 0 { if mysql.config.MaxConns != 0 {
m.db.SetMaxOpenConns(m.config.MaxConns) mysql.db.SetMaxOpenConns(mysql.config.MaxConns)
m.db.SetMaxIdleConns(m.config.MaxConns) mysql.db.SetMaxIdleConns(mysql.config.MaxConns)
} }
if m.config.ConnMaxLifetime != 0 { if mysql.config.ConnMaxLifetime != 0 {
m.db.SetConnMaxLifetime(m.config.ConnMaxLifetime) mysql.db.SetConnMaxLifetime(mysql.config.ConnMaxLifetime)
} }
err = m.fixSchemas() err = mysql.fixSchemas()
if err != nil { if err != nil {
return err return err
} }
err = m.prepareStatements() err = mysql.prepareStatements()
if err != nil { if err != nil {
return err return err
} }
go m.cleanupLoop() go mysql.cleanupLoop()
go m.forgetLoop() go mysql.forgetLoop()
return nil return nil
} }
func (mysql *MySQL) fixSchemas() (err error) { func (mysql *MySQL) fixSchemas() (err error) {
_, err = mysql.db.Exec(`CREATE TABLE IF NOT EXISTS metadata ( // 3M now handles this
key_name VARCHAR(32) primary key,
value VARCHAR(32) NOT NULL
) CHARSET=ascii COLLATE=ascii_bin;`)
if err != nil {
return err
}
var schema string
err = mysql.db.QueryRow(`select value from metadata where key_name = ?;`, keySchemaVersion).Scan(&schema)
if err == sql.ErrNoRows {
err = mysql.createTables()
if err != nil {
return
}
_, err = mysql.db.Exec(`insert into metadata (key_name, value) values (?, ?);`, keySchemaVersion, latestDbSchema)
if err != nil {
return
}
_, err = mysql.db.Exec(`insert into metadata (key_name, value) values (?, ?);`, keySchemaMinorVersion, latestDbMinorVersion)
if err != nil {
return
}
return
} else if err == nil && schema != latestDbSchema {
// TODO figure out what to do about schema changes
return fmt.Errorf("incompatible schema: got %s, expected %s", schema, latestDbSchema)
} else if err != nil {
return err
}
var minorVersion string
err = mysql.db.QueryRow(`select value from metadata where key_name = ?;`, keySchemaMinorVersion).Scan(&minorVersion)
if err == sql.ErrNoRows {
// XXX for now, the only minor version upgrade is the account tracking tables
err = mysql.createComplianceTables()
if err != nil {
return
}
err = mysql.createCorrespondentsTable()
if err != nil {
return
}
_, err = mysql.db.Exec(`insert into metadata (key_name, value) values (?, ?);`, keySchemaMinorVersion, latestDbMinorVersion)
if err != nil {
return
}
} else if err == nil && minorVersion == "1" {
// upgrade from 2.1 to 2.2: create the correspondents table
err = mysql.createCorrespondentsTable()
if err != nil {
return
}
_, err = mysql.db.Exec(`update metadata set value = ? where key_name = ?;`, latestDbMinorVersion, keySchemaMinorVersion)
if err != nil {
return
}
} else if err == nil && minorVersion != latestDbMinorVersion {
// TODO: if minorVersion < latestDbMinorVersion, upgrade,
// if latestDbMinorVersion < minorVersion, ignore because backwards compatible
}
return return
} }
func (mysql *MySQL) createTables() (err error) { func (mysql *MySQL) createTables() (err error) {
_, err = mysql.db.Exec(`CREATE TABLE history ( // 3M now handles this
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
data BLOB NOT NULL,
msgid BINARY(16) NOT NULL,
KEY (msgid(4))
) CHARSET=ascii COLLATE=ascii_bin;`)
if err != nil {
return err
}
_, err = mysql.db.Exec(fmt.Sprintf(`CREATE TABLE sequence (
history_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
target VARBINARY(%[1]d) NOT NULL,
nanotime BIGINT UNSIGNED NOT NULL,
KEY (target, nanotime)
) CHARSET=ascii COLLATE=ascii_bin;`, MaxTargetLength))
if err != nil {
return err
}
/* XXX: this table used to be:
CREATE TABLE sequence (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
target VARBINARY(%[1]d) NOT NULL,
nanotime BIGINT UNSIGNED NOT NULL,
history_id BIGINT NOT NULL,
KEY (target, nanotime),
KEY (history_id)
) CHARSET=ascii COLLATE=ascii_bin;
Some users may still be using the old schema.
*/
_, err = mysql.db.Exec(fmt.Sprintf(`CREATE TABLE conversations (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
target VARBINARY(%[1]d) NOT NULL,
correspondent VARBINARY(%[1]d) NOT NULL,
nanotime BIGINT UNSIGNED NOT NULL,
history_id BIGINT NOT NULL,
KEY (target, correspondent, nanotime),
KEY (history_id)
) CHARSET=ascii COLLATE=ascii_bin;`, MaxTargetLength))
if err != nil {
return err
}
err = mysql.createCorrespondentsTable()
if err != nil {
return err
}
err = mysql.createComplianceTables()
if err != nil {
return err
}
return nil return nil
} }
func (mysql *MySQL) createCorrespondentsTable() (err error) {
_, err = mysql.db.Exec(fmt.Sprintf(`CREATE TABLE correspondents (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
target VARBINARY(%[1]d) NOT NULL,
correspondent VARBINARY(%[1]d) NOT NULL,
nanotime BIGINT UNSIGNED NOT NULL,
UNIQUE KEY (target, correspondent),
KEY (target, nanotime),
KEY (nanotime)
) CHARSET=ascii COLLATE=ascii_bin;`, MaxTargetLength))
return
}
func (mysql *MySQL) createComplianceTables() (err error) { func (mysql *MySQL) createComplianceTables() (err error) {
_, err = mysql.db.Exec(fmt.Sprintf(`CREATE TABLE account_messages ( // 3M now handles this
history_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
account VARBINARY(%[1]d) NOT NULL,
KEY (account, history_id)
) CHARSET=ascii COLLATE=ascii_bin;`, MaxTargetLength))
if err != nil {
return err
}
_, err = mysql.db.Exec(fmt.Sprintf(`CREATE TABLE forget (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
account VARBINARY(%[1]d) NOT NULL
) CHARSET=ascii COLLATE=ascii_bin;`, MaxTargetLength))
if err != nil {
return err
}
return nil return nil
} }
@ -326,10 +182,6 @@ func (mysql *MySQL) doCleanup(age time.Duration) (count int, err error) {
mysql.logger.Debug("mysql", fmt.Sprintf("deleting %d history rows, max age %s", len(ids), utils.NanoToTimestamp(maxNanotime))) mysql.logger.Debug("mysql", fmt.Sprintf("deleting %d history rows, max age %s", len(ids), utils.NanoToTimestamp(maxNanotime)))
if maxNanotime != 0 {
mysql.deleteCorrespondents(ctx, maxNanotime)
}
return len(ids), mysql.deleteHistoryIDs(ctx, ids) return len(ids), mysql.deleteHistoryIDs(ctx, ids)
} }
@ -346,21 +198,14 @@ func (mysql *MySQL) deleteHistoryIDs(ctx context.Context, ids []uint64) (err err
inBuf.WriteRune(')') inBuf.WriteRune(')')
inClause := inBuf.String() inClause := inBuf.String()
_, err = mysql.db.ExecContext(ctx, fmt.Sprintf(`DELETE FROM conversations WHERE history_id in %s;`, inClause))
if err != nil {
return
}
_, err = mysql.db.ExecContext(ctx, fmt.Sprintf(`DELETE FROM sequence WHERE history_id in %s;`, inClause))
if err != nil {
return
}
if mysql.isTrackingAccountMessages() { if mysql.isTrackingAccountMessages() {
_, err = mysql.db.ExecContext(ctx, fmt.Sprintf(`DELETE FROM account_messages WHERE history_id in %s;`, inClause)) _, err = mysql.db.ExecContext(ctx, fmt.Sprintf(`DELETE FROM account_messages WHERE history_id in %s;`, inClause))
if err != nil { if err != nil {
return return
} }
} }
_, err = mysql.db.ExecContext(ctx, fmt.Sprintf(`DELETE FROM history WHERE id in %s;`, inClause)) fmt.Printf(`DELETE FROM history WHERE msgid in %s;`, inClause)
_, err = mysql.db.ExecContext(ctx, fmt.Sprintf(`DELETE FROM history WHERE msgid in %s;`, inClause))
if err != nil { if err != nil {
return return
} }
@ -369,57 +214,35 @@ func (mysql *MySQL) deleteHistoryIDs(ctx context.Context, ids []uint64) (err err
} }
func (mysql *MySQL) selectCleanupIDs(ctx context.Context, age time.Duration) (ids []uint64, maxNanotime int64, err error) { func (mysql *MySQL) selectCleanupIDs(ctx context.Context, age time.Duration) (ids []uint64, maxNanotime int64, err error) {
before := timestampSnowflake(time.Now().Add(-age))
maxNanotime = time.Now().Add(-age).Unix() * 1000000000
rows, err := mysql.db.QueryContext(ctx, ` rows, err := mysql.db.QueryContext(ctx, `
SELECT history.id, sequence.nanotime, conversations.nanotime SELECT history.msgid
FROM history FROM history
LEFT JOIN sequence ON history.id = sequence.history_id WHERE msgid < ?
LEFT JOIN conversations on history.id = conversations.history_id ORDER BY history.msgid LIMIT ?;`, before, cleanupRowLimit)
ORDER BY history.id LIMIT ?;`, cleanupRowLimit)
if err != nil { if err != nil {
return return
} }
defer rows.Close() defer rows.Close()
idset := make(map[uint64]struct{}, cleanupRowLimit) ids = make([]uint64, cleanupRowLimit)
threshold := time.Now().Add(-age).UnixNano()
i := 0
for rows.Next() { for rows.Next() {
var id uint64 var id uint64
var seqNano, convNano sql.NullInt64 err = rows.Scan(&id)
err = rows.Scan(&id, &seqNano, &convNano)
if err != nil { if err != nil {
return return
} }
nanotime := extractNanotime(seqNano, convNano)
// returns 0 if not found; in that case the data is inconsistent
// and we should delete the entry
if nanotime < threshold {
idset[id] = struct{}{}
if nanotime > maxNanotime {
maxNanotime = nanotime
}
}
}
ids = make([]uint64, len(idset))
i := 0
for id := range idset {
ids[i] = id ids[i] = id
i++ i++
} }
ids = ids[0:i]
return return
} }
func (mysql *MySQL) deleteCorrespondents(ctx context.Context, threshold int64) {
result, err := mysql.db.ExecContext(ctx, `DELETE FROM correspondents WHERE nanotime <= (?);`, threshold)
if err != nil {
mysql.logError("error deleting correspondents", err)
} else {
count, err := result.RowsAffected()
if !mysql.logError("error deleting correspondents", err) {
mysql.logger.Debug(fmt.Sprintf("deleted %d correspondents entries", count))
}
}
}
// wait for forget queue items and process them one by one // wait for forget queue items and process them one by one
func (mysql *MySQL) forgetLoop() { func (mysql *MySQL) forgetLoop() {
defer func() { defer func() {
@ -525,23 +348,7 @@ func (mysql *MySQL) doForgetIteration(account string) (count int, err error) {
func (mysql *MySQL) prepareStatements() (err error) { func (mysql *MySQL) prepareStatements() (err error) {
mysql.insertHistory, err = mysql.db.Prepare(`INSERT INTO history mysql.insertHistory, err = mysql.db.Prepare(`INSERT INTO history
(data, msgid) VALUES (?, ?);`) (data, msgid, target, sender, nanotime) VALUES (?, ?, ?, ?, ?);`)
if err != nil {
return
}
mysql.insertSequence, err = mysql.db.Prepare(`INSERT INTO sequence
(target, nanotime, history_id) VALUES (?, ?, ?);`)
if err != nil {
return
}
mysql.insertConversation, err = mysql.db.Prepare(`INSERT INTO conversations
(target, correspondent, nanotime, history_id) VALUES (?, ?, ?, ?);`)
if err != nil {
return
}
mysql.insertCorrespondent, err = mysql.db.Prepare(`INSERT INTO correspondents
(target, correspondent, nanotime) VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE nanotime = GREATEST(nanotime, ?);`)
if err != nil { if err != nil {
return return
} }
@ -551,6 +358,38 @@ func (mysql *MySQL) prepareStatements() (err error) {
return return
} }
mysql.getReactionsQuery, err = mysql.db.Prepare(`select react, count(*) as total, (select JSON_ARRAYAGG(user)
from reactions r
where r.msgid = main.msgid
and r.react = main.react
limit 3) as sample
from reactions as main
where main.msgid = ?
group by react, msgid;`)
if err != nil {
return
}
mysql.getSingleReaction, err = mysql.db.Prepare(`SELECT COUNT(*) FROM reactions WHERE msgid = ? AND user = ? AND react = ?`)
if err != nil {
return
}
mysql.deleteReaction, err = mysql.db.Prepare(`DELETE FROM reactions WHERE msgid = ? AND user = ? AND react = ?`)
if err != nil {
return
}
mysql.addReaction, err = mysql.db.Prepare(`INSERT INTO reactions(msgid, user, react) VALUES (?, ?, ?)`)
if err != nil {
return
}
mysql.getMessageById, err = mysql.db.Prepare(`SELECT msgid, data, target, sender, nanotime, pm FROM history WHERE msgid = ?`)
if err != nil {
return
}
return return
} }
@ -607,11 +446,6 @@ func (mysql *MySQL) AddChannelItem(target string, item history.Item, account str
return return
} }
err = mysql.insertSequenceEntry(ctx, target, item.Message.Time.UnixNano(), id)
if err != nil {
return
}
err = mysql.insertAccountMessageEntry(ctx, id, account) err = mysql.insertAccountMessageEntry(ctx, id, account)
if err != nil { if err != nil {
return return
@ -620,39 +454,21 @@ func (mysql *MySQL) AddChannelItem(target string, item history.Item, account str
return return
} }
func (mysql *MySQL) insertSequenceEntry(ctx context.Context, target string, messageTime int64, id int64) (err error) {
_, err = mysql.insertSequence.ExecContext(ctx, target, messageTime, id)
mysql.logError("could not insert sequence entry", err)
return
}
func (mysql *MySQL) insertConversationEntry(ctx context.Context, target, correspondent string, messageTime int64, id int64) (err error) {
_, err = mysql.insertConversation.ExecContext(ctx, target, correspondent, messageTime, id)
mysql.logError("could not insert conversations entry", err)
return
}
func (mysql *MySQL) insertCorrespondentsEntry(ctx context.Context, target, correspondent string, messageTime int64, historyId int64) (err error) {
_, err = mysql.insertCorrespondent.ExecContext(ctx, target, correspondent, messageTime, messageTime)
mysql.logError("could not insert conversations entry", err)
return
}
func (mysql *MySQL) insertBase(ctx context.Context, item history.Item) (id int64, err error) { func (mysql *MySQL) insertBase(ctx context.Context, item history.Item) (id int64, err error) {
value, err := marshalItem(&item) var value []byte
value, err = marshalItem(&item)
if mysql.logError("could not marshal item", err) { if mysql.logError("could not marshal item", err) {
return return
} }
var account = item.Account
msgidBytes, err := decodeMsgid(item.Message.Msgid) if account == "" {
if mysql.logError("could not decode msgid", err) { account = "*"
return
} }
result, err := mysql.insertHistory.ExecContext(ctx, value, item.Message.Msgid, item.Target, account, item.Message.Time.UnixNano())
result, err := mysql.insertHistory.ExecContext(ctx, value, msgidBytes)
if mysql.logError("could not insert item", err) { if mysql.logError("could not insert item", err) {
return return
} }
id, err = result.LastInsertId() id, err = result.LastInsertId()
if mysql.logError("could not insert item", err) { if mysql.logError("could not insert item", err) {
return return
@ -686,36 +502,7 @@ func (mysql *MySQL) AddDirectMessage(sender, senderAccount, recipient, recipient
ctx, cancel := context.WithTimeout(context.Background(), mysql.getTimeout()) ctx, cancel := context.WithTimeout(context.Background(), mysql.getTimeout())
defer cancel() defer cancel()
id, err := mysql.insertBase(ctx, item) _, err = mysql.insertBase(ctx, item)
if err != nil {
return
}
nanotime := item.Message.Time.UnixNano()
if senderAccount != "" {
err = mysql.insertConversationEntry(ctx, senderAccount, recipient, nanotime, id)
if err != nil {
return
}
err = mysql.insertCorrespondentsEntry(ctx, senderAccount, recipient, nanotime, id)
if err != nil {
return
}
}
if recipientAccount != "" && sender != recipient {
err = mysql.insertConversationEntry(ctx, recipientAccount, sender, nanotime, id)
if err != nil {
return
}
err = mysql.insertCorrespondentsEntry(ctx, recipientAccount, sender, nanotime, id)
if err != nil {
return
}
}
err = mysql.insertAccountMessageEntry(ctx, id, senderAccount)
if err != nil { if err != nil {
return return
} }
@ -724,7 +511,7 @@ func (mysql *MySQL) AddDirectMessage(sender, senderAccount, recipient, recipient
} }
// note that accountName is the unfolded name // note that accountName is the unfolded name
func (mysql *MySQL) DeleteMsgid(msgid, accountName string) (err error) { func (mysql *MySQL) DeleteMsgid(msgid, account string) (err error) {
if mysql.db == nil { if mysql.db == nil {
return nil return nil
} }
@ -737,11 +524,11 @@ func (mysql *MySQL) DeleteMsgid(msgid, accountName string) (err error) {
return return
} }
if accountName != "*" { if account != "*" {
var item history.Item var item history.Item
err = unmarshalItem(data, &item) err = unmarshalItem(data, &item)
// delete if the entry is corrupt // delete if the entry is corrupt
if err == nil && item.AccountName != accountName { if err == nil && item.Account != account {
return ErrDisallowed return ErrDisallowed
} }
} }
@ -752,7 +539,10 @@ func (mysql *MySQL) DeleteMsgid(msgid, accountName string) (err error) {
} }
func (mysql *MySQL) Export(account string, writer io.Writer) { func (mysql *MySQL) Export(account string, writer io.Writer) {
if mysql.db == nil { // no eu presence...
// maybe fix this when i know the new schema works
return
/*if mysql.db == nil {
return return
} }
@ -764,10 +554,8 @@ func (mysql *MySQL) Export(account string, writer io.Writer) {
defer cancel() defer cancel()
rows, rowsErr := mysql.db.QueryContext(ctx, ` rows, rowsErr := mysql.db.QueryContext(ctx, `
SELECT account_messages.history_id, history.data, sequence.target FROM account_messages SELECT history.data, msgid, target FROM history
INNER JOIN history ON history.id = account_messages.history_id WHERE sender = ? AND account_messages.history_id > ?
INNER JOIN sequence ON account_messages.history_id = sequence.history_id
WHERE account_messages.account = ? AND account_messages.history_id > ?
LIMIT ?`, account, lastSeen, cleanupRowLimit) LIMIT ?`, account, lastSeen, cleanupRowLimit)
if rowsErr != nil { if rowsErr != nil {
err = rowsErr err = rowsErr
@ -779,7 +567,7 @@ func (mysql *MySQL) Export(account string, writer io.Writer) {
var blob, jsonBlob []byte var blob, jsonBlob []byte
var target string var target string
var item history.Item var item history.Item
err = rows.Scan(&id, &blob, &target) err = rows.Scan(&blob, &id, &target)
if err != nil { if err != nil {
return return
} }
@ -787,7 +575,7 @@ func (mysql *MySQL) Export(account string, writer io.Writer) {
if err != nil { if err != nil {
return return
} }
item.CfCorrespondent = target item.Target = target
jsonBlob, err = json.Marshal(item) jsonBlob, err = json.Marshal(item)
if err != nil { if err != nil {
return return
@ -807,28 +595,66 @@ func (mysql *MySQL) Export(account string, writer io.Writer) {
} }
mysql.logError("could not export history", err) mysql.logError("could not export history", err)
return*/
}
// Kinda an intermediary function due to the CEF DB structure
func (mysql *MySQL) GetMessage(msgid string) (id uint64, item history.Item, target string, sender string, nanotime uint64, pm bool, err error) {
ctx, cancel := context.WithTimeout(context.Background(), mysql.getTimeout())
defer cancel()
var data []byte
row := mysql.getMessageById.QueryRowContext(ctx, msgid)
err = row.Scan(&id, &data, &target, &sender, &nanotime, &pm)
if err != nil {
return
}
err = unmarshalItem(data, &item)
return
}
func (mysql *MySQL) HasReactionFromUser(msgid string, user string, reaction string) (exists bool) {
ctx, cancel := context.WithTimeout(context.Background(), mysql.getTimeout())
defer cancel()
row := mysql.getSingleReaction.QueryRowContext(ctx, msgid, user, reaction)
var count int
row.Scan(&count)
return count > 0
}
func (mysql *MySQL) AddReaction(msgid string, user string, reaction string) (err error) {
ctx, cancel := context.WithTimeout(context.Background(), mysql.getTimeout())
defer cancel()
_, err = mysql.addReaction.ExecContext(ctx, msgid, user, reaction)
return
}
func (mysql *MySQL) DeleteReaction(msgid string, user string, reaction string) (err error) {
ctx, cancel := context.WithTimeout(context.Background(), mysql.getTimeout())
defer cancel()
_, err = mysql.deleteReaction.ExecContext(ctx, msgid, user, reaction)
return return
} }
func (mysql *MySQL) lookupMsgid(ctx context.Context, msgid string, includeData bool) (result time.Time, id uint64, data []byte, err error) { func (mysql *MySQL) lookupMsgid(ctx context.Context, msgid string, includeData bool) (result time.Time, id uint64, data []byte, err error) {
decoded, err := decodeMsgid(msgid)
if err != nil { if err != nil {
return return
} }
cols := `sequence.nanotime, conversations.nanotime` cols := `history.nanotime`
if includeData { if includeData {
cols = `sequence.nanotime, conversations.nanotime, history.id, history.data` cols = `history.nanotime, history.id, history.data`
} }
// Since CEF uses snowflakes and vanilla ergo uses blobs, we cast as int to make it function.
// May have to adjust it some day
row := mysql.db.QueryRowContext(ctx, fmt.Sprintf(` row := mysql.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT %s FROM history SELECT %s FROM history
LEFT JOIN sequence ON history.id = sequence.history_id WHERE history.msgid = CAST(? AS UNSIGNED) LIMIT 1;`, cols), msgid)
LEFT JOIN conversations ON history.id = conversations.history_id var nanoSeq sql.NullInt64
WHERE history.msgid = ? LIMIT 1;`, cols), decoded)
var nanoSeq, nanoConv sql.NullInt64
if !includeData { if !includeData {
err = row.Scan(&nanoSeq, &nanoConv) err = row.Scan(&nanoSeq)
} else { } else {
err = row.Scan(&nanoSeq, &nanoConv, &id, &data) err = row.Scan(&nanoSeq, &id, &data)
} }
if err != sql.ErrNoRows { if err != sql.ErrNoRows {
mysql.logError("could not resolve msgid to time", err) mysql.logError("could not resolve msgid to time", err)
@ -836,7 +662,7 @@ func (mysql *MySQL) lookupMsgid(ctx context.Context, msgid string, includeData b
if err != nil { if err != nil {
return return
} }
nanotime := extractNanotime(nanoSeq, nanoConv) nanotime := nanoSeq.Int64
if nanotime == 0 { if nanotime == 0 {
err = sql.ErrNoRows err = sql.ErrNoRows
return return
@ -845,15 +671,6 @@ func (mysql *MySQL) lookupMsgid(ctx context.Context, msgid string, includeData b
return return
} }
func extractNanotime(seq, conv sql.NullInt64) (result int64) {
if seq.Valid {
return seq.Int64
} else if conv.Valid {
return conv.Int64
}
return
}
func (mysql *MySQL) selectItems(ctx context.Context, query string, args ...interface{}) (results []history.Item, err error) { func (mysql *MySQL) selectItems(ctx context.Context, query string, args ...interface{}) (results []history.Item, err error) {
rows, err := mysql.db.QueryContext(ctx, query, args...) rows, err := mysql.db.QueryContext(ctx, query, args...)
if mysql.logError("could not select history items", err) { if mysql.logError("could not select history items", err) {
@ -864,8 +681,10 @@ func (mysql *MySQL) selectItems(ctx context.Context, query string, args ...inter
for rows.Next() { for rows.Next() {
var blob []byte var blob []byte
var msgid uint64
var item history.Item var item history.Item
err = rows.Scan(&blob)
err = rows.Scan(&blob, &msgid)
if mysql.logError("could not scan history item", err) { if mysql.logError("could not scan history item", err) {
return return
} }
@ -873,17 +692,36 @@ func (mysql *MySQL) selectItems(ctx context.Context, query string, args ...inter
if mysql.logError("could not unmarshal history item", err) { if mysql.logError("could not unmarshal history item", err) {
return return
} }
reactions, rErr := mysql.getReactionsQuery.Query(msgid)
if mysql.logError("could not get reactions", rErr) {
return
}
var react string
var total int
var sample string
for reactions.Next() {
reactions.Scan(&react, &total, &sample)
var sampleDecoded []string
json.Unmarshal([]byte(sample), &sampleDecoded)
item.Reactions = append(item.Reactions, history.Reaction{
Name: react,
Total: total,
SampleUsers: sampleDecoded,
})
}
results = append(results, item) results = append(results, item)
} }
return return
} }
func timestampSnowflake(t time.Time) uint64 {
var ts = t.Unix() & 0xffffffffffff
return uint64(ts << 16)
}
func (mysql *MySQL) betweenTimestamps(ctx context.Context, target, correspondent string, after, before, cutoff time.Time, limit int) (results []history.Item, err error) { func (mysql *MySQL) betweenTimestamps(ctx context.Context, target, correspondent string, after, before, cutoff time.Time, limit int) (results []history.Item, err error) {
useSequence := correspondent == ""
table := "sequence"
if !useSequence {
table = "conversations"
}
after, before, ascending := history.MinMaxAsc(after, before, cutoff) after, before, ascending := history.MinMaxAsc(after, before, cutoff)
direction := "ASC" direction := "ASC"
@ -892,32 +730,30 @@ func (mysql *MySQL) betweenTimestamps(ctx context.Context, target, correspondent
} }
var queryBuf strings.Builder var queryBuf strings.Builder
args := make([]interface{}, 0, 7)
args := make([]interface{}, 0, 6) if correspondent == "" {
fmt.Fprintf(&queryBuf, fmt.Fprintf(&queryBuf, "SELECT data, msgid FROM history WHERE target = ? ")
"SELECT history.data from history INNER JOIN %[1]s ON history.id = %[1]s.history_id WHERE", table)
if useSequence {
fmt.Fprintf(&queryBuf, " sequence.target = ?")
args = append(args, target) args = append(args, target)
} else { } else {
fmt.Fprintf(&queryBuf, " conversations.target = ? AND conversations.correspondent = ?") fmt.Fprintf(&queryBuf, "SELECT data, msgid FROM history WHERE (target = ? and sender = ?) OR (target = ? and sender = ?)")
args = append(args, target) args = append(args, target, correspondent, correspondent, target)
args = append(args, correspondent)
} }
if !after.IsZero() { if !after.IsZero() {
fmt.Fprintf(&queryBuf, " AND %s.nanotime > ?", table) fmt.Fprintf(&queryBuf, " AND nanotime > ?")
args = append(args, after.UnixNano()) args = append(args, after.UnixNano())
} }
if !before.IsZero() { if !before.IsZero() {
fmt.Fprintf(&queryBuf, " AND %s.nanotime < ?", table) fmt.Fprintf(&queryBuf, " AND nanotime <= ?")
args = append(args, before.UnixNano()) args = append(args, before.UnixNano())
} }
fmt.Fprintf(&queryBuf, " ORDER BY %[1]s.nanotime %[2]s LIMIT ?;", table, direction)
fmt.Fprintf(&queryBuf, " ORDER BY nanotime %[1]s LIMIT ?;", direction)
args = append(args, limit) args = append(args, limit)
results, err = mysql.selectItems(ctx, queryBuf.String(), args...) results, err = mysql.selectItems(ctx, queryBuf.String(), args...)
if err == nil && !ascending { if err == nil && !ascending {
utils.ReverseSlice(results) slices.Reverse(results)
} }
return return
} }
@ -930,19 +766,19 @@ func (mysql *MySQL) listCorrespondentsInternal(ctx context.Context, target strin
} }
var queryBuf strings.Builder var queryBuf strings.Builder
args := make([]interface{}, 0, 4) args := make([]interface{}, 0, 5)
queryBuf.WriteString(`SELECT correspondents.correspondent, correspondents.nanotime from correspondents queryBuf.WriteString(`SELECT target, sender, nanotime from history
WHERE target = ?`) WHERE target = ? OR (sender = ? and pm = true)`)
args = append(args, target) args = append(args, target, target)
if !after.IsZero() { if !after.IsZero() {
queryBuf.WriteString(" AND correspondents.nanotime > ?") queryBuf.WriteString(" AND nanotime > ?")
args = append(args, after.UnixNano()) args = append(args, after.UnixNano())
} }
if !before.IsZero() { if !before.IsZero() {
queryBuf.WriteString(" AND correspondents.nanotime < ?") queryBuf.WriteString(" AND nanotime < ?")
args = append(args, before.UnixNano()) args = append(args, before.UnixNano())
} }
fmt.Fprintf(&queryBuf, " ORDER BY correspondents.nanotime %s LIMIT ?;", direction) fmt.Fprintf(&queryBuf, " ORDER BY nanotime %s LIMIT ?;", direction)
args = append(args, limit) args = append(args, limit)
query := queryBuf.String() query := queryBuf.String()
@ -951,21 +787,30 @@ func (mysql *MySQL) listCorrespondentsInternal(ctx context.Context, target strin
return return
} }
defer rows.Close() defer rows.Close()
var correspondent string var msgTarget string
var msgSender string
var nanotime int64 var nanotime int64
for rows.Next() { for rows.Next() {
err = rows.Scan(&correspondent, &nanotime) err = rows.Scan(&msgTarget, &msgSender, &nanotime)
if err != nil { if err != nil {
return return
} }
results = append(results, history.TargetListing{ if msgTarget == target {
CfName: correspondent, results = append(results, history.TargetListing{
Time: time.Unix(0, nanotime), CfName: msgSender,
}) Time: time.Unix(0, nanotime),
})
} else {
results = append(results, history.TargetListing{
CfName: msgTarget,
Time: time.Unix(0, nanotime),
})
}
} }
if !ascending { if !ascending {
utils.ReverseSlice(results) slices.Reverse(results)
} }
return return
@ -1041,6 +886,7 @@ func (s *mySQLHistorySequence) Between(start, end history.Selector, limit int) (
defer cancel() defer cancel()
startTime := start.Time startTime := start.Time
if start.Msgid != "" { if start.Msgid != "" {
startTime, _, _, err = s.mysql.lookupMsgid(ctx, start.Msgid, false) startTime, _, _, err = s.mysql.lookupMsgid(ctx, start.Msgid, false)
if err != nil { if err != nil {
@ -1054,6 +900,7 @@ func (s *mySQLHistorySequence) Between(start, end history.Selector, limit int) (
endTime := end.Time endTime := end.Time
if end.Msgid != "" { if end.Msgid != "" {
endTime, _, _, err = s.mysql.lookupMsgid(ctx, end.Msgid, false) endTime, _, _, err = s.mysql.lookupMsgid(ctx, end.Msgid, false)
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
@ -1100,3 +947,41 @@ func (mysql *MySQL) MakeSequence(target, correspondent string, cutoff time.Time)
cutoff: cutoff, cutoff: cutoff,
} }
} }
func (mysql *MySQL) GetPMs(casefoldedUser string) (results map[string]int64, err error) {
if mysql.db == nil {
return
}
results = make(map[string]int64)
ctx, cancel := context.WithTimeout(context.Background(), mysql.getTimeout())
defer cancel()
var queryBuf strings.Builder
args := make([]interface{}, 0)
queryBuf.WriteString(`SELECT max(nanotime), target, sender FROM history WHERE target = ? OR (sender = ? and pm = true) GROUP BY target, sender;`)
args = append(args, casefoldedUser, casefoldedUser)
rows, err := mysql.db.QueryContext(ctx, queryBuf.String(), args...)
if mysql.logError("could not get pms", err) {
return
}
defer rows.Close()
var last int64
var target, sender string
for rows.Next() {
err = rows.Scan(&last, &target, &sender)
if mysql.logError("could not get pms", err) {
return
}
// We really don't need nanosecond precision
if target != casefoldedUser {
results[target] = last / 1000000
} else {
results[sender] = last / 1000000
}
}
return
}

View file

@ -4,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"github.com/ergochat/ergo/irc/history" "github.com/ergochat/ergo/irc/history"
"github.com/ergochat/ergo/irc/utils"
) )
// 123 / '{' is the magic number that means JSON; // 123 / '{' is the magic number that means JSON;
@ -17,7 +16,3 @@ func marshalItem(item *history.Item) (result []byte, err error) {
func unmarshalItem(data []byte, result *history.Item) (err error) { func unmarshalItem(data []byte, result *history.Item) (err error) {
return json.Unmarshal(data, result) return json.Unmarshal(data, result)
} }
func decodeMsgid(msgid string) ([]byte, error) {
return utils.B32Encoder.DecodeString(msgid)
}

View file

@ -43,6 +43,8 @@ func performNickChange(server *Server, client *Client, target *Client, session *
} }
} else if err == errNicknameReserved { } else if err == errNicknameReserved {
if !isSanick { if !isSanick {
// see #1594 for context: ERR_NICKNAMEINUSE can confuse clients if the nickname is not
// literally in use:
if !client.registered { if !client.registered {
rb.Add(nil, server.name, ERR_NICKNAMEINUSE, details.nick, utils.SafeErrorParam(nickname), client.t("Nickname is reserved by a different account")) rb.Add(nil, server.name, ERR_NICKNAMEINUSE, details.nick, utils.SafeErrorParam(nickname), client.t("Nickname is reserved by a different account"))
} }
@ -91,11 +93,11 @@ func performNickChange(server *Server, client *Client, target *Client, session *
isBot := !isSanick && client.HasMode(modes.Bot) isBot := !isSanick && client.HasMode(modes.Bot)
message := utils.MakeMessage("") message := utils.MakeMessage("")
histItem := history.Item{ histItem := history.Item{
Type: history.Nick, Type: history.Nick,
Nick: origNickMask, Nick: origNickMask,
AccountName: details.accountName, Account: details.account,
Message: message, Message: message,
IsBot: isBot, IsBot: isBot,
} }
histItem.Params[0] = assignedNickname histItem.Params[0] = assignedNickname
@ -120,7 +122,11 @@ func performNickChange(server *Server, client *Client, target *Client, session *
} }
for _, channel := range target.Channels() { for _, channel := range target.Channels() {
channel.AddHistoryItem(histItem, details.account) if channel.memberIsVisible(client) {
// I LOVE MUTATING STATE!
histItem.Target = channel.NameCasefolded()
channel.AddHistoryItem(histItem, details.account)
}
} }
newCfnick := target.NickCasefolded() newCfnick := target.NickCasefolded()

View file

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

View file

@ -4,7 +4,10 @@
package irc package irc
import ( import (
"github.com/ergochat/ergo/irc/history"
"runtime/debug" "runtime/debug"
"strconv"
"strings"
"time" "time"
"github.com/ergochat/ergo/irc/caps" "github.com/ergochat/ergo/irc/caps"
@ -121,8 +124,12 @@ func (rb *ResponseBuffer) AddFromClient(time time.Time, msgid string, fromNickMa
rb.AddMessage(msg) rb.AddMessage(msg)
} }
// AddSplitMessageFromClient adds a new split message from a specific client to our queue.
func (rb *ResponseBuffer) AddSplitMessageFromClient(fromNickMask string, fromAccount string, isBot bool, tags map[string]string, command string, target string, message utils.SplitMessage) { func (rb *ResponseBuffer) AddSplitMessageFromClient(fromNickMask string, fromAccount string, isBot bool, tags map[string]string, command string, target string, message utils.SplitMessage) {
rb.AddSplitMessageFromClientWithReactions(fromNickMask, fromAccount, isBot, tags, command, target, message, nil)
}
// AddSplitMessageFromClient adds a new split message from a specific client to our queue.
func (rb *ResponseBuffer) AddSplitMessageFromClientWithReactions(fromNickMask string, fromAccount string, isBot bool, tags map[string]string, command string, target string, message utils.SplitMessage, reactions []history.Reaction) {
if message.Is512() { if message.Is512() {
if message.Message == "" { if message.Message == "" {
// XXX this is a TAGMSG // XXX this is a TAGMSG
@ -142,10 +149,25 @@ func (rb *ResponseBuffer) AddSplitMessageFromClient(fromNickMask string, fromAcc
if i == 0 { if i == 0 {
msgid = message.Msgid msgid = message.Msgid
} }
rb.AddFromClient(message.Time, msgid, fromNickMask, fromAccount, isBot, tags, command, target, messagePair.Message) mergedTags := make(map[string]string)
for k, v := range tags {
mergedTags[k] = v
}
for k, v := range messagePair.Tags {
mergedTags[k] = v
}
rb.AddFromClient(message.Time, msgid, fromNickMask, fromAccount, isBot, mergedTags, command, target, messagePair.Message)
} }
} }
} }
if reactions != nil && len(reactions) >= 1 {
var text string
for _, react := range reactions {
text = strings.Join([]string{message.Msgid, react.Name, strconv.Itoa(react.Total)}, " ")
text += " " + strings.Join(react.SampleUsers, " ")
rb.Add(nil, rb.target.server.name, "REACTIONS", text)
}
}
} }
func (rb *ResponseBuffer) addEchoMessage(tags map[string]string, nickMask, accountName, command, target string, message utils.SplitMessage) { func (rb *ResponseBuffer) addEchoMessage(tags map[string]string, nickMask, accountName, command, target string, message utils.SplitMessage) {
@ -193,6 +215,9 @@ func (rb *ResponseBuffer) sendBatchEnd(blocking bool) {
// Starts a nested batch (see the ResponseBuffer struct definition for a description of // Starts a nested batch (see the ResponseBuffer struct definition for a description of
// how this works) // how this works)
func (rb *ResponseBuffer) StartNestedBatch(batchType string, params ...string) (batchID string) { func (rb *ResponseBuffer) StartNestedBatch(batchType string, params ...string) (batchID string) {
if !rb.session.capabilities.Has(caps.Batch) {
return
}
batchID = rb.session.generateBatchID() batchID = rb.session.generateBatchID()
msgParams := make([]string, len(params)+2) msgParams := make([]string, len(params)+2)
msgParams[0] = "+" + batchID msgParams[0] = "+" + batchID
@ -219,19 +244,6 @@ func (rb *ResponseBuffer) EndNestedBatch(batchID string) {
rb.AddMessage(ircmsg.MakeMessage(nil, rb.target.server.name, "BATCH", "-"+batchID)) 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. // Send sends all messages in the buffer to the client.
// Afterwards, the buffer is in an undefined state and MUST NOT be used further. // 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. // If `blocking` is true you MUST be sending to the client from its own goroutine.

View file

@ -110,6 +110,9 @@ func sendRoleplayMessage(server *Server, client *Client, source string, targetSt
Type: history.Privmsg, Type: history.Privmsg,
Message: splitMessage, Message: splitMessage,
Nick: sourceMask, Nick: sourceMask,
Target: target,
// TODO: does this work?
Account: "$RP",
}, client.Account()) }, client.Account())
} else { } else {
target, err := CasefoldName(targetString) target, err := CasefoldName(targetString)

View file

@ -5,8 +5,11 @@ package irc
import ( import (
"bufio" "bufio"
"bytes"
"io" "io"
"net/http"
"os/exec" "os/exec"
"strings"
"syscall" "syscall"
"time" "time"
) )
@ -21,7 +24,27 @@ type scriptResponse struct {
err error err error
} }
func RunHttp(command string, args []string, input []byte, timeout time.Duration) (output []byte, err error) {
client := http.Client{
Timeout: timeout,
}
post, err := client.Post(command, "application/json", bytes.NewBuffer(input))
if err != nil {
return nil, err
}
defer post.Body.Close()
output, err = io.ReadAll(post.Body)
if err != nil {
return nil, err
}
return
}
func RunScript(command string, args []string, input []byte, timeout, killTimeout time.Duration) (output []byte, err error) { func RunScript(command string, args []string, input []byte, timeout, killTimeout time.Duration) (output []byte, err error) {
if strings.HasPrefix(command, "http") {
return RunHttp(command, args, input, timeout)
}
cmd := exec.Command(command, args...) cmd := exec.Command(command, args...)
stdin, err := cmd.StdinPipe() stdin, err := cmd.StdinPipe()
if err != nil { if err != nil {

View file

@ -36,6 +36,8 @@ import (
"github.com/ergochat/ergo/irc/mysql" "github.com/ergochat/ergo/irc/mysql"
"github.com/ergochat/ergo/irc/sno" "github.com/ergochat/ergo/irc/sno"
"github.com/ergochat/ergo/irc/utils" "github.com/ergochat/ergo/irc/utils"
"github.com/redis/go-redis/v9"
) )
const ( const (
@ -96,6 +98,9 @@ type Server struct {
semaphores ServerSemaphores semaphores ServerSemaphores
flock flock.Flocker flock flock.Flocker
defcon atomic.Uint32 defcon atomic.Uint32
// CEF
redis *redis.Client
} }
// NewServer returns a new Oragono server. // NewServer returns a new Oragono server.
@ -163,6 +168,13 @@ func (server *Server) Shutdown() {
func (server *Server) Run() { func (server *Server) Run() {
defer server.Shutdown() defer server.Shutdown()
redisOpts, err := redis.ParseURL(server.Config().Cef.Redis)
if err != nil {
panic(err)
}
server.redis = redis.NewClient(redisOpts)
startRedis(server)
for { for {
select { select {
case <-server.exitSignals: case <-server.exitSignals:
@ -314,9 +326,7 @@ func (server *Server) checkBanScriptExemptSASL(config *Config, session *Session)
func (server *Server) tryRegister(c *Client, session *Session) (exiting bool) { func (server *Server) tryRegister(c *Client, session *Session) (exiting bool) {
// XXX PROXY or WEBIRC MUST be sent as the first line of the session; // 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 we are here at all that means we have the final value of the IP
if session.rawHostname == "" { c.finalizeHostname(session)
session.client.lookupHostname(session, false)
}
// try to complete registration normally // try to complete registration normally
// XXX(#1057) username can be filled in by an ident query without the client // XXX(#1057) username can be filled in by an ident query without the client
@ -359,10 +369,7 @@ func (server *Server) tryRegister(c *Client, session *Session) (exiting bool) {
rb := NewResponseBuffer(session) rb := NewResponseBuffer(session)
nickError := performNickChange(server, c, c, session, c.preregNick, rb) nickError := performNickChange(server, c, c, session, c.preregNick, rb)
rb.Send(true) rb.Send(true)
if nickError == errInsecureReattach { if nickError != nil {
c.Quit(c.t("You can't mix secure and insecure connections to this account"), nil)
return true
} else if nickError != nil {
c.preregNick = "" c.preregNick = ""
return false return false
} }
@ -397,6 +404,12 @@ func (server *Server) tryRegister(c *Client, session *Session) (exiting bool) {
} }
server.playRegistrationBurst(session) server.playRegistrationBurst(session)
if len(config.Channels.AutoJoin) > 0 {
// only applicable to new clients, not reattaches:
server.handleAutojoins(session, config.Channels.AutoJoin)
}
return false return false
} }
@ -433,7 +446,9 @@ func (server *Server) playRegistrationBurst(session *Session) {
session.Send(nil, server.name, RPL_MYINFO, d.nick, server.name, Ver, rplMyInfo1, rplMyInfo2, rplMyInfo3) session.Send(nil, server.name, RPL_MYINFO, d.nick, server.name, Ver, rplMyInfo1, rplMyInfo2, rplMyInfo3)
rb := NewResponseBuffer(session) rb := NewResponseBuffer(session)
server.RplISupport(c, rb) if !(rb.session.capabilities.Has(caps.ExtendedISupport) && rb.session.isupportSentPrereg) {
server.RplISupport(c, rb)
}
if d.account != "" && session.capabilities.Has(caps.Persistence) { if d.account != "" && session.capabilities.Has(caps.Persistence) {
reportPersistenceStatus(c, rb, false) reportPersistenceStatus(c, rb, false)
} }
@ -455,10 +470,17 @@ func (server *Server) playRegistrationBurst(session *Session) {
// RplISupport outputs our ISUPPORT lines to the client. This is used on connection and in VERSION responses. // RplISupport outputs our ISUPPORT lines to the client. This is used on connection and in VERSION responses.
func (server *Server) RplISupport(client *Client, rb *ResponseBuffer) { func (server *Server) RplISupport(client *Client, rb *ResponseBuffer) {
server.sendRplISupportLines(client, rb, server.Config().Server.isupport.CachedReply)
}
func (server *Server) sendRplISupportLines(client *Client, rb *ResponseBuffer, lines [][]string) {
if rb.session.capabilities.Has(caps.ExtendedISupport) {
batchID := rb.StartNestedBatch(caps.ExtendedISupportBatchType)
defer rb.EndNestedBatch(batchID)
}
translatedISupport := client.t("are supported by this server") translatedISupport := client.t("are supported by this server")
nick := client.Nick() nick := client.Nick()
config := server.Config() for _, cachedTokenLine := range lines {
for _, cachedTokenLine := range config.Server.isupport.CachedReply {
length := len(cachedTokenLine) + 2 length := len(cachedTokenLine) + 2
tokenline := make([]string, length) tokenline := make([]string, length)
tokenline[0] = nick tokenline[0] = nick
@ -505,6 +527,14 @@ func (server *Server) MOTD(client *Client, rb *ResponseBuffer) {
rb.Add(nil, server.name, RPL_ENDOFMOTD, client.nick, client.t("End of MOTD command")) 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 { func (client *Client) whoisChannelsNames(target *Client, multiPrefix bool, hasPrivs bool) []string {
var chstrs []string var chstrs []string
targetInvis := target.HasMode(modes.Invisible) targetInvis := target.HasMode(modes.Invisible)
@ -691,9 +721,6 @@ func (server *Server) applyConfig(config *Config) (err error) {
if !oldConfig.Accounts.NickReservation.Enabled { if !oldConfig.Accounts.NickReservation.Enabled {
server.accounts.buildNickToAccountIndex(config) server.accounts.buildNickToAccountIndex(config)
} }
if !oldConfig.Channels.Registration.Enabled {
server.channels.loadRegisteredChannels(config)
}
// resize history buffers as needed // resize history buffers as needed
if config.historyChangedFrom(oldConfig) { if config.historyChangedFrom(oldConfig) {
for _, channel := range server.channels.Channels() { for _, channel := range server.channels.Channels() {
@ -788,13 +815,19 @@ func (server *Server) applyConfig(config *Config) (err error) {
} }
if !initial { if !initial {
// push new info to all of our clients // send 005 updates (somewhat rare)
for _, sClient := range server.clients.AllClients() { if len(newISupportReplies) != 0 {
for _, tokenline := range newISupportReplies { for _, sClient := range server.clients.AllClients() {
sClient.Send(nil, server.name, RPL_ISUPPORT, append([]string{sClient.nick}, tokenline...)...) for _, session := range sClient.Sessions() {
rb := NewResponseBuffer(session)
server.sendRplISupportLines(sClient, rb, newISupportReplies)
rb.Send(false)
}
} }
}
if sendRawOutputNotice { if sendRawOutputNotice {
for _, sClient := range server.clients.AllClients() {
sClient.Notice(sClient.t("This server is in debug mode and is logging all user I/O. If you do not wish for everything you send to be readable by the server owner(s), please disconnect.")) sClient.Notice(sClient.t("This server is in debug mode and is logging all user I/O. If you do not wish for everything you send to be readable by the server owner(s), please disconnect."))
} }
} }
@ -1046,7 +1079,7 @@ func (server *Server) ForgetHistory(accountName string) {
return return
} }
predicate := func(item *history.Item) bool { return item.AccountName == accountName } predicate := func(item *history.Item) bool { return item.Account == accountName }
for _, channel := range server.channels.Channels() { for _, channel := range server.channels.Channels() {
channel.history.Delete(predicate) channel.history.Delete(predicate)
@ -1060,7 +1093,7 @@ func (server *Server) ForgetHistory(accountName string) {
// deletes a message. target is a hint about what buffer it's in (not required for // deletes a message. target is a hint about what buffer it's in (not required for
// persistent history, where all the msgids are indexed together). if accountName // persistent history, where all the msgids are indexed together). if accountName
// is anything other than "*", it must match the recorded AccountName of the message // is anything other than "*", it must match the recorded AccountName of the message
func (server *Server) DeleteMessage(target, msgid, accountName string) (err error) { func (server *Server) DeleteMessage(target, msgid, account string) (err error) {
config := server.Config() config := server.Config()
var hist *history.Buffer var hist *history.Buffer
@ -1083,10 +1116,10 @@ func (server *Server) DeleteMessage(target, msgid, accountName string) (err erro
} }
if hist == nil { if hist == nil {
err = server.historyDB.DeleteMsgid(msgid, accountName) err = server.historyDB.DeleteMsgid(msgid, account)
} else { } else {
count := hist.Delete(func(item *history.Item) bool { count := hist.Delete(func(item *history.Item) bool {
return item.Message.Msgid == msgid && (accountName == "*" || item.AccountName == accountName) return item.Message.Msgid == msgid && (account == "*" || item.Account == account)
}) })
if count == 0 { if count == 0 {
err = errNoop err = errNoop

View file

@ -107,6 +107,10 @@ func (service *ircService) Notice(rb *ResponseBuffer, text string) {
rb.Add(nil, service.prefix, "NOTICE", rb.target.Nick(), text) rb.Add(nil, service.prefix, "NOTICE", rb.target.Nick(), text)
} }
func (service *ircService) TaggedNotice(rb *ResponseBuffer, text string, tags map[string]string) {
rb.Add(tags, service.prefix, "NOTICE", rb.target.Nick(), text)
}
// all service commands at the protocol level, by uppercase command name // all service commands at the protocol level, by uppercase command name
// e.g., NICKSERV, NS // e.g., NICKSERV, NS
var ergoServicesByCommandAlias map[string]*ircService var ergoServicesByCommandAlias map[string]*ircService

View file

@ -55,17 +55,18 @@ type Client struct {
// Dial returns a new Client connected to an SMTP server at addr. // Dial returns a new Client connected to an SMTP server at addr.
// The addr must include a port, as in "mail.example.com:smtp". // The addr must include a port, as in "mail.example.com:smtp".
func Dial(addr string, timeout time.Duration, implicitTLS bool) (*Client, error) { func Dial(protocol, addr string, localAddress net.Addr, timeout time.Duration, implicitTLS bool) (*Client, error) {
var conn net.Conn var conn net.Conn
var err error var err error
dialer := net.Dialer{ dialer := net.Dialer{
Timeout: timeout, Timeout: timeout,
LocalAddr: localAddress,
} }
start := time.Now() start := time.Now()
if !implicitTLS { if !implicitTLS {
conn, err = dialer.Dial("tcp", addr) conn, err = dialer.Dial(protocol, addr)
} else { } else {
conn, err = tls.DialWithDialer(&dialer, "tcp", addr, nil) conn, err = tls.DialWithDialer(&dialer, protocol, addr, nil)
} }
if err != nil { if err != nil {
return nil, err return nil, err
@ -341,7 +342,7 @@ var testHookStartTLS func(*tls.Config) // nil, except for tests
// functionality. Higher-level packages exist outside of the standard // functionality. Higher-level packages exist outside of the standard
// library. // library.
// XXX: modified in Ergo to add `requireTLS`, `heloDomain`, and `timeout` arguments // 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, implicitTLS 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 { if err := validateLine(from); err != nil {
return err return err
} }
@ -350,7 +351,7 @@ func SendMail(addr string, a Auth, heloDomain string, from string, to []string,
return err return err
} }
} }
c, err := Dial(addr, timeout, implicitTLS) c, err := Dial(protocol, addr, localAddress, timeout, implicitTLS)
if err != nil { if err != nil {
return err return err
} }

View file

@ -60,6 +60,10 @@ const (
// confusables detection: standard skeleton algorithm (which may be ineffective // confusables detection: standard skeleton algorithm (which may be ineffective
// over the larger set of permitted identifiers) // over the larger set of permitted identifiers)
CasemappingPermissive 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. // XXX this is a global variable without explicit synchronization.
@ -110,6 +114,10 @@ func casefoldWithSetting(str string, setting Casemapping) (string, error) {
return foldASCII(str) return foldASCII(str)
case CasemappingPermissive: case CasemappingPermissive:
return foldPermissive(str) 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 { switch globalCasemappingSetting {
default: default:
return realSkeleton(name) return realSkeleton(name)
case CasemappingASCII: case CasemappingASCII, CasemappingRFC1459, CasemappingRFC1459Strict:
// identity function is fine because we independently case-normalize in Casefold // identity function is fine because we independently case-normalize in Casefold
return name, nil return name, nil
} }
@ -302,6 +310,23 @@ func foldASCII(str string) (result string, err error) {
return strings.ToLower(str), nil 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 { func IsPrintableASCII(str string) bool {
for i := 0; i < len(str); i++ { for i := 0; i < len(str); i++ {
// allow space here because it's technically printable; // 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") 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 ClientSet = utils.HashSet[*Client]
type memberData struct { type memberData struct {
modes *modes.ModeSet modes modes.ModeSet
joinTime int64 joinTime int64
} }
// MemberSet is a set of members with modes. // 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. // Add adds the given client to this set.
func (members MemberSet) Add(member *Client) { func (members MemberSet) Add(member *Client) {
members[member] = memberData{ members[member] = &memberData{
modes: modes.NewModeSet(),
joinTime: time.Now().UnixNano(), 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. // BitsetEmpty returns whether the bitset is empty.
// This has false positives under concurrent modification (i.e., it can return true // 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 // even though w.r.t. the sequence of atomic modifications, there was no point at

View file

@ -14,6 +14,7 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"net" "net"
"strconv"
"strings" "strings"
"time" "time"
) )
@ -31,8 +32,26 @@ var (
const ( const (
SecretTokenLength = 26 SecretTokenLength = 26
MachineId = 1 // Since there's no scaling Ergo, id is fixed at 1. Other things can have 2-127
) )
var inc uint64 = 0
// slingamn, if you ever see this, i'm sorry - I just didn't want to attach what i think is redundant data to every
// message.
func GenerateMessageId() uint64 {
inc++
var ts = time.Now().Unix() & 0xffffffffffff
var flake = uint64(ts << 16)
flake |= MachineId << 10
flake |= inc % 0x3ff
return flake
}
func GenerateMessageIdStr() string {
return strconv.FormatUint(GenerateMessageId(), 10)
}
// generate a secret token that cannot be brute-forced via online attacks // generate a secret token that cannot be brute-forced via online attacks
func GenerateSecretToken() string { func GenerateSecretToken() string {
// 128 bits of entropy are enough to resist any online attack: // 128 bits of entropy are enough to resist any online attack:

View file

@ -16,6 +16,7 @@ func IsRestrictedCTCPMessage(message string) bool {
type MessagePair struct { type MessagePair struct {
Message string Message string
Tags map[string]string
Concat bool // should be relayed with the multiline-concat tag Concat bool // should be relayed with the multiline-concat tag
} }
@ -37,19 +38,20 @@ type SplitMessage struct {
func MakeMessage(original string) (result SplitMessage) { func MakeMessage(original string) (result SplitMessage) {
result.Message = original result.Message = original
result.Msgid = GenerateSecretToken() result.Msgid = GenerateMessageIdStr()
result.SetTime() result.SetTime()
return return
} }
func (sm *SplitMessage) Append(message string, concat bool) { func (sm *SplitMessage) Append(message string, concat bool, tags map[string]string) {
if sm.Msgid == "" { if sm.Msgid == "" {
sm.Msgid = GenerateSecretToken() sm.Msgid = GenerateMessageIdStr()
} }
sm.Split = append(sm.Split, MessagePair{ sm.Split = append(sm.Split, MessagePair{
Message: message, Message: message,
Concat: concat, Concat: concat,
Tags: tags,
}) })
} }
@ -125,6 +127,28 @@ func (t *TokenLineBuilder) Add(token string) {
t.buf.WriteString(token) 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. // Lines terminates the line-building and returns all the lines.
func (t *TokenLineBuilder) Lines() (result []string) { func (t *TokenLineBuilder) Lines() (result []string) {
result = t.result result = t.result

View file

@ -43,3 +43,26 @@ func TestBuildTokenLines(t *testing.T) {
val = BuildTokenLines(10, []string{"abcd", "efgh", "ijkl"}, ",") val = BuildTokenLines(10, []string{"abcd", "efgh", "ijkl"}, ",")
assertEqual(val, []string{"abcd,efgh", "ijkl"}, t) 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) delete(s, elem)
} }
func CopyMap[K comparable, V any](input map[K]V) (result map[K]V) { func SetLiteral[T comparable](elems ...T) HashSet[T] {
result = make(map[K]V, len(input)) result := make(HashSet[T], len(elems))
for key, value := range input { for _, elem := range elems {
result[key] = value result.Add(elem)
} }
return return result
}
// 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
} }

View file

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

View file

@ -203,7 +203,7 @@ func zncPlayPrivmsgsFrom(client *Client, rb *ResponseBuffer, target string, star
zncMax := client.server.Config().History.ZNCMax zncMax := client.server.Config().History.ZNCMax
items, err := sequence.Between(history.Selector{Time: start}, history.Selector{Time: end}, zncMax) items, err := sequence.Between(history.Selector{Time: start}, history.Selector{Time: end}, zncMax)
if err == nil && len(items) != 0 { if err == nil && len(items) != 0 {
client.replayPrivmsgHistory(rb, items, target, false) client.replayPrivmsgHistory(rb, items, target, false, "", "", 0)
} }
} }
@ -211,7 +211,7 @@ func zncPlayPrivmsgsFromAll(client *Client, rb *ResponseBuffer, start, end time.
zncMax := client.server.Config().History.ZNCMax zncMax := client.server.Config().History.ZNCMax
items, err := client.privmsgsBetween(start, end, maxDMTargetsForAutoplay, zncMax) items, err := client.privmsgsBetween(start, end, maxDMTargetsForAutoplay, zncMax)
if err == nil && len(items) != 0 { if err == nil && len(items) != 0 {
client.replayPrivmsgHistory(rb, items, "", false) client.replayPrivmsgHistory(rb, items, "", false, "", "", 0)
} }
} }

@ -1 +1 @@
Subproject commit 6815dd238b8afd8ad73712d50d3e93cf997c26db Subproject commit a1324407893b603fe6b55ce7c4ee385938291ae1

View file

@ -108,9 +108,10 @@ server:
# the recommended default is 'ascii' (traditional ASCII-only identifiers). # the recommended default is 'ascii' (traditional ASCII-only identifiers).
# the other options are 'precis', which allows UTF8 identifiers that are "sane" # the other options are 'precis', which allows UTF8 identifiers that are "sane"
# (according to UFC 8265), with additional mitigations for homoglyph attacks, # (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 # 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 # however, note that changing it once the network is already up and running is
# problematic. # problematic.
casemapping: "ascii" casemapping: "ascii"
@ -192,6 +193,9 @@ server:
# - "192.168.1.1" # - "192.168.1.1"
# - "192.168.10.1/24" # - "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 # maximum length of clients' sendQ in bytes
# this should be big enough to hold bursts of channel/direct messages # this should be big enough to hold bursts of channel/direct messages
max-sendq: 96k max-sendq: 96k
@ -337,7 +341,7 @@ server:
# in a "closed-loop" system where you control the server and all the clients, # 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 # 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: # 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 # 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 # if you don't want to publicize how popular the server is
@ -378,6 +382,10 @@ accounts:
sender: "admin@my.network" sender: "admin@my.network"
require-tls: true require-tls: true
helo-domain: "my.network" # defaults to server name if unset 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 # options to enable DKIM signing of outgoing emails (recommended, but
# requires creating a DNS entry for the public key): # requires creating a DNS entry for the public key):
# dkim: # dkim:
@ -391,8 +399,14 @@ accounts:
# username: "admin" # username: "admin"
# password: "hunter2" # password: "hunter2"
# implicit-tls: false # TLS from the first byte, typically on port 465 # implicit-tls: false # TLS from the first byte, typically on port 465
blacklist-regexes: # addresses that are not accepted for registration:
# - ".*@mailinator.com" 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 timeout: 60s
# email-based password reset: # email-based password reset:
password-reset: password-reset:
@ -553,6 +567,40 @@ accounts:
# how many scripts are allowed to run at once? 0 for no limit: # how many scripts are allowed to run at once? 0 for no limit:
max-concurrency: 64 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 # channel options
channels: channels:
# modes that are set when new channels are created # modes that are set when new channels are created
@ -587,6 +635,12 @@ channels:
# (0 or omit for no expiration): # (0 or omit for no expiration):
invite-expiration: 24h 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: # operator classes:
# an operator has a single "class" (defining a privilege level), which can include # an operator has a single "class" (defining a privilege level), which can include
# multiple "capabilities" (defining privileged actions they can take). all # multiple "capabilities" (defining privileged actions they can take). all
@ -737,7 +791,7 @@ lock-file: "ircd.lock"
# datastore configuration # datastore configuration
datastore: datastore:
# path to the datastore # path to the database file (used to store account and channel registrations):
path: ircd.db path: ircd.db
# if the database schema requires an upgrade, `autoupgrade` will attempt to # if the database schema requires an upgrade, `autoupgrade` will attempt to
@ -780,6 +834,9 @@ limits:
# identlen is the max ident length allowed # identlen is the max ident length allowed
identlen: 20 identlen: 20
# realnamelen is the maximum realname length allowed
realnamelen: 150
# channellen is the max channel length allowed # channellen is the max channel length allowed
channellen: 64 channellen: 64
@ -799,7 +856,7 @@ limits:
whowas-entries: 100 whowas-entries: 100
# maximum length of channel lists (beI modes) # maximum length of channel lists (beI modes)
chan-list-modes: 60 chan-list-modes: 100
# maximum number of messages to accept during registration (prevents # maximum number of messages to accept during registration (prevents
# DoS / resource exhaustion attacks): # DoS / resource exhaustion attacks):
@ -954,7 +1011,8 @@ history:
# options to control how messages are stored and deleted: # options to control how messages are stored and deleted:
retention: 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 allow-individual-delete: false
# if persistent history is enabled, create additional index tables, # 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. // 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) { 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 var conn net.Conn
if timeout > 0 { if timeout > 0 {
conn, err = net.DialTimeout("tcp", net.JoinHostPort(ip, "113"), timeout) 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 { if err != nil {
return return
} }
defer conn.Close()
// stop the ident read after <timeout> seconds // if timeout is 0, `deadline` is the empty time.Time{} which means no deadline:
if timeout > 0 { conn.SetDeadline(deadline)
conn.SetDeadline(time.Now().Add(timeout))
}
_, 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 { if err != nil {
return return
} }

View file

@ -5,6 +5,7 @@ package ircfmt
import ( import (
"regexp" "regexp"
"strconv"
"strings" "strings"
) )
@ -19,24 +20,126 @@ const (
underline string = "\x1f" underline string = "\x1f"
reset string = "\x0f" reset string = "\x0f"
runecolour rune = '\x03' metacharacters = (bold + colour + monospace + reverseColour + italic + strikethrough + underline + reset)
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"
) )
// 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 ( var (
// valtoescape replaces most of IRC characters with our escapes. // 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") 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. // escapetoval contains most of our escapes and how they map to real IRC characters.
// intentionally skips colour, since that's handled elsewhere. // intentionally skips colour, since that's handled elsewhere.
@ -98,7 +201,9 @@ var (
"light blue": "12", "light blue": "12",
"pink": "13", "pink": "13",
"grey": "14", "grey": "14",
"gray": "14",
"light grey": "15", "light grey": "15",
"light gray": "15",
"default": "99", "default": "99",
} }
@ -123,7 +228,7 @@ func Escape(in string) string {
out.WriteString("$c") out.WriteString("$c")
inRunes = inRunes[2:] // strip colour code chars 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("[]") out.WriteString("[]")
continue continue
} }
@ -131,14 +236,14 @@ func Escape(in string) string {
var foreBuffer, backBuffer string var foreBuffer, backBuffer string
foreBuffer += string(inRunes[0]) foreBuffer += string(inRunes[0])
inRunes = inRunes[1:] inRunes = inRunes[1:]
if 0 < len(inRunes) && strings.Contains(colours1, string(inRunes[0])) { if 0 < len(inRunes) && isDigit(inRunes[0]) {
foreBuffer += string(inRunes[0]) foreBuffer += string(inRunes[0])
inRunes = inRunes[1:] 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]) backBuffer += string(inRunes[1])
inRunes = inRunes[2:] inRunes = inRunes[2:]
if 0 < len(inRunes) && strings.Contains(colours1, string(inRunes[0])) { if 0 < len(inRunes) && isDigit(inRunes[1]) {
backBuffer += string(inRunes[0]) backBuffer += string(inRunes[0])
inRunes = inRunes[1:] inRunes = inRunes[1:]
} }
@ -178,52 +283,27 @@ func Escape(in string) string {
return out.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 // 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!" // IE, it turns this: "This is a \x02cool\x02, \x034red\x0f message!"
// into: "This is a cool, red message!" // into: "This is a cool, red message!"
func Strip(in string) string { func Strip(in string) string {
out := strings.Builder{} splitChunks := Split(in)
runes := []rune(in) if len(splitChunks) == 0 {
if out.Len() < len(runes) { // Reduce allocations where needed return ""
out.Grow(len(in) - out.Len()) } else if len(splitChunks) == 1 {
} return splitChunks[0].Content
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:]
} else { } 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. // resolve "light blue" to "12", "12" to "12", "asdf" to "", etc.

View file

@ -196,6 +196,15 @@ func trimInitialSpaces(str string) string {
return str[i:] return str[i:]
} }
func isASCII(str string) bool {
for i := 0; i < len(str); i++ {
if str[i] > 127 {
return false
}
}
return true
}
func parseLine(line string, maxTagDataLength int, truncateLen int) (ircmsg Message, err error) { func parseLine(line string, maxTagDataLength int, truncateLen int) (ircmsg Message, err error) {
// remove either \n or \r\n from the end of the line: // remove either \n or \r\n from the end of the line:
line = strings.TrimSuffix(line, "\n") line = strings.TrimSuffix(line, "\n")
@ -238,7 +247,7 @@ func parseLine(line string, maxTagDataLength int, truncateLen int) (ircmsg Messa
// truncate if desired // truncate if desired
if truncateLen != 0 && truncateLen < len(line) { if truncateLen != 0 && truncateLen < len(line) {
err = ErrorBodyTooLong err = ErrorBodyTooLong
line = line[:truncateLen] line = TruncateUTF8Safe(line, truncateLen)
} }
// modern: "These message parts, and parameters themselves, are separated // modern: "These message parts, and parameters themselves, are separated
@ -265,11 +274,16 @@ func parseLine(line string, maxTagDataLength int, truncateLen int) (ircmsg Messa
commandEnd = len(line) commandEnd = len(line)
paramStart = len(line) paramStart = len(line)
} }
// normalize command to uppercase: baseCommand := line[:commandEnd]
ircmsg.Command = strings.ToUpper(line[:commandEnd]) if len(baseCommand) == 0 {
if len(ircmsg.Command) == 0 {
return ircmsg, ErrorLineIsEmpty return ircmsg, ErrorLineIsEmpty
} }
// technically this must be either letters or a 3-digit numeric:
if !isASCII(baseCommand) {
return ircmsg, ErrorLineContainsBadChar
}
// normalize command to uppercase:
ircmsg.Command = strings.ToUpper(baseCommand)
line = line[paramStart:] line = line[paramStart:]
for { for {

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
}

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

@ -0,0 +1,111 @@
package ircutils
import (
"encoding/base64"
"errors"
)
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)
result = make([]string, 0, (len(response)/400)+1)
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
buf []byte
}
// NewSASLBuffer returns a new SASLBuffer. maxLength is the maximum amount of
// 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 == "+" {
// total size is a multiple of 400 (possibly 0)
output = b.buf
b.Clear()
return true, output, nil
}
if len(value) > 400 {
b.Clear()
return true, nil, ErrSASLTooLong
}
curLen := len(b.buf)
chunkDecodedLen := base64.StdEncoding.DecodedLen(len(value))
if b.maxLength != 0 && (curLen+chunkDecodedLen) > b.maxLength {
b.Clear()
return true, nil, ErrSASLLimitExceeded
}
// "append-make pattern" as in the bytes.Buffer implementation:
b.buf = append(b.buf, make([]byte, chunkDecodedLen)...)
n, err := base64.StdEncoding.Decode(b.buf[curLen:], []byte(value))
b.buf = b.buf[0 : curLen+n]
if err != nil {
b.Clear()
return true, nil, err
}
if len(value) < 400 {
output = b.buf
b.Clear()
return true, output, nil
} else {
return false, nil, nil
}
}
// Clear resets the buffer state.
func (b *SASLBuffer) Clear() {
// we can't reuse this buffer in general since we may have returned it
b.buf = nil
}

View file

@ -7,24 +7,11 @@ import (
"strings" "strings"
"unicode" "unicode"
"unicode/utf8" "unicode/utf8"
"github.com/ergochat/irc-go/ircmsg"
) )
// truncate a message, taking care not to make valid UTF8 into invalid UTF8 var TruncateUTF8Safe = ircmsg.TruncateUTF8Safe
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
}
// Sanitizes human-readable text to make it safe for IRC; // Sanitizes human-readable text to make it safe for IRC;
// assumes UTF-8 and uses the replacement character where // assumes UTF-8 and uses the replacement character where

View file

@ -1,22 +0,0 @@
## Migration Guide (v3.2.1)
Starting from [v3.2.1](https://github.com/golang-jwt/jwt/releases/tag/v3.2.1]), the import path has changed from `github.com/dgrijalva/jwt-go` to `github.com/golang-jwt/jwt`. Future releases will be using the `github.com/golang-jwt/jwt` import path and continue the existing versioning scheme of `v3.x.x+incompatible`. Backwards-compatible patches and fixes will be done on the `v3` release branch, where as new build-breaking features will be developed in a `v4` release, possibly including a SIV-style import path.
### go.mod replacement
In a first step, the easiest way is to use `go mod edit` to issue a replacement.
```
go mod edit -replace github.com/dgrijalva/jwt-go=github.com/golang-jwt/jwt@v3.2.1+incompatible
go mod tidy
```
This will still keep the old import path in your code but replace it with the new package and also introduce a new indirect dependency to `github.com/golang-jwt/jwt`. Try to compile your project; it should still work.
### Cleanup
If your code still consistently builds, you can replace all occurences of `github.com/dgrijalva/jwt-go` with `github.com/golang-jwt/jwt`, either manually or by using tools such as `sed`. Finally, the `replace` directive in the `go.mod` file can be removed.
## Older releases (before v3.2.0)
The original migration guide for older releases can be found at https://github.com/dgrijalva/jwt-go/blob/master/MIGRATION_GUIDE.md.

View file

@ -1,113 +0,0 @@
# jwt-go
[![build](https://github.com/golang-jwt/jwt/actions/workflows/build.yml/badge.svg)](https://github.com/golang-jwt/jwt/actions/workflows/build.yml)
[![Go Reference](https://pkg.go.dev/badge/github.com/golang-jwt/jwt.svg)](https://pkg.go.dev/github.com/golang-jwt/jwt)
A [go](http://www.golang.org) (or 'golang' for search engine friendliness) implementation of [JSON Web Tokens](https://datatracker.ietf.org/doc/html/rfc7519).
**IMPORT PATH CHANGE:** Starting from [v3.2.1](https://github.com/golang-jwt/jwt/releases/tag/v3.2.1), the import path has changed from `github.com/dgrijalva/jwt-go` to `github.com/golang-jwt/jwt`. After the original author of the library suggested migrating the maintenance of `jwt-go`, a dedicated team of open source maintainers decided to clone the existing library into this repository. See [dgrijalva/jwt-go#462](https://github.com/dgrijalva/jwt-go/issues/462) for a detailed discussion on this topic.
Future releases will be using the `github.com/golang-jwt/jwt` import path and continue the existing versioning scheme of `v3.x.x+incompatible`. Backwards-compatible patches and fixes will be done on the `v3` release branch, where as new build-breaking features will be developed in a `v4` release, possibly including a SIV-style import path.
**SECURITY NOTICE:** Some older versions of Go have a security issue in the crypto/elliptic. Recommendation is to upgrade to at least 1.15 See issue [dgrijalva/jwt-go#216](https://github.com/dgrijalva/jwt-go/issues/216) for more detail.
**SECURITY NOTICE:** It's important that you [validate the `alg` presented is what you expect](https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/). This library attempts to make it easy to do the right thing by requiring key types match the expected alg, but you should take the extra step to verify it in your usage. See the examples provided.
### Supported Go versions
Our support of Go versions is aligned with Go's [version release policy](https://golang.org/doc/devel/release#policy).
So we will support a major version of Go until there are two newer major releases.
We no longer support building jwt-go with unsupported Go versions, as these contain security vulnerabilities
which will not be fixed.
## What the heck is a JWT?
JWT.io has [a great introduction](https://jwt.io/introduction) to JSON Web Tokens.
In short, it's a signed JSON object that does something useful (for example, authentication). It's commonly used for `Bearer` tokens in Oauth 2. A token is made of three parts, separated by `.`'s. The first two parts are JSON objects, that have been [base64url](https://datatracker.ietf.org/doc/html/rfc4648) encoded. The last part is the signature, encoded the same way.
The first part is called the header. It contains the necessary information for verifying the last part, the signature. For example, which encryption method was used for signing and what key was used.
The part in the middle is the interesting bit. It's called the Claims and contains the actual stuff you care about. Refer to [RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519) for information about reserved keys and the proper way to add your own.
## What's in the box?
This library supports the parsing and verification as well as the generation and signing of JWTs. Current supported signing algorithms are HMAC SHA, RSA, RSA-PSS, and ECDSA, though hooks are present for adding your own.
## Examples
See [the project documentation](https://pkg.go.dev/github.com/golang-jwt/jwt) for examples of usage:
* [Simple example of parsing and validating a token](https://pkg.go.dev/github.com/golang-jwt/jwt#example-Parse-Hmac)
* [Simple example of building and signing a token](https://pkg.go.dev/github.com/golang-jwt/jwt#example-New-Hmac)
* [Directory of Examples](https://pkg.go.dev/github.com/golang-jwt/jwt#pkg-examples)
## Extensions
This library publishes all the necessary components for adding your own signing methods. Simply implement the `SigningMethod` interface and register a factory method using `RegisterSigningMethod`.
Here's an example of an extension that integrates with multiple Google Cloud Platform signing tools (AppEngine, IAM API, Cloud KMS): https://github.com/someone1/gcp-jwt-go
## Compliance
This library was last reviewed to comply with [RTF 7519](https://datatracker.ietf.org/doc/html/rfc7519) dated May 2015 with a few notable differences:
* In order to protect against accidental use of [Unsecured JWTs](https://datatracker.ietf.org/doc/html/rfc7519#section-6), tokens using `alg=none` will only be accepted if the constant `jwt.UnsafeAllowNoneSignatureType` is provided as the key.
## Project Status & Versioning
This library is considered production ready. Feedback and feature requests are appreciated. The API should be considered stable. There should be very few backwards-incompatible changes outside of major version updates (and only with good reason).
This project uses [Semantic Versioning 2.0.0](http://semver.org). Accepted pull requests will land on `main`. Periodically, versions will be tagged from `main`. You can find all the releases on [the project releases page](https://github.com/golang-jwt/jwt/releases).
While we try to make it obvious when we make breaking changes, there isn't a great mechanism for pushing announcements out to users. You may want to use this alternative package include: `gopkg.in/golang-jwt/jwt.v3`. It will do the right thing WRT semantic versioning.
**BREAKING CHANGES:***
* Version 3.0.0 includes _a lot_ of changes from the 2.x line, including a few that break the API. We've tried to break as few things as possible, so there should just be a few type signature changes. A full list of breaking changes is available in `VERSION_HISTORY.md`. See `MIGRATION_GUIDE.md` for more information on updating your code.
## Usage Tips
### Signing vs Encryption
A token is simply a JSON object that is signed by its author. this tells you exactly two things about the data:
* The author of the token was in the possession of the signing secret
* The data has not been modified since it was signed
It's important to know that JWT does not provide encryption, which means anyone who has access to the token can read its contents. If you need to protect (encrypt) the data, there is a companion spec, `JWE`, that provides this functionality. JWE is currently outside the scope of this library.
### Choosing a Signing Method
There are several signing methods available, and you should probably take the time to learn about the various options before choosing one. The principal design decision is most likely going to be symmetric vs asymmetric.
Symmetric signing methods, such as HSA, use only a single secret. This is probably the simplest signing method to use since any `[]byte` can be used as a valid secret. They are also slightly computationally faster to use, though this rarely is enough to matter. Symmetric signing methods work the best when both producers and consumers of tokens are trusted, or even the same system. Since the same secret is used to both sign and validate tokens, you can't easily distribute the key for validation.
Asymmetric signing methods, such as RSA, use different keys for signing and verifying tokens. This makes it possible to produce tokens with a private key, and allow any consumer to access the public key for verification.
### Signing Methods and Key Types
Each signing method expects a different object type for its signing keys. See the package documentation for details. Here are the most common ones:
* The [HMAC signing method](https://pkg.go.dev/github.com/golang-jwt/jwt#SigningMethodHMAC) (`HS256`,`HS384`,`HS512`) expect `[]byte` values for signing and validation
* The [RSA signing method](https://pkg.go.dev/github.com/golang-jwt/jwt#SigningMethodRSA) (`RS256`,`RS384`,`RS512`) expect `*rsa.PrivateKey` for signing and `*rsa.PublicKey` for validation
* The [ECDSA signing method](https://pkg.go.dev/github.com/golang-jwt/jwt#SigningMethodECDSA) (`ES256`,`ES384`,`ES512`) expect `*ecdsa.PrivateKey` for signing and `*ecdsa.PublicKey` for validation
### JWT and OAuth
It's worth mentioning that OAuth and JWT are not the same thing. A JWT token is simply a signed JSON object. It can be used anywhere such a thing is useful. There is some confusion, though, as JWT is the most common type of bearer token used in OAuth2 authentication.
Without going too far down the rabbit hole, here's a description of the interaction of these technologies:
* OAuth is a protocol for allowing an identity provider to be separate from the service a user is logging in to. For example, whenever you use Facebook to log into a different service (Yelp, Spotify, etc), you are using OAuth.
* OAuth defines several options for passing around authentication data. One popular method is called a "bearer token". A bearer token is simply a string that _should_ only be held by an authenticated user. Thus, simply presenting this token proves your identity. You can probably derive from here why a JWT might make a good bearer token.
* Because bearer tokens are used for authentication, it's important they're kept secret. This is why transactions that use bearer tokens typically happen over SSL.
### Troubleshooting
This library uses descriptive error messages whenever possible. If you are not getting the expected result, have a look at the errors. The most common place people get stuck is providing the correct type of key to the parser. See the above section on signing methods and key types.
## More
Documentation can be found [on pkg.go.dev](https://pkg.go.dev/github.com/golang-jwt/jwt).
The command line utility included in this project (cmd/jwt) provides a straightforward example of token creation and parsing as well as a useful tool for debugging your own integration. You'll also find several implementation examples in the documentation.

View file

@ -1,146 +0,0 @@
package jwt
import (
"crypto/subtle"
"fmt"
"time"
)
// For a type to be a Claims object, it must just have a Valid method that determines
// if the token is invalid for any supported reason
type Claims interface {
Valid() error
}
// Structured version of Claims Section, as referenced at
// https://tools.ietf.org/html/rfc7519#section-4.1
// See examples for how to use this with your own claim types
type StandardClaims struct {
Audience string `json:"aud,omitempty"`
ExpiresAt int64 `json:"exp,omitempty"`
Id string `json:"jti,omitempty"`
IssuedAt int64 `json:"iat,omitempty"`
Issuer string `json:"iss,omitempty"`
NotBefore int64 `json:"nbf,omitempty"`
Subject string `json:"sub,omitempty"`
}
// Validates time based claims "exp, iat, nbf".
// There is no accounting for clock skew.
// As well, if any of the above claims are not in the token, it will still
// be considered a valid claim.
func (c StandardClaims) Valid() error {
vErr := new(ValidationError)
now := TimeFunc().Unix()
// The claims below are optional, by default, so if they are set to the
// default value in Go, let's not fail the verification for them.
if !c.VerifyExpiresAt(now, false) {
delta := time.Unix(now, 0).Sub(time.Unix(c.ExpiresAt, 0))
vErr.Inner = fmt.Errorf("token is expired by %v", delta)
vErr.Errors |= ValidationErrorExpired
}
if !c.VerifyIssuedAt(now, false) {
vErr.Inner = fmt.Errorf("Token used before issued")
vErr.Errors |= ValidationErrorIssuedAt
}
if !c.VerifyNotBefore(now, false) {
vErr.Inner = fmt.Errorf("token is not valid yet")
vErr.Errors |= ValidationErrorNotValidYet
}
if vErr.valid() {
return nil
}
return vErr
}
// Compares the aud claim against cmp.
// If required is false, this method will return true if the value matches or is unset
func (c *StandardClaims) VerifyAudience(cmp string, req bool) bool {
return verifyAud([]string{c.Audience}, cmp, req)
}
// Compares the exp claim against cmp.
// If required is false, this method will return true if the value matches or is unset
func (c *StandardClaims) VerifyExpiresAt(cmp int64, req bool) bool {
return verifyExp(c.ExpiresAt, cmp, req)
}
// Compares the iat claim against cmp.
// If required is false, this method will return true if the value matches or is unset
func (c *StandardClaims) VerifyIssuedAt(cmp int64, req bool) bool {
return verifyIat(c.IssuedAt, cmp, req)
}
// Compares the iss claim against cmp.
// If required is false, this method will return true if the value matches or is unset
func (c *StandardClaims) VerifyIssuer(cmp string, req bool) bool {
return verifyIss(c.Issuer, cmp, req)
}
// Compares the nbf claim against cmp.
// If required is false, this method will return true if the value matches or is unset
func (c *StandardClaims) VerifyNotBefore(cmp int64, req bool) bool {
return verifyNbf(c.NotBefore, cmp, req)
}
// ----- helpers
func verifyAud(aud []string, cmp string, required bool) bool {
if len(aud) == 0 {
return !required
}
// use a var here to keep constant time compare when looping over a number of claims
result := false
var stringClaims string
for _, a := range aud {
if subtle.ConstantTimeCompare([]byte(a), []byte(cmp)) != 0 {
result = true
}
stringClaims = stringClaims + a
}
// case where "" is sent in one or many aud claims
if len(stringClaims) == 0 {
return !required
}
return result
}
func verifyExp(exp int64, now int64, required bool) bool {
if exp == 0 {
return !required
}
return now <= exp
}
func verifyIat(iat int64, now int64, required bool) bool {
if iat == 0 {
return !required
}
return now >= iat
}
func verifyIss(iss string, cmp string, required bool) bool {
if iss == "" {
return !required
}
if subtle.ConstantTimeCompare([]byte(iss), []byte(cmp)) != 0 {
return true
} else {
return false
}
}
func verifyNbf(nbf int64, now int64, required bool) bool {
if nbf == 0 {
return !required
}
return now >= nbf
}

View file

@ -1,59 +0,0 @@
package jwt
import (
"errors"
)
// Error constants
var (
ErrInvalidKey = errors.New("key is invalid")
ErrInvalidKeyType = errors.New("key is of invalid type")
ErrHashUnavailable = errors.New("the requested hash function is unavailable")
)
// The errors that might occur when parsing and validating a token
const (
ValidationErrorMalformed uint32 = 1 << iota // Token is malformed
ValidationErrorUnverifiable // Token could not be verified because of signing problems
ValidationErrorSignatureInvalid // Signature validation failed
// Standard Claim validation errors
ValidationErrorAudience // AUD validation failed
ValidationErrorExpired // EXP validation failed
ValidationErrorIssuedAt // IAT validation failed
ValidationErrorIssuer // ISS validation failed
ValidationErrorNotValidYet // NBF validation failed
ValidationErrorId // JTI validation failed
ValidationErrorClaimsInvalid // Generic claims validation error
)
// Helper for constructing a ValidationError with a string error message
func NewValidationError(errorText string, errorFlags uint32) *ValidationError {
return &ValidationError{
text: errorText,
Errors: errorFlags,
}
}
// The error from Parse if token is not valid
type ValidationError struct {
Inner error // stores the error returned by external dependencies, i.e.: KeyFunc
Errors uint32 // bitfield. see ValidationError... constants
text string // errors that do not have a valid error just have text
}
// Validation error is an error type
func (e ValidationError) Error() string {
if e.Inner != nil {
return e.Inner.Error()
} else if e.text != "" {
return e.text
} else {
return "token is invalid"
}
}
// No errors
func (e *ValidationError) valid() bool {
return e.Errors == 0
}

View file

@ -1,120 +0,0 @@
package jwt
import (
"encoding/json"
"errors"
// "fmt"
)
// Claims type that uses the map[string]interface{} for JSON decoding
// This is the default claims type if you don't supply one
type MapClaims map[string]interface{}
// VerifyAudience Compares the aud claim against cmp.
// If required is false, this method will return true if the value matches or is unset
func (m MapClaims) VerifyAudience(cmp string, req bool) bool {
var aud []string
switch v := m["aud"].(type) {
case string:
aud = append(aud, v)
case []string:
aud = v
case []interface{}:
for _, a := range v {
vs, ok := a.(string)
if !ok {
return false
}
aud = append(aud, vs)
}
}
return verifyAud(aud, cmp, req)
}
// Compares the exp claim against cmp.
// If required is false, this method will return true if the value matches or is unset
func (m MapClaims) VerifyExpiresAt(cmp int64, req bool) bool {
exp, ok := m["exp"]
if !ok {
return !req
}
switch expType := exp.(type) {
case float64:
return verifyExp(int64(expType), cmp, req)
case json.Number:
v, _ := expType.Int64()
return verifyExp(v, cmp, req)
}
return false
}
// Compares the iat claim against cmp.
// If required is false, this method will return true if the value matches or is unset
func (m MapClaims) VerifyIssuedAt(cmp int64, req bool) bool {
iat, ok := m["iat"]
if !ok {
return !req
}
switch iatType := iat.(type) {
case float64:
return verifyIat(int64(iatType), cmp, req)
case json.Number:
v, _ := iatType.Int64()
return verifyIat(v, cmp, req)
}
return false
}
// Compares the iss claim against cmp.
// If required is false, this method will return true if the value matches or is unset
func (m MapClaims) VerifyIssuer(cmp string, req bool) bool {
iss, _ := m["iss"].(string)
return verifyIss(iss, cmp, req)
}
// Compares the nbf claim against cmp.
// If required is false, this method will return true if the value matches or is unset
func (m MapClaims) VerifyNotBefore(cmp int64, req bool) bool {
nbf, ok := m["nbf"]
if !ok {
return !req
}
switch nbfType := nbf.(type) {
case float64:
return verifyNbf(int64(nbfType), cmp, req)
case json.Number:
v, _ := nbfType.Int64()
return verifyNbf(v, cmp, req)
}
return false
}
// Validates time based claims "exp, iat, nbf".
// There is no accounting for clock skew.
// As well, if any of the above claims are not in the token, it will still
// be considered a valid claim.
func (m MapClaims) Valid() error {
vErr := new(ValidationError)
now := TimeFunc().Unix()
if !m.VerifyExpiresAt(now, false) {
vErr.Inner = errors.New("Token is expired")
vErr.Errors |= ValidationErrorExpired
}
if !m.VerifyIssuedAt(now, false) {
vErr.Inner = errors.New("Token used before issued")
vErr.Errors |= ValidationErrorIssuedAt
}
if !m.VerifyNotBefore(now, false) {
vErr.Inner = errors.New("Token is not valid yet")
vErr.Errors |= ValidationErrorNotValidYet
}
if vErr.valid() {
return nil
}
return vErr
}

View file

@ -1,148 +0,0 @@
package jwt
import (
"bytes"
"encoding/json"
"fmt"
"strings"
)
type Parser struct {
ValidMethods []string // If populated, only these methods will be considered valid
UseJSONNumber bool // Use JSON Number format in JSON decoder
SkipClaimsValidation bool // Skip claims validation during token parsing
}
// Parse, validate, and return a token.
// keyFunc will receive the parsed token and should return the key for validating.
// If everything is kosher, err will be nil
func (p *Parser) Parse(tokenString string, keyFunc Keyfunc) (*Token, error) {
return p.ParseWithClaims(tokenString, MapClaims{}, keyFunc)
}
func (p *Parser) ParseWithClaims(tokenString string, claims Claims, keyFunc Keyfunc) (*Token, error) {
token, parts, err := p.ParseUnverified(tokenString, claims)
if err != nil {
return token, err
}
// Verify signing method is in the required set
if p.ValidMethods != nil {
var signingMethodValid = false
var alg = token.Method.Alg()
for _, m := range p.ValidMethods {
if m == alg {
signingMethodValid = true
break
}
}
if !signingMethodValid {
// signing method is not in the listed set
return token, NewValidationError(fmt.Sprintf("signing method %v is invalid", alg), ValidationErrorSignatureInvalid)
}
}
// Lookup key
var key interface{}
if keyFunc == nil {
// keyFunc was not provided. short circuiting validation
return token, NewValidationError("no Keyfunc was provided.", ValidationErrorUnverifiable)
}
if key, err = keyFunc(token); err != nil {
// keyFunc returned an error
if ve, ok := err.(*ValidationError); ok {
return token, ve
}
return token, &ValidationError{Inner: err, Errors: ValidationErrorUnverifiable}
}
vErr := &ValidationError{}
// Validate Claims
if !p.SkipClaimsValidation {
if err := token.Claims.Valid(); err != nil {
// If the Claims Valid returned an error, check if it is a validation error,
// If it was another error type, create a ValidationError with a generic ClaimsInvalid flag set
if e, ok := err.(*ValidationError); !ok {
vErr = &ValidationError{Inner: err, Errors: ValidationErrorClaimsInvalid}
} else {
vErr = e
}
}
}
// Perform validation
token.Signature = parts[2]
if err = token.Method.Verify(strings.Join(parts[0:2], "."), token.Signature, key); err != nil {
vErr.Inner = err
vErr.Errors |= ValidationErrorSignatureInvalid
}
if vErr.valid() {
token.Valid = true
return token, nil
}
return token, vErr
}
// WARNING: Don't use this method unless you know what you're doing
//
// This method parses the token but doesn't validate the signature. It's only
// ever useful in cases where you know the signature is valid (because it has
// been checked previously in the stack) and you want to extract values from
// it.
func (p *Parser) ParseUnverified(tokenString string, claims Claims) (token *Token, parts []string, err error) {
parts = strings.Split(tokenString, ".")
if len(parts) != 3 {
return nil, parts, NewValidationError("token contains an invalid number of segments", ValidationErrorMalformed)
}
token = &Token{Raw: tokenString}
// parse Header
var headerBytes []byte
if headerBytes, err = DecodeSegment(parts[0]); err != nil {
if strings.HasPrefix(strings.ToLower(tokenString), "bearer ") {
return token, parts, NewValidationError("tokenstring should not contain 'bearer '", ValidationErrorMalformed)
}
return token, parts, &ValidationError{Inner: err, Errors: ValidationErrorMalformed}
}
if err = json.Unmarshal(headerBytes, &token.Header); err != nil {
return token, parts, &ValidationError{Inner: err, Errors: ValidationErrorMalformed}
}
// parse Claims
var claimBytes []byte
token.Claims = claims
if claimBytes, err = DecodeSegment(parts[1]); err != nil {
return token, parts, &ValidationError{Inner: err, Errors: ValidationErrorMalformed}
}
dec := json.NewDecoder(bytes.NewBuffer(claimBytes))
if p.UseJSONNumber {
dec.UseNumber()
}
// JSON Decode. Special case for map type to avoid weird pointer behavior
if c, ok := token.Claims.(MapClaims); ok {
err = dec.Decode(&c)
} else {
err = dec.Decode(&claims)
}
// Handle decode error
if err != nil {
return token, parts, &ValidationError{Inner: err, Errors: ValidationErrorMalformed}
}
// Lookup signature method
if method, ok := token.Header["alg"].(string); ok {
if token.Method = GetSigningMethod(method); token.Method == nil {
return token, parts, NewValidationError("signing method (alg) is unavailable.", ValidationErrorUnverifiable)
}
} else {
return token, parts, NewValidationError("signing method (alg) is unspecified.", ValidationErrorUnverifiable)
}
return token, parts, nil
}

View file

@ -1,35 +0,0 @@
package jwt
import (
"sync"
)
var signingMethods = map[string]func() SigningMethod{}
var signingMethodLock = new(sync.RWMutex)
// Implement SigningMethod to add new methods for signing or verifying tokens.
type SigningMethod interface {
Verify(signingString, signature string, key interface{}) error // Returns nil if signature is valid
Sign(signingString string, key interface{}) (string, error) // Returns encoded signature or error
Alg() string // returns the alg identifier for this method (example: 'HS256')
}
// Register the "alg" name and a factory function for signing method.
// This is typically done during init() in the method's implementation
func RegisterSigningMethod(alg string, f func() SigningMethod) {
signingMethodLock.Lock()
defer signingMethodLock.Unlock()
signingMethods[alg] = f
}
// Get a signing method from an "alg" string
func GetSigningMethod(alg string) (method SigningMethod) {
signingMethodLock.RLock()
defer signingMethodLock.RUnlock()
if methodF, ok := signingMethods[alg]; ok {
method = methodF()
}
return
}

View file

@ -1,104 +0,0 @@
package jwt
import (
"encoding/base64"
"encoding/json"
"strings"
"time"
)
// TimeFunc provides the current time when parsing token to validate "exp" claim (expiration time).
// You can override it to use another time value. This is useful for testing or if your
// server uses a different time zone than your tokens.
var TimeFunc = time.Now
// Parse methods use this callback function to supply
// the key for verification. The function receives the parsed,
// but unverified Token. This allows you to use properties in the
// Header of the token (such as `kid`) to identify which key to use.
type Keyfunc func(*Token) (interface{}, error)
// A JWT Token. Different fields will be used depending on whether you're
// creating or parsing/verifying a token.
type Token struct {
Raw string // The raw token. Populated when you Parse a token
Method SigningMethod // The signing method used or to be used
Header map[string]interface{} // The first segment of the token
Claims Claims // The second segment of the token
Signature string // The third segment of the token. Populated when you Parse a token
Valid bool // Is the token valid? Populated when you Parse/Verify a token
}
// Create a new Token. Takes a signing method
func New(method SigningMethod) *Token {
return NewWithClaims(method, MapClaims{})
}
func NewWithClaims(method SigningMethod, claims Claims) *Token {
return &Token{
Header: map[string]interface{}{
"typ": "JWT",
"alg": method.Alg(),
},
Claims: claims,
Method: method,
}
}
// Get the complete, signed token
func (t *Token) SignedString(key interface{}) (string, error) {
var sig, sstr string
var err error
if sstr, err = t.SigningString(); err != nil {
return "", err
}
if sig, err = t.Method.Sign(sstr, key); err != nil {
return "", err
}
return strings.Join([]string{sstr, sig}, "."), nil
}
// Generate the signing string. This is the
// most expensive part of the whole deal. Unless you
// need this for something special, just go straight for
// the SignedString.
func (t *Token) SigningString() (string, error) {
var err error
parts := make([]string, 2)
for i := range parts {
var jsonValue []byte
if i == 0 {
if jsonValue, err = json.Marshal(t.Header); err != nil {
return "", err
}
} else {
if jsonValue, err = json.Marshal(t.Claims); err != nil {
return "", err
}
}
parts[i] = EncodeSegment(jsonValue)
}
return strings.Join(parts, "."), nil
}
// Parse, validate, and return a token.
// keyFunc will receive the parsed token and should return the key for validating.
// If everything is kosher, err will be nil
func Parse(tokenString string, keyFunc Keyfunc) (*Token, error) {
return new(Parser).Parse(tokenString, keyFunc)
}
func ParseWithClaims(tokenString string, claims Claims, keyFunc Keyfunc) (*Token, error) {
return new(Parser).ParseWithClaims(tokenString, claims, keyFunc)
}
// Encode JWT specific base64url encoding with padding stripped
func EncodeSegment(seg []byte) string {
return base64.RawURLEncoding.EncodeToString(seg)
}
// Decode JWT specific base64url encoding with padding stripped
func DecodeSegment(seg string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(seg)
}

195
vendor/github.com/golang-jwt/jwt/v5/MIGRATION_GUIDE.md generated vendored Normal file
View file

@ -0,0 +1,195 @@
# Migration Guide (v5.0.0)
Version `v5` contains a major rework of core functionalities in the `jwt-go`
library. This includes support for several validation options as well as a
re-design of the `Claims` interface. Lastly, we reworked how errors work under
the hood, which should provide a better overall developer experience.
Starting from [v5.0.0](https://github.com/golang-jwt/jwt/releases/tag/v5.0.0),
the import path will be:
"github.com/golang-jwt/jwt/v5"
For most users, changing the import path *should* suffice. However, since we
intentionally changed and cleaned some of the public API, existing programs
might need to be updated. The following sections describe significant changes
and corresponding updates for existing programs.
## Parsing and Validation Options
Under the hood, a new `Validator` struct takes care of validating the claims. A
long awaited feature has been the option to fine-tune the validation of tokens.
This is now possible with several `ParserOption` functions that can be appended
to most `Parse` functions, such as `ParseWithClaims`. The most important options
and changes are:
* Added `WithLeeway` to support specifying the leeway that is allowed when
validating time-based claims, such as `exp` or `nbf`.
* Changed default behavior to not check the `iat` claim. Usage of this claim
is OPTIONAL according to the JWT RFC. The claim itself is also purely
informational according to the RFC, so a strict validation failure is not
recommended. If you want to check for sensible values in these claims,
please use the `WithIssuedAt` parser option.
* Added `WithAudience`, `WithSubject` and `WithIssuer` to support checking for
expected `aud`, `sub` and `iss`.
* Added `WithStrictDecoding` and `WithPaddingAllowed` options to allow
previously global settings to enable base64 strict encoding and the parsing
of base64 strings with padding. The latter is strictly speaking against the
standard, but unfortunately some of the major identity providers issue some
of these incorrect tokens. Both options are disabled by default.
## Changes to the `Claims` interface
### Complete Restructuring
Previously, the claims interface was satisfied with an implementation of a
`Valid() error` function. This had several issues:
* The different claim types (struct claims, map claims, etc.) then contained
similar (but not 100 % identical) code of how this validation was done. This
lead to a lot of (almost) duplicate code and was hard to maintain
* It was not really semantically close to what a "claim" (or a set of claims)
really is; which is a list of defined key/value pairs with a certain
semantic meaning.
Since all the validation functionality is now extracted into the validator, all
`VerifyXXX` and `Valid` functions have been removed from the `Claims` interface.
Instead, the interface now represents a list of getters to retrieve values with
a specific meaning. This allows us to completely decouple the validation logic
with the underlying storage representation of the claim, which could be a
struct, a map or even something stored in a database.
```go
type Claims interface {
GetExpirationTime() (*NumericDate, error)
GetIssuedAt() (*NumericDate, error)
GetNotBefore() (*NumericDate, error)
GetIssuer() (string, error)
GetSubject() (string, error)
GetAudience() (ClaimStrings, error)
}
```
Users that previously directly called the `Valid` function on their claims,
e.g., to perform validation independently of parsing/verifying a token, can now
use the `jwt.NewValidator` function to create a `Validator` independently of the
`Parser`.
```go
var v = jwt.NewValidator(jwt.WithLeeway(5*time.Second))
v.Validate(myClaims)
```
### Supported Claim Types and Removal of `StandardClaims`
The two standard claim types supported by this library, `MapClaims` and
`RegisteredClaims` both implement the necessary functions of this interface. The
old `StandardClaims` struct, which has already been deprecated in `v4` is now
removed.
Users using custom claims, in most cases, will not experience any changes in the
behavior as long as they embedded `RegisteredClaims`. If they created a new
claim type from scratch, they now need to implemented the proper getter
functions.
### Migrating Application Specific Logic of the old `Valid`
Previously, users could override the `Valid` method in a custom claim, for
example to extend the validation with application-specific claims. However, this
was always very dangerous, since once could easily disable the standard
validation and signature checking.
In order to avoid that, while still supporting the use-case, a new
`ClaimsValidator` interface has been introduced. This interface consists of the
`Validate() error` function. If the validator sees, that a `Claims` struct
implements this interface, the errors returned to the `Validate` function will
be *appended* to the regular standard validation. It is not possible to disable
the standard validation anymore (even only by accident).
Usage examples can be found in [example_test.go](./example_test.go), to build
claims structs like the following.
```go
// MyCustomClaims includes all registered claims, plus Foo.
type MyCustomClaims struct {
Foo string `json:"foo"`
jwt.RegisteredClaims
}
// Validate can be used to execute additional application-specific claims
// validation.
func (m MyCustomClaims) Validate() error {
if m.Foo != "bar" {
return errors.New("must be foobar")
}
return nil
}
```
## Changes to the `Token` and `Parser` struct
The previously global functions `DecodeSegment` and `EncodeSegment` were moved
to the `Parser` and `Token` struct respectively. This will allow us in the
future to configure the behavior of these two based on options supplied on the
parser or the token (creation). This also removes two previously global
variables and moves them to parser options `WithStrictDecoding` and
`WithPaddingAllowed`.
In order to do that, we had to adjust the way signing methods work. Previously
they were given a base64 encoded signature in `Verify` and were expected to
return a base64 encoded version of the signature in `Sign`, both as a `string`.
However, this made it necessary to have `DecodeSegment` and `EncodeSegment`
global and was a less than perfect design because we were repeating
encoding/decoding steps for all signing methods. Now, `Sign` and `Verify`
operate on a decoded signature as a `[]byte`, which feels more natural for a
cryptographic operation anyway. Lastly, `Parse` and `SignedString` take care of
the final encoding/decoding part.
In addition to that, we also changed the `Signature` field on `Token` from a
`string` to `[]byte` and this is also now populated with the decoded form. This
is also more consistent, because the other parts of the JWT, mainly `Header` and
`Claims` were already stored in decoded form in `Token`. Only the signature was
stored in base64 encoded form, which was redundant with the information in the
`Raw` field, which contains the complete token as base64.
```go
type Token struct {
Raw string // Raw contains the raw token
Method SigningMethod // Method is the signing method used or to be used
Header map[string]interface{} // Header is the first segment of the token in decoded form
Claims Claims // Claims is the second segment of the token in decoded form
Signature []byte // Signature is the third segment of the token in decoded form
Valid bool // Valid specifies if the token is valid
}
```
Most (if not all) of these changes should not impact the normal usage of this
library. Only users directly accessing the `Signature` field as well as
developers of custom signing methods should be affected.
# Migration Guide (v4.0.0)
Starting from [v4.0.0](https://github.com/golang-jwt/jwt/releases/tag/v4.0.0),
the import path will be:
"github.com/golang-jwt/jwt/v4"
The `/v4` version will be backwards compatible with existing `v3.x.y` tags in
this repo, as well as `github.com/dgrijalva/jwt-go`. For most users this should
be a drop-in replacement, if you're having troubles migrating, please open an
issue.
You can replace all occurrences of `github.com/dgrijalva/jwt-go` or
`github.com/golang-jwt/jwt` with `github.com/golang-jwt/jwt/v4`, either manually
or by using tools such as `sed` or `gofmt`.
And then you'd typically run:
```
go get github.com/golang-jwt/jwt/v4
go mod tidy
```
# Older releases (before v3.2.0)
The original migration guide for older releases can be found at
https://github.com/dgrijalva/jwt-go/blob/master/MIGRATION_GUIDE.md.

167
vendor/github.com/golang-jwt/jwt/v5/README.md generated vendored Normal file
View file

@ -0,0 +1,167 @@
# jwt-go
[![build](https://github.com/golang-jwt/jwt/actions/workflows/build.yml/badge.svg)](https://github.com/golang-jwt/jwt/actions/workflows/build.yml)
[![Go
Reference](https://pkg.go.dev/badge/github.com/golang-jwt/jwt/v5.svg)](https://pkg.go.dev/github.com/golang-jwt/jwt/v5)
[![Coverage Status](https://coveralls.io/repos/github/golang-jwt/jwt/badge.svg?branch=main)](https://coveralls.io/github/golang-jwt/jwt?branch=main)
A [go](http://www.golang.org) (or 'golang' for search engine friendliness)
implementation of [JSON Web
Tokens](https://datatracker.ietf.org/doc/html/rfc7519).
Starting with [v4.0.0](https://github.com/golang-jwt/jwt/releases/tag/v4.0.0)
this project adds Go module support, but maintains backwards compatibility with
older `v3.x.y` tags and upstream `github.com/dgrijalva/jwt-go`. See the
[`MIGRATION_GUIDE.md`](./MIGRATION_GUIDE.md) for more information. Version
v5.0.0 introduces major improvements to the validation of tokens, but is not
entirely backwards compatible.
> After the original author of the library suggested migrating the maintenance
> of `jwt-go`, a dedicated team of open source maintainers decided to clone the
> existing library into this repository. See
> [dgrijalva/jwt-go#462](https://github.com/dgrijalva/jwt-go/issues/462) for a
> detailed discussion on this topic.
**SECURITY NOTICE:** Some older versions of Go have a security issue in the
crypto/elliptic. Recommendation is to upgrade to at least 1.15 See issue
[dgrijalva/jwt-go#216](https://github.com/dgrijalva/jwt-go/issues/216) for more
detail.
**SECURITY NOTICE:** It's important that you [validate the `alg` presented is
what you
expect](https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/).
This library attempts to make it easy to do the right thing by requiring key
types match the expected alg, but you should take the extra step to verify it in
your usage. See the examples provided.
### Supported Go versions
Our support of Go versions is aligned with Go's [version release
policy](https://golang.org/doc/devel/release#policy). So we will support a major
version of Go until there are two newer major releases. We no longer support
building jwt-go with unsupported Go versions, as these contain security
vulnerabilities which will not be fixed.
## What the heck is a JWT?
JWT.io has [a great introduction](https://jwt.io/introduction) to JSON Web
Tokens.
In short, it's a signed JSON object that does something useful (for example,
authentication). It's commonly used for `Bearer` tokens in Oauth 2. A token is
made of three parts, separated by `.`'s. The first two parts are JSON objects,
that have been [base64url](https://datatracker.ietf.org/doc/html/rfc4648)
encoded. The last part is the signature, encoded the same way.
The first part is called the header. It contains the necessary information for
verifying the last part, the signature. For example, which encryption method
was used for signing and what key was used.
The part in the middle is the interesting bit. It's called the Claims and
contains the actual stuff you care about. Refer to [RFC
7519](https://datatracker.ietf.org/doc/html/rfc7519) for information about
reserved keys and the proper way to add your own.
## What's in the box?
This library supports the parsing and verification as well as the generation and
signing of JWTs. Current supported signing algorithms are HMAC SHA, RSA,
RSA-PSS, and ECDSA, though hooks are present for adding your own.
## Installation Guidelines
1. To install the jwt package, you first need to have
[Go](https://go.dev/doc/install) installed, then you can use the command
below to add `jwt-go` as a dependency in your Go program.
```sh
go get -u github.com/golang-jwt/jwt/v5
```
2. Import it in your code:
```go
import "github.com/golang-jwt/jwt/v5"
```
## Usage
A detailed usage guide, including how to sign and verify tokens can be found on
our [documentation website](https://golang-jwt.github.io/jwt/usage/create/).
## Examples
See [the project documentation](https://pkg.go.dev/github.com/golang-jwt/jwt/v5)
for examples of usage:
* [Simple example of parsing and validating a
token](https://pkg.go.dev/github.com/golang-jwt/jwt/v5#example-Parse-Hmac)
* [Simple example of building and signing a
token](https://pkg.go.dev/github.com/golang-jwt/jwt/v5#example-New-Hmac)
* [Directory of
Examples](https://pkg.go.dev/github.com/golang-jwt/jwt/v5#pkg-examples)
## Compliance
This library was last reviewed to comply with [RFC
7519](https://datatracker.ietf.org/doc/html/rfc7519) dated May 2015 with a few
notable differences:
* In order to protect against accidental use of [Unsecured
JWTs](https://datatracker.ietf.org/doc/html/rfc7519#section-6), tokens using
`alg=none` will only be accepted if the constant
`jwt.UnsafeAllowNoneSignatureType` is provided as the key.
## Project Status & Versioning
This library is considered production ready. Feedback and feature requests are
appreciated. The API should be considered stable. There should be very few
backwards-incompatible changes outside of major version updates (and only with
good reason).
This project uses [Semantic Versioning 2.0.0](http://semver.org). Accepted pull
requests will land on `main`. Periodically, versions will be tagged from
`main`. You can find all the releases on [the project releases
page](https://github.com/golang-jwt/jwt/releases).
**BREAKING CHANGES:*** A full list of breaking changes is available in
`VERSION_HISTORY.md`. See `MIGRATION_GUIDE.md` for more information on updating
your code.
## Extensions
This library publishes all the necessary components for adding your own signing
methods or key functions. Simply implement the `SigningMethod` interface and
register a factory method using `RegisterSigningMethod` or provide a
`jwt.Keyfunc`.
A common use case would be integrating with different 3rd party signature
providers, like key management services from various cloud providers or Hardware
Security Modules (HSMs) or to implement additional standards.
| Extension | Purpose | Repo |
| --------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
| GCP | Integrates with multiple Google Cloud Platform signing tools (AppEngine, IAM API, Cloud KMS) | https://github.com/someone1/gcp-jwt-go |
| AWS | Integrates with AWS Key Management Service, KMS | https://github.com/matelang/jwt-go-aws-kms |
| JWKS | Provides support for JWKS ([RFC 7517](https://datatracker.ietf.org/doc/html/rfc7517)) as a `jwt.Keyfunc` | https://github.com/MicahParks/keyfunc |
*Disclaimer*: Unless otherwise specified, these integrations are maintained by
third parties and should not be considered as a primary offer by any of the
mentioned cloud providers
## More
Go package documentation can be found [on
pkg.go.dev](https://pkg.go.dev/github.com/golang-jwt/jwt/v5). Additional
documentation can be found on [our project
page](https://golang-jwt.github.io/jwt/).
The command line utility included in this project (cmd/jwt) provides a
straightforward example of token creation and parsing as well as a useful tool
for debugging your own integration. You'll also find several implementation
examples in the documentation.
[golang-jwt](https://github.com/orgs/golang-jwt) incorporates a modified version
of the JWT logo, which is distributed under the terms of the [MIT
License](https://github.com/jsonwebtoken/jsonwebtoken.github.io/blob/master/LICENSE.txt).

19
vendor/github.com/golang-jwt/jwt/v5/SECURITY.md generated vendored Normal file
View file

@ -0,0 +1,19 @@
# Security Policy
## Supported Versions
As of February 2022 (and until this document is updated), the latest version `v4` is supported.
## Reporting a Vulnerability
If you think you found a vulnerability, and even if you are not sure, please report it to jwt-go-security@googlegroups.com or one of the other [golang-jwt maintainers](https://github.com/orgs/golang-jwt/people). Please try be explicit, describe steps to reproduce the security issue with code example(s).
You will receive a response within a timely manner. If the issue is confirmed, we will do our best to release a patch as soon as possible given the complexity of the problem.
## Public Discussions
Please avoid publicly discussing a potential security vulnerability.
Let's take this offline and find a solution first, this limits the potential impact as much as possible.
We appreciate your help!

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