3
0
mirror of https://github.com/ergochat/ergo.git synced 2025-12-21 10:28:24 +01:00

Compare commits

...

495 Commits

Author SHA1 Message Date
Shivaram Lingamneni
d5fb189a55
changelog and version bump for v2.17.0-rc1 (#2308) 2025-12-14 04:43:30 -05:00
Shivaram Lingamneni
53664694c4
add RPL_WHOISKEYVALUE output (#2302) 2025-12-14 04:32:02 -05:00
Shivaram Lingamneni
d26aa37f2c
pin Alpine to 3.22 (#2306)
* Upgrade the production image from 3.19 to 3.22 (3.19 went EOL
  2025-11-01)
* Downgrade the build image to 3.22 (3.23 is buggy, see #2305)
2025-12-14 02:50:21 -05:00
Shivaram Lingamneni
9ca936a777 bump irctest 2025-12-14 02:15:44 -05:00
Shivaram Lingamneni
fdd261a1e6
fix #2303 (#2304)
Fix inconsistent behavior when history.enabled is set but
history.chathistory-maxmessages is not
2025-12-14 02:13:15 -05:00
Shivaram Lingamneni
aef5d77b3b
Merge pull request #2301 from slingamn/ratelimited
more metadata follow-ups
2025-12-08 01:48:39 -05:00
Shivaram Lingamneni
0ce9016098 add persistence for user metadata 2025-12-07 03:05:34 -05:00
Shivaram Lingamneni
f91d1d94f6 add METADATA response when MONITOR triggered 2025-12-01 07:03:26 +00:00
Shivaram Lingamneni
0119bbc36f implement FAIL METADATA RATE_LIMITED 2025-12-01 04:09:45 +00:00
Shivaram Lingamneni
96aa018352 bump irctest 2025-11-26 03:27:13 -05:00
Shivaram Lingamneni
68faf82787
Merge pull request #2299 from ergochat/shivaram_ping.1
configurable idle timeouts
2025-11-09 21:49:39 -05:00
Shivaram Lingamneni
5cda5bdac9
Merge pull request #2298 from slingamn/shivaram_operthrottle
changes to OPER command
2025-11-09 21:41:53 -05:00
Shivaram Lingamneni
ed841ee62a configurable idle timeouts
Fixes #2292
2025-11-09 21:11:04 -05:00
Shivaram Lingamneni
6fdac13ad4 changes to OPER command
* Impose a throttle on OPER attempts regardless of whether they caused a
  password check.
* Never disconnect the client on a failed attempt, even if there was a
  password check.
* Change error numeric to ERR_NOOPERHOST
* Explicit information about the failure in the server log (copying Insp)

Fixes #2296.
2025-11-09 19:34:31 -05:00
Shivaram Lingamneni
efc1627d23
Merge pull request #2295 from slingamn/shivaram_pushreject
fix validation of web push URLs
2025-10-26 00:58:52 -04:00
Shivaram Lingamneni
6b8265fb17 fix validation of web push URLs
They are validated by test message, but it would have been possible
to add an http url.

If an http url was added, it's still possible to remove it via
NS PUSH DELETE.
2025-10-26 00:55:30 -04:00
Shivaram Lingamneni
92f069846c
Merge pull request #2290 from slingamn/goupgrade
upgrade to go 1.25
2025-08-17 21:11:02 -07:00
Shivaram Lingamneni
8913bd7fa9 upgrade to go 1.25 2025-08-18 00:07:11 -04:00
Shivaram Lingamneni
064291e902 add an explicit note covering #2289 2025-08-12 16:23:36 -04:00
Shivaram Lingamneni
65295cbafa
Merge pull request #2283 from slingamn/makefile
refactor makefile to label individual targets phony
2025-06-26 22:17:06 -04:00
Shivaram Lingamneni
f0b1f34da7 refactor makefile to label individual targets phony 2025-06-26 22:14:19 -04:00
Shivaram Lingamneni
f918e28513 bump irctest 2025-06-26 01:31:51 -04:00
Shivaram Lingamneni
8798676ae9
update metadata corresponding to spec edits (#2282)
* spec update: metadata keys are lowercase

* add batch parameter to metadata batches

* fix: connecting clients receive METADATA, not RPL_KEYVALUE

* spec update: send RPL_METADATASUBS in a metadata-subs batch

* move some helpers

* bump irctest to forked hash

This is https://github.com/progval/irctest/pull/314 but I don't want to
couple the merges

* fix: empty value is valid

* fix: deleting a nonexistent key gets a FAIL
2025-06-22 18:59:42 -04:00
Shivaram Lingamneni
cca400de73 fix: actually broadcast prereg updates to subscribers
Missed in #2281, needs a test presumably :-)
2025-06-22 13:59:36 -04:00
Shivaram Lingamneni
73e51333ad
implement metadata before-connect (#2281)
* metadata spec update: disallow colon entirely

* refactor key validation

* implement metadata before-connect

* play the metadata in reg burst to all clients with the cap

* bump irctest

* remove all case normalization for keys

From spec discussion, we will most likely either require keys to be lowercase,
or else treat them as case-opaque, similar to message tag keys.
2025-06-22 13:57:46 -04:00
Shivaram Lingamneni
a5e435a26b bump irctest 2025-06-21 22:04:33 -04:00
Shivaram Lingamneni
17ed01c1ed
Merge pull request #2279 from slingamn/doc
fix some documentation
2025-06-19 13:29:08 -04:00
Shivaram Lingamneni
8f18454e8f fix help string for HISTORY 2025-06-19 13:25:34 -04:00
Shivaram Lingamneni
23844d4103 update documentation for globalUtf8EnforcementSetting 2025-06-19 13:22:07 -04:00
Shivaram Lingamneni
3b7db7fff7
round 1 of follow-up for metadata (#2277)
* refactoring
* send an empty batch if necessary, as per spec
* don't broadcast no-op updates
* don't trim spaces before validating the key
* bump irctest to cover metadata
* replay existing metadata to reattaching always-on clients
* use canonicalized name everywhere
* use utils.SafeErrorParam in FAIL lines
* validate key names for sub
* fix error for METADATA CLEAR
* max-keys is enforced for channels as well
* remove unlimited configurations
* maintain the limit exactly without off-by-one cases
* add final channel registration check
2025-06-18 00:22:49 -04:00
thatcher-gaming
4dcbc48159
metadata-2 (#2273)
Initial implementation of draft/metadata-2
2025-06-15 04:06:45 -04:00
Shivaram Lingamneni
0f5603eca2 bump irctest to upstream master 2025-06-09 02:20:49 -04:00
Shivaram Lingamneni
7d4f5e4adf
Merge pull request #2271 from slingamn/register
fix #2270
2025-06-09 02:19:55 -04:00
Shivaram Lingamneni
16568c5ab7 fix #2270
REGISTER should strip the guest format when applicable, same as NS REGISTER.
2025-06-08 16:50:34 -04:00
Shivaram Lingamneni
9a186f8e54
Fix invalid FAIL codes in REGISTER (#2269)
* nickserv.go: Update FAIL codes to match spec

* handlers.go: Fix FAIL code

* use ACCOUNT_EXISTS for errNameReserved

* bump irctest to development version

---------

Co-authored-by: Valerie Liu <79415174+ValwareIRC@users.noreply.github.com>
2025-06-08 01:43:43 -04:00
Shivaram Lingamneni
7828218bc7
Merge pull request #2264 from slingamn/cutprefix
fix #2147
2025-05-26 01:56:06 -04:00
Shivaram Lingamneni
7138e76151 fix #2147
use strings.CutPrefix when possible
2025-05-25 01:59:55 -04:00
Sarah Rose
e4aac56bda
API enhancements (#2261)
Fixes #2257 and #2260

* add `/v1/status` endpoint
* add `/v1/account_list` endpoint
* add fields to `/v1/account_details` response
2025-05-25 00:47:20 -04:00
Shivaram Lingamneni
4da6511674
Merge pull request #2262 from slingamn/constant
clean up constant redefinition
2025-05-23 00:36:46 -04:00
Shivaram Lingamneni
253972a9d2 clean up constant redefinition 2025-05-23 00:18:36 -04:00
Shivaram Lingamneni
a1c46a4be7 clarify channel registration instructions 2025-05-19 00:02:04 -04:00
Shivaram Lingamneni
7718081440
Merge pull request #2258 from slingamn/deps
upgrade dependencies for 2.17 release cycle
2025-05-18 02:07:40 -04:00
Shivaram Lingamneni
e7501ef847 upgrade go-msgauth 2025-05-18 01:28:48 -04:00
Shivaram Lingamneni
e404942d83 upgrade x dependencies 2025-05-18 01:27:52 -04:00
Shivaram Lingamneni
0a947115d6 set up new development version 2025-05-18 01:15:11 -04:00
Shivaram Lingamneni
9b9c39ddd4 changelog entry for API config 2025-05-18 01:09:13 -04:00
Shivaram Lingamneni
e200e9fd8f bump version and changelog for v2.16.0 2025-05-18 00:37:21 -04:00
Shivaram Lingamneni
66a7a488b7
bump version and changelog for v2.16.0-rc1 (#2255) 2025-05-11 01:07:13 -04:00
Shivaram Lingamneni
28ed16261c
Merge pull request #2254 from ergochat/shivaram_alwaysonbug.1
fix #2252
2025-05-08 00:14:51 -04:00
Shivaram Lingamneni
686ce4d5b2 fix #2252
Fix SAREGISTER creating always-on clients with no user modes.

Also fix UNREGISTER/ERASE not deleting the stored push subscriptions.
2025-05-07 22:09:08 -04:00
Shivaram Lingamneni
808799b100
Merge pull request #2253 from slingamn/batchname
fix isupport batch name
2025-05-01 22:18:08 -04:00
Shivaram Lingamneni
e382036ddb fix isupport batch name 2025-05-01 14:43:06 -04:00
Shivaram Lingamneni
43fe72f83e
clean up redundant caching (#2251) 2025-04-28 00:52:40 -04:00
Shivaram Lingamneni
4ab1a10eec clean up redundant caching 2025-04-28 00:29:40 -04:00
Shivaram Lingamneni
54b17b0700
improve robustness of timestamp parsing (#2250)
* Clamp CHATHISTORY timestamp selectors to be in [0, MaxInt64]
* Convert everything to UTC up front (probably a no-op)
2025-04-24 23:37:48 -04:00
Shivaram Lingamneni
2cf569c5d9
Merge pull request #2249 from slingamn/targetspanic
validate that CHATHISTORY limit parameters are nonnegative
2025-04-24 23:37:32 -04:00
Shivaram Lingamneni
a4194c38d8 validate that CHATHISTORY limit parameters are nonnegative
See #2248. Reported by @prdes
2025-04-24 12:33:53 -04:00
Shivaram Lingamneni
5bab190d33
fix #2244 (#2247)
Fix #2244

Produce an explicit error on receiving the UTF-8 BOM
2025-04-21 22:37:53 -04:00
Shivaram Lingamneni
68cee9e2cd
use emersion/go-msgauth for DKIM (#2242)
Fixes #1041 (support ed25519-sha256 for DKIM)
2025-04-07 00:24:08 -04:00
Shivaram Lingamneni
9c3173f573
safer 005 length limits (#2241)
* Limit the payload to 380 bytes instead of 400
* Don't translate the final parameter

This leaves about 60 bytes for the server name.
2025-04-06 02:59:03 -04:00
Shivaram Lingamneni
98e04c10a8
fix #2220 (#2240)
Allow publishing arbitrary ISUPPORT via the config file
2025-04-06 01:41:03 -04:00
Shivaram Lingamneni
a6df370bd9
block HTTP DoS attacks (#2239)
Block uses of the JS Fetch API to send HTTP message bodies that are also valid
IRC. The constraint on such messages is that they must begin with a valid HTTP
verb; we can detect this and reject them immediately.
2025-03-30 21:33:06 -04:00
Shivaram Lingamneni
9791606f62
allow customizing the NPC and SCENE nickmasks (#2237)
See #2229
2025-03-30 21:32:55 -04:00
Shivaram Lingamneni
7256d83ff0
implement command aliases (#2236)
See #2229
2025-03-30 21:32:37 -04:00
Shivaram Lingamneni
f5bb5afdd6
bump CI to noble (#2235) 2025-03-30 02:48:51 -04:00
Shivaram Lingamneni
d3eb787a1e bump irctest 2025-03-27 05:20:22 -04:00
Shivaram Lingamneni
19dbe10c99
fix panic on KILL (#2234)
Introduced by #2218, reported by knolle
2025-03-26 21:21:05 -04:00
Shivaram Lingamneni
467df24914
fix #2228 (#2233)
If the server is UTF8ONLY, validate that the MOTD is UTF8
2025-03-22 23:13:31 -04:00
Shivaram Lingamneni
9dc2fd52ed
Merge pull request #2232 from ergochat/dependabot/go_modules/github.com/golang-jwt/jwt/v5-5.2.2
Bump github.com/golang-jwt/jwt/v5 from 5.2.1 to 5.2.2
2025-03-22 20:52:14 -04:00
dependabot[bot]
a46732f6ab
Bump github.com/golang-jwt/jwt/v5 from 5.2.1 to 5.2.2
Bumps [github.com/golang-jwt/jwt/v5](https://github.com/golang-jwt/jwt) from 5.2.1 to 5.2.2.
- [Release notes](https://github.com/golang-jwt/jwt/releases)
- [Changelog](https://github.com/golang-jwt/jwt/blob/main/VERSION_HISTORY.md)
- [Commits](https://github.com/golang-jwt/jwt/compare/v5.2.1...v5.2.2)

---
updated-dependencies:
- dependency-name: github.com/golang-jwt/jwt/v5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-21 22:14:08 +00:00
Shivaram Lingamneni
ea81ec86e1
MVP for HTTP API (#2231)
Co-authored-by: Klaas Tammling <klaas@tammling.hamburg>
2025-03-18 23:13:03 -04:00
Shivaram Lingamneni
4bcd008416
fix CHATHISTORY TARGETS from MySQL backend using server local TZ (#2224)
time.Unix() returns a time.Time with the Location populated to the server's
timezone. Such times will format incorrectly with IRCv3TimestampFormat unless
they are manually converted to UTC.
2025-03-17 02:22:25 -04:00
Shivaram Lingamneni
aed216a62e update filenames in docker documentation 2025-03-16 16:18:16 -04:00
Shivaram Lingamneni
f3e24c7bdb
Merge pull request #2223 from slingamn/saferate
add SAFERATE to 005
2025-03-09 21:41:24 -04:00
Shivaram Lingamneni
23b65e225b add SAFERATE to 005
See discussion at https://github.com/ircv3/ircv3-specifications/pull/556
2025-03-06 13:30:17 -05:00
Shivaram Lingamneni
4ced4ef328
Merge pull request #2219 from slingamn/quit_tags
fix #2218
2025-02-16 02:20:13 -05:00
Shivaram Lingamneni
ec3417be79 fix #2218
The client's own QUIT line should respect server-time
2025-02-16 01:33:24 -05:00
Shivaram Lingamneni
7e18362d35
upgrade to go 1.24 (#2217) 2025-02-12 21:15:21 -05:00
Shivaram Lingamneni
eb84ede5f7
Merge pull request #2216 from slingamn/redact
fix #2215
2025-02-06 01:39:32 -05:00
Shivaram Lingamneni
d50f1471eb fix #2215
Hide the message-redaction capability if allow-individual-delete is disabled.
(Technically REDACT can still be used, but only by ircops, so advertising it
is misleading in the most common case).
2025-02-06 01:36:02 -05:00
Shivaram Lingamneni
d9f663c400
Merge pull request #2214 from slingamn/logline.2
minor refactoring
2025-02-06 00:21:15 -05:00
Shivaram Lingamneni
e1b5a05c27 refactor service help sorting 2025-02-05 00:47:23 -05:00
Shivaram Lingamneni
a850602bcc refactor 005 token generation 2025-02-05 00:47:23 -05:00
Shivaram Lingamneni
d1126b53eb return constant strings in 004/005 2025-02-05 00:47:23 -05:00
Shivaram Lingamneni
4851825d4f use slices.SortFunc for modes 2025-02-05 00:47:23 -05:00
Shivaram Lingamneni
8fa6e19c2e fix #2213
We intended to suppress 324 and 329 when there is no valid mode change, only
unknown modes. Fix these checks.
2025-02-03 21:35:51 -05:00
Shivaram Lingamneni
07669f9eb4 remove hashset from mode parsing 2025-02-03 21:29:34 -05:00
Shivaram Lingamneni
4dfb7cc7ae use slices.Contains in irc/modes 2025-02-03 21:29:34 -05:00
Shivaram Lingamneni
b6a8cc20c2 add conn ID to SASL auth logline 2025-02-03 21:29:34 -05:00
Shivaram Lingamneni
cf7db4bc2a
Merge pull request #2212 from slingamn/deps
upgrade dependencies for v2.16 release cycle
2025-01-26 13:07:34 -05:00
Shivaram Lingamneni
b6f6959acc upgrade buntdb 2025-01-26 04:17:34 -05:00
Shivaram Lingamneni
af124cd964 upgrade x dependencies 2025-01-26 00:58:13 -05:00
Shivaram Lingamneni
e60afda556 set up new development version 2025-01-26 00:55:31 -05:00
Shivaram Lingamneni
c92f23b0cb bump version and changelog for v2.15.0 2025-01-25 22:55:19 -05:00
Shivaram Lingamneni
656eea43e7 add more warnings about push notifications and tor 2025-01-25 18:25:59 -05:00
Shivaram Lingamneni
881f403164 bump webpush-go to release version 2025-01-16 01:04:37 -05:00
Shivaram Lingamneni
b38ca31ced
Merge pull request #2211 from slingamn/pushsync
fix buggy persistence of push timestamps
2025-01-15 21:22:57 -08:00
Shivaram Lingamneni
7b71839615 fix buggy persistence of push timestamps
getPushSubscriptions() could have a stale view of the latest subscription
renewal and successful push times. We don't want to rebuild on every renewal
or every push, so add a boolean refresh argument that controls rebuilding.
2025-01-16 00:06:11 -05:00
Shivaram Lingamneni
9dd7a2bbcb
Merge pull request #2200 from slingamn/download_url
fix download link
2025-01-15 20:50:01 -08:00
Shivaram Lingamneni
148d743eb1 update changelog 2025-01-15 23:27:33 -05:00
Shivaram Lingamneni
2a79f64f2d
Merge pull request #2210 from slingamn/rearrange
tweaks to logging and NS PUSH LIST
2025-01-15 20:22:23 -08:00
Shivaram Lingamneni
799e1b14f4 delete services debug line 2025-01-15 22:12:40 -05:00
Shivaram Lingamneni
2163d96348 add connID to another logline 2025-01-15 22:12:40 -05:00
Shivaram Lingamneni
e520ba7e0e list push subscription times as well 2025-01-15 22:12:40 -05:00
Shivaram Lingamneni
92e2aa987e move debug log ID within NS CLIENTS LIST output 2025-01-15 22:12:40 -05:00
Shivaram Lingamneni
ab2d842b27
changelog and version updates for v2.15.0-rc1 (#2209) 2025-01-13 22:57:04 -05:00
Shivaram Lingamneni
21ee867ebb
fix #2198 (#2199)
Add require-sasl support to KLINE / UBAN on NUH masks
2025-01-13 22:20:47 -05:00
Shivaram Lingamneni
36e5451aa5
implement draft/webpush (#2205) 2025-01-13 21:47:21 -05:00
Shivaram Lingamneni
efd3764337
add unique connection ID to debug logs (#2207)
Fixes #2206
2025-01-11 23:07:04 -05:00
Shivaram Lingamneni
375079e636
Merge pull request #2201 from donatj/patch-1
Improve Docker "Persisting Data" Section
2024-12-19 01:26:23 +01:00
Jesse Donat
38862b0529
Improve Docker "Persisting Data" Section
`pwd` is case sensitive on linux
Add missing `--name ergo`
2024-12-18 09:23:48 -06:00
Shivaram Lingamneni
2bb9980e56 fix download link 2024-12-11 22:50:24 -05: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
Shivaram Lingamneni
796bc198ed
upgrade go to 1.23 (#2187) 2024-08-15 23:50:27 -04:00
Shivaram Lingamneni
df6aa4c34b
enable building for solaris (#2183) 2024-08-02 15:09:28 -04: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
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 38a6d17ee5ce6e1096c3dfd6d11f6f35d9a71ca6
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 2182b1da69ceaafad30859e45be0645d6c915b2c.

---------

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

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

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

* tweak member caching

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

* add another entry

* copyedits
2022-12-18 00:01:32 -05:00
Shivaram Lingamneni
9ab8b6710c
Merge pull request #2015 from slingamn/ascii
change default casefolding to ascii
2022-12-11 18:57:35 -08:00
Shivaram Lingamneni
05e5e88de4 change default casefolding to ascii
See discussion on #1718
2022-12-11 19:41:15 -05:00
Shivaram Lingamneni
d17faf6bcb fix #1975
Provide a nondestructive stack trace dump option even when the http pprof
listener is disabled
2022-12-10 22:15:37 -08:00
Shivaram Lingamneni
77de026961 persistence broadcast needs a cap check 2022-12-10 22:05:46 -08:00
Shivaram Lingamneni
898f84c613 update persistence spec link 2022-12-10 22:05:46 -08:00
Shivaram Lingamneni
ae1de2554e add persistence broadcasting 2022-12-10 22:05:46 -08:00
Shivaram Lingamneni
893922afe0 don't report PERSISTENCE STATUS in reg burst for anonymous clients 2022-12-10 22:05:46 -08:00
Shivaram Lingamneni
99d27ff737 initial implementation of draft/persistence 2022-12-10 22:05:46 -08:00
Shivaram Lingamneni
fa3de3e149 fix #1983
TAGMSG should not get automatic RPL_AWAY replies
2022-12-10 21:01:28 -08:00
Shivaram Lingamneni
2bfa13b7d0
Merge pull request #2012 from slingamn/doc_update
document that persistent history is not affected by buffer limits
2022-12-10 20:54:28 -08:00
Shivaram Lingamneni
25e698d57f document that persistent history is not affected by buffer limits 2022-12-06 00:18:59 -05:00
Shivaram Lingamneni
30b760483e
Merge pull request #2010 from slingamn/who_services.1
fix #1850
2022-12-03 19:06:44 -08:00
Shivaram Lingamneni
825cdab67d fix #1850
Add WHO responses for services
2022-12-02 07:23:29 -05:00
Shivaram Lingamneni
f665525735
Merge pull request #2009 from slingamn/both_005s
re-add draft/CHATHISTORY 005
2022-12-01 22:43:32 -08:00
Shivaram Lingamneni
35b5613349 re-add draft/CHATHISTORY 005
Kiwi expects it due to https://github.com/kiwiirc/kiwiirc/pull/1244 , but
the corresponding spec change only altered the cap name, not the 005 name.
2022-12-02 01:30:46 -05:00
Shivaram Lingamneni
a5983a1bd1
Merge pull request #2008 from slingamn/chathistory_isupport
fix CHATHISTORY 005 token name
2022-11-30 01:16:28 -08:00
Shivaram Lingamneni
ebda5e6d9a bump irctest 2022-11-30 04:13:04 -05:00
Shivaram Lingamneni
e40f550af8 fix CHATHISTORY 005 token name
Unclear where we got draft/CHATHISTORY from, it looks like the merged drafts
have always used unprefixed CHATHISTORY as the token name.
2022-11-30 04:10:47 -05:00
Shivaram Lingamneni
e20c983b57
fix #2002 (#2003)
* fix #2002

`CS AMODE #channel +f nickname` is invalid, but was being accepted
incorrectly.

* simplify logic
2022-11-06 14:41:29 -05:00
Shivaram Lingamneni
c3b3bf9941
Merge pull request #2006 from progval/ratified-extmonitor
Use ratified extended-monitor cap name
2022-11-06 11:40:31 -08:00
Valentin Lorentz
dd8073208c Use ratified extended-monitor cap name
https://github.com/ircv3/ircv3-specifications/pull/508
2022-11-01 07:41:55 +01:00
Shivaram Lingamneni
062491ebfc
Merge pull request #2005 from PeGaSuS-Coder/patch-1
Update MANUAL.md
2022-10-22 16:05:25 -07:00
Shivaram Lingamneni
7df041d0a6
remove emphasis on "after" 2022-10-22 19:03:33 -04:00
PeGaSuS
06a204d0d3
Update MANUAL.md
- Change instructions to place the script from `post` to `deploy` which will only update the certificate and key IF the renewal is successful.
2022-10-21 22:21:02 +02:00
Shivaram Lingamneni
a9c77af1cb
Merge pull request #1998 from slingamn/away
fix #1996
2022-09-11 01:44:18 -07:00
Shivaram Lingamneni
4e0d2d65e8 fix #1996
According to the de facto standard, `AWAY :\r\n` is equivalent to `AWAY\r\n`.
Our behavior was inconsistent before, now it consistently matches the de facto
standard.
2022-09-11 04:09:26 -04:00
Shivaram Lingamneni
57a213123f
Merge pull request #1995 from slingamn/trylock.1
replace some utils.Semaphore with (*sync.Mutex).TryLock
2022-09-07 11:14:22 -07:00
Shivaram Lingamneni
746309e386 replace some utils.Semaphore with (*sync.Mutex).TryLock
See #1994
2022-09-02 04:25:39 -04:00
Shivaram Lingamneni
531a1d6864 bump irctest 2022-08-28 01:58:42 -04:00
Shivaram Lingamneni
0e8f447326
Merge pull request #1993 from slingamn/fakelag_budget.3
exempt a configurable number of MARKREAD commands from fakelag
2022-08-22 20:26:11 -07:00
Shivaram Lingamneni
7ad31497c2 exempt a configurable number of MARKREAD commands from fakelag 2022-08-22 23:23:17 -04:00
Shivaram Lingamneni
7d6ff58bf8
Merge pull request #1992 from slingamn/issue1991_who.1
fix #1991
2022-08-22 20:07:44 -07:00
Shivaram Lingamneni
68bd2d87e0 fix #1991
WHO <nickname> should not respect +i
2022-08-22 23:03:17 -04:00
Shivaram Lingamneni
8ff5a048f3
Merge pull request #1989 from slingamn/atomic_uint64.1
use new aligned atomic types everywhere
2022-08-21 10:02:06 -07:00
Shivaram Lingamneni
594991d6cc
Merge pull request #1988 from mogad0n/fix/ubanlistduration
/UBAN LIST explicitly states 'indefinite' durations for Klines/Dlines
2022-08-20 21:51:01 -07:00
df234b842e uban list explicitly shows indefinite durations 2022-08-11 03:04:20 +05:30
Shivaram Lingamneni
35128bfc23 use new aligned atomic types everywhere
See 69448b13a10a14517 / #1969; the compiler can now ensure that a uint64
intended for atomic access is always aligned to a 64-bit boundary.
Convert atomic operations on uint32s and pointers as well.
2022-08-10 02:47:39 -04:00
Shivaram Lingamneni
507dc2d838
Merge pull request #1987 from slingamn/go_upgrade
upgrade go to 1.19
2022-08-03 18:16:33 -07:00
Shivaram Lingamneni
a99c8a42f9 remove utils.ConfigStore in favor of atomic.Pointer[T] 2022-08-03 00:59:00 -04:00
Shivaram Lingamneni
de1be675f5 upgrade go to 1.19 2022-08-03 00:56:19 -04:00
Shivaram Lingamneni
5b72cd8622 apply go1.19 gofmt 2022-08-03 00:54:50 -04:00
Shivaram Lingamneni
096c12fb52
Merge pull request #1981 from slingamn/issue1980
fix #1980
2022-07-14 19:07:59 -07:00
Shivaram Lingamneni
1d10eb934a fix #1980
Sanitize ::1 to 0::1 in WHOX output
2022-07-14 21:53:36 -04:00
Shivaram Lingamneni
26e0dae11d
Merge pull request #1977 from slingamn/deps
upgrade dependencies for v2.11 release cycle
2022-06-17 07:50:54 -07:00
Shivaram Lingamneni
321ff109b1 upgrade x/crypto 2022-06-17 10:47:28 -04:00
Shivaram Lingamneni
86f124e938 upgrade buntdb 2022-06-17 10:46:22 -04:00
Shivaram Lingamneni
2138847984
Merge pull request #1976 from slingamn/parallelize_testing
parallelize tests via the native go mechanism
2022-06-16 22:03:38 -07:00
Shivaram Lingamneni
f032fda48d parallelize tests via the native go mechanism 2022-06-16 14:56:28 -04:00
Shivaram Lingamneni
2cace0b5a2
Merge pull request #1973 from progval/crowdin-url
Update Crowdin URL
2022-06-11 18:46:53 -07:00
Valentin Lorentz
4208e11571 Update Crowdin URL 2022-06-11 22:33:47 +02:00
Shivaram Lingamneni
c0e7aac862
Merge pull request #1970 from ergochat/devel+issue1969_alignment
fix #1969
2022-06-10 07:53:04 -07:00
Shivaram Lingamneni
69448b13a1 fix #1969
On a 32-bit architecture, 64-bit atomic loads and stores must be aligned to a
64-bit boundary. Since the (mysql.MySQL) struct is directly included in the
Server struct, it is impossible to guarantee this via the standard technique
of putting the 64-bit value at the beginning of the struct definition
(since the point at which it is included in the parent struct may cross a
64-bit boundary).

This optimization is probably pointless anyway, adding an additional
indirection won't make a difference.
2022-06-10 10:35:56 -04:00
Shivaram Lingamneni
810ec75f95 bump irctest 2022-05-29 15:47:34 -04:00
Shivaram Lingamneni
86f7668c68 set up new development version 2022-05-29 15:46:36 -04:00
Shivaram Lingamneni
e8cd87d8fd
Merge pull request #1967 from slingamn/release
bump version and changelog for v2.10.0
2022-05-29 12:14:45 -07:00
Shivaram Lingamneni
101fd53d6d bump version and changelog for v2.10.0 2022-05-29 02:24:18 -04:00
Shivaram Lingamneni
acd95b9924 bump version and changelog for v2.10.0-rc2 2022-05-23 15:16:51 -04:00
Shivaram Lingamneni
40d70b8aeb
Merge pull request #1962 from slingamn/readmarker_again
only send MARKREAD to sessions with the read-marker cap
2022-05-20 02:03:31 -04:00
Shivaram Lingamneni
ef088373a8 only send MARKREAD to sessions with the read-marker cap 2022-05-20 01:58:14 -04:00
Shivaram Lingamneni
ae55a4c660
Merge pull request #1961 from slingamn/readmarker
fix critical bugs in draft/read-marker
2022-05-20 01:51:41 -04:00
Shivaram Lingamneni
2b86660e5c fix read markers not being reloaded on restart 2022-05-20 01:46:41 -04:00
Shivaram Lingamneni
c3d4be45f1 fix timestamp syntax in MARKREAD 2022-05-20 01:46:41 -04:00
Shivaram Lingamneni
67b2f4ccd2
README.md: fix docker repository link 2022-05-16 11:53:33 -04:00
Shivaram Lingamneni
104d0321e8 bump version and changelog for v2.10.0-rc1 2022-05-16 01:25:37 -04:00
Shivaram Lingamneni
14d1614bba
Merge pull request #1956 from slingamn/changelog.2
changelog updates for v2.10.0-rc1
2022-05-16 01:24:23 -04:00
Shivaram Lingamneni
e48c3fa687
Merge pull request #1957 from slingamn/saregister_responses
fix #1905
2022-05-16 00:38:13 -04:00
16b8d9090b
Documentation: add Python3 syntax highlighting, +i/AMODE documentation and add FAQ on AMODE privileges (#1959)
* MANUAL.md: correct shebangs

* docs/{USERGUIDE,MANUAL}.md: mention amode +v joining through +i

* MANUAL.md: add a FAQ on special privileges of AMODEs

* USERGUIDE.md: add missing channel name

* MANUAL.md: mention that special privileges are cumulative

* Revert "MANUAL.md: correct shebangs"

This reverts commit 75a77c55370089f2b144abc4104db5e13dcc2cb7.

* MANUAL.md: restore syntax highlighting for python3
2022-05-15 11:07:49 -04:00
Shivaram Lingamneni
5e5cc3040b
Update CHANGELOG.md
Co-authored-by: Aminda Suomalainen <suomalainen+git@mikaela.info>
2022-05-14 21:31:01 -04:00
Shivaram Lingamneni
bcaed1aff1 update documentation of moderation techniques
Fixes #1697
2022-05-13 17:30:47 -04:00
Shivaram Lingamneni
7192df4592 update documentation of operator capabilities 2022-05-13 17:18:42 -04:00
Shivaram Lingamneni
da07c0072c bump irctest to latest 2022-05-13 16:42:30 -04:00
Shivaram Lingamneni
6f9e07d2a2 fix #1905
NS SAREGISTER should send machine-readable responses. A simple approach:
check if the account-registration cap is enabled, and if so, send the
the same responses that would be sent by the REGISTER command.
2022-05-13 15:35:11 -04:00
Shivaram Lingamneni
52e0f8e7e9 changelog updates for v2.10.0-rc1 2022-05-13 14:53:39 -04:00
Shivaram Lingamneni
d6d5bbe27b
Merge pull request #1955 from slingamn/history_privs
exempt operators from history cutoffs
2022-05-13 02:04:56 -04:00
Shivaram Lingamneni
737697d1d4 exempt operators from history cutoffs
See #1593; this enables a client-side implementation of bulk deletion
2022-05-12 16:43:11 -04:00
Shivaram Lingamneni
dd75eb1084 fix incorrect HOSTSERV HELP SET
Reported by @Mikaela
2022-05-06 14:48:01 -04:00
Shivaram Lingamneni
a13235880c
Merge pull request #1954 from slingamn/accept.1
fix #1688
2022-05-06 11:40:17 -04:00
Shivaram Lingamneni
87789676c0 add tests covering (*AcceptManager).Unaccept 2022-05-05 22:43:33 -04:00
Shivaram Lingamneni
c454c45d6a
Merge pull request #1953 from slingamn/issue1886_unregistered.1
fix #1886
2022-05-05 22:40:50 -04:00
Shivaram Lingamneni
4948b48b8f
Merge pull request #1952 from slingamn/saverify.1
add SAVERIFY command
2022-05-05 22:40:07 -04:00
Shivaram Lingamneni
c5579a6a34 fix #1688
* Add ACCEPT-tracking functionality (authorizing users to send DMs
  despite +R or other applicable restrictions)
* Sending a DM automatically accepts the recipient
* Add explicit ACCEPT command
2022-05-05 22:34:43 -04:00
Shivaram Lingamneni
b11dc1c84c fix #1886
Add more clarify in NS INFO and SAREGISTER about unregistered nicknames
2022-05-05 01:04:28 -04:00
Shivaram Lingamneni
78548aa9df add SAVERIFY command
Fixes #1924
2022-05-04 16:41:01 -04:00
Shivaram Lingamneni
03092769e7 bump irctest 2022-05-04 14:59:10 -04:00
Shivaram Lingamneni
d5814c10ab
Merge pull request #1949 from slingamn/generic_config.2
genericize atomic config changes
2022-05-04 01:30:52 -04:00
Shivaram Lingamneni
34ad3a2dc1 ConfigStore: clarify intended use 2022-05-03 23:27:24 -04:00
Shivaram Lingamneni
f7853b15ca
Merge pull request #1950 from slingamn/kline_snotice.3
fix #1941
2022-05-03 22:54:54 -04:00
Shivaram Lingamneni
077081076c fix #1941
KLINE'd clients would produce a QUIT snotice without a corresponding
CONNECT snotice; explicitly suppress the QUIT snotice.
2022-05-03 13:13:29 -04:00
Shivaram Lingamneni
dea2e7961a
Merge pull request #1948 from slingamn/reverse_again
remove history.ReverseCorrespondents in favor of generics
2022-05-03 12:42:23 -04:00
Shivaram Lingamneni
c603d41d08 genericize atomic config changes 2022-05-03 11:12:11 -04:00
Shivaram Lingamneni
c87dead39b remove history.ReverseCorrespondents in favor of generics 2022-05-03 02:45:36 -04:00
Shivaram Lingamneni
66bf6244f3
Merge pull request #1947 from Mikaela/docs-op-invite
MANUAL.md: clarify appropiate privs for invite
2022-05-01 16:44:51 -04:00
e6905f4543
MANUAL.md: clarify appropiate privs for invite
Resolves: #1946
2022-05-01 23:29:38 +03:00
Shivaram Lingamneni
71fe4ecf48
Merge pull request #1945 from slingamn/generic_reverse
use genericized slice-reversing function
2022-04-30 21:29:25 -04:00
Shivaram Lingamneni
8eaf6f5166
Merge pull request #1944 from slingamn/kline_snotice.1
make quit logging more consistent
2022-04-30 21:29:14 -04:00
Shivaram Lingamneni
2df5fb1956 use genericized slice-reversing function 2022-04-29 13:39:11 -04:00
Shivaram Lingamneni
42883972a8 make quit logging more consistent
Log it at level INFO for parity with connect / connect-ip
2022-04-28 14:19:11 -04:00
Shivaram Lingamneni
2a3b8e648c
Merge pull request #1940 from slingamn/connect_faq
manual: add faq on connection problems
2022-04-28 03:21:38 -04:00
Shivaram Lingamneni
ae5e1fb49f manual: add faq on connection problems 2022-04-27 15:13:02 -04:00
Shivaram Lingamneni
15303d0247
consistently plug Goguma in documentation (#1939)
* consistently plug Goguma in documentation

* fix userguide header
2022-04-27 13:24:27 -04:00
Shivaram Lingamneni
432f0f62d5
Merge pull request #1938 from slingamn/ratified_bot
bump bot mode spec to ratified version
2022-04-26 15:55:05 -04:00
Shivaram Lingamneni
374bd834fd bump irctest temporarily for new bot tag name 2022-04-26 15:51:55 -04:00
Shivaram Lingamneni
5ecba1d40b use ratified bot mode tag name 2022-04-26 15:43:24 -04:00
Shivaram Lingamneni
5c7df07d91
Merge pull request #1936 from slingamn/nick_empty
fix #1933
2022-04-25 18:02:31 -04:00
Shivaram Lingamneni
911b00787b
Merge pull request #1937 from slingamn/whoischannels
fix #1935
2022-04-25 18:01:48 -04:00
Shivaram Lingamneni
2b8eb93c00 clean up magic numbers 2022-04-24 11:57:21 -04:00
Shivaram Lingamneni
51cdebf167 fix #1935
RPL_WHOISCHANNELS didn't have proper line breaks
2022-04-24 02:47:31 -04:00
Shivaram Lingamneni
61fd7a2534 fix the rest of #1933
`NICK :` pre-registration needs to be special-cased to immediately
send ERR_NONICKNAMEGIVEN (unlike erroneous nonempty nicknames,
which are processed when registration is complete)
2022-04-24 01:39:45 -04:00
Shivaram Lingamneni
7201f14b8b partial fix for #1933
If the nickname must equal the account name (because always-on or
force-nick-equals-account), the correct error response to an empty
or otherwise invalid nickname is the usual "You must use your account
name as your nickname".
2022-04-24 00:31:20 -04:00
Shivaram Lingamneni
504cc44bf7
Merge pull request #1934 from progval/patch-5
Fix implementation of `LIST <n`
2022-04-18 23:58:54 -04:00
Val Lorentz
379632a9e6
Fix implementation of LIST <n 2022-04-16 22:55:58 +02:00
Shivaram Lingamneni
1f08c97238
Merge pull request #1926 from slingamn/readmarker.6
implement draft/read-marker capability
2022-04-08 01:33:06 -04:00
Shivaram Lingamneni
2c488f5ebf
Merge pull request #1929 from slingamn/issue1928_list_err
fix #1928
2022-04-07 18:21:55 -04:00
Shivaram Lingamneni
2fb8b836db fix #1928
LIST should not return ERR_NOSUCHCHANNEL for nonexistent channels
2022-04-07 11:44:23 -04:00
Shivaram Lingamneni
ac2fc0da28
Merge pull request #1927 from FiskFan1999/deletemessagewarn
histserv delete now requires two params
2022-04-02 21:27:13 -04:00
William Rehwinkel
934ad1cec2 histserv delete now requires two params 2022-04-01 20:52:09 -04:00
Shivaram Lingamneni
1adda8d42c bump irctest 2022-03-30 23:16:11 -04:00
Shivaram Lingamneni
32f7868bfd implement draft/read-marker capability 2022-03-30 23:16:09 -04:00
Shivaram Lingamneni
6bd94391ef Dockerfile: un-pin alpine version 2022-03-30 11:45:09 -04:00
Shivaram Lingamneni
b7e2bd9f33
Merge pull request #1925 from slingamn/generics
upgrade to go 1.18, start using generics
2022-03-30 11:42:06 -04:00
Shivaram Lingamneni
fd5317e68e bump .github and Dockerfile for go1.18 2022-03-30 01:17:05 -04:00
Shivaram Lingamneni
a549827f17 upgrade to go 1.18, use generics 2022-03-30 00:44:51 -04:00
Shivaram Lingamneni
446c654dea docs: add a caution about operator passwords with spaces 2022-03-15 16:28:01 -04:00
Shivaram Lingamneni
6d892fe371 bump irctest to latest 2022-03-06 02:06:38 -05:00
Shivaram Lingamneni
9f6e26450b make SAREGISTER override DEFCON
DEFCON 4 and lower were blocking SAREGISTER. This is wrong; admins should be
allowed to make new accounts even under DEFCON (this may be needed
specifically to work around the DEFCON restriction).
2022-03-01 07:57:15 -05:00
William Rehwinkel
4010f3fc02
Fix #1911 +s channels don't appear in /list even though on the channel (#1923)
* Fix #1911 +s channels don't appear in /list even though on the channel

* use channel.HasClient instead of custom iterative checker
2022-02-28 20:31:16 -05:00
Shivaram Lingamneni
99294b8968 docs: expand FAQ on coerce-ident 2022-02-23 07:56:28 -05:00
Shivaram Lingamneni
ba474b9b9a bump irctest to latest 2022-02-21 16:56:24 -05:00
Shivaram Lingamneni
302c9cb908
Merge pull request #1919 from slingamn/default
bump recommended CHATHISTORY limit to 1000
2022-02-20 15:42:31 -05:00
Shivaram Lingamneni
87d9addcfc
Merge pull request #1920 from thesamesam/openrc-init
distrib: refine OpenRC init scripts
2022-02-19 22:53:35 -05:00
Sam James
7c766b2096
distrib: refine OpenRC init scripts
- Add logging to init script
- Add delay so OpenRC realises if we crashed quickly b/c of e.g. bad config
   file
- General cleanups (like supporting multiple instances, style changes)

This should make it a lot easier to see what's going wrong when something
breaks.

Bug: ergochat/ergo#1914
Signed-off-by: Sam James <sam@gentoo.org>
2022-02-19 22:39:39 +00:00
Shivaram Lingamneni
b66ea9f56d bump recommended CHATHISTORY limit to 1000
Discussed with emersion, this is the value used in soju and it works fine.
2022-02-18 13:06:24 -05:00
Shivaram Lingamneni
1e7775f6de
Merge pull request #1918 from slingamn/znc_exact
use exact integer parsing for znc.in/playback
2022-02-15 11:28:32 -05:00
Shivaram Lingamneni
197a9d4b5e use exact integer parsing for znc.in/playback 2022-02-15 10:50:38 -05:00
Shivaram Lingamneni
cba3a2fc10
Merge pull request #1916 from slingamn/issue1895_info
fix #1895
2022-02-11 16:27:59 -05:00
Shivaram Lingamneni
2b0d94dfee manual: add note about secure-nets 2022-02-11 13:28:35 -05:00
Shivaram Lingamneni
0a2a850005
Merge pull request #1917 from progval/patch-4
Update help of REGISTER and VERIFY commands
2022-02-08 16:22:58 -05:00
Val Lorentz
e7abd93e90
Update help of REGISTER and VERIFY commands 2022-02-08 20:25:41 +01:00
Shivaram Lingamneni
0afa7edffe fix #1895
Include server start time in INFO output.
2022-02-08 07:38:11 -05:00
Shivaram Lingamneni
59ef59870a
Merge pull request #1915 from slingamn/privileged_kick
fix #1906
2022-02-08 05:52:04 -05:00
Shivaram Lingamneni
b492b8385b bump irctest 2022-02-07 19:11:31 -05:00
Shivaram Lingamneni
fcb86c54f7 fix #1906
Having the 'samode' capability made all KICK commands privileged. This appears
to have been introduced unintentionally by 42316bc04f4761d and I can't find
any discussion of a rationale. Since this goes against our policy that all
ircop (as opposed to channel founder) privileges must be invoked explicitly
(e.g. SAJOIN, SAMODE), remove this.
2022-02-07 19:06:31 -05:00
Shivaram Lingamneni
6c0e9619cb
Merge pull request #1904 from slingamn/prefix.1
bump irc-go to v0.1.0
2022-01-20 18:03:25 -05:00
Shivaram Lingamneni
e3e8136f85 update ergo to work with irc-go v0.1.0 2022-01-20 18:00:04 -05:00
Shivaram Lingamneni
74f3ea1d2e bump irc-go to v0.1.0 2022-01-20 17:59:57 -05:00
Shivaram Lingamneni
fa7b76d66a
Merge pull request #1903 from slingamn/zero_nick
fix #1896
2022-01-19 22:32:08 -05:00
Shivaram Lingamneni
e3c9eb8e71 fix #1896
Don't allow any new uses of 0 as a nickname, since it conflicts with
the use of 0 as a placeholder for account name in WHOX.
2022-01-19 04:14:00 -05:00
Shivaram Lingamneni
c2bf59ca38
Merge pull request #1897 from slingamn/whox_show_ip
show arbitrary IP in WHOX
2022-01-19 02:16:14 -05:00
Shivaram Lingamneni
86c5839044
Merge pull request #1902 from slingamn/amode_v_join
fix #1901
2022-01-19 01:15:58 -05:00
Shivaram Lingamneni
eb477c3793 fix #1901
AMODE +v should allow you to join a +i channel
2022-01-19 00:54:03 -05:00
Shivaram Lingamneni
bf3c0ad70e
Merge pull request #1900 from csmith/docs-faq-webirc-secure
Add FAQ about WebIRC and secure clients
2022-01-19 00:52:22 -05:00
Chris Smith
0da7e68e6d Add FAQ about WebIRC and secure clients 2022-01-18 16:15:48 +00:00
Shivaram Lingamneni
6dc6abc455 set up new development version (again) 2022-01-10 19:03:54 -05:00
Shivaram Lingamneni
a73ad5fc10 bump irctest to latest 2022-01-10 18:58:11 -05:00
Shivaram Lingamneni
dba5d3faae show arbitrary IP in WHOX
This extends #1650 to cover WHO as well as WHOIS
2022-01-09 17:24:24 -05:00
721 changed files with 98721 additions and 33098 deletions

View File

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

View File

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

View File

@ -1,8 +1,11 @@
# .goreleaser.yml
# Build customization
version: 2
project_name: ergo
builds:
- main: ergo.go
env:
- CGO_ENABLED=0
binary: ergo
goos:
- linux
@ -15,6 +18,7 @@ builds:
- amd64
- arm
- arm64
- riscv64
goarm:
- 6
ignore:
@ -22,30 +26,41 @@ builds:
goarch: arm
- goos: windows
goarch: arm64
- goos: windows
goarch: riscv64
- goos: darwin
goarch: arm
- goos: darwin
goarch: riscv64
- goos: freebsd
goarch: arm
- goos: freebsd
goarch: arm64
- goos: freebsd
goarch: riscv64
- goos: openbsd
goarch: arm
- goos: openbsd
goarch: arm64
- goos: openbsd
goarch: riscv64
- goos: plan9
goarch: arm
- goos: plan9
goarch: arm64
- goos: plan9
goarch: riscv64
flags:
- -trimpath
archives:
-
name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .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
replacements:
amd64: x86_64
darwin: macos
format_overrides:
- goos: windows
format: zip
@ -56,6 +71,7 @@ archives:
- ergo.motd
- default.yaml
- traditional.yaml
- docs/API.md
- docs/MANUAL.md
- docs/USERGUIDE.md
- languages/*.yaml

View File

@ -1,7 +1,327 @@
# Changelog
All notable changes to Ergo will be documented in this file.
## [2.9.1] - 2021-01-10
## [2.17.0-rc1] - 2025-12-14
We're pleased to be publishing the release candidate for v2.17.0 (the official release should follow within a week or so). This release adds support for the [IRCv3 metadata specification](https://ircv3.net/specs/extensions/metadata), thanks to [@thatcher-gaming](https://github.com/thatcher-gaming), as well as bug fixes and minor updates.
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 [@branchgrove](https://github.com/branchgrove), [@Brutus5000](https://github.com/Brutus5000), [@progval](https://github.com/progval), [@SarahRoseLives](https://github.com/SarahRoseLives), [@thatcher-gaming](https://github.com/thatcher-gaming), [@ValwareIRC](https://github.com/ValwareIRC), and Xogium for contributing patches, reporting issues, and helping test.
### Config changes
* Added `accounts.metadata` block to configure the new metadata feature. If this block is absent, metadata is disabled. See `default.yaml` for an example. (#2273)
* Added `server.idle-timeouts` for configurable idle timeouts; when unset, the previous hardcoded defaults are used (#2292, thanks [@Brutus5000](https://github.com/Brutus5000)!)
* Added `server.oper-throttle` to configure throttling for failed `OPER` attempts; when unset, this defaults to 1 attempt every 10 seconds (#2296)
### Added
* Implemented support for the [draft/metadata-2](https://ircv3.net/specs/extensions/metadata) specification, allowing clients to set and retrieve metadata on accounts and channels (#2273, #2277, #2281, #2282, #2301, thanks [@thatcher-gaming](https://github.com/thatcher-gaming)!)
* Added `/v1/status` and `/v1/account_list` HTTP API endpoints (#2261, thanks [@SarahRoseLives](https://github.com/SarahRoseLives)!)
* Enhanced `/v1/account_details` API response with additional fields (#2261, thanks [@SarahRoseLives](https://github.com/SarahRoseLives)!)
### Fixed
* Fixed `REGISTER` command to strip guest format when applicable, matching `NS REGISTER` behavior (#2270, #2271, thanks [@ValwareIRC](https://github.com/ValwareIRC) and [@thatcher-gaming](https://github.com/thatcher-gaming)!)
* Fixed invalid `FAIL` codes in `REGISTER` command (#2269, thanks [@ValwareIRC](https://github.com/ValwareIRC)!)
* Fixed validation of web push URLs to reject non-HTTPS URLs (#2295)
* Fixed inconsistent behavior when `history.enabled` is set but `history.chathistory-maxmessages` is not (#2303, #2304, thanks [@branchgrove](https://github.com/branchgrove)!)
### Changed
* The `OPER` command now imposes a throttle on all attempts, never disconnects the client on failure, and logs non-sensitive information about failed attempts (#2296, #2298, thanks Xogium!)
### Internal
* Official release builds use Go 1.25 (#2290)
* Upgraded the Docker base image from Alpine 3.19 to 3.22 (#2306)
## [2.16.0] - 2025-05-18
We're pleased to be publishing v2.16.0, a new stable release. This release contains bug fixes and some minor updates.
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 [@csmith](https://github.com/csmith), [@delthas](https://github.com/delthas), donio, [@emersion](https://github.com/emersion), [@KlaasT](https://github.com/KlaasT), [@knolley](https://github.com/knolley), [@Mailaender](https://github.com/Mailaender), and [@prdes](https://github.com/prdes) for reporting issues and helping test.
### Config changes
* Added `api` block for configuring the new HTTP API. If this block is absent, the API is disabled (#2231)
* Added `server.additional-isupport` for publishing arbitrary ISUPPORT tokens (#2220, #2240)
* Added `server.command-aliases` to configure aliases for server commands (#2229, #2236)
* Added options to `roleplay` to customize the NUH's sent for `NPC` and `SCENE`. Roleplay remains deprecated and disabled by default. (#2237)
### Security
* Mitigated HTTP DoS attacks by rejecting IRC sessions that begin with an HTTP verb, such as `POST`. If you were relying on this to create IRC sessions via an HTTP client, please open an issue. (#2239)
### Added
* Added an HTTP API, providing programmatic access to Ergo functionality (#2231, thanks [@KlaasT](https://github.com/KlaasT)!)
* Added SAFERATE to 005 ISUPPORT tokens (#2223, thanks [@delthas](https://github.com/delthas)!)
* Added support for ed25519-sha256 for DKIM. However, enabling this algorithm is not recommended since mainstream email providers still do not support it. (#1041, #2242)
### Fixed
* Fixed `CHATHISTORY TARGETS` from MySQL backend reporting incorrect timestamps when the server timezone is not UTC (#2224)
* Fixed batch name parameter in `draft/isupport` responses (#2253)
* Fixed `NS UNREGISTER` not deleting the stored push subscriptions (#2254)
* Fixed cases where `NS SAREGISTER` could create clients without applying the default user modes (#2252, #2254, thanks donio!)
* Improved validation of `CHATHISTORY` parameters (#2248, #2249, thanks [@prdes](https://github.com/prdes)!)
* Added validation to ensure the MOTD is UTF-8 when `enforce-utf8` is enabled (the recommended default) (#2228, #2233, thanks [@KlaasT](https://github.com/KlaasT)!)
* The client's own `QUIT` line now respects the `server-time` capability (#2218, #2219)
* Fixed sending unnecessary replies to certain invalid `MODE` changes (#2213)
* Improved safety of ISUPPORT length limits (#2241)
### Changed
* The `draft/message-redaction` capability is no longer advertised when `allow-individual-delete` is disabled (#2215, #2216, thanks [@delthas](https://github.com/delthas)!)
* Receiving the UTF-8 BOM (byte-order mark) at the start of an IRC connection now produces an explicit error (#2244, #2247, thanks [@csmith](https://github.com/csmith), [@Mailaender](https://github.com/Mailaender)!)
### Internal
* Release builds use Go 1.24.3 (#2217)
## [2.15.0] - 2025-01-26
We're pleased to be publishing v2.15.0, a new stable release. This release adds support for mobile push notifications, via the [draft/webpush](https://github.com/ircv3/ircv3-specifications/pull/471) specification. More information on this is available in the [manual](https://github.com/ergochat/ergo/blob/ab2d842b270d9df217c779df9c7a5c594d85fdd5/docs/MANUAL.md#push-notifications) and [user guide](https://github.com/ergochat/ergo/blob/ab2d842b270d9df217c779df9c7a5c594d85fdd5/docs/USERGUIDE.md#push-notifications). This feature is still considered to be in an experimental state; `default.yaml` ships with it disabled, and its configuration may have backwards-incompatible changes in the future.
This release includes changes to the config file format, all of which are fully backwards-compatible and do not require updating the file before upgrading.
This release includes a database change. If you have `datastore.autoupgrade` set to `true` in your configuration, it will be automatically applied when you restart Ergo. Otherwise, you can update the database manually by running `ergo upgradedb` (see the manual for complete instructions).
Many thanks to [@delthas](https://github.com/delthas), [@donatj](https://github.com/donatj), donio, [@emersion](https://github.com/emersion), and [@eskimo](https://github.com/eskimo) for contributing patches and helping test.
### Config changes
* Added `webpush` block to the config file to configure push notifications. See `default.yaml` for an example. Note that at this time, `default.yaml` ships with support for push notifications disabled; operators can enable them by setting `webpush.enabled: true`. In the absence of such a block, push notifications are disabled.
* We recommend the addition of `"WEBPUSH": 1` to `fakelag.command-budgets`, to speed up mobile reattach when web push is enabled. See `default.yaml` for an example.
### Added
* Added support for the [draft/webpush](https://github.com/ircv3/ircv3-specifications/pull/471) specification (#2205, thanks [@emersion](https://github.com/emersion), [@eskimo](https://github.com/eskimo)!)
* Added support for the [draft/extended-isupport](https://github.com/ircv3/ircv3-specifications/pull/543) specification (#2184, thanks [@emersion](https://github.com/emersion)!)
* `UBAN ADD` now accepts `REQUIRE-SASL` with NUH masks, i.e. k-lines (#2198, #2199)
* Ergo now publishes the `SAFELIST` ISUPPORT parameter (#2196, thanks [@delthas](https://github.com/delthas)!)
### Fixed
* Fixed incorrect parameters when pushing `005` (ISUPPORT) updates to clients on rehash (#2177, #2184)
### Internal
* Official release builds use Go 1.23.5
* Added a unique identifier to identify connections in debug logs. This has no privacy implications in a standard, non-debug configuration of Ergo. (#2206, thanks donio!)
* Added support for Solaris on amd64 CPUs (#2183)
## [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
We're pleased to be publishing v2.11.0, a new stable release. This is another bugfix release aimed at improving client compatibility and keeping up with the IRCv3 specification process.
This release includes changes to the config file format, all of which are fully backwards-compatible and do not require updating the file before upgrading. It includes no changes to the database file format.
Many thanks to dedekro, [@emersion](https://github.com/emersion), [@eskimo](https://github.com/eskimo), [@FiskFan1999](https://github.com/FiskFan1999), hauser, [@jwheare](https://github.com/jwheare), [@kingter-sutjiadi](https://github.com/kingter-sutjiadi), knolle, [@Mikaela](https://github.com/Mikaela), [@mogad0n](https://github.com/mogad0n), [@PeGaSuS-Coder](https://github.com/PeGaSuS-Coder), and [@progval](https://github.com/progval) for contributing patches, reporting issues, and helping test.
### Config changes
* Added `fakelag.command-budgets`, which allows each client session a limited number of specific commands that are exempt from fakelag. This improves compatibility with Goguma in particular. For the current recommended default, see `default.yaml` (#1978, thanks [@emersion](https://github.com/emersion)!)
* The recommended value of `server.casemapping` is now `ascii` instead of `precis`. PRECIS remains fully supported; if you are already running an Ergo instance, we do not recommend changing the value unless you are confident that your existing users are not relying on non-ASCII nicknames and channel names. (#1718)
### Changed
* Network services like `NickServ` now appear in `WHO` responses where applicable (#1850, thanks [@emersion](https://github.com/emersion)!)
* The `extended-monitor` capability now appears under its ratified name (#2006, thanks [@progval](https://github.com/progval)!)
* `TAGMSG` no longer receives automatic `RPL_AWAY` responses (#1983, thanks [@eskimo](https://github.com/eskimo)!)
* `UBAN` now states explicitly that bans without a time limit have "indefinite" duration (#1988, thanks [@mogad0n](https://github.com/mogad0n)!)
### Fixed
* `WHO` with a bare nickname as an argument now shows invisible users, comparable to `WHOIS` (#1991, thanks [@emersion](https://github.com/emersion)!)
* MySQL did not work on 32-bit architectures; this has been fixed (#1969, thanks hauser!)
* Fixed the name of the `CHATHISTORY` 005 token (#2008, #2009, thanks [@emersion](https://github.com/emersion)!)
* Fixed handling of the address `::1` in WHOX output (#1980, thanks knolle!)
* Fixed handling of `AWAY` with an empty parameter (the de facto standard is to treat as a synonym for no parameter, which means "back") (#1996, thanks [@emersion](https://github.com/emersion), [@jwheare](https://github.com/jwheare)!)
* Fixed incorrect handling of some invalid modes in `CS AMODE` (#2002, thanks [@eskimo](https://github.com/eskimo)!)
* Fixed incorrect help text for `NS SAVERIFY` (#2021, thanks [@FiskFan1999](https://github.com/FiskFan1999)!)
### Added
* Added the `draft/persistence` capability and associated `PERSISTENCE` command. This is a first attempt to standardize Ergo's "always-on" functionality so that clients can interact with it programmatically. (#1982)
* Sending `SIGUSR1` to the Ergo process now prints a full goroutine stack dump to stderr, allowing debugging even when the HTTP pprof listener is disabled (#1975)
### Internal
* Upgraded to Go 1.19; this makes further architecture-specific bugs like #1969 much less likely (#1987, #1989)
* The test suite is now parallelized (#1976, thanks [@progval](https://github.com/progval)!)
## [2.10.0] - 2022-05-29
We're pleased to be publishing v2.10.0, a new stable release.
This release contains no changes to the config file format or database file format.
Many thanks to [@csmith](https://github.com/csmith), [@FiskFan1999](https://github.com/FiskFan1999), [@Mikaela](https://github.com/Mikaela), [@progval](https://github.com/progval), and [@thesamesam](https://github.com/thesamesam) for contributing patches, and to [@emersion](https://github.com/emersion), [@eskimo](https://github.com/eskimo), [@FiskFan1999](https://github.com/FiskFan1999), [@jigsy1](https://github.com/jigsy1), [@Mikaela](https://github.com/Mikaela), [@mogad0n](https://github.com/mogad0n), [@progval](https://github.com/progval), and [@xnaas](https://github.com/xnaas) for reporting issues and helping test.
### Config changes
* For better interoperability with [Goguma](https://sr.ht/~emersion/goguma/), the recommended value of `history.chathistory-maxmessages` has been increased to `1000` (previously `100`) (#1919)
### Changed
* Persistent voice (`AMODE +v`) in a channel is now treated as a permanent invite (i.e. overriding `+i` on the channel) (#1901, thanks [@eskimo](https://github.com/eskimo)!)
* If you are `+R`, sending a direct message to an anonymous user allows them to send you replies (#1687, #1688, thanks [@Mikaela](https://github.com/Mikaela) and [@progval](https://github.com/progval)!)
* `0` is no longer valid as a nickname or account name, with a grandfather exception if it was registered on a previous version of Ergo (#1896)
* Implemented the [ratified version of the bot mode spec](https://ircv3.net/specs/extensions/bot-mode); the tag name is now `bot` instead of `draft/bot` (#1938)
* Privileged WHOX on a user with multiclient shows an arbitrarily chosen client IP address, comparable to WHO (#1897)
* `SAREGISTER` is allowed even under `DEFCON` levels 4 and lower (#1922)
* Operators with the `history` capability are now exempted from time cutoff restrictions on history retrieval (#1593, #1955)
### Added
* Added `draft/read-marker` capability, allowing server-side tracking of read messages for synchronization across multiple clients. (#1926, thanks [@emersion](https://github.com/emersion)!)
* `INFO` now includes the server start time (#1895, thanks [@xnaas](https://github.com/xnaas)!)
* Added `ACCEPT` command modeled on Charybdis/Solanum, allowing `+R` users to whitelist users who can DM them (#1688, thanks [@Mikaela](https://github.com/Mikaela)!)
* Added `NS SAVERIFY` for operators to manually complete an account verification (#1924, #1952, thanks [@tacerus](https://github.com/tacerus)!)
### Fixed
* Having the `samode` operator capability made all uses of the `KICK` command privileged (i.e. overriding normal channel privilege checks); this has been fixed (#1906, thanks [@pcho](https://github.com/pcho)!)
* Fixed `LIST <n` always returning no results (#1934, thanks [@progval](https://github.com/progval) and [@mitchr](https://github.com/mitchr)!)
* NickServ commands are now more clear about when a nickname is unavailable because it was previously registered and unregistered (#1886, thanks [@Mikaela](https://github.com/Mikaela)!)
* Fixed KLINE'd clients producing a `QUIT` snotice without a corresponding `CONNECT` snotice (#1941, thanks [@tacerus](https://github.com/tacerus), [@xnaas](https://github.com/xnaas)!)
* Fixed incorrect handling of long/multiline `319 RPL_WHOISCHANNELS` responses (#1935, thanks [@Mikaela](https://github.com/Mikaela)!)
* Fixed `LIST` returning `403 ERR_NOSUCHCHANNEL` for a nonexistent channel; the correct response is an empty list (#1928, thanks [@emersion](https://github.com/emersion)!)
* Fixed `+s` ("secret") channels not appearing in `LIST` even when the client is already a member (#1911, #1923, thanks [@jigsy1](https://github.com/jigsy1) and [@FiskFan1999](https://github.com/FiskFan1999)!)
* Fixed a spurious success message in `HISTSERV DELETE` by always requiring a consistent number of parameters (#1881, #1927, thanks [@FiskFan1999](https://github.com/FiskFan1999)!)
* Sending the empty string as a nickname would not always produce the expected error numeric `431 ERR_NONICKNAMEGIVEN`; this has been fixed (#1933, #1936, thanks [@kylef](https://github.com/kylef)!)
* `znc.in/playback` timestamps are now parsed as pairs of exact integers, not as floats (#1918)
### Internal
* Upgraded to Go 1.18 (#1925)
* Upgraded Alpine version in official Docker image
* Fixed some issues in the example OpenRC init scripts (#1914, #1920, thanks [@thesamesam](https://github.com/thesamesam)!)
## [2.9.1] - 2022-01-10
Ergo 2.9.1 is a bugfix release, fixing a regression introduced in 2.9.0. We regret the oversight.
@ -13,7 +333,7 @@ Many thanks to [@FiskFan1999](https://github.com/FiskFan1999) for reporting the
* Every use of NS SAREGISTER would fail; this has been fixed (#1898, thanks [@FiskFan1999](https://github.com/FiskFan1999)!)
## [2.9.0] - 2021-01-09
## [2.9.0] - 2022-01-09
We're pleased to be publishing 2.9.0, a new stable release. This release contains mostly bug fixes, with some enhancements to moderation tools.
@ -1044,7 +1364,7 @@ Thanks to [slingamn](https://github.com/slingamn) for a lot of heavy lifting thi
## [0.11.0] - 2018-04-15
And v0.11.0 finally comes along! This release has been in the works for almost four months now, with an alpha and beta helping square away the issues.
We're adding a lot of features to improve debugging, better support international users, and make things better for network administrators. Among the new features, you can use the `LANGUAGE` command to set a custom server language (see our [CrowdIn](https://crowdin.com/project/oragono) to contribute), expose a debugging `pprof` endpoint, reserve nicknames with `NickServ`, and force email verification for new user accounts. On the improvements side we have a `CAP REQ` fix, and we now have a manual that contains a nice overview of Oragono's documentation.
We're adding a lot of features to improve debugging, better support international users, and make things better for network administrators. Among the new features, you can use the `LANGUAGE` command to set a custom server language (see our [CrowdIn](https://crowdin.com/project/ergochat) to contribute), expose a debugging `pprof` endpoint, reserve nicknames with `NickServ`, and force email verification for new user accounts. On the improvements side we have a `CAP REQ` fix, and we now have a manual that contains a nice overview of Oragono's documentation.
If you have any trouble with this release, please let us know with an issue on our tracker, or by talking to us in `#oragono` on Freenode.

View File

@ -170,7 +170,7 @@ In addition, throughout most of the codebase, if a string is created using the b
## Updating Translations
We support translating server strings using [CrowdIn](https://crowdin.com/project/oragono)! To send updated source strings to CrowdIn, you should:
We support translating server strings using [CrowdIn](https://crowdin.com/project/ergochat)! To send updated source strings to CrowdIn, you should:
1. `cd` to the base directory (the one this `DEVELOPING` file is in).
2. Install the `pyyaml` and `docopt` deps using `pip3 install pyyamp docopt`.

View File

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

View File

@ -1,49 +1,48 @@
.PHONY: all install build release capdefs test smoke gofmt irctest
GIT_COMMIT := $(shell git rev-parse HEAD 2> /dev/null)
GIT_TAG := $(shell git tag --points-at HEAD 2> /dev/null | head -n 1)
# disable linking against native libc / libpthread by default;
# this can be overridden by passing CGO_ENABLED=1 to make
export CGO_ENABLED ?= 0
capdef_file = ./irc/caps/defs.go
all: install
.PHONY: all
all: build
.PHONY: install
install:
go install -v -ldflags "-X main.commit=$(GIT_COMMIT) -X main.version=$(GIT_TAG)"
.PHONY: build
build:
go build -v -ldflags "-X main.commit=$(GIT_COMMIT) -X main.version=$(GIT_TAG)"
.PHONY: release
release:
goreleaser --skip-publish --rm-dist
goreleaser --skip=publish --clean
.PHONY: capdefs
capdefs:
python3 ./gencapdefs.py > ${capdef_file}
.PHONY: test
test:
python3 ./gencapdefs.py | diff - ${capdef_file}
cd irc && go test . && go vet .
cd irc/caps && go test . && go vet .
cd irc/cloaks && go test . && go vet .
cd irc/connection_limits && go test . && go vet .
cd irc/email && go test . && go vet .
cd irc/flatip && go test . && go vet .
cd irc/history && go test . && go vet .
cd irc/isupport && go test . && go vet .
cd irc/migrations && go test . && go vet .
cd irc/modes && go test . && go vet .
cd irc/mysql && go test . && go vet .
cd irc/passwd && go test . && go vet .
cd irc/sno && go test . && go vet .
cd irc/utils && go test . && go vet .
go test ./...
go vet ./...
./.check-gofmt.sh
smoke:
.PHONY: smoke
smoke: install
ergo mkcerts --conf ./default.yaml || true
ergo run --conf ./default.yaml --smoke
.PHONY: gofmt
gofmt:
./.check-gofmt.sh --fix
irctest:
.PHONY: irctest
irctest: install
git submodule update --init
cd irctest && make ergo

6
README
View File

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

View File

@ -25,20 +25,20 @@ If you want to take a look at a running Ergo instance or test some client code,
* integrated services: NickServ for user accounts, ChanServ for channel registration, and HostServ for vanity hosts
* bouncer-like features: storing and replaying history, allowing multiple clients to use the same nickname
* UTF-8 nick and channel names with rfc7613 (PRECIS)
* native TLS/SSL support, including support for client certificates
* [IRCv3 support](https://ircv3.net/software/servers.html)
* [yaml](https://yaml.org/) configuration
* updating server config and TLS certificates on-the-fly (rehashing)
* SASL authentication
* [LDAP support](https://github.com/ergochat/ergo-ldap)
* supports [multiple languages](https://crowdin.com/project/ergochat) (you can also set a default language for your network)
* optional support for UTF-8 nick and channel names with RFC 8265 (PRECIS)
* advanced security and privacy features (support for requiring SASL for all logins, cloaking IPs, and running as a Tor hidden service)
* an extensible privilege system for IRC operators
* ident lookups for usernames
* automated client connection limits
* passwords stored with [bcrypt](https://godoc.org/golang.org/x/crypto)
* `UBAN`, a unified ban system that can target IPs, networks, masks, and registered accounts (`KLINE` and `DLINE` are also supported)
* [IRCv3 support](https://ircv3.net/software/servers.html)
* a focus on developing with [specifications](https://ergo.chat/specs.html)
For more detailed information on Ergo's functionality, see:
@ -54,9 +54,9 @@ Extract it into a folder, then run the following commands:
```sh
cp default.yaml ircd.yaml
vim ircd.yaml # modify the config file to your liking
ergo mkcerts
ergo run # server should be ready to go!
vim ircd.yaml # modify the config file to your liking
./ergo mkcerts
./ergo run # server should be ready to go!
```
**Note:** See the [productionizing guide in our manual](https://github.com/ergochat/ergo/blob/stable/docs/MANUAL.md#productionizing-with-systemd) for recommendations on how to run a production network, including obtaining valid TLS certificates.
@ -71,7 +71,7 @@ Some platforms/distros also have Ergo packages maintained for them:
### Using Docker
A Dockerfile and example docker-compose recipe are available in the `distrib/docker` directory. Ergo is automatically published
to Docker Hub at [ergochat/ergo](https://hub.docker.com/r/ergochat/ergo). For more information, see the distrib/docker
to the GitHub Container Registry at [ghcr.io/ergochat/ergo](https://ghcr.io/ergochat/ergo). For more information, see the distrib/docker
[README file](https://github.com/ergochat/ergo/blob/master/distrib/docker/README.md).
### From Source
@ -84,7 +84,7 @@ For information on contributing to Ergo, see [DEVELOPING.md](https://github.com/
#### Building
You'll need an [up-to-date distribution of the Go language for your OS and architecture](https://golang.org/dl/). Once that's installed (check the output of `go version`), just check out your desired branch or tag and run `make build`. This will produce an executable binary named `ergo` in the base directory of the project. (Ergo vendors all its dependencies, so you will not need to fetch any dependencies remotely.)
You'll need an [up-to-date distribution of the Go language for your OS and architecture](https://golang.org/dl/). Once that's installed (check the output of `go version`), just check out your desired branch or tag and run `make`. This will produce an executable binary named `ergo` in the base directory of the project. (Ergo vendors all its dependencies, so you will not need to fetch any dependencies remotely.)
## Configuration

View File

@ -100,6 +100,7 @@ server:
max-connections-per-duration: 64
# strict transport security, to get clients to automagically use TLS
# (irrelevant in the recommended configuration, with no public plaintext listener)
sts:
# whether to advertise STS
#
@ -131,15 +132,16 @@ server:
# casemapping controls what kinds of strings are permitted as identifiers (nicknames,
# channel names, account names, etc.), and how they are normalized for case.
# with the recommended default of 'precis', UTF8 identifiers that are "sane"
# (according to RFC 8265) are allowed, and the server additionally tries to protect
# against confusable characters ("homoglyph attacks").
# the other options are 'ascii' (traditional ASCII-only identifiers), and 'permissive',
# which allows identifiers to contain unusual characters like emoji, but makes users
# vulnerable to homoglyph attacks. unless you're really confident in your decision,
# we recommend leaving this value at its default (changing it once the network is
# already up and running is problematic).
casemapping: "precis"
# the recommended default is 'ascii' (traditional ASCII-only identifiers).
# the other options are 'precis', which allows UTF8 identifiers that are "sane"
# (according to UFC 8265), with additional mitigations for homoglyph attacks,
# 'permissive', which allows identifiers containing unusual characters like
# emoji, at the cost of increased vulnerability to homoglyph attacks and potential
# client compatibility problems, and the legacy mappings 'rfc1459' and
# 'rfc1459-strict'. we recommend leaving this value at its default;
# however, note that changing it once the network is already up and running is
# problematic.
casemapping: "ascii"
# enforce-utf8 controls whether the server will preemptively discard non-UTF8
# messages (since they cannot be relayed to websocket clients), or will allow
@ -164,7 +166,10 @@ server:
# the value must begin with a '~' character. comment out / omit to disable:
coerce-ident: '~u'
# password to login to the server, generated using `ergo genpasswd`:
# 'password' allows you to require a global, shared password (the IRC `PASS` command)
# to connect to the server. for operator passwords, see the `opers` section of the
# config. for a more secure way to create a private server, see the `require-sasl`
# section. you must hash the password with `ergo genpasswd`, then enter the hash here:
#password: "$2a$04$0123456789abcdef0123456789abcdef0123456789abcdef01234"
# motd filename
@ -175,6 +180,17 @@ server:
# if this is true, the motd is escaped using formatting codes like $c, $b, and $i
motd-formatting: true
# idle timeouts for inactive clients
idle-timeouts:
# give the client this long to complete connection registration (i.e. the initial
# IRC handshake, including capability negotiation and SASL)
registration: 60s
# if the client hasn't sent anything for this long, send them a PING
ping: 1m30s
# if the client hasn't sent anything for this long (including the PONG to the
# above PING), disconnect them
disconnect: 2m30s
# relaying using the RELAYMSG command
relaymsg:
# is relaymsg enabled at all?
@ -215,6 +231,10 @@ server:
# - "192.168.1.1"
# - "192.168.10.1/24"
# whether to accept the hostname parameter on the WEBIRC line as the IRC hostname
# (the default/recommended Ergo configuration will use cloaks instead)
accept-hostname: false
# maximum length of clients' sendQ in bytes
# this should be big enough to hold bursts of channel/direct messages
max-sendq: 96k
@ -349,6 +369,10 @@ server:
secure-nets:
# - "10.0.0.0/8"
# allow attempts to OPER with a password at most this often. defaults to
# 10 seconds when unset.
oper-throttle: 10s
# Ergo will write files to disk under certain circumstances, e.g.,
# CPU profiling or data export. by default, these files will be written
# to the working directory. set this to customize:
@ -361,12 +385,23 @@ server:
# in a "closed-loop" system where you control the server and all the clients,
# you may want to increase the maximum (non-tag) length of an IRC line from
# the default value of 512. DO NOT change this on a public server:
# max-line-len: 512
#max-line-len: 512
# send all 0's as the LUSERS (user counts) output to non-operators; potentially useful
# if you don't want to publicize how popular the server is
suppress-lusers: false
# publish additional key-value pairs in ISUPPORT (the 005 numeric).
# keys that collide with a key published by Ergo will be silently ignored.
additional-isupport:
#"draft/FILEHOST": "https://example.com/filehost"
#"draft/bazbat": "" # empty string means no value
# optionally map command alias names to existing ergo commands. most deployments
# should ignore this.
#command-aliases:
#"UMGEBUNG": "AMBIANCE"
# account options
accounts:
# is account authentication enabled, i.e., can users log into existing accounts?
@ -402,6 +437,10 @@ accounts:
sender: "admin@my.network"
require-tls: true
helo-domain: "my.network" # defaults to server name if unset
# set to `tcp4` to force sending over IPv4, `tcp6` to force IPv6:
# protocol: "tcp4"
# set to force a specific source/local IPv4 or IPv6 address:
# local-address: "1.2.3.4"
# options to enable DKIM signing of outgoing emails (recommended, but
# requires creating a DNS entry for the public key):
# dkim:
@ -414,8 +453,15 @@ accounts:
# port: 25
# username: "admin"
# password: "hunter2"
blacklist-regexes:
# - ".*@mailinator.com"
# implicit-tls: false # TLS from the first byte, typically on port 465
# addresses that are not accepted for registration:
address-blacklist:
# - "*@mailinator.com"
address-blacklist-syntax: "glob" # change to "regex" for regular expressions
# file of newline-delimited address blacklist entries (no enclosing quotes)
# in the above syntax (i.e. either globs or regexes). supersedes
# address-blacklist if set:
# address-blacklist-file: "/path/to/address-blacklist-file"
timeout: 60s
# email-based password reset:
password-reset:
@ -447,6 +493,10 @@ accounts:
# this is useful for compatibility with old clients that don't support SASL
login-via-pass-command: true
# advertise the SCRAM-SHA-256 authentication method. set to false in case of
# compatibility issues with certain clients:
advertise-scram: true
# require-sasl controls whether clients are required to have accounts
# (and sign into them using SASL) to connect to the server
require-sasl:
@ -487,7 +537,7 @@ accounts:
# 1. these nicknames cannot be registered or reserved
# 2. if a client is automatically renamed by the server,
# this is the template that will be used (e.g., Guest-nccj6rgmt97cg)
# 3. if enforce-guest-format (see below) is enabled, clients without
# 3. if force-guest-format (see below) is enabled, clients without
# a registered account will have this template applied to their
# nicknames (e.g., 'katie' will become 'Guest-katie')
guest-nickname-format: "Guest-*"
@ -572,6 +622,40 @@ accounts:
# how many scripts are allowed to run at once? 0 for no limit:
max-concurrency: 64
# support for login via OAuth2 bearer tokens
oauth2:
enabled: false
# should we automatically create users on presentation of a valid token?
autocreate: true
# enable this to use auth-script for validation:
auth-script: false
introspection-url: "https://example.com/api/oidc/introspection"
introspection-timeout: 10s
# omit for auth method `none`; required for auth method `client_secret_basic`:
client-id: "ergo"
client-secret: "4TA0I7mJ3fUUcW05KJiODg"
# support for login via JWT bearer tokens
jwt-auth:
enabled: false
# should we automatically create users on presentation of a valid token?
autocreate: true
# any of these token definitions can be accepted, allowing for key rotation
tokens:
-
algorithm: "hmac" # either 'hmac', 'rsa', or 'eddsa' (ed25519)
# hmac takes a symmetric key, rsa and eddsa take PEM-encoded public keys;
# either way, the key can be specified either as a YAML string:
key: "nANiZ1De4v6WnltCHN2H7Q"
# or as a path to the file containing the key:
#key-file: "jwt_pubkey.pem"
# list of JWT claim names to search for the user's account name (make sure the format
# is what you expect, especially if using "sub"):
account-claims: ["preferred_username"]
# if a claim is formatted as an email address, require it to have the following domain,
# and then strip off the domain and use the local-part as the account name:
#strip-domain: "example.com"
# channel options
channels:
# modes that are set when new channels are created
@ -607,6 +691,12 @@ channels:
# (0 or omit for no expiration):
invite-expiration: 24h
# channels that new clients will automatically join. this should be used with
# caution, since traditional IRC users will likely view it as an antifeature.
# it may be useful in small community networks that have a single "primary" channel:
#auto-join:
# - "#lounge"
# operator classes:
# an operator has a single "class" (defining a privilege level), which can include
# multiple "capabilities" (defining privileged actions they can take). all
@ -623,9 +713,9 @@ oper-classes:
# capability names
capabilities:
- "kill" # disconnect user sessions
- "ban" # ban IPs, CIDRs, and NUH masks ("d-line" and "k-line")
- "nofakelag" # remove "fakelag" restrictions on rate of message sending
- "relaymsg" # use RELAYMSG in any channel (see the 'relaymsg' config block)
- "ban" # ban IPs, CIDRs, NUH masks, and suspend accounts (UBAN / DLINE / KLINE)
- "nofakelag" # exempted from "fakelag" restrictions on rate of message sending
- "relaymsg" # use RELAYMSG in any channel (see the `relaymsg` config block)
- "vhosts" # add and remove vhosts from users
- "sajoin" # join arbitrary channels, including private channels
- "samode" # modify arbitrary channel and user modes
@ -649,6 +739,7 @@ oper-classes:
- "history" # modify or delete history messages
- "defcon" # use the DEFCON command (restrict server capabilities)
- "massmessage" # message all users on the server
- "metadata" # modify arbitrary metadata on channels and users
# ircd operators
opers:
@ -713,7 +804,7 @@ logging:
# be logged, even if you explicitly include it
#
# useful types include:
# * everything (usually used with exclusing some types below)
# * everything (usually used with excluding some types below)
# server server startup, rehash, and shutdown events
# accounts account registration and authentication
# channels channel creation and operations
@ -757,7 +848,7 @@ lock-file: "ircd.lock"
# datastore configuration
datastore:
# path to the datastore
# path to the database file (used to store account and channel registrations):
path: ircd.db
# if the database schema requires an upgrade, `autoupgrade` will attempt to
@ -800,6 +891,9 @@ limits:
# identlen is the max ident length allowed
identlen: 20
# realnamelen is the maximum realname length allowed
realnamelen: 150
# channellen is the max channel length allowed
channellen: 64
@ -819,7 +913,7 @@ limits:
whowas-entries: 100
# maximum length of channel lists (beI modes)
chan-list-modes: 60
chan-list-modes: 100
# maximum number of messages to accept during registration (prevents
# DoS / resource exhaustion attacks):
@ -849,6 +943,15 @@ fakelag:
# sending any commands:
cooldown: 2s
# exempt a certain number of command invocations per session from fakelag;
# this is to speed up "resynchronization" of client state during reattach
command-budgets:
"CHATHISTORY": 16
"MARKREAD": 16
"MONITOR": 1
"WHO": 4
"WEBPUSH": 1
# the roleplay commands are semi-standardized extensions to IRC that allow
# sending and receiving messages from pseudo-nicknames. this can be used either
# for actual roleplaying, or for bridging IRC with other protocols.
@ -866,6 +969,12 @@ roleplay:
# add the real nickname, in parentheses, to the end of every roleplay message?
add-suffix: true
# allow customizing the NUH's sent for NPC and SCENE commands
# NPC: the first %s is the NPC name, the second is the user's real nick
#npc-nick-mask: "*%s*!%s@npc.fakeuser.invalid"
# SCENE: the %s is the client's real nick
#scene-nick-mask: "=Scene=!%s@npc.fakeuser.invalid"
# external services can integrate with the ircd using JSON Web Tokens (https://jwt.io).
# in effect, the server can sign a token attesting that the client is present on
# the server, is a member of a particular channel, etc.
@ -913,7 +1022,7 @@ history:
# maximum number of CHATHISTORY messages that can be
# requested at once (0 disables support for CHATHISTORY)
chathistory-maxmessages: 100
chathistory-maxmessages: 1000
# maximum number of messages that can be replayed at once during znc emulation
# (znc.in/playback, or automatic replay on initial reattach to a persistent client):
@ -942,7 +1051,9 @@ history:
# options to store history messages in a persistent database (currently only MySQL).
# in order to enable any of this functionality, you must configure a MySQL server
# in the `datastore.mysql` section.
# in the `datastore.mysql` section. enabling persistence overrides the history
# size limits above (`channel-length`, `client-length`, etc.); persistent
# history has no limits other than those imposed by expire-time.
persistent:
enabled: false
@ -964,7 +1075,8 @@ history:
# options to control how messages are stored and deleted:
retention:
# allow users to delete their own messages from history?
# allow users to delete their own messages from history,
# and channel operators to delete messages in their channel?
allow-individual-delete: false
# if persistent history is enabled, create additional index tables,
@ -990,3 +1102,56 @@ history:
# whether to allow customization of the config at runtime using environment variables,
# e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details.
allow-environment-overrides: true
# metadata support for setting key/value data on channels and nicknames.
metadata:
# can clients store metadata?
enabled: true
# how many keys can a client subscribe to?
max-subs: 100
# how many keys can be stored per entity?
max-keys: 100
# rate limiting for client metadata updates, which are expensive to process
client-throttle:
enabled: true
duration: 2m
max-attempts: 10
# experimental support for mobile push notifications
# see the manual for potential security, privacy, and performance implications.
# DO NOT enable if you are running a Tor or I2P hidden service (i.e. one
# with no public IP listeners, only Tor/I2P listeners).
webpush:
# are push notifications enabled at all?
enabled: false
# request timeout for POST'ing the http notification
timeout: 10s
# delay sending the notification for this amount of time, then suppress it
# if the client sent MARKREAD to indicate that it was read on another device
delay: 0s
# subscriber field for the VAPID JWT authorization:
#subscriber: "https://your-website.com/"
# maximum number of push subscriptions per user
max-subscriptions: 4
# expiration time for a push subscription; it must be renewed within this time
# by the client reconnecting to IRC. we also detect whether the client is no longer
# successfully receiving push messages.
expiration: 14d
# HTTP API. we strongly recommend leaving this disabled unless you have a specific
# need for it.
api:
# is the API enabled at all?
enabled: false
# listen address:
listener: "127.0.0.1:8089"
# serve over TLS (strongly recommended if the listener is public):
#tls:
#cert: fullchain.pem
#key: privkey.pem
# one or more static bearer tokens accepted for HTTP bearer authentication.
# these must be strong, unique, high-entropy printable ASCII strings.
# to generate a new token, use `ergo gentoken` or:
# python3 -c "import secrets; print(secrets.token_urlsafe(32))"
bearer-tokens:
- "example"

34
distrib/apparmor/ergo Normal file
View File

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

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

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

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

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

View File

@ -18,7 +18,7 @@ certificates. To get a working ircd, all you need to do is run the image and
expose the ports:
```shell
docker run --name ergo -d -p 6667:6667 -p 6697:6697 ghcr.io/ergochat/ergo:stable
docker run --init --name ergo -d -p 6667:6667 -p 6697:6697 ghcr.io/ergochat/ergo:stable
```
This will start Ergo and listen on ports 6667 (plain text) and 6697 (TLS).
@ -38,6 +38,11 @@ You should see a line similar to:
Oper username:password is admin:cnn2tm9TP3GeI4vLaEMS
```
We recommend the use of `--init` (`init: true` in docker-compose) to solve an
edge case involving unreaped zombie processes when Ergo's script API is used
for authentication or IP validation. For more details, see
[krallin/tini#8](https://github.com/krallin/tini/issues/8).
## Persisting data
Ergo has a persistent data store, used to keep account details, channel
@ -48,14 +53,14 @@ For example, to create a new docker volume and then mount it:
```shell
docker volume create ergo-data
docker run -d -v ergo-data:/ircd -p 6667:6667 -p 6697:6697 ghcr.io/ergochat/ergo:stable
docker run --init --name ergo -d -v ergo-data:/ircd -p 6667:6667 -p 6697:6697 ghcr.io/ergochat/ergo:stable
```
Or to mount a folder from your host machine:
```shell
mkdir ergo-data
docker run -d -v $(PWD)/ergo-data:/ircd -p 6667:6667 -p 6697:6697 ghcr.io/ergochat/ergo:stable
docker run --init --name ergo -d -v $(pwd)/ergo-data:/ircd -p 6667:6667 -p 6697:6697 ghcr.io/ergochat/ergo:stable
```
## Customising the config
@ -80,8 +85,8 @@ docker kill -s SIGHUP ergo
## Using custom TLS certificates
TLS certs will by default be read from /ircd/tls.crt, with a private key
in /ircd/tls.key. You can customise this path in the ircd.yaml file if
TLS certs will by default be read from /ircd/fullchain.pem, with a private key
in /ircd/privkey.pem. You can customise this path in the ircd.yaml file if
you wish to mount the certificates from another volume. For information
on using Let's Encrypt certificates, see
[this manual entry](https://github.com/ergochat/ergo/blob/master/docs/MANUAL.md#using-valid-tls-certificates).

View File

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

View File

@ -1,2 +1,3 @@
# /etc/conf.d/ergo: config file for /etc/init.d/ergo
ERGO_CONFIGFILE="/etc/ergo/ircd.yaml"
ERGO_USERNAME="ergo"

View File

@ -1,17 +1,30 @@
#!/sbin/openrc-run
command=/usr/bin/ergo
command_args="run --conf ${ERGO_CONFIGFILE:-"/etc/ergo/ircd.yaml"}"
command_background=true
extra_started_commands="reload"
pidfile=/var/run/ergo.pid
name="ergo"
name=${RC_SVCNAME}
description="ergo IRC daemon"
command=/usr/bin/ergo
command_args="run --conf ${ERGO_CONFIGFILE:-'/etc/ergo/ircd.yaml'}"
command_user=${ERGO_USERNAME:-ergo}
command_background=true
pidfile=/var/run/${RC_SVCNAME}.pid
output_log="/var/log/${RC_SVCNAME}.out"
error_log="/var/log/${RC_SVCNAME}.err"
# --wait: to wait 1 second after launching to see if it survived startup
start_stop_daemon_args="--wait 1000"
extra_started_commands="reload"
depend() {
use dns
provide ircd
}
start_pre() {
checkpath --owner ${command_user}:${command_user} --mode 0640 --file /var/log/${RC_SVCNAME}.out /var/log/${RC_SVCNAME}.err
}
reload() {
ebegin "Reloading ${RC_SVCNAME}"
start-stop-daemon --signal HUP --pidfile "${pidfile}"

124
docs/API.md Normal file
View File

@ -0,0 +1,124 @@
__ __ ______ ___ ______ ___
__/ // /_/ ____/ __ \/ ____/ __ \
/_ // __/ __/ / /_/ / / __/ / / /
/_ // __/ /___/ _, _/ /_/ / /_/ /
/_//_/ /_____/_/ |_|\____/\____/
Ergo IRCd API Documentation
https://ergo.chat/
_Copyright © Daniel Oaks <daniel@danieloaks.net>, Shivaram Lingamneni <slingamn@cs.stanford.edu>_
--------------------------------------------------------------------------------------------
Ergo has an experimental HTTP API. Some general information about the API:
1. All requests to the API are via POST.
1. All requests to the API are authenticated via bearer authentication. This is a header named `Authorization` with the value `Bearer <token>`. A list of valid tokens is hardcoded in the Ergo config. Future versions of Ergo may allow additional validation schemes for tokens.
1. The request parameters are sent as JSON in the POST body.
1. Any status code other than 200 is an error response; the response body is undefined in this case (likely human-readable text for debugging).
1. A 200 status code indicates successful execution of the request. The response body will be JSON and may indicate application-level success or failure (typically via the `success` field, which takes a boolean value).
API endpoints are versioned (currently all endpoints have a `/v1/` path prefix). Backwards-incompatible updates will most likely take the form of endpoints with new names, or an increased version prefix. Any exceptions to this will be specifically documented in the changelog.
All API endpoints should be considered highly privileged. Bearer tokens should be kept secret. Access to the API should be either over a trusted link (like loopback) or secured via verified TLS. See the `api` section of `default.yaml` for examples of how to configure this.
Here's an example of how to test an API configured to run over loopback TCP in plaintext:
```bash
curl -d '{"accountName": "invalidaccountname", "passphrase": "invalidpassphrase"}' -H 'Authorization: Bearer EYBbXVilnumTtfn4A9HE8_TiKLGWEGylre7FG6gEww0' -v http://127.0.0.1:8089/v1/check_auth
```
This returns:
```json
{"success":false}
```
Endpoints
=========
`/v1/account_details`
----------------
This endpoint fetches account details and returns them as JSON. The request is a JSON object with fields:
* `accountName`: string, name of the account
The response is a JSON object with fields:
* `success`: whether the account exists or not
* `accountName`: canonical, case-unfolded version of the account name
* `email`: email address of the account provided
* `registeredAt`: string, registration date/time of the account (in ISO8601 format)
* `channels`: array of strings, list of channels the account is registered on or associated with
`/v1/check_auth`
----------------
This endpoint verifies the credentials of a NickServ account; this allows Ergo to be used as the source of truth for authentication by another system. The request is a JSON object with fields:
* `accountName`: string, name of the account
* `passphrase`: string, alleged passphrase of the account
The response is a JSON object with fields:
* `success`: whether the credentials provided were valid
* `accountName`: canonical, case-unfolded version of the account name
`/v1/rehash`
------------
This endpoint rehashes the server (i.e. reloads the configuration file, TLS certificates, and other associated data). The body is ignored. The response is a JSON object with fields:
* `success`: boolean, indicates whether the rehash was successful
* `error`: string, optional, human-readable description of the failure
`/v1/saregister`
----------------
This endpoint registers an account in NickServ, with the same semantics as `NS SAREGISTER`. The request is a JSON object with fields:
* `accountName`: string, name of the account
* `passphrase`: string, passphrase of the account
The response is a JSON object with fields:
* `success`: whether the account creation succeeded
* `errorCode`: string, optional, machine-readable description of the error. Possible values include: `ACCOUNT_EXISTS`, `INVALID_PASSPHRASE`, `UNKNOWN_ERROR`.
* `error`: string, optional, human-readable description of the failure.
`/v1/account_list`
-------------------
This endpoint fetches a list of all accounts. The request body is ignored and can be empty.
The response is a JSON object with fields:
* `success`: whether the request succeeded
* `accounts`: array of objects, each with fields:
* `success`: boolean, whether this individual account query succeeded
* `accountName`: string, canonical, case-unfolded version of the account name
* `totalCount`: integer, total number of accounts returned
`/v1/status`
-------------
This endpoint returns status information about the running Ergo server. The request body is ignored and can be empty.
The response is a JSON object with fields:
* `success`: whether the request succeeded
* `version`: string, Ergo server version string
* `go_version`: string, version of Go runtime used
* `start_time`: string, server start time in ISO8601 format
* `users`: object with fields:
* `total`: total number of users connected
* `invisible`: number of invisible users
* `operators`: number of operators connected
* `unknown`: number of users with unknown status
* `max`: maximum number of users seen connected at once
* `channels`: integer, number of channels currently active
* `servers`: integer, number of servers connected in the network

View File

@ -44,6 +44,7 @@ _Copyright © Daniel Oaks <daniel@danieloaks.net>, Shivaram Lingamneni <slingamn
- [Persistent history with MySQL](#persistent-history-with-mysql)
- [IP cloaking](#ip-cloaking)
- [Moderation](#moderation)
- [Push notifications](#push-notifications)
- [Frequently Asked Questions](#frequently-asked-questions)
- [IRC over TLS](#irc-over-tls)
- [Redirect from plaintext to TLS](#how-can-i-redirect-users-from-plaintext-to-tls)
@ -60,7 +61,9 @@ _Copyright © Daniel Oaks <daniel@danieloaks.net>, Shivaram Lingamneni <slingamn
- [Migrating from Anope or Atheme](#migrating-from-anope-or-atheme)
- [HOPM](#hopm)
- [Tor](#tor)
- [I2P](#i2p)
- [ZNC](#znc)
- [API](#api)
- [External authentication systems](#external-authentication-systems)
- [DNSBLs and other IP checking systems](#dnsbls-and-other-ip-checking-systems)
- [Acknowledgements](#acknowledgements)
@ -168,6 +171,7 @@ Rehashing also reloads TLS certificates and the MOTD. Some configuration setting
Ergo can also be configured using environment variables, using the following technique:
1. Ensure that `allow-environment-variables` is set to `true` in the YAML config file itself (see `default.yaml` for an example)
1. Find the "path" of the config variable you want to override in the YAML file, e.g., `server.websockets.allowed-origins`
1. Convert each path component from "kebab case" to "screaming snake case", e.g., `SERVER`, `WEBSOCKETS`, and `ALLOWED_ORIGINS`.
1. Prepend `ERGO` to the components, then join them all together using `__` as the separator, e.g., `ERGO__SERVER__WEBSOCKETS__ALLOWED_ORIGINS`.
@ -182,8 +186,8 @@ The recommended way to operate ergo as a service on Linux is via systemd. This p
The only major distribution that currently packages Ergo is Arch Linux; the aforementioned AUR package includes a systemd unit file. However, it should be fairly straightforward to set up a productionized Ergo on any Linux distribution. Here's a quickstart guide for Debian/Ubuntu:
1. Create a dedicated, unprivileged role user who will own the ergo process and all its associated files: `adduser --system --group ergo`. This user now has a home directory at `/home/ergo`. To prevent other users from viewing Ergo's configuration file, database, and certificates, restrict the permissions on the home directory: `chmod 0700 /home/ergo`.
1. Copy the executable binary `ergo`, the config file `ircd.yaml`, the database `ircd.db`, and the self-signed TLS certificate (`fullchain.pem` and `privkey.pem`) to `/home/ergo`. (If you don't have an `ircd.db`, it will be auto-created as `/home/ergo/ircd.db` on first launch.) Ensure that they are all owned by the new ergo role user: `sudo chown ergo:ergo /home/ergo/*`. Ensure that the configuration file logs to stderr.
1. Create a dedicated, unprivileged role user who will own the ergo process and all its associated files: `adduser --system --group --home=/home/ergo ergo`. This user now has a home directory at `/home/ergo`. To prevent other users from viewing Ergo's configuration file, database, and certificates, restrict the permissions on the home directory: `chmod 0700 /home/ergo`.
1. Copy the executable binary `ergo`, the config file `ircd.yaml`, the database `ircd.db`, and the self-signed TLS certificate (`fullchain.pem` and `privkey.pem`) to `/home/ergo`. (If you don't have an `ircd.db`, it will be auto-created as `/home/ergo/ircd.db` on first launch.) Ensure that they are all owned by the new ergo role user: `sudo chown -R ergo:ergo /home/ergo`. Ensure that the configuration file logs to stderr.
1. Install our example [ergo.service](https://github.com/ergochat/ergo/blob/stable/distrib/systemd/ergo.service) file to `/etc/systemd/system/ergo.service`.
1. Enable and start the new service with the following commands:
1. `systemctl daemon-reload`
@ -200,7 +204,7 @@ On a non-systemd system, ergo can be configured to log to a file and used [logro
The other major hurdle for productionizing (but one well worth the effort) is obtaining valid TLS certificates for your domain, if you haven't already done so:
1. The simplest way to get valid TLS certificates is from [Let's Encrypt](https://letsencrypt.org/) with [Certbot](https://certbot.eff.org/). The correct procedure will depend on whether you are already running a web server on port 80. If you are, follow the guides on the Certbot website; if you aren't, you can use `certbot certonly --standalone --preferred-challenges http -d example.com` (replace `example.com` with your domain).
1. At this point, you should have certificates available at `/etc/letsencrypt/live/example.com` (replacing `example.com` with your domain). You should serve `fullchain.pem` as the certificate and `privkey.pem` as its private key. However, these files are owned by root and the private key is not readable by the ergo role user, so you won't be able to use them directly in their current locations. You can write a post-renewal hook for certbot to make copies of these certificates accessible to the ergo role user. For example, install the following script as `/etc/letsencrypt/renewal-hooks/post/install-ergo-certificates`, again replacing `example.com` with your domain name, and chmod it 0755:
1. At this point, you should have certificates available at `/etc/letsencrypt/live/example.com` (replacing `example.com` with your domain). You should serve `fullchain.pem` as the certificate and `privkey.pem` as its private key. However, these files are owned by root and the private key is not readable by the ergo role user, so you won't be able to use them directly in their current locations. You can write a renewal deploy hook for certbot to make copies of these certificates accessible to the ergo role user. For example, install the following script as `/etc/letsencrypt/renewal-hooks/deploy/install-ergo-certificates` (which will update the certificate and key after a successful renewal), again replacing `example.com` with your domain name, and chmod it 0755:
````bash
#!/bin/bash
@ -391,7 +395,7 @@ To change to a specific language, you can use the `LANGUAGE` command like this:
The above will change the server language to Romanian, with a fallback to Chinese. English will always be the final fallback, if there's a line that is not translated. Substitute any of the other language codes in to select other languages, and run `/LANGUAGE en` to get back to standard English.
Our language and translation functionality is very early, so feel free to let us know if there are any troubles with it! If you know another language and you'd like to contribute, we've got a CrowdIn project here: [https://crowdin.com/project/oragono](https://crowdin.com/project/oragono)
Our language and translation functionality is very early, so feel free to let us know if there are any troubles with it! If you know another language and you'd like to contribute, we've got a CrowdIn project here: [https://crowdin.com/project/ergochat](https://crowdin.com/project/ergochat)
## Multiclient ("Bouncer")
@ -411,7 +415,7 @@ Ergo supports two methods of storing history, an in-memory buffer with a configu
Unfortunately, client support for history playback is still patchy. In descending order of support:
1. The [IRCv3 chathistory specification](https://ircv3.net/specs/extensions/chathistory) offers the most fine-grained control over history replay. It is supported by [Kiwi IRC](https://github.com/kiwiirc/kiwiirc), and hopefully other clients soon.
1. The [IRCv3 chathistory specification](https://ircv3.net/specs/extensions/chathistory) offers the most fine-grained control over history replay. It is supported by [Gamja](https://git.sr.ht/~emersion/gamja), [Goguma](https://sr.ht/~emersion/goguma/), and [Kiwi IRC](https://github.com/kiwiirc/kiwiirc), and hopefully other clients soon.
1. We emulate the [ZNC playback module](https://wiki.znc.in/Playback) for clients that support it. You may need to enable support for it explicitly in your client (see the "ZNC" section below).
1. If you set your client to always-on (see the previous section for details), you can set a "device ID" for each device you use. Ergo will then remember the last time your device was present on the server, and each time you sign on, it will attempt to replay exactly those messages you missed. There are a few ways to set your device ID when connecting:
- You can add it to your SASL username with an `@`, e.g., if your SASL username is `alice` you can send `alice@phone`
@ -471,15 +475,30 @@ However, Ergo's multiclient and always-on features mean that abuse prevention (a
See the `/HELP` (or `/HELPOP`) entries for these commands for more information, but here's a rough workflow for mitigating spam or other attacks:
1. Subscribe to the `a` snomask to monitor for abusive registration attempts (this is set automatically in the default operator config, but can be added manually with `/mode mynick +s u`)
2. Given abusive traffic from a nickname, use `/UBAN INFO <nickname>` to find out information about their connection
3. If they are using an account, suspend the account with `/UBAN ADD <account>`, which will disconnect them
4. If they are not using an account, or if they're spamming new registrations from an IP, you can add a temporary ban on their IP/network with `/UBAN ADD <ip | network>`
1. Given abusive traffic from a nickname, use `/UBAN INFO <nickname>` to find out information about their connection
2. If they are using an account, suspend the account with `/UBAN ADD <account>`, which will disconnect them
3. If they are not using an account, or if they're spamming new registrations from an IP, you can add a temporary ban on their IP/network with `/UBAN ADD <ip | network>`
4. Subscribe to the `a` snomask to monitor for abusive registration attempts (`/mode mynick +s u`)
5. When facing a flood of abusive registrations that cannot be stemmed with `/DLINE`, use `/DEFCON 4` to temporarily restrict registrations. (At `/DEFCON 2`, all new connections to the server will require SASL, but this will likely be disruptive to legitimate users as well.)
These techniques require operator privileges: `UBAN` requires the `ban` operator capability, subscribing to snomasks requires `snomasks`, and `DEFCON` requires `defcon`. All three of these capabilities are included by default in the `server-admin` role.
For channel operators, `/msg ChanServ HOWTOBAN #channel nickname` will provide similar information about the best way to ban a user from a channel.
## Push notifications
Ergo now has experimental support for push notifications via the [draft/webpush](https://github.com/ircv3/ircv3-specifications/pull/471) IRCv3 specification. Support for push notifications is disabled by default; operators can enable it by setting `webpush.enabled` to `true` in the configuration file. This has security, privacy, and performance implications:
* If push notifications are enabled, Ergo will send HTTP POST requests to HTTP endpoints of the user's choosing. Although the user has limited control over the POST body (since it is encrypted with random key material), and Ergo disallows requests to local or internal IP addresses, this may potentially impact the IP reputation of the Ergo host, or allow an attacker to probe endpoints that whitelist the Ergo host's IP address.
* Push notifications result in the disclosure of metadata (that the user received a message, and the approximate time of the message) to third-party messaging infrastructure. In the typical case, this will include a push endpoint controlled by the application vendor, plus the push infrastructure controlled by Apple or Google.
* The message contents (including the sender's identity) are protected by [encryption](https://datatracker.ietf.org/doc/html/rfc8291) between the server and the user's endpoint device. However, the encryption algorithm is not forward-secret (a long-term private key is stored on the user's device) or post-quantum (the server retains a copy of the corresponding elliptic curve public key).
* Push notifications are relatively expensive to process, and may increase the impact of spam or denial-of-service attacks on the Ergo server.
* Push notifications negate the anonymization provided by Tor and I2P; an Ergo instance intended to run as a Tor onion service ("hidden service") or exclusively behind an I2P address must disable them in the Ergo configuration file.
Operators and end users are invited to share feedback about push notifications, either via the project issue tracker or the support channel. Note that in order to receive push notifications, the user must be logged in with always-on enabled, and must be using a client (e.g. Goguma) that supports them.
-------------------------------------------------------------------------------------------
@ -496,9 +515,19 @@ There are two ways to make suggestions, either:
- Talk to us in the `#ergo` channel on irc.ergo.chat or irc.libera.chat.
## Why can't I connect?
If your client or bot is failing to connect to Ergo, here are some things to check:
1. If your server has a firewall, does it allow traffic to the relevant IRC port? Try `nc -v yourdomain.name 6697` from the client machine to test connectivity to the server's default TLS port.
1. Is your client trying to connect in plaintext to Ergo's TLS port, or vice versa? If your client is configured to use TLS, ensure that it is also configured to connect to Ergo's TLS port (6697 by default). If your client is configured to use plaintext, ensure that it is also configured to connect to the plaintext port (by default, 6667 and only via a loopback IP address such as `127.0.0.1`).
1. Look for error messages on the client side (you may need to enable your client's "raw log").
1. If all else fails, try turning on debug logging on the server side. Find the `logging` section of your Ergo config and change the default `level: info` to `level: debug`. Then rehash or restart Ergo.
## Why can't I oper?
If you try to oper unsuccessfully, Ergo will disconnect you from the network. If you're unable to oper, here are some things to double-check:
If your `OPER` command fails, check your server logs for more information. Here are some general issues to double-check:
1. Did you correctly generate the hashed password with `ergo genpasswd`?
1. Did you add the password hash to the correct config file, then save the file?
@ -506,6 +535,8 @@ If you try to oper unsuccessfully, Ergo will disconnect you from the network. If
The config file accepts hashed passwords, not plaintext passwords. You must run `ergo genpasswd`, type your actual password in, and then receive a hashed blob back (it will look like `$2a$04$GvCFlShLZQjId3dARzwOWu9Nvq6lndXINw2Sdm6mUcwxhtx1U/hIm`). Enter that into the relevant `opers` block in your config file, then save the file.
Although it's theoretically possible to use an operator password that contains spaces, your client may not support it correctly, so it's advisable to choose a password without spaces. (The period character `.` is an acceptable alternative separator if your password is based on randomly chosen words.)
After that, you must rehash or restart Ergo to apply the config change. If a rehash didn't accomplish the desired effects, you might want to try a restart instead.
@ -514,9 +545,10 @@ After that, you must rehash or restart Ergo to apply the config change. If a reh
The default/recommended configuration of Ergo does not query remote ident servers, and furthermore ignores any user/ident sent with the `USER` command. All user/ident fields are set to a constant `~u`. There are a few reasons for this:
1. Remote ident lookups slow down connection initiation and pose privacy and security concerns (since they transmit usernames over the Internet in plaintext).
2. Ignoring user/ident simplifies bans; in general, a channel ban in Ergo should target either the nickname or the hostname. As a channel operator, `/msg ChanServ HOWTOBAN #channel nick` will recommend a way of banning any given user.
3. Ident is commonly used to distinguish users connecting from the same trusted shell host or shared bouncer. This is less important with Ergo, which can act as a bouncer itself.
4. Because of limitations of the IRC protocol, every character of the user/ident field counts against the maximum size of a message that can be sent.
2. Ident is commonly used to distinguish users connecting from the same trusted shell host or shared bouncer. This is less important with Ergo, which can act as a bouncer itself.
3. Ignoring user/ident simplifies bans; in general, a channel ban in Ergo should target either the nickname or the hostname. As a channel operator, `/msg ChanServ HOWTOBAN #channel nick` will recommend a way of banning any given user.
4. Elaborating on this rationale somewhat: of the various pieces of information we could try to convey in the user/ident field (traditional user/ident, account name, nickname, or host/IP information), any choice would involve either ambiguity (since, e.g. account names can be present or absent) or would be redundant with information we already expose in the nickname or hostname. Coercing the field to `~u` is deterministic, unambiguous, and compatible with typical client behavior (clients should assume that any tilde-prefixed value is untrusted data and can collide arbitrarily with other values from the same hostname).
5. Because of limitations of the IRC protocol, every character of the user/ident field counts against the maximum size of a message that can be sent.
As an operator, you can modify this behavior if desired; see the `check-ident` and `coerce-ident` settings in the config file.
@ -547,15 +579,26 @@ Under these circumstances, users can follow the following steps:
1. Register a channel (`/msg ChanServ register #example`)
1. Set it to be invite-only (`/mode #example +i`)
1. Add the desired nick/account names to the invite exception list (`/mode #example +I alice`)
1. Anyone with persistent half-operator status or higher will also be able to join without an invite (`/msg ChanServ amode #example +h alice`)
1. Anyone with persistent voice or higher prefix will also be able to join without an invite (`/msg ChanServ amode #example +v alice`)
Similarly, for a public channel (one without `+i`), users can ban nick/account names with `/mode #example +b bob`. (To restrict the channel to users with valid accounts, set it to registered-only with `/mode #example +R`.)
## What special privileges do AMODEs contain?
Some persistent modes contain persistent privileges over temporary modes. These are cumulative, meaning that +o will get privileges of +h which again gets privileges of +v.
* AMODE +v will be able to join when the channel is `invite-only` (without being on the +I list).
* AMODE +h will be able to join even if a ban matches them (without being on the +e list).
## How do I send an announcement to all connected users?
Ergo supports a simplified form of the "global notice" or "wallops" capabilities found in other ircds. With the `massmessage` operator capability, you can `/NOTICE $$* text of your announcement`, and it will be sent to all connected users. If you have human-readable hostnames enabled (in the default/recommended configuration they are not), you can also `/NOTICE $#wild*card.host.name`.
## Why does Ergo say my connection is insecure when I'm connected using TLS?
If the client you are connecting with uses the [WebIRC](https://ircv3.net/specs/extensions/webirc.html) command then it is responsible for saying whether the connection is "secure" or not, even if the connection to ergo is made over TLS. For example, a web-based client would mark connections as secure if you used HTTPS but not if you used plain HTTP. Older versions of the WebIRC specification didn't include the secure parameter at all; any connections from software using the older protocol will therefore be treated as insecure by Ergo.
If you are using a reverse proxy (such as stunnel, nginx, Traefik, or Caddy) to terminate TLS, but the connection between the reverse proxy and Ergo is using a non-loopback IP (i.e. outside the `127.0.0.0/8` or `0::1/128` ranges), then Ergo will view the connection as being "insecure". If the network is in fact secure against passive monitoring and active manipulation (e.g. a trusted LAN, a VPN link, or a Docker internal IP), you can add it to `server.secure-nets`, which will cause the connection to be treated as "secure".
-------------------------------------------------------------------------------------------
@ -597,6 +640,8 @@ Many clients do not have this support. However, you can designate port 6667 as a
Ergo supports the use of reverse proxies (such as nginx, or a Kubernetes [LoadBalancer](https://kubernetes.io/docs/concepts/services-networking/service/#loadbalancer)) that sit between it and the client. In these deployments, the [PROXY protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt) is used to pass the end user's IP through to Ergo. These proxies can be used to terminate TLS externally to Ergo, e.g., if you need to support versions of the TLS protocol that are not implemented natively by Go, or if you want to consolidate your certificate management into a single nginx instance.
### IRC Sockets
The first step is to add the reverse proxy's IP to `proxy-allowed-from` and `ip-limits.exempted`. (Use `localhost` to exempt all loopback IPs and Unix domain sockets.)
After that, there are two possibilities:
@ -612,6 +657,10 @@ After that, there are two possibilities:
proxy: true
```
### Websockets through HTTP reverse proxies
Ergo will honor the `X-Forwarded-For` headers on incoming websocket connections, if the peer IP address appears in `proxy-allowed-from`. For these connections, set `proxy: false`, or omit the `proxy` option.
## Client certificates
@ -757,13 +806,13 @@ This means that users who attempt to join `#test`, but cannot due to another cha
### +i - Invite-Only
If this channel mode is set on a channel, users will only be able to join if someone has `/INVITE`'d them first.
If this channel mode is set on a channel, users will only be able to join if a channel operator has `/INVITE`'d them first.
To set a channel to invite-only:
/MODE #test +i
To unset the mode and let anyone join:
To unset the mode and let anyone join (and invite):
/MODE #test -i
@ -900,7 +949,7 @@ Admins have the same moderation privileges as channel operators (see below), but
This prefix means that the given user is an operator on the channel (chanop, for short). For example, if `@ruby` is on a channel, then **ruby** is an op.
Chanops are the default type of channel moderators. They can change the channel modes, ban/kick users, and add or remove chanop (or lower) privileges from users.
Chanops are the default type of channel moderators. They can change the channel modes, ban/kick users, and add or remove chanop (or lower) privileges from users. They can also invite users to invite-only channels.
### +h (%) - Halfop
@ -991,6 +1040,24 @@ or with Gamja, create a new `config.json` (in the base directory of the Gamja in
}
```
On Apache 2.4.47 or higher, websocket proxying can be configured with:
```
RequestHeader setifempty X-Forwarded-Proto https
ProxyPreserveHost On
ProxyPass /webirc http://127.0.0.1:8067 upgrade=websocket
ProxyPassReverse /webirc http://127.0.0.1:8067
```
On Caddy, websocket proxying can be configured with:
```
handle_path /webirc {
reverse_proxy 127.0.0.1:8067
}
```
## Migrating from Anope or Atheme
You can import user and channel registrations from an Anope or Atheme database into a new Ergo database (not all features are supported). Use the following steps:
@ -1070,6 +1137,7 @@ Tor provides end-to-end encryption for onion services, so there's no need to ena
The second way is to run Ergo as a true hidden service, where the server's actual IP address is a secret. This requires hardening measures on the Ergo side:
* Ergo should not accept any connections on its public interfaces. You should remove any listener that starts with the address of a public interface, or with `:`, which means "listen on all available interfaces". You should listen only on `127.0.0.1:6667` and a Unix domain socket such as `/hidden_service_sockets/ergo_tor_sock`.
* Push notifications will reveal the server's true IP address, so they must be disabled; set `webpush.enabled` to `false`.
* In this mode, it is especially important that all operator passwords are strong and all operators are trusted (operators have a larger attack surface to deanonymize the server).
* Onion services are at risk of being deanonymized if a client can trick the server into performing a non-Tor network request. Ergo should not perform any such requests (such as hostname resolution or ident lookups) in response to input received over a correctly configured Tor listener. However, Ergo has not been thoroughly audited against such deanonymization attacks --- therefore, Ergo should be deployed with additional sandboxing to protect against this:
* Ergo should run with no direct network connectivity, e.g., by running in its own Linux network namespace. systemd implements this with the [PrivateNetwork](https://www.freedesktop.org/software/systemd/man/systemd.exec.html) configuration option: add `PrivateNetwork=true` to Ergo's systemd unit file.
@ -1092,6 +1160,16 @@ Instructions on how client software should connect to an .onion address are outs
1. [Hexchat](https://hexchat.github.io/) is known to support .onion addresses, once it has been configured to use a local Tor daemon as a SOCKS proxy (Settings -> Preferences -> Network Setup -> Proxy Server).
1. Pidgin should work with [torsocks](https://trac.torproject.org/projects/tor/wiki/doc/torsocks).
## I2P
I2P is an anonymizing overlay network similar to Tor. The recommended configuration for I2P is to treat it similarly to Tor: have the i2pd reverse proxy its connections to an Ergo listener configured with `tor: true`. See the [i2pd configuration guide](https://i2pd.readthedocs.io/en/latest/tutorials/irc/#running-anonymous-irc-server) for more details; note that the instructions to separate I2P traffic from other localhost traffic are unnecessary for a `tor: true` listener.
I2P can additionally expose an opaque client identifier (the user's "b32 address"). Exposing this identifier via Ergo is not recommended, but if you wish to do so, you can use the following procedure:
1. Enable WEBIRC support in the i2pd configuration by adding the `webircpassword` key to the [i2pd server block](https://i2pd.readthedocs.io/en/latest/tutorials/irc/#running-anonymous-irc-server)
1. Remove `tor: true` from the relevant Ergo listener config
1. Enable WEBIRC support in Ergo (starting from the default/recommended configuration, find the existing webirc block, delete the `certfp` configuration, change `password` to use the output of `ergo genpasswd` on the password you configured i2pd to send, and set `accept-hostname: true`)
1. To prevent Ergo from overwriting the hostname as passed from i2pd, set the following options: `server.ip-cloaking.enabled: false` and `server.lookup-hostnames: false`. (There is currently no support for applying cloaks to regular IP traffic but displaying the b32 address for I2P traffic).
## ZNC
@ -1099,6 +1177,10 @@ ZNC 1.6.x (still pretty common in distros that package old versions of IRC softw
Ergo can emulate certain capabilities of the ZNC bouncer for the benefit of clients, in particular the third-party [playback](https://wiki.znc.in/Playback) module. This enables clients with specific support for ZNC to receive selective history playback automatically. To configure this in [Textual](https://www.codeux.com/textual/), go to "Server properties", select "Vendor specific", uncheck "Do not automatically join channels on connect", and check "Only play back messages you missed". Other clients with support are listed on ZNC's wiki page.
## API
Ergo offers an HTTP API that can be used to control Ergo, or to allow other applications to use Ergo as a source of truth for authentication. The API is documented separately; see [API.md](https://github.com/ergochat/ergo/blob/stable/docs/API.md) on the website, or the `API.md` file that was bundled with your release.
## External authentication systems
Ergo can be configured to call arbitrary scripts to authenticate users; see the `auth-script` section of the config. The API for these scripts is as follows: Ergo will invoke the script with a configurable set of arguments, then send it the authentication data as JSON on the first line (`\n`-terminated) of stdin. The input is a JSON dictionary with the following keys:
@ -1117,7 +1199,7 @@ The script must print a single line (`\n`-terminated) to its output and exit. Th
Here is a toy example of an authentication script in Python that checks that the account name and the password are equal (and rejects any attempts to authenticate via certfp):
```
```python3
#!/usr/bin/python3
import sys, json

View File

@ -4,7 +4,7 @@
/_ // __/ /___/ _, _/ /_/ / /_/ /
/_//_/ /_____/_/ |_|\____/\____/
Ergo IRCd Manual
Ergo IRCd User Guide
https://ergo.chat/
_Copyright © Daniel Oaks <daniel@danieloaks.net>, Shivaram Lingamneni <slingamn@cs.stanford.edu>_
@ -23,6 +23,7 @@ _Copyright © Daniel Oaks <daniel@danieloaks.net>, Shivaram Lingamneni <slingamn
- [Always-on](#always-on)
- [Multiclient](#multiclient)
- [History](#history)
- [Push notifications](#push-notifications)
--------------------------------------------------------------------------------------------
@ -85,7 +86,7 @@ Once you've registered your nickname, you can use it to register channels. By de
/msg ChanServ register #myChannel
```
You must already be an operator (have the `+o` channel mode --- your client may display this as an `@` next to your nickname). If you're not a channel operator in the channel you want to register, ask your server administrator for help.
The channel must exist (if it doesn't, you can create it with `/join #myChannel`) and you must already be an operator (have the `+o` channel mode --- your client may display this as an `@` next to your nickname). If you're not a channel operator in the channel you want to register, ask your server administrator for help.
# Always-on
@ -103,7 +104,7 @@ Ergo natively supports attaching multiple clients to the same nickname (this nor
Ergo stores message history on the server side (typically not an unlimited amount --- consult your server's FAQ, or your server administrator, to find out how much is being stored and how long it's being retained).
1. The [IRCv3 chathistory specification](https://ircv3.net/specs/extensions/chathistory) offers the most fine-grained control over history replay. It is supported by [Gamja](https://git.sr.ht/~emersion/gamja) and [Kiwi IRC](https://github.com/kiwiirc/kiwiirc), and hopefully other clients soon.
1. The [IRCv3 chathistory specification](https://ircv3.net/specs/extensions/chathistory) offers the most fine-grained control over history replay. It is supported by [Gamja](https://git.sr.ht/~emersion/gamja), [Goguma](https://sr.ht/~emersion/goguma/), and [Kiwi IRC](https://github.com/kiwiirc/kiwiirc), and hopefully other clients soon.
1. We emulate the [ZNC playback module](https://wiki.znc.in/Playback) for clients that support it. You may need to enable support for it explicitly in your client. For example, in [Textual](https://www.codeux.com/textual/), go to "Server properties", select "Vendor specific", uncheck "Do not automatically join channels on connect", and check "Only play back messages you missed". ZNC's wiki page covers other common clients (although if the feature is only supported via a script or third-party extension, the following option may be easier).
1. If you set your client to always-on (see the previous section for details), you can set a "device ID" for each device you use. Ergo will then remember the last time your device was present on the server, and each time you sign on, it will attempt to replay exactly those messages you missed. There are a few ways to set your device ID when connecting:
- You can add it to your SASL username with an `@`, e.g., if your SASL username is `alice` you can send `alice@phone`
@ -119,5 +120,9 @@ If you have registered a channel, you can make it private. The best way to do th
1. Set your channel to be invite-only (`/mode #example +i`)
1. Identify the users you want to be able to access the channel. Ensure that they have registered their accounts (you should be able to see their registration status if you `/WHOIS` their nicknames).
1. Add the desired nick/account names to the invite exception list (`/mode #example +I alice`)
1. Add the desired nick/account names to the invite exception list (`/mode #example +I alice`) or give them persistent voice (`/msg ChanServ AMODE #example +v alice`)
1. If you want to grant a persistent channel privilege to a user, you can do it with `CS AMODE` (`/msg ChanServ AMODE #example +o bob`)
# Push notifications
Ergo has experimental support for mobile push notifications. The server operator must enable this functionality; to check whether this is the case, you can send `/msg NickServ push list`. You must additionally be using a client (e.g. Goguma) that supports the functionality, and your account must be set to always-on (`/msg NickServ set always-on true`, as described above).

49
ergo.go
View File

@ -7,6 +7,7 @@ package main
import (
"bufio"
_ "embed"
"fmt"
"log"
"os"
@ -14,31 +15,29 @@ import (
"syscall"
"golang.org/x/crypto/bcrypt"
"golang.org/x/crypto/ssh/terminal"
"golang.org/x/term"
"github.com/docopt/docopt-go"
"github.com/ergochat/ergo/irc"
"github.com/ergochat/ergo/irc/logger"
"github.com/ergochat/ergo/irc/mkcerts"
"github.com/ergochat/ergo/irc/utils"
)
// set via linker flags, either by make or by goreleaser:
var commit = "" // git hash
var version = "" // tagged version
//go:embed default.yaml
var defaultConfig string
// get a password from stdin from the user
func getPassword() string {
fd := int(os.Stdin.Fd())
if terminal.IsTerminal(fd) {
bytePassword, err := terminal.ReadPassword(int(syscall.Stdin))
if err != nil {
log.Fatal("Error reading password:", err.Error())
}
return string(bytePassword)
func getPasswordFromTerminal() string {
bytePassword, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
log.Fatal("Error reading password:", err.Error())
}
reader := bufio.NewReader(os.Stdin)
text, _ := reader.ReadString('\n')
return strings.TrimSpace(text)
return string(bytePassword)
}
func fileDoesNotExist(file string) bool {
@ -100,6 +99,8 @@ Usage:
ergo importdb <database.json> [--conf <filename>] [--quiet]
ergo genpasswd [--conf <filename>] [--quiet]
ergo mkcerts [--conf <filename>] [--quiet]
ergo defaultconfig
ergo gentoken
ergo run [--conf <filename>] [--quiet] [--smoke]
ergo -h | --help
ergo --version
@ -114,19 +115,20 @@ Options:
// don't require a config file for genpasswd
if arguments["genpasswd"].(bool) {
var password string
fd := int(os.Stdin.Fd())
if terminal.IsTerminal(fd) {
if term.IsTerminal(int(syscall.Stdin)) {
fmt.Print("Enter Password: ")
password = getPassword()
password = getPasswordFromTerminal()
fmt.Print("\n")
fmt.Print("Reenter Password: ")
confirm := getPassword()
confirm := getPasswordFromTerminal()
fmt.Print("\n")
if confirm != password {
log.Fatal("passwords do not match")
}
} else {
password = getPassword()
reader := bufio.NewReader(os.Stdin)
text, _ := reader.ReadString('\n')
password = strings.TrimSpace(text)
}
if err := irc.ValidatePassphrase(password); err != nil {
log.Printf("WARNING: this password contains characters that may cause problems with your IRC client software.\n")
@ -136,10 +138,13 @@ Options:
if err != nil {
log.Fatal("encoding error:", err.Error())
}
fmt.Print(string(hash))
if terminal.IsTerminal(fd) {
fmt.Println()
}
fmt.Println(string(hash))
return
} else if arguments["defaultconfig"].(bool) {
fmt.Print(defaultConfig)
return
} else if arguments["gentoken"].(bool) {
fmt.Println(utils.GenerateSecretKey())
return
} else if arguments["mkcerts"].(bool) {
doMkcerts(arguments["--conf"].(string), arguments["--quiet"].(bool))
@ -188,7 +193,7 @@ Options:
// warning if running a non-final version
if strings.Contains(irc.Ver, "unreleased") {
logman.Warning("server", "You are currently running an unreleased beta version of Ergo that may be unstable and could corrupt your database.\nIf you are running a production network, please download the latest build from https://ergo.chat/downloads.html and run that instead.")
logman.Warning("server", "You are currently running an unreleased beta version of Ergo that may be unstable and could corrupt your database.\nIf you are running a production network, please download the latest build from https://ergo.chat/about and run that instead.")
}
server, err := irc.NewServer(config, logman)

View File

@ -63,6 +63,12 @@ CAPDEFS = [
url="https://ircv3.net/specs/extensions/extended-join-3.1.html",
standard="IRCv3",
),
CapDef(
identifier="ExtendedMonitor",
name="extended-monitor",
url="https://ircv3.net/specs/extensions/extended-monitor.html",
standard="IRCv3",
),
CapDef(
identifier="InviteNotify",
name="invite-notify",
@ -81,6 +87,12 @@ CAPDEFS = [
url="https://gist.github.com/DanielOaks/8126122f74b26012a3de37db80e4e0c6",
standard="proposed IRCv3",
),
CapDef(
identifier="MessageRedaction",
name="draft/message-redaction",
url="https://github.com/progval/ircv3-specifications/blob/redaction/extensions/message-redaction.md",
standard="proposed IRCv3",
),
CapDef(
identifier="MessageTags",
name="message-tags",
@ -178,11 +190,60 @@ CAPDEFS = [
standard="draft IRCv3",
),
CapDef(
identifier="ExtendedMonitor",
name="draft/extended-monitor",
url="https://github.com/ircv3/ircv3-specifications/pull/466",
identifier="ReadMarker",
name="draft/read-marker",
url="https://github.com/ircv3/ircv3-specifications/pull/489",
standard="draft IRCv3",
),
CapDef(
identifier="Persistence",
name="draft/persistence",
url="https://github.com/ircv3/ircv3-specifications/pull/503",
standard="proposed IRCv3",
),
CapDef(
identifier="Preaway",
name="draft/pre-away",
url="https://github.com/ircv3/ircv3-specifications/pull/514",
standard="proposed IRCv3",
),
CapDef(
identifier="StandardReplies",
name="standard-replies",
url="https://github.com/ircv3/ircv3-specifications/pull/506",
standard="IRCv3",
),
CapDef(
identifier="NoImplicitNames",
name="draft/no-implicit-names",
url="https://github.com/ircv3/ircv3-specifications/pull/527",
standard="proposed IRCv3",
),
CapDef(
identifier="ExtendedISupport",
name="draft/extended-isupport",
url="https://github.com/ircv3/ircv3-specifications/pull/543",
standard="proposed IRCv3",
),
CapDef(
identifier="WebPush",
name="draft/webpush",
url="https://github.com/ircv3/ircv3-specifications/pull/471",
standard="proposed IRCv3",
),
CapDef(
identifier="SojuWebPush",
name="soju.im/webpush",
url="https://github.com/ircv3/ircv3-specifications/pull/471",
standard="Soju/Goguma vendor",
),
CapDef(
identifier="Metadata",
name="draft/metadata-2",
url="https://ircv3.net/specs/extensions/metadata",
standard="draft IRCv3",
),
]
def validate_defs():
@ -218,7 +279,7 @@ package caps
const (
// number of recognized capabilities:
numCapabs = %d
// length of the uint64 array that represents the bitset:
// length of the uint32 array that represents the bitset:
bitsetLen = %d
)
""" % (numCapabs, bitsetLen), file=output)

34
go.mod
View File

@ -1,43 +1,45 @@
module github.com/ergochat/ergo
go 1.17
go 1.25
require (
code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815
github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1
github.com/ergochat/go-ident v0.0.0-20200511222032-830550b1d775
github.com/ergochat/irc-go v0.0.0-20210617222258-256f1601d3ce
github.com/go-sql-driver/mysql v1.6.0
github.com/go-test/deep v1.0.6 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881
github.com/ergochat/irc-go v0.5.0-rc2
github.com/go-sql-driver/mysql v1.7.0
github.com/gofrs/flock v0.8.1
github.com/gorilla/websocket v1.4.2
github.com/okzk/sdnotify v0.0.0-20180710141335-d9becc38acbd
github.com/onsi/ginkgo v1.12.0 // indirect
github.com/onsi/gomega v1.9.0 // indirect
github.com/stretchr/testify v1.4.0 // indirect
github.com/tidwall/buntdb v1.2.7
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208
github.com/tidwall/buntdb v1.3.2
github.com/xdg-go/scram v1.0.2
golang.org/x/crypto v0.0.0-20211115234514-b4de73f9ece8
golang.org/x/text v0.3.7
golang.org/x/crypto v0.38.0
golang.org/x/term v0.32.0
golang.org/x/text v0.25.0
gopkg.in/yaml.v2 v2.4.0
)
require github.com/gofrs/flock v0.8.1
require (
github.com/emersion/go-msgauth v0.7.0
github.com/ergochat/webpush-go/v2 v2.0.0
github.com/golang-jwt/jwt/v5 v5.2.2
)
require (
github.com/tidwall/btree v0.6.1 // indirect
github.com/tidwall/gjson v1.10.2 // indirect
github.com/tidwall/grect v0.1.3 // indirect
github.com/tidwall/btree v1.4.2 // indirect
github.com/tidwall/gjson v1.14.3 // indirect
github.com/tidwall/grect v0.1.4 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/rtred v0.1.2 // indirect
github.com/tidwall/tinyqueue v0.1.1 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 // indirect
golang.org/x/sys v0.33.0 // indirect
)
replace github.com/gorilla/websocket => github.com/ergochat/websocket v1.4.2-oragono1

73
go.sum
View File

@ -6,25 +6,29 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/emersion/go-msgauth v0.6.8 h1:kW/0E9E8Zx5CdKsERC/WnAvnXvX7q9wTHia1OA4944A=
github.com/emersion/go-msgauth v0.6.8/go.mod h1:YDwuyTCUHu9xxmAeVj0eW4INnwB6NNZoPdLerpSxRrc=
github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc=
github.com/emersion/go-msgauth v0.7.0/go.mod h1:mmS9I6HkSovrNgq0HNXTeu8l3sRAAuQ9RMvbM4KU7Ck=
github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1 h1:WLHTOodthVyv5NvYLIvWl112kSFv5IInKKrRN2qpons=
github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1/go.mod h1:mov+uh1DPWsltdQnOdzn08UO9GsJ3MEvhtu0Ci37fdk=
github.com/ergochat/go-ident v0.0.0-20200511222032-830550b1d775 h1:QSJIdpr3HOzJDPwxT7hp7WbjoZcS+5GqVvsBscqChk0=
github.com/ergochat/go-ident v0.0.0-20200511222032-830550b1d775/go.mod h1:d2qvgjD0TvGNSvUs+mZgX090RiJlrzUYW6vtANGOy3A=
github.com/ergochat/irc-go v0.0.0-20210617222258-256f1601d3ce h1:RfyjeynouKZjmnN8WGzCSrtuHGZ9dwfSYBq405FPoqs=
github.com/ergochat/irc-go v0.0.0-20210617222258-256f1601d3ce/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881 h1:+J5m88nvybxB5AnBVGzTXM/yHVytt48rXBGcJGzSbms=
github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881/go.mod h1:ASYJtQujNitna6cVHsNQTGrfWvMPJ5Sa2lZlmsH65uM=
github.com/ergochat/irc-go v0.5.0-rc2 h1:VuSQJF5K4hWvYSzGa4b8vgL6kzw8HF6LSOejE+RWpAo=
github.com/ergochat/irc-go v0.5.0-rc2/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
github.com/ergochat/scram v1.0.2-ergo1 h1:2bYXiRFQH636pT0msOG39fmEYl4Eq+OuutcyDsCix/g=
github.com/ergochat/scram v1.0.2-ergo1/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
github.com/ergochat/webpush-go/v2 v2.0.0 h1:n6eoJk8RpzJFeBJ6gxvqo/dngnVEmJbzJwzKtCZbByo=
github.com/ergochat/webpush-go/v2 v2.0.0/go.mod h1:OQlhnq8JeHDzRzAy6bdDObr19uqbHliOV+z7mHbYr4c=
github.com/ergochat/websocket v1.4.2-oragono1 h1:plMUunFBM6UoSCIYCKKclTdy/TkkHfUslhOfJQzfueM=
github.com/ergochat/websocket v1.4.2-oragono1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-test/deep v1.0.6 h1:UHSEyLZUwX9Qoi99vVwvewiMC8mM2bf7XEM2nqvzEn8=
github.com/go-test/deep v1.0.6/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
@ -43,14 +47,15 @@ github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJy
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/tidwall/assert v0.1.0 h1:aWcKyRBUAdLoVebxo95N7+YZVTFF/ASTr7BN4sLP6XI=
github.com/tidwall/assert v0.1.0/go.mod h1:QLYtGyeqse53vuELQheYl9dngGCJQ+mTtlxcktb+Kj8=
github.com/tidwall/btree v0.6.1 h1:75VVgBeviiDO+3g4U+7+BaNBNhNINxB0ULPT3fs9pMY=
github.com/tidwall/btree v0.6.1/go.mod h1:TzIRzen6yHbibdSfK6t8QimqbUnoxUSrZfeW7Uob0q4=
github.com/tidwall/buntdb v1.2.7 h1:SIyObKAymzLyGhDeIhVk2Yc1/EwfCC75Uyu77CHlVoA=
github.com/tidwall/buntdb v1.2.7/go.mod h1:b6KvZM27x/8JLI5hgRhRu60pa3q0Tz9c50TyD46OHUM=
github.com/tidwall/gjson v1.10.2 h1:APbLGOM0rrEkd8WBw9C24nllro4ajFuJu0Sc9hRz8Bo=
github.com/tidwall/gjson v1.10.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/grect v0.1.3 h1:z9YwQAMUxVSBde3b7Sl8Da37rffgNfZ6Fq6h9t6KdXE=
github.com/tidwall/grect v0.1.3/go.mod h1:8GMjwh3gPZVpLBI/jDz9uslCe0dpxRpWDdtN0lWAS/E=
github.com/tidwall/btree v1.4.2 h1:PpkaieETJMUxYNADsjgtNRcERX7mGc/GP2zp/r5FM3g=
github.com/tidwall/btree v1.4.2/go.mod h1:LGm8L/DZjPLmeWGjv5kFrY8dL4uVhMmzmmLYmsObdKE=
github.com/tidwall/buntdb v1.3.2 h1:qd+IpdEGs0pZci37G4jF51+fSKlkuUTMXuHhXL1AkKg=
github.com/tidwall/buntdb v1.3.2/go.mod h1:lZZrZUWzlyDJKlLQ6DKAy53LnG7m5kHyrEHvvcDmBpU=
github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw=
github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/grect v0.1.4 h1:dA3oIgNgWdSspFzn1kS4S/RDpZFLrIxAZOdJKjYapOg=
github.com/tidwall/grect v0.1.4/go.mod h1:9FBsaYRaR0Tcy4UwefBX/UDcDcDy9V5jUcxHzv2jd5Q=
github.com/tidwall/lotsa v1.0.2 h1:dNVBH5MErdaQ/xd9s769R31/n2dXavsQ0Yf4TMEHHw8=
github.com/tidwall/lotsa v1.0.2/go.mod h1:X6NiU+4yHA3fE3Puvpnn1XMDrFZrE9JO2/w+UMuqgR8=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@ -61,30 +66,34 @@ github.com/tidwall/rtred v0.1.2 h1:exmoQtOLvDoO8ud++6LwVsAMTu0KPzLTUrMln8u1yu8=
github.com/tidwall/rtred v0.1.2/go.mod h1:hd69WNXQ5RP9vHd7dqekAz+RIdtfBogmglkZSRxCHFQ=
github.com/tidwall/tinyqueue v0.1.1 h1:SpNEvEggbpyN5DIReaJ2/1ndroY8iyEGxPYxoSaymYE=
github.com/tidwall/tinyqueue v0.1.1/go.mod h1:O/QNHwrnjqr6IHItYrzoHAKYhBkLI67Q096fQP5zMYw=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/stringprep v1.0.2 h1:6iq84/ryjjeRmMJwxutI51F2GIPlP5BfTvXHeYjyhBc=
github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM=
golang.org/x/crypto v0.0.0-20211115234514-b4de73f9ece8 h1:5QRxNnVsaJP6NAse0UdkRgL3zHMvCRRkrDVLNdNpdy4=
golang.org/x/crypto v0.0.0-20211115234514-b4de73f9ece8/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

76
irc/accept.go Normal file
View File

@ -0,0 +1,76 @@
package irc
import (
"sync"
"github.com/ergochat/ergo/irc/utils"
)
// tracks ACCEPT relationships, i.e., `accepter` is willing to receive DMs from
// `accepted` despite some restriction (currently the only relevant restriction
// is that `accepter` is +R and `accepted` is not logged in)
type AcceptManager struct {
sync.RWMutex
// maps recipient -> whitelist of permitted senders:
// this is what we actually check
clientToAccepted map[*Client]utils.HashSet[*Client]
// this is the reverse mapping, it's needed so we can
// clean up the forward mapping during (*Client).destroy():
clientToAccepters map[*Client]utils.HashSet[*Client]
}
func (am *AcceptManager) Initialize() {
am.clientToAccepted = make(map[*Client]utils.HashSet[*Client])
am.clientToAccepters = make(map[*Client]utils.HashSet[*Client])
}
func (am *AcceptManager) MaySendTo(sender, recipient *Client) (result bool) {
am.RLock()
defer am.RUnlock()
return am.clientToAccepted[recipient].Has(sender)
}
func (am *AcceptManager) Accept(accepter, accepted *Client) {
am.Lock()
defer am.Unlock()
var m utils.HashSet[*Client]
m = am.clientToAccepted[accepter]
if m == nil {
m = make(utils.HashSet[*Client])
am.clientToAccepted[accepter] = m
}
m.Add(accepted)
m = am.clientToAccepters[accepted]
if m == nil {
m = make(utils.HashSet[*Client])
am.clientToAccepters[accepted] = m
}
m.Add(accepter)
}
func (am *AcceptManager) Unaccept(accepter, accepted *Client) {
am.Lock()
defer am.Unlock()
delete(am.clientToAccepted[accepter], accepted)
delete(am.clientToAccepters[accepted], accepter)
}
func (am *AcceptManager) Remove(client *Client) {
am.Lock()
defer am.Unlock()
for accepter := range am.clientToAccepters[client] {
delete(am.clientToAccepted[accepter], client)
}
for accepted := range am.clientToAccepted[client] {
delete(am.clientToAccepters[accepted], client)
}
delete(am.clientToAccepters, client)
delete(am.clientToAccepted, client)
}

108
irc/accept_test.go Normal file
View File

@ -0,0 +1,108 @@
package irc
import (
"testing"
)
func TestAccept(t *testing.T) {
var am AcceptManager
am.Initialize()
alice := new(Client)
bob := new(Client)
eve := new(Client)
// must not panic:
am.Unaccept(eve, bob)
assertEqual(am.MaySendTo(alice, bob), false)
assertEqual(am.MaySendTo(bob, alice), false)
assertEqual(am.MaySendTo(alice, eve), false)
assertEqual(am.MaySendTo(eve, alice), false)
assertEqual(am.MaySendTo(bob, eve), false)
assertEqual(am.MaySendTo(eve, bob), false)
am.Accept(alice, bob)
assertEqual(am.MaySendTo(alice, bob), false)
assertEqual(am.MaySendTo(bob, alice), true)
assertEqual(am.MaySendTo(alice, eve), false)
assertEqual(am.MaySendTo(eve, alice), false)
assertEqual(am.MaySendTo(bob, eve), false)
assertEqual(am.MaySendTo(eve, bob), false)
am.Accept(bob, alice)
assertEqual(am.MaySendTo(alice, bob), true)
assertEqual(am.MaySendTo(bob, alice), true)
assertEqual(am.MaySendTo(alice, eve), false)
assertEqual(am.MaySendTo(eve, alice), false)
assertEqual(am.MaySendTo(bob, eve), false)
assertEqual(am.MaySendTo(eve, bob), false)
am.Accept(bob, eve)
assertEqual(am.MaySendTo(alice, bob), true)
assertEqual(am.MaySendTo(bob, alice), true)
assertEqual(am.MaySendTo(alice, eve), false)
assertEqual(am.MaySendTo(eve, alice), false)
assertEqual(am.MaySendTo(bob, eve), false)
assertEqual(am.MaySendTo(eve, bob), true)
am.Accept(eve, bob)
assertEqual(am.MaySendTo(alice, bob), true)
assertEqual(am.MaySendTo(bob, alice), true)
assertEqual(am.MaySendTo(alice, eve), false)
assertEqual(am.MaySendTo(eve, alice), false)
assertEqual(am.MaySendTo(bob, eve), true)
assertEqual(am.MaySendTo(eve, bob), true)
am.Unaccept(eve, bob)
assertEqual(am.MaySendTo(alice, bob), true)
assertEqual(am.MaySendTo(bob, alice), true)
assertEqual(am.MaySendTo(alice, eve), false)
assertEqual(am.MaySendTo(eve, alice), false)
assertEqual(am.MaySendTo(bob, eve), false)
assertEqual(am.MaySendTo(eve, bob), true)
am.Remove(alice)
assertEqual(am.MaySendTo(alice, bob), false)
assertEqual(am.MaySendTo(bob, alice), false)
assertEqual(am.MaySendTo(alice, eve), false)
assertEqual(am.MaySendTo(eve, alice), false)
assertEqual(am.MaySendTo(bob, eve), false)
assertEqual(am.MaySendTo(eve, bob), true)
am.Remove(bob)
assertEqual(am.MaySendTo(alice, bob), false)
assertEqual(am.MaySendTo(bob, alice), false)
assertEqual(am.MaySendTo(alice, eve), false)
assertEqual(am.MaySendTo(eve, alice), false)
assertEqual(am.MaySendTo(bob, eve), false)
assertEqual(am.MaySendTo(eve, bob), false)
}
func TestAcceptInternal(t *testing.T) {
var am AcceptManager
am.Initialize()
alice := new(Client)
bob := new(Client)
eve := new(Client)
am.Accept(alice, bob)
am.Accept(bob, alice)
am.Accept(bob, eve)
am.Remove(alice)
am.Remove(bob)
// assert that there is no memory leak
for _, client := range []*Client{alice, bob, eve} {
assertEqual(len(am.clientToAccepted[client]), 0)
assertEqual(len(am.clientToAccepters[client]), 0)
}
}

View File

@ -4,6 +4,7 @@
package irc
import (
"context"
"crypto/rand"
"crypto/x509"
"encoding/json"
@ -23,6 +24,7 @@ import (
"github.com/ergochat/ergo/irc/email"
"github.com/ergochat/ergo/irc/migrations"
"github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/oauth2"
"github.com/ergochat/ergo/irc/passwd"
"github.com/ergochat/ergo/irc/utils"
)
@ -39,8 +41,8 @@ const (
keyAccountSettings = "account.settings %s"
keyAccountVHost = "account.vhost %s"
keyCertToAccount = "account.creds.certfp %s"
keyAccountChannels = "account.channels %s" // channels registered to the account
keyAccountLastSeen = "account.lastseen %s"
keyAccountReadMarkers = "account.readmarkers %s"
keyAccountModes = "account.modes %s" // user modes for the always-on client as a string
keyAccountRealname = "account.realname %s" // client realname stored as string
keyAccountSuspended = "account.suspended %s" // client realname stored as string
@ -48,7 +50,9 @@ const (
keyAccountEmailChange = "account.emailchange %s"
// for an always-on client, a map of channel names they're in to their current modes
// (not to be confused with their amodes, which a non-always-on client can have):
keyAccountChannelToModes = "account.channeltomodes %s"
keyAccountChannelToModes = "account.channeltomodes %s"
keyAccountPushSubscriptions = "account.pushsubscriptions %s"
keyAccountMetadata = "account.metadata %s"
maxCertfpsPerAccount = 5
)
@ -129,9 +133,12 @@ func (am *AccountManager) createAlwaysOnClients(config *Config) {
am.server.AddAlwaysOnClient(
account,
am.loadChannels(accountName),
am.loadLastSeen(accountName),
am.loadTimeMap(keyAccountLastSeen, accountName),
am.loadTimeMap(keyAccountReadMarkers, accountName),
am.loadModes(accountName),
am.loadRealname(accountName),
am.loadPushSubscriptions(accountName),
am.loadMetadata(accountName),
)
}
}
@ -356,7 +363,7 @@ func (am *AccountManager) Register(client *Client, account string, callbackNames
config := am.server.Config()
// final "is registration allowed" check:
if !(config.Accounts.Registration.Enabled || callbackNamespace == "admin") || am.server.Defcon() <= 4 {
if callbackNamespace != "admin" && (!config.Accounts.Registration.Enabled || am.server.Defcon() <= 4) {
return errFeatureDisabled
}
@ -432,7 +439,7 @@ func (am *AccountManager) Register(client *Client, account string, callbackNames
// can't register an account with the same name as a registered nick
if am.NickToAccount(account) != "" {
return errAccountAlreadyRegistered
return errNameReserved
}
return am.server.store.Update(func(tx *buntdb.Tx) error {
@ -647,9 +654,18 @@ func (am *AccountManager) loadModes(account string) (uModes modes.Modes) {
func (am *AccountManager) saveLastSeen(account string, lastSeen map[string]time.Time) {
key := fmt.Sprintf(keyAccountLastSeen, account)
am.saveTimeMap(account, key, lastSeen)
}
func (am *AccountManager) saveReadMarkers(account string, readMarkers map[string]time.Time) {
key := fmt.Sprintf(keyAccountReadMarkers, account)
am.saveTimeMap(account, key, readMarkers)
}
func (am *AccountManager) saveTimeMap(account, key string, timeMap map[string]time.Time) {
var val string
if len(lastSeen) != 0 {
text, _ := json.Marshal(lastSeen)
if len(timeMap) != 0 {
text, _ := json.Marshal(timeMap)
val = string(text)
}
err := am.server.store.Update(func(tx *buntdb.Tx) error {
@ -661,12 +677,12 @@ func (am *AccountManager) saveLastSeen(account string, lastSeen map[string]time.
return nil
})
if err != nil {
am.server.logger.Error("internal", "error persisting lastSeen", account, err.Error())
am.server.logger.Error("internal", "error persisting timeMap", key, err.Error())
}
}
func (am *AccountManager) loadLastSeen(account string) (lastSeen map[string]time.Time) {
key := fmt.Sprintf(keyAccountLastSeen, account)
func (am *AccountManager) loadTimeMap(baseKey, account string) (lastSeen map[string]time.Time) {
key := fmt.Sprintf(baseKey, account)
var lsText string
am.server.store.Update(func(tx *buntdb.Tx) error {
lsText, _ = tx.Get(key)
@ -703,6 +719,74 @@ func (am *AccountManager) loadRealname(account string) (realname string) {
return
}
func (am *AccountManager) savePushSubscriptions(account string, subs []storedPushSubscription) {
j, err := json.Marshal(subs)
if err != nil {
am.server.logger.Error("internal", "error storing push subscriptions", err.Error())
return
}
val := string(j)
key := fmt.Sprintf(keyAccountPushSubscriptions, account)
am.server.store.Update(func(tx *buntdb.Tx) error {
tx.Set(key, val, nil)
return nil
})
return
}
func (am *AccountManager) loadPushSubscriptions(account string) (result []storedPushSubscription) {
key := fmt.Sprintf(keyAccountPushSubscriptions, account)
var val string
am.server.store.View(func(tx *buntdb.Tx) error {
val, _ = tx.Get(key)
return nil
})
if val == "" {
return nil
}
if err := json.Unmarshal([]byte(val), &result); err == nil {
return result
} else {
am.server.logger.Error("internal", "error loading push subscriptions", err.Error())
return nil
}
}
func (am *AccountManager) saveMetadata(account string, metadata map[string]string) {
j, err := json.Marshal(metadata)
if err != nil {
am.server.logger.Error("internal", "error storing metadata", err.Error())
return
}
val := string(j)
key := fmt.Sprintf(keyAccountMetadata, account)
am.server.store.Update(func(tx *buntdb.Tx) error {
tx.Set(key, val, nil)
return nil
})
return
}
func (am *AccountManager) loadMetadata(account string) (result map[string]string) {
key := fmt.Sprintf(keyAccountMetadata, account)
var val string
am.server.store.View(func(tx *buntdb.Tx) error {
val, _ = tx.Get(key)
return nil
})
if val == "" {
return nil
}
if err := json.Unmarshal([]byte(val), &result); err == nil {
return result
} else {
am.server.logger.Error("internal", "error loading metadata", err.Error())
return nil
}
}
func (am *AccountManager) addRemoveCertfp(account, certfp string, add bool, hasPrivs bool) (err error) {
certfp, err = utils.NormalizeCertfp(certfp)
if err != nil {
@ -811,7 +895,7 @@ func (am *AccountManager) dispatchMailtoCallback(client *Client, account string,
return
}
func (am *AccountManager) Verify(client *Client, account string, code string) error {
func (am *AccountManager) Verify(client *Client, account string, code string, admin bool) error {
casefoldedAccount, err := CasefoldName(account)
var skeleton string
if err != nil || account == "" || account == "*" {
@ -874,18 +958,20 @@ func (am *AccountManager) Verify(client *Client, account string, code string) er
return errAccountAlreadyVerified
}
// actually verify the code
// a stored code of "" means a none callback / no code required
success := false
storedCode, err := tx.Get(verificationCodeKey)
if err == nil {
// this is probably unnecessary
if storedCode == "" || utils.SecretTokensMatch(storedCode, code) {
success = true
if !admin {
// actually verify the code
// a stored code of "" means a none callback / no code required
success := false
storedCode, err := tx.Get(verificationCodeKey)
if err == nil {
// this is probably unnecessary
if storedCode == "" || utils.SecretTokensMatch(storedCode, code) {
success = true
}
}
if !success {
return errAccountVerificationInvalidCode
}
}
if !success {
return errAccountVerificationInvalidCode
}
// verify the account
@ -936,7 +1022,7 @@ func (am *AccountManager) Verify(client *Client, account string, code string) er
if client != nil {
am.Login(client, clientAccount)
if client.AlwaysOn() {
client.markDirty(IncludeRealname)
client.markDirty(IncludeAllAttrs)
}
}
// we may need to do nick enforcement here:
@ -954,7 +1040,7 @@ func (am *AccountManager) Verify(client *Client, account string, code string) er
func (am *AccountManager) SARegister(account, passphrase string) (err error) {
err = am.Register(nil, account, "admin", "", passphrase, "")
if err == nil {
err = am.Verify(nil, account, "")
err = am.Verify(nil, account, "", true)
}
return
}
@ -1107,7 +1193,7 @@ func (am *AccountManager) NsSendpass(client *Client, accountName string) (err er
message := email.ComposeMail(config.Accounts.Registration.EmailVerification, account.Settings.Email, subject)
fmt.Fprintf(&message, client.t("We received a request to reset your password on %[1]s for account: %[2]s"), am.server.name, account.Name)
message.WriteString("\r\n")
fmt.Fprintf(&message, client.t("If you did not initiate this request, you can safely ignore this message."))
message.WriteString(client.t("If you did not initiate this request, you can safely ignore this message."))
message.WriteString("\r\n")
message.WriteString("\r\n")
message.WriteString(client.t("Otherwise, to reset your password, issue the following command (replace `new_password` with your desired password):"))
@ -1415,6 +1501,74 @@ func (am *AccountManager) AuthenticateByPassphrase(client *Client, accountName s
return err
}
func (am *AccountManager) AuthenticateByBearerToken(client *Client, tokenType, token string) (err error) {
switch tokenType {
case "oauth2":
return am.AuthenticateByOAuthBearer(client, oauth2.OAuthBearerOptions{Token: token})
case "jwt":
return am.AuthenticateByJWT(client, token)
default:
return errInvalidBearerTokenType
}
}
func (am *AccountManager) AuthenticateByOAuthBearer(client *Client, opts oauth2.OAuthBearerOptions) (err error) {
config := am.server.Config()
if !config.Accounts.OAuth2.Enabled {
return errFeatureDisabled
}
if throttled, remainingTime := client.checkLoginThrottle(); throttled {
return &ThrottleError{remainingTime}
}
var username string
if config.Accounts.AuthScript.Enabled && config.Accounts.OAuth2.AuthScript {
username, err = am.authenticateByOAuthBearerScript(client, config, opts)
} else {
username, err = config.Accounts.OAuth2.Introspect(context.Background(), opts.Token)
}
if err != nil {
return err
}
account, err := am.loadWithAutocreation(username, config.Accounts.OAuth2.Autocreate)
if err == nil {
am.Login(client, account)
}
return err
}
func (am *AccountManager) AuthenticateByJWT(client *Client, token string) (err error) {
config := am.server.Config()
// enabled check is encapsulated here:
accountName, err := config.Accounts.JWTAuth.Validate(token)
if err != nil {
am.server.logger.Debug("accounts", "invalid JWT token", err.Error())
return errAccountInvalidCredentials
}
account, err := am.loadWithAutocreation(accountName, config.Accounts.JWTAuth.Autocreate)
if err == nil {
am.Login(client, account)
}
return err
}
func (am *AccountManager) authenticateByOAuthBearerScript(client *Client, config *Config, opts oauth2.OAuthBearerOptions) (username string, err error) {
output, err := CheckAuthScript(am.server.semaphores.AuthScript, config.Accounts.AuthScript.ScriptConfig,
AuthScriptInput{OAuthBearer: &opts, IP: client.IP().String()})
if err != nil {
am.server.logger.Error("internal", "failed shell auth invocation", err.Error())
return "", oauth2.ErrInvalidToken
} else if output.Success {
return output.AccountName, nil
} else {
return "", oauth2.ErrInvalidToken
}
}
// AllNicks returns the uncasefolded nicknames for all accounts, including additional (grouped) nicks.
func (am *AccountManager) AllNicks() (result []string) {
accountNamePrefix := fmt.Sprintf(keyAccountName, "")
@ -1470,6 +1624,22 @@ func (am *AccountManager) LoadAccount(accountName string) (result ClientAccount,
return
}
func (am *AccountManager) accountWasUnregistered(accountName string) (result bool) {
casefoldedAccount, err := CasefoldName(accountName)
if err != nil {
return false
}
unregisteredKey := fmt.Sprintf(keyAccountUnregistered, casefoldedAccount)
am.server.store.View(func(tx *buntdb.Tx) error {
if _, err := tx.Get(unregisteredKey); err == nil {
result = true
}
return nil
})
return
}
// look up the unfolded version of an account name, possibly after deletion
func (am *AccountManager) AccountToAccountName(account string) (result string) {
casefoldedAccount, err := CasefoldName(account)
@ -1736,25 +1906,26 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
nicksKey := fmt.Sprintf(keyAccountAdditionalNicks, casefoldedAccount)
settingsKey := fmt.Sprintf(keyAccountSettings, casefoldedAccount)
vhostKey := fmt.Sprintf(keyAccountVHost, casefoldedAccount)
channelsKey := fmt.Sprintf(keyAccountChannels, casefoldedAccount)
joinedChannelsKey := fmt.Sprintf(keyAccountChannelToModes, casefoldedAccount)
lastSeenKey := fmt.Sprintf(keyAccountLastSeen, casefoldedAccount)
readMarkersKey := fmt.Sprintf(keyAccountReadMarkers, casefoldedAccount)
unregisteredKey := fmt.Sprintf(keyAccountUnregistered, casefoldedAccount)
modesKey := fmt.Sprintf(keyAccountModes, casefoldedAccount)
realnameKey := fmt.Sprintf(keyAccountRealname, casefoldedAccount)
suspendedKey := fmt.Sprintf(keyAccountSuspended, casefoldedAccount)
pwResetKey := fmt.Sprintf(keyAccountPwReset, casefoldedAccount)
emailChangeKey := fmt.Sprintf(keyAccountEmailChange, casefoldedAccount)
pushSubscriptionsKey := fmt.Sprintf(keyAccountPushSubscriptions, casefoldedAccount)
metadataKey := fmt.Sprintf(keyAccountMetadata, casefoldedAccount)
var clients []*Client
defer func() {
am.killClients(clients)
}()
var registeredChannels []string
// on our way out, unregister all the account's channels and delete them from the db
defer func() {
for _, channelName := range registeredChannels {
for _, channelName := range am.server.channels.ChannelsForAccount(casefoldedAccount) {
err := am.server.channels.SetUnregistered(channelName, casefoldedAccount)
if err != nil {
am.server.logger.Error("internal", "couldn't unregister channel", channelName, err.Error())
@ -1769,7 +1940,6 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
defer am.serialCacheUpdateMutex.Unlock()
var accountName string
var channelsStr string
keepProtections := false
am.server.store.Update(func(tx *buntdb.Tx) error {
// get the unfolded account name; for an active account, this is
@ -1797,15 +1967,16 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
credText, err = tx.Get(credentialsKey)
tx.Delete(credentialsKey)
tx.Delete(vhostKey)
channelsStr, _ = tx.Get(channelsKey)
tx.Delete(channelsKey)
tx.Delete(joinedChannelsKey)
tx.Delete(lastSeenKey)
tx.Delete(readMarkersKey)
tx.Delete(modesKey)
tx.Delete(realnameKey)
tx.Delete(suspendedKey)
tx.Delete(pwResetKey)
tx.Delete(emailChangeKey)
tx.Delete(pushSubscriptionsKey)
tx.Delete(metadataKey)
return nil
})
@ -1827,7 +1998,6 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
skeleton, _ := Skeleton(accountName)
additionalNicks := unmarshalReservedNicks(rawNicks)
registeredChannels = unmarshalRegisteredChannels(channelsStr)
am.Lock()
defer am.Unlock()
@ -1859,21 +2029,6 @@ func unmarshalRegisteredChannels(channelsStr string) (result []string) {
return
}
func (am *AccountManager) ChannelsForAccount(account string) (channels []string) {
cfaccount, err := CasefoldName(account)
if err != nil {
return
}
var channelStr string
key := fmt.Sprintf(keyAccountChannels, cfaccount)
am.server.store.View(func(tx *buntdb.Tx) error {
channelStr, _ = tx.Get(key)
return nil
})
return unmarshalRegisteredChannels(channelStr)
}
func (am *AccountManager) AuthenticateByCertificate(client *Client, certfp string, peerCerts []*x509.Certificate, authzid string) (err error) {
if certfp == "" {
return errAccountInvalidCredentials
@ -1930,8 +2085,10 @@ func (am *AccountManager) AuthenticateByCertificate(client *Client, certfp strin
return err
}
if authzid != "" && authzid != account {
return errAuthzidAuthcidMismatch
if authzid != "" {
if cfAuthzid, err := CasefoldName(authzid); err != nil || cfAuthzid != account {
return errAuthzidAuthcidMismatch
}
}
// ok, we found an account corresponding to their certificate
@ -2136,6 +2293,8 @@ var (
"PLAIN": authPlainHandler,
"EXTERNAL": authExternalHandler,
"SCRAM-SHA-256": authScramHandler,
"OAUTHBEARER": authOauthBearerHandler,
"IRCV3BEARER": authIRCv3BearerHandler,
}
)

311
irc/api.go Normal file
View File

@ -0,0 +1,311 @@
package irc
import (
"crypto/subtle"
"encoding/json"
"fmt"
"net/http"
"runtime"
"strings"
"github.com/ergochat/ergo/irc/utils"
)
func newAPIHandler(server *Server) http.Handler {
api := &ergoAPI{
server: server,
mux: http.NewServeMux(),
}
api.mux.HandleFunc("POST /v1/rehash", api.handleRehash)
api.mux.HandleFunc("POST /v1/check_auth", api.handleCheckAuth)
api.mux.HandleFunc("POST /v1/saregister", api.handleSaregister)
api.mux.HandleFunc("POST /v1/account_details", api.handleAccountDetails)
api.mux.HandleFunc("POST /v1/account_list", api.handleAccountList)
api.mux.HandleFunc("POST /v1/status", api.handleStatus)
return api
}
type ergoAPI struct {
server *Server
mux *http.ServeMux
}
func (a *ergoAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer a.server.HandlePanic(nil)
defer a.server.logger.Debug("api", r.URL.Path)
if a.checkBearerAuth(r.Header.Get("Authorization")) {
a.mux.ServeHTTP(w, r)
} else {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}
}
func (a *ergoAPI) checkBearerAuth(authHeader string) (authorized bool) {
if authHeader == "" {
return false
}
c := a.server.Config()
if !c.API.Enabled {
return false
}
spaceIdx := strings.IndexByte(authHeader, ' ')
if spaceIdx < 0 {
return false
}
if !strings.EqualFold("Bearer", authHeader[:spaceIdx]) {
return false
}
providedTokenBytes := []byte(authHeader[spaceIdx+1:])
for _, tokenBytes := range c.API.bearerTokenBytes {
if subtle.ConstantTimeCompare(tokenBytes, providedTokenBytes) == 1 {
return true
}
}
return false
}
func (a *ergoAPI) decodeJSONRequest(request any, w http.ResponseWriter, r *http.Request) (err error) {
err = json.NewDecoder(r.Body).Decode(request)
if err != nil {
http.Error(w, fmt.Sprintf("failed to deserialize json request: %v", err), http.StatusBadRequest)
}
return err
}
func (a *ergoAPI) writeJSONResponse(response any, w http.ResponseWriter, r *http.Request) {
j, err := json.Marshal(response)
if err == nil {
j = append(j, '\n') // less annoying in curl output
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(j)
} else {
a.server.logger.Error("internal", "failed to serialize API response", r.URL.Path, err.Error())
http.Error(w, fmt.Sprintf("failed to serialize json response: %v", err), http.StatusInternalServerError)
}
}
type apiGenericResponse struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
ErrorCode string `json:"errorCode,omitempty"`
}
func (a *ergoAPI) handleRehash(w http.ResponseWriter, r *http.Request) {
var response apiGenericResponse
err := a.server.rehash()
if err == nil {
response.Success = true
} else {
response.Success = false
response.Error = err.Error()
}
a.writeJSONResponse(response, w, r)
}
type apiCheckAuthResponse struct {
apiGenericResponse
AccountName string `json:"accountName,omitempty"`
}
func (a *ergoAPI) handleCheckAuth(w http.ResponseWriter, r *http.Request) {
var request AuthScriptInput
if err := a.decodeJSONRequest(&request, w, r); err != nil {
return
}
var response apiCheckAuthResponse
// try passphrase if present
if request.AccountName != "" && request.Passphrase != "" {
account, err := a.server.accounts.checkPassphrase(request.AccountName, request.Passphrase)
switch err {
case nil:
// success, no error
response.Success = true
response.AccountName = account.Name
case errAccountDoesNotExist, errAccountInvalidCredentials, errAccountUnverified, errAccountSuspended:
// fail, no error
response.Success = false
default:
response.Success = false
response.Error = err.Error()
}
}
// try certfp if present
if !response.Success && request.Certfp != "" {
// TODO support cerftp
}
a.writeJSONResponse(response, w, r)
}
type apiSaregisterRequest struct {
AccountName string `json:"accountName"`
Passphrase string `json:"passphrase"`
}
func (a *ergoAPI) handleSaregister(w http.ResponseWriter, r *http.Request) {
var request apiSaregisterRequest
if err := a.decodeJSONRequest(&request, w, r); err != nil {
return
}
var response apiGenericResponse
err := a.server.accounts.SARegister(request.AccountName, request.Passphrase)
if err == nil {
response.Success = true
} else {
response.Success = false
response.Error = err.Error()
switch err {
case errAccountAlreadyRegistered, errAccountAlreadyVerified, errNameReserved:
response.ErrorCode = "ACCOUNT_EXISTS"
case errAccountBadPassphrase:
response.ErrorCode = "INVALID_PASSPHRASE"
default:
response.ErrorCode = "UNKNOWN_ERROR"
}
}
a.writeJSONResponse(response, w, r)
}
type apiAccountDetailsResponse struct {
apiGenericResponse
AccountName string `json:"accountName,omitempty"`
Email string `json:"email,omitempty"`
RegisteredAt string `json:"registeredAt,omitempty"`
Channels []string `json:"channels,omitempty"`
}
type apiAccountDetailsRequest struct {
AccountName string `json:"accountName"`
}
func (a *ergoAPI) handleAccountDetails(w http.ResponseWriter, r *http.Request) {
var request apiAccountDetailsRequest
if err := a.decodeJSONRequest(&request, w, r); err != nil {
return
}
var response apiAccountDetailsResponse
if request.AccountName != "" {
accountData, err := a.server.accounts.LoadAccount(request.AccountName)
if err == nil {
if !accountData.Verified {
err = errAccountUnverified
} else if accountData.Suspended != nil {
err = errAccountSuspended
}
}
switch err {
case nil:
response.AccountName = accountData.Name
response.Email = accountData.Settings.Email
if !accountData.RegisteredAt.IsZero() {
response.RegisteredAt = accountData.RegisteredAt.Format(utils.IRCv3TimestampFormat)
}
// Get channels the account is in
response.Channels = a.server.channels.ChannelsForAccount(accountData.NameCasefolded)
response.Success = true
case errAccountDoesNotExist, errAccountUnverified, errAccountSuspended:
response.Success = false
default:
response.Success = false
response.ErrorCode = "UNKNOWN_ERROR"
response.Error = err.Error()
}
} else {
response.Success = false
response.ErrorCode = "INVALID_REQUEST"
}
a.writeJSONResponse(response, w, r)
}
type apiAccountListResponse struct {
apiGenericResponse
Accounts []apiAccountDetailsResponse `json:"accounts"`
TotalCount int `json:"totalCount"`
}
func (a *ergoAPI) handleAccountList(w http.ResponseWriter, r *http.Request) {
var response apiAccountListResponse
// Get all account names
accounts := a.server.accounts.AllNicks()
response.TotalCount = len(accounts)
// Load account details
response.Accounts = make([]apiAccountDetailsResponse, len(accounts))
for i, account := range accounts {
accountData, err := a.server.accounts.LoadAccount(account)
if err != nil {
response.Accounts[i] = apiAccountDetailsResponse{
apiGenericResponse: apiGenericResponse{
Success: false,
Error: err.Error(),
},
}
continue
}
response.Accounts[i] = apiAccountDetailsResponse{
apiGenericResponse: apiGenericResponse{
Success: true,
},
AccountName: accountData.Name,
Email: accountData.Settings.Email,
}
}
response.Success = true
a.writeJSONResponse(response, w, r)
}
type apiStatusResponse struct {
apiGenericResponse
Version string `json:"version"`
GoVersion string `json:"go_version"`
Commit string `json:"commit,omitempty"`
StartTime string `json:"start_time"`
Users struct {
Total int `json:"total"`
Invisible int `json:"invisible"`
Operators int `json:"operators"`
Unknown int `json:"unknown"`
Max int `json:"max"`
} `json:"users"`
Channels int `json:"channels"`
Servers int `json:"servers"`
}
func (a *ergoAPI) handleStatus(w http.ResponseWriter, r *http.Request) {
server := a.server
stats := server.stats.GetValues()
response := apiStatusResponse{
apiGenericResponse: apiGenericResponse{Success: true},
Version: SemVer,
GoVersion: runtime.Version(),
Commit: Commit,
StartTime: server.ctime.Format(utils.IRCv3TimestampFormat),
}
response.Users.Total = stats.Total
response.Users.Invisible = stats.Invisible
response.Users.Operators = stats.Operators
response.Users.Unknown = stats.Unknown
response.Users.Max = stats.Max
response.Channels = server.channels.Len()
response.Servers = 1
a.writeJSONResponse(response, w, r)
}

View File

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

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

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

View File

@ -60,12 +60,15 @@ const (
MultilineConcatTag = "draft/multiline-concat"
// draft/relaymsg:
RelaymsgTagName = "draft/relaymsg"
// BOT mode: https://github.com/ircv3/ircv3-specifications/pull/439
BotTagName = "draft/bot"
// BOT mode: https://ircv3.net/specs/extensions/bot-mode
BotTagName = "bot"
// https://ircv3.net/specs/extensions/chathistory
ChathistoryTargetsBatchType = "draft/chathistory-targets"
ExtendedISupportBatchType = "draft/isupport"
)
func init() {
nameToCapability = make(map[string]Capability)
nameToCapability = make(map[string]Capability, numCapabs)
for capab, name := range capabilityNames {
nameToCapability[name] = Capability(capab)
}

View File

@ -7,9 +7,9 @@ package caps
const (
// number of recognized capabilities:
numCapabs = 28
// length of the uint64 array that represents the bitset:
bitsetLen = 1
numCapabs = 38
// length of the uint32 array that represents the bitset:
bitsetLen = 2
)
const (
@ -53,22 +53,50 @@ const (
// https://github.com/ircv3/ircv3-specifications/pull/362
EventPlayback Capability = iota
// ExtendedMonitor is the draft IRCv3 capability named "draft/extended-monitor":
// https://github.com/ircv3/ircv3-specifications/pull/466
ExtendedMonitor Capability = iota
// ExtendedISupport is the proposed IRCv3 capability named "draft/extended-isupport":
// https://github.com/ircv3/ircv3-specifications/pull/543
ExtendedISupport Capability = iota
// Languages is the proposed IRCv3 capability named "draft/languages":
// https://gist.github.com/DanielOaks/8126122f74b26012a3de37db80e4e0c6
Languages Capability = iota
// MessageRedaction is the proposed IRCv3 capability named "draft/message-redaction":
// https://github.com/progval/ircv3-specifications/blob/redaction/extensions/message-redaction.md
MessageRedaction Capability = iota
// Metadata is the draft IRCv3 capability named "draft/metadata-2":
// https://ircv3.net/specs/extensions/metadata
Metadata Capability = iota
// Multiline is the proposed IRCv3 capability named "draft/multiline":
// https://github.com/ircv3/ircv3-specifications/pull/398
Multiline Capability = iota
// NoImplicitNames is the proposed IRCv3 capability named "draft/no-implicit-names":
// https://github.com/ircv3/ircv3-specifications/pull/527
NoImplicitNames Capability = iota
// Persistence is the proposed IRCv3 capability named "draft/persistence":
// https://github.com/ircv3/ircv3-specifications/pull/503
Persistence Capability = iota
// Preaway is the proposed IRCv3 capability named "draft/pre-away":
// https://github.com/ircv3/ircv3-specifications/pull/514
Preaway Capability = iota
// ReadMarker is the draft IRCv3 capability named "draft/read-marker":
// https://github.com/ircv3/ircv3-specifications/pull/489
ReadMarker Capability = iota
// Relaymsg is the proposed IRCv3 capability named "draft/relaymsg":
// https://github.com/ircv3/ircv3-specifications/pull/417
Relaymsg Capability = iota
// WebPush is the proposed IRCv3 capability named "draft/webpush":
// https://github.com/ircv3/ircv3-specifications/pull/471
WebPush Capability = iota
// EchoMessage is the IRCv3 capability named "echo-message":
// https://ircv3.net/specs/extensions/echo-message-3.2.html
EchoMessage Capability = iota
@ -81,6 +109,10 @@ const (
// https://ircv3.net/specs/extensions/extended-join-3.1.html
ExtendedJoin Capability = iota
// ExtendedMonitor is the IRCv3 capability named "extended-monitor":
// https://ircv3.net/specs/extensions/extended-monitor.html
ExtendedMonitor Capability = iota
// InviteNotify is the IRCv3 capability named "invite-notify":
// https://ircv3.net/specs/extensions/invite-notify-3.2.html
InviteNotify Capability = iota
@ -109,6 +141,14 @@ const (
// https://ircv3.net/specs/extensions/setname.html
SetName Capability = iota
// SojuWebPush is the Soju/Goguma vendor capability named "soju.im/webpush":
// https://github.com/ircv3/ircv3-specifications/pull/471
SojuWebPush Capability = iota
// StandardReplies is the IRCv3 capability named "standard-replies":
// https://github.com/ircv3/ircv3-specifications/pull/506
StandardReplies Capability = iota
// STS is the IRCv3 capability named "sts":
// https://ircv3.net/specs/extensions/sts.html
STS Capability = iota
@ -139,13 +179,21 @@ var (
"draft/channel-rename",
"draft/chathistory",
"draft/event-playback",
"draft/extended-monitor",
"draft/extended-isupport",
"draft/languages",
"draft/message-redaction",
"draft/metadata-2",
"draft/multiline",
"draft/no-implicit-names",
"draft/persistence",
"draft/pre-away",
"draft/read-marker",
"draft/relaymsg",
"draft/webpush",
"echo-message",
"ergo.chat/nope",
"extended-join",
"extended-monitor",
"invite-notify",
"labeled-response",
"message-tags",
@ -153,6 +201,8 @@ var (
"sasl",
"server-time",
"setname",
"soju.im/webpush",
"standard-replies",
"sts",
"userhost-in-names",
"znc.in/playback",

View File

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

View File

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

View File

@ -7,18 +7,22 @@ package irc
import (
"fmt"
"iter"
"maps"
"strconv"
"strings"
"time"
"sync"
"github.com/ergochat/irc-go/ircutils"
"github.com/ergochat/irc-go/ircmsg"
"github.com/ergochat/ergo/irc/caps"
"github.com/ergochat/ergo/irc/datastore"
"github.com/ergochat/ergo/irc/history"
"github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/utils"
"github.com/ergochat/ergo/irc/webpush"
)
type ChannelSettings struct {
@ -33,7 +37,6 @@ type Channel struct {
key string
forward string
members MemberSet
membersCache []*Client // allow iteration over channel members without holding the lock
name string
nameCasefolded string
server *Server
@ -47,39 +50,43 @@ type Channel struct {
userLimit int
accountToUMode map[string]modes.Mode
history history.Buffer
stateMutex sync.RWMutex // tier 1
writerSemaphore utils.Semaphore // tier 1.5
joinPartMutex sync.Mutex // tier 3
ensureLoaded utils.Once // manages loading stored registration info from the database
stateMutex sync.RWMutex // tier 1
writebackLock sync.Mutex // tier 1.5
joinPartMutex sync.Mutex // tier 3
dirtyBits uint
settings ChannelSettings
uuid utils.UUID
metadata map[string]string
// these caches are paired to allow iteration over channel members without holding the lock
membersCache []*Client
memberDataCache []*memberData
}
// NewChannel creates a new channel from a `Server` and a `name`
// string, which must be unique on the server.
func NewChannel(s *Server, name, casefoldedName string, registered bool) *Channel {
func NewChannel(s *Server, name, casefoldedName string, registered bool, regInfo RegisteredChannel) *Channel {
config := s.Config()
channel := &Channel{
createdTime: time.Now().UTC(), // may be overwritten by applyRegInfo
members: make(MemberSet),
name: name,
nameCasefolded: casefoldedName,
server: s,
writerSemaphore: utils.NewSemaphore(1),
createdTime: time.Now().UTC(), // may be overwritten by applyRegInfo
members: make(MemberSet),
name: name,
nameCasefolded: casefoldedName,
server: s,
}
channel.initializeLists()
channel.history.Initialize(0, 0)
if !registered {
if registered {
channel.applyRegInfo(regInfo)
} else {
channel.resizeHistory(config)
for _, mode := range config.Channels.defaultModes {
channel.flags.SetMode(mode, true)
}
// no loading to do, so "mark" the load operation as "done":
channel.ensureLoaded.Do(func() {})
} // else: modes will be loaded before first join
channel.uuid = utils.GenerateUUIDv4()
}
return channel
}
@ -93,24 +100,6 @@ func (channel *Channel) initializeLists() {
channel.accountToUMode = make(map[string]modes.Mode)
}
// EnsureLoaded blocks until the channel's registration info has been loaded
// from the database.
func (channel *Channel) EnsureLoaded() {
channel.ensureLoaded.Do(func() {
nmc := channel.NameCasefolded()
info, err := channel.server.channelRegistry.LoadChannel(nmc)
if err == nil {
channel.applyRegInfo(info)
} else {
channel.server.logger.Error("internal", "couldn't load channel", nmc, err.Error())
}
})
}
func (channel *Channel) IsLoaded() bool {
return channel.ensureLoaded.Done()
}
func (channel *Channel) resizeHistory(config *Config) {
status, _, _ := channel.historyStatus(config)
if status == HistoryEphemeral {
@ -127,6 +116,7 @@ func (channel *Channel) applyRegInfo(chanReg RegisteredChannel) {
channel.stateMutex.Lock()
defer channel.stateMutex.Unlock()
channel.uuid = chanReg.UUID
channel.registeredFounder = chanReg.Founder
channel.registeredTime = chanReg.RegisteredAt
channel.topic = chanReg.Topic
@ -138,6 +128,7 @@ func (channel *Channel) applyRegInfo(chanReg RegisteredChannel) {
channel.userLimit = chanReg.UserLimit
channel.settings = chanReg.Settings
channel.forward = chanReg.Forward
channel.metadata = chanReg.Metadata
for _, mode := range chanReg.Modes {
channel.flags.SetMode(mode, true)
@ -151,41 +142,42 @@ func (channel *Channel) applyRegInfo(chanReg RegisteredChannel) {
}
// obtain a consistent snapshot of the channel state that can be persisted to the DB
func (channel *Channel) ExportRegistration(includeFlags uint) (info RegisteredChannel) {
func (channel *Channel) ExportRegistration() (info RegisteredChannel) {
channel.stateMutex.RLock()
defer channel.stateMutex.RUnlock()
info.Name = channel.name
info.NameCasefolded = channel.nameCasefolded
info.UUID = channel.uuid
info.Founder = channel.registeredFounder
info.RegisteredAt = channel.registeredTime
if includeFlags&IncludeTopic != 0 {
info.Topic = channel.topic
info.TopicSetBy = channel.topicSetBy
info.TopicSetTime = channel.topicSetTime
}
info.Topic = channel.topic
info.TopicSetBy = channel.topicSetBy
info.TopicSetTime = channel.topicSetTime
if includeFlags&IncludeModes != 0 {
info.Key = channel.key
info.Forward = channel.forward
info.Modes = channel.flags.AllModes()
info.UserLimit = channel.userLimit
}
info.Key = channel.key
info.Forward = channel.forward
info.Modes = channel.flags.AllModes()
info.UserLimit = channel.userLimit
if includeFlags&IncludeLists != 0 {
info.Bans = channel.lists[modes.BanMask].Masks()
info.Invites = channel.lists[modes.InviteMask].Masks()
info.Excepts = channel.lists[modes.ExceptMask].Masks()
info.AccountToUMode = make(map[string]modes.Mode)
for account, mode := range channel.accountToUMode {
info.AccountToUMode[account] = mode
}
}
info.Bans = channel.lists[modes.BanMask].Masks()
info.Invites = channel.lists[modes.InviteMask].Masks()
info.Excepts = channel.lists[modes.ExceptMask].Masks()
info.AccountToUMode = maps.Clone(channel.accountToUMode)
if includeFlags&IncludeSettings != 0 {
info.Settings = channel.settings
}
info.Settings = channel.settings
info.Metadata = channel.metadata
return
}
func (channel *Channel) exportSummary() (info RegisteredChannel) {
channel.stateMutex.RLock()
defer channel.stateMutex.RUnlock()
info.Name = channel.name
info.Founder = channel.registeredFounder
info.RegisteredAt = channel.registeredTime
return
}
@ -212,11 +204,11 @@ func (channel *Channel) MarkDirty(dirtyBits uint) {
// ChannelManager's lock (that way, no one can join and make the channel dirty again
// between this method exiting and the actual deletion).
func (channel *Channel) IsClean() bool {
if !channel.writerSemaphore.TryAcquire() {
if !channel.writebackLock.TryLock() {
// a database write (which may fail) is in progress, the channel cannot be cleaned up
return false
}
defer channel.writerSemaphore.Release()
defer channel.writebackLock.Unlock()
channel.stateMutex.RLock()
defer channel.stateMutex.RUnlock()
@ -228,17 +220,19 @@ func (channel *Channel) IsClean() bool {
}
func (channel *Channel) wakeWriter() {
if channel.writerSemaphore.TryAcquire() {
if channel.writebackLock.TryLock() {
go channel.writeLoop()
}
}
// equivalent of Socket.send()
func (channel *Channel) writeLoop() {
defer channel.server.HandlePanic(nil)
for {
// TODO(#357) check the error value of this and implement timed backoff
channel.performWrite(0)
channel.writerSemaphore.Release()
channel.writebackLock.Unlock()
channel.stateMutex.RLock()
isDirty := channel.dirtyBits != 0
@ -252,7 +246,7 @@ func (channel *Channel) writeLoop() {
return // nothing to do
} // else: isDirty, so we need to write again
if !channel.writerSemaphore.TryAcquire() {
if !channel.writebackLock.TryLock() {
return
}
}
@ -275,8 +269,8 @@ func (channel *Channel) Store(dirtyBits uint) (err error) {
}
}()
channel.writerSemaphore.Acquire()
defer channel.writerSemaphore.Release()
channel.writebackLock.Lock()
defer channel.writebackLock.Unlock()
return channel.performWrite(dirtyBits)
}
@ -292,9 +286,19 @@ func (channel *Channel) performWrite(additionalDirtyBits uint) (err error) {
return
}
info := channel.ExportRegistration(dirtyBits)
err = channel.server.channelRegistry.StoreChannel(info, dirtyBits)
if err != nil {
var success bool
info := channel.ExportRegistration()
if b, err := info.Serialize(); err == nil {
if err := channel.server.dstore.Set(datastore.TableChannels, info.UUID, b, time.Time{}); err == nil {
success = true
} else {
channel.server.logger.Error("internal", "couldn't persist channel", info.Name, err.Error())
}
} else {
channel.server.logger.Error("internal", "couldn't serialize channel", info.Name, err.Error())
}
if !success {
channel.stateMutex.Lock()
channel.dirtyBits = channel.dirtyBits | dirtyBits
channel.stateMutex.Unlock()
@ -318,6 +322,7 @@ func (channel *Channel) SetRegistered(founder string) error {
// SetUnregistered deletes the channel's registration information.
func (channel *Channel) SetUnregistered(expectedFounder string) {
uuid := utils.GenerateUUIDv4()
channel.stateMutex.Lock()
defer channel.stateMutex.Unlock()
@ -328,6 +333,9 @@ func (channel *Channel) SetUnregistered(expectedFounder string) {
var zeroTime time.Time
channel.registeredTime = zeroTime
channel.accountToUMode = make(map[string]modes.Mode)
// reset the UUID so that any re-registration will persist under
// a separate key:
channel.uuid = uuid
}
// implements `CHANSERV CLEAR #chan ACCESS` (resets bans, invites, excepts, and amodes)
@ -423,16 +431,19 @@ func (channel *Channel) AcceptTransfer(client *Client) (err error) {
func (channel *Channel) regenerateMembersCache() {
channel.stateMutex.RLock()
result := make([]*Client, len(channel.members))
membersCache := make([]*Client, len(channel.members))
dataCache := make([]*memberData, len(channel.members))
i := 0
for client := range channel.members {
result[i] = client
for client, info := range channel.members {
membersCache[i] = client
dataCache[i] = info
i++
}
channel.stateMutex.RUnlock()
channel.stateMutex.Lock()
channel.membersCache = result
channel.membersCache = membersCache
channel.memberDataCache = dataCache
channel.stateMutex.Unlock()
}
@ -440,59 +451,45 @@ func (channel *Channel) regenerateMembersCache() {
func (channel *Channel) Names(client *Client, rb *ResponseBuffer) {
channel.stateMutex.RLock()
clientData, isJoined := channel.members[client]
chname := channel.name
membersCache, memberDataCache := channel.membersCache, channel.memberDataCache
channel.stateMutex.RUnlock()
symbol := "=" // https://modern.ircdocs.horse/#rplnamreply-353
if channel.flags.HasMode(modes.Secret) {
symbol = "@"
}
isOper := client.HasRoleCapabs("sajoin")
respectAuditorium := channel.flags.HasMode(modes.Auditorium) && !isOper &&
(!isJoined || clientData.modes.HighestChannelUserMode() == modes.Mode(0))
isMultiPrefix := rb.session.capabilities.Has(caps.MultiPrefix)
isUserhostInNames := rb.session.capabilities.Has(caps.UserhostInNames)
maxNamLen := 480 - len(client.server.name) - len(client.Nick())
var namesLines []string
var buffer strings.Builder
maxNamLen := 480 - len(client.server.name) - len(client.Nick()) - len(chname)
var tl utils.TokenLineBuilder
tl.Initialize(maxNamLen, " ")
if isJoined || !channel.flags.HasMode(modes.Secret) || isOper {
for _, target := range channel.Members() {
for i, target := range membersCache {
if !isJoined && target.HasMode(modes.Invisible) && !isOper {
continue
}
var nick string
if isUserhostInNames {
nick = target.NickMaskString()
} else {
nick = target.Nick()
}
channel.stateMutex.RLock()
memberData, _ := channel.members[target]
channel.stateMutex.RUnlock()
modeSet := memberData.modes
if modeSet == nil {
memberData := memberDataCache[i]
if respectAuditorium && memberData.modes.HighestChannelUserMode() == modes.Mode(0) {
continue
}
if !isJoined && target.HasMode(modes.Invisible) && !isOper {
continue
}
if respectAuditorium && modeSet.HighestChannelUserMode() == modes.Mode(0) {
continue
}
prefix := modeSet.Prefixes(isMultiPrefix)
if buffer.Len()+len(nick)+len(prefix)+1 > maxNamLen {
namesLines = append(namesLines, buffer.String())
buffer.Reset()
}
if buffer.Len() > 0 {
buffer.WriteString(" ")
}
buffer.WriteString(prefix)
buffer.WriteString(nick)
}
if buffer.Len() > 0 {
namesLines = append(namesLines, buffer.String())
tl.AddParts(memberData.modes.Prefixes(isMultiPrefix), nick)
}
}
for _, line := range namesLines {
if buffer.Len() > 0 {
rb.Add(nil, client.server.name, RPL_NAMREPLY, client.nick, "=", channel.name, line)
}
for _, line := range tl.Lines() {
rb.Add(nil, client.server.name, RPL_NAMREPLY, client.nick, symbol, chname, line)
}
rb.Add(nil, client.server.name, RPL_ENDOFNAMES, client.nick, channel.name, client.t("End of NAMES list"))
rb.Add(nil, client.server.name, RPL_ENDOFNAMES, client.nick, chname, client.t("End of NAMES list"))
}
// does `clientMode` give you privileges to grant/remove `targetMode` to/from people,
@ -516,7 +513,7 @@ func channelUserModeHasPrivsOver(clientMode modes.Mode, targetMode modes.Mode) b
// ClientIsAtLeast returns whether the client has at least the given channel privilege.
func (channel *Channel) ClientIsAtLeast(client *Client, permission modes.Mode) bool {
channel.stateMutex.RLock()
memberData := channel.members[client]
memberData, present := channel.members[client]
founder := channel.registeredFounder
channel.stateMutex.RUnlock()
@ -524,6 +521,10 @@ func (channel *Channel) ClientIsAtLeast(client *Client, permission modes.Mode) b
return true
}
if !present {
return false
}
for _, mode := range modes.ChannelUserModes {
if memberData.modes.HasMode(mode) {
return true
@ -555,11 +556,14 @@ func (channel *Channel) ClientStatus(client *Client) (present bool, joinTimeSecs
// helper for persisting channel-user modes for always-on clients;
// return the channel name and all channel-user modes for a client
func (channel *Channel) alwaysOnStatus(client *Client) (chname string, status alwaysOnChannelStatus) {
func (channel *Channel) alwaysOnStatus(client *Client) (ok bool, chname string, status alwaysOnChannelStatus) {
channel.stateMutex.RLock()
defer channel.stateMutex.RUnlock()
chname = channel.name
data := channel.members[client]
data, ok := channel.members[client]
if !ok {
return
}
status.Modes = data.modes.String()
status.JoinTime = data.joinTime
return
@ -573,20 +577,20 @@ func (channel *Channel) setMemberStatus(client *Client, status alwaysOnChannelSt
}
channel.stateMutex.Lock()
defer channel.stateMutex.Unlock()
if _, ok := channel.members[client]; !ok {
return
if mData, ok := channel.members[client]; ok {
mData.modes.Clear()
for _, mode := range status.Modes {
mData.modes.SetMode(modes.Mode(mode), true)
}
mData.joinTime = status.JoinTime
}
memberData := channel.members[client]
memberData.modes = newModes
memberData.joinTime = status.JoinTime
channel.members[client] = memberData
}
func (channel *Channel) ClientHasPrivsOver(client *Client, target *Client) bool {
channel.stateMutex.RLock()
founder := channel.registeredFounder
clientModes := channel.members[client].modes
targetModes := channel.members[target].modes
clientData, clientOK := channel.members[client]
targetData, targetOK := channel.members[target]
channel.stateMutex.RUnlock()
if founder != "" {
@ -597,7 +601,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 {
@ -767,7 +775,8 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
return errWrongChannelKey, forward
}
if channel.flags.HasMode(modes.InviteOnly) &&
// #1901: +h and up exempt from all restrictions, but +v additionally exempts from +i:
if channel.flags.HasMode(modes.InviteOnly) && persistentMode == 0 &&
!channel.lists[modes.InviteMask].Match(details.nickMaskCasefolded) {
return errInviteOnly, forward
}
@ -883,10 +892,20 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, isBot, nil, "JOIN", chname)
}
if rb.session.capabilities.Has(caps.ReadMarker) {
rb.Add(nil, client.server.name, "MARKREAD", chname, client.GetReadMarker(chcfname))
}
if rb.session.capabilities.Has(caps.Metadata) {
syncChannelMetadata(client.server, rb, channel)
}
if rb.session.client == client {
// don't send topic and names for a SAJOIN of a different client
channel.SendTopic(client, rb, false)
channel.Names(client, rb)
if !rb.session.capabilities.Has(caps.NoImplicitNames) {
channel.Names(client, rb)
}
} else {
// ensure that SAJOIN sends a MODE line to the originating client, if applicable
if givenMode != 0 {
@ -966,13 +985,20 @@ func (channel *Channel) playJoinForSession(session *Session) {
client := session.client
sessionRb := NewResponseBuffer(session)
details := client.Details()
chname := channel.Name()
if session.capabilities.Has(caps.ExtendedJoin) {
sessionRb.Add(nil, details.nickMask, "JOIN", channel.Name(), details.accountName, details.realname)
sessionRb.Add(nil, details.nickMask, "JOIN", chname, details.accountName, details.realname)
} else {
sessionRb.Add(nil, details.nickMask, "JOIN", channel.Name())
sessionRb.Add(nil, details.nickMask, "JOIN", chname)
}
if session.capabilities.Has(caps.ReadMarker) {
chcfname := channel.NameCasefolded()
sessionRb.Add(nil, client.server.name, "MARKREAD", chname, client.GetReadMarker(chcfname))
}
channel.SendTopic(client, sessionRb, false)
channel.Names(client, sessionRb)
if !session.capabilities.Has(caps.NoImplicitNames) {
channel.Names(client, sessionRb)
}
sessionRb.Send(false)
}
@ -1056,7 +1082,7 @@ func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.I
}
}
batchID := rb.StartNestedHistoryBatch(chname)
batchID := rb.StartNestedBatch("chathistory", chname)
defer rb.EndNestedBatch(batchID)
for _, item := range items {
@ -1188,7 +1214,7 @@ func (channel *Channel) SetTopic(client *Client, topic string, rb *ResponseBuffe
return
}
topic = ircutils.TruncateUTF8Safe(topic, client.server.Config().Limits.TopicLen)
topic = ircmsg.TruncateUTF8Safe(topic, client.server.Config().Limits.TopicLen)
channel.stateMutex.Lock()
chname := channel.name
@ -1225,20 +1251,26 @@ func (channel *Channel) CanSpeak(client *Client) (bool, modes.Mode) {
channel.stateMutex.RLock()
memberData, hasClient := channel.members[client]
channel.stateMutex.RUnlock()
clientModes := memberData.modes
highestMode := func() modes.Mode {
if !hasClient {
return modes.Mode(0)
}
return memberData.modes.HighestChannelUserMode()
}
if !hasClient && channel.flags.HasMode(modes.NoOutside) {
// TODO: enforce regular +b bans on -n channels?
return false, modes.NoOutside
}
if channel.isMuted(client) && clientModes.HighestChannelUserMode() == modes.Mode(0) {
if channel.isMuted(client) && highestMode() == modes.Mode(0) {
return false, modes.BanMask
}
if channel.flags.HasMode(modes.Moderated) && clientModes.HighestChannelUserMode() == modes.Mode(0) {
if channel.flags.HasMode(modes.Moderated) && highestMode() == modes.Mode(0) {
return false, modes.Moderated
}
if channel.flags.HasMode(modes.RegisteredOnlySpeak) && client.Account() == "" &&
clientModes.HighestChannelUserMode() == modes.Mode(0) {
highestMode() == modes.Mode(0) {
return false, modes.RegisteredOnlySpeak
}
return true, modes.Mode('?')
@ -1297,23 +1329,26 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
isBot := client.HasMode(modes.Bot)
chname := channel.Name()
if !client.server.Config().Server.Compatibility.allowTruncation {
// 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)
}
config := client.server.Config()
dispatchWebPush := false
if !config.Server.Compatibility.allowTruncation {
if !validateSplitMessageLen(histType, details.nickMask, chname, message) {
rb.Add(nil, client.server.name, ERR_INPUTTOOLONG, details.nick, client.t("Line too long to be relayed without truncation"))
return
}
}
// 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) {
channel.stateMutex.RLock()
cuData := channel.members[client]
cuData, ok := channel.members[client]
channel.stateMutex.RUnlock()
if cuData.modes.HighestChannelUserMode() == modes.Mode(0) {
if !ok || cuData.modes.HighestChannelUserMode() == modes.Mode(0) {
// max(statusmsg_minmode, halfop)
if minPrefixMode == modes.Mode(0) || minPrefixMode == modes.Voice {
minPrefixMode = modes.Halfop
@ -1332,6 +1367,9 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
continue
}
// TODO consider when we might want to push TAGMSG
dispatchWebPush = dispatchWebPush || (config.WebPush.Enabled && histType != history.Tagmsg && member.hasPushSubscriptions())
for _, session := range member.Sessions() {
if session == rb.session {
continue // we already sent echo-message, if applicable
@ -1355,6 +1393,42 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
Tags: clientOnlyTags,
IsBot: isBot,
}, details.account)
if dispatchWebPush {
channel.dispatchWebPush(client, command, details.nickMask, details.accountName, chname, message)
}
}
}
func (channel *Channel) dispatchWebPush(client *Client, command, nuh, accountName, chname string, msg utils.SplitMessage) {
msgBytes, err := webpush.MakePushMessage(command, nuh, accountName, chname, msg)
if err != nil {
channel.server.logger.Error("internal", "can't serialize push message", err.Error())
return
}
messageText := strings.ToLower(msg.CombinedValue())
for _, member := range channel.Members() {
if member == client {
continue // don't push to the client's own devices even if they mentioned themself
}
if !member.hasPushSubscriptions() {
continue
}
// this is the casefolded account name for comparison to the casefolded message text:
account := member.Account()
if account == "" {
continue
}
if !webpush.IsHighlight(messageText, account) {
continue
}
member.dispatchPushMessage(pushMessage{
msg: msgBytes,
urgency: webpush.UrgencyHigh,
cftarget: channel.NameCasefolded(),
time: msg.Time,
})
}
}
@ -1441,7 +1515,7 @@ func (channel *Channel) Kick(client *Client, target *Client, comment string, rb
return
}
comment = ircutils.TruncateUTF8Safe(comment, channel.server.Config().Limits.KickLen)
comment = ircmsg.TruncateUTF8Safe(comment, channel.server.Config().Limits.KickLen)
message := utils.MakeMessage(comment)
details := client.Details()
@ -1482,6 +1556,7 @@ func (channel *Channel) Purge(source string) {
chname := channel.name
members := channel.membersCache
channel.membersCache = nil
channel.memberDataCache = nil
channel.members = make(MemberSet)
// TODO try to prevent Purge racing against (pending) Join?
channel.stateMutex.Unlock()
@ -1602,6 +1677,40 @@ func (channel *Channel) auditoriumFriends(client *Client) (friends []*Client) {
return
}
func (channel *Channel) sessionsWithCaps(capabs ...caps.Capability) iter.Seq[*Session] {
return func(yield func(*Session) bool) {
for _, member := range channel.Members() {
for _, sess := range member.Sessions() {
if sess.capabilities.HasAll(capabs...) {
if !yield(sess) {
return
}
}
}
}
}
}
// returns whether the client is visible to unprivileged users in the channel
// (i.e., respecting auditorium mode). note that this assumes that the client
// is a member; if the client is not, it may return true anyway
func (channel *Channel) memberIsVisible(client *Client) bool {
// fast path, we assume they're a member so if this isn't an auditorium,
// they're visible:
if !channel.flags.HasMode(modes.Auditorium) {
return true
}
channel.stateMutex.RLock()
defer channel.stateMutex.RUnlock()
clientData, found := channel.members[client]
if !found {
return false
}
return clientData.modes.HighestChannelUserMode() != modes.Mode(0)
}
// data for RPL_LIST
func (channel *Channel) listData() (memberCount int, name, topic string) {
channel.stateMutex.RLock()

View File

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

View File

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

View File

@ -6,6 +6,7 @@ package irc
import (
"fmt"
"regexp"
"slices"
"sort"
"strings"
"time"
@ -213,8 +214,17 @@ func csAmodeHandler(service *ircService, server *Server, client *Client, command
}
modeChanges, unknown := modes.ParseChannelModeChanges(params[1:]...)
invalid := len(unknown) != 0
// #2002: +f takes an argument but is not a channel-user mode,
// check for anything valid as a channel mode change that is not valid
// as an AMODE change
for _, modeChange := range modeChanges {
if !slices.Contains(modes.ChannelUserModes, modeChange.Mode) {
invalid = true
}
}
var change modes.ModeChange
if len(modeChanges) > 1 || len(unknown) > 0 {
if len(modeChanges) > 1 || invalid {
service.Notice(rb, client.t("Invalid mode change"))
return
} else if len(modeChanges) == 1 {
@ -450,7 +460,7 @@ func csRegisterHandler(service *ircService, server *Server, client *Client, comm
// check whether a client has already registered too many channels
func checkChanLimit(service *ircService, client *Client, rb *ResponseBuffer) (ok bool) {
account := client.Account()
channelsAlreadyRegistered := client.server.accounts.ChannelsForAccount(account)
channelsAlreadyRegistered := client.server.channels.ChannelsForAccount(account)
ok = len(channelsAlreadyRegistered) < client.server.Config().Channels.Registration.MaxChannelsPerAccount || client.HasRoleCapabs("chanreg")
if !ok {
service.Notice(rb, client.t("You have already registered the maximum number of channels; try dropping some with /CS UNREGISTER"))
@ -487,8 +497,8 @@ func csUnregisterHandler(service *ircService, server *Server, client *Client, co
return
}
info := channel.ExportRegistration(0)
channelKey := info.NameCasefolded
info := channel.exportSummary()
channelKey := channel.NameCasefolded()
if !csPrivsCheck(service, info, client, rb) {
return
}
@ -510,7 +520,7 @@ func csClearHandler(service *ircService, server *Server, client *Client, command
service.Notice(rb, client.t("Channel does not exist"))
return
}
if !csPrivsCheck(service, channel.ExportRegistration(0), client, rb) {
if !csPrivsCheck(service, channel.exportSummary(), client, rb) {
return
}
@ -541,7 +551,7 @@ func csTransferHandler(service *ircService, server *Server, client *Client, comm
service.Notice(rb, client.t("Channel does not exist"))
return
}
regInfo := channel.ExportRegistration(0)
regInfo := channel.exportSummary()
chname = regInfo.Name
account := client.Account()
isFounder := account != "" && account == regInfo.Founder
@ -720,11 +730,6 @@ func csPurgeListHandler(service *ircService, client *Client, rb *ResponseBuffer)
}
func csListHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
if !client.HasRoleCapabs("chanreg") {
service.Notice(rb, client.t("Insufficient privileges"))
return
}
var searchRegex *regexp.Regexp
if len(params) > 0 {
var err error
@ -737,7 +742,7 @@ func csListHandler(service *ircService, server *Server, client *Client, command
service.Notice(rb, ircfmt.Unescape(client.t("*** $bChanServ LIST$b ***")))
channels := server.channelRegistry.AllChannels()
channels := server.channels.AllRegisteredChannels()
for _, channel := range channels {
if searchRegex == nil || searchRegex.MatchString(channel) {
service.Notice(rb, fmt.Sprintf(" %s", channel))
@ -762,7 +767,7 @@ func csInfoHandler(service *ircService, server *Server, client *Client, command
// purge status
if client.HasRoleCapabs("chanreg") {
purgeRecord, err := server.channelRegistry.LoadPurgeRecord(chname)
purgeRecord, err := server.channels.LoadPurgeRecord(chname)
if err == nil {
service.Notice(rb, fmt.Sprintf(client.t("Channel %s was purged by the server operators and cannot be used"), chname))
service.Notice(rb, fmt.Sprintf(client.t("Purged by operator: %s"), purgeRecord.Oper))
@ -780,13 +785,7 @@ func csInfoHandler(service *ircService, server *Server, client *Client, command
var chinfo RegisteredChannel
channel := server.channels.Get(params[0])
if channel != nil {
chinfo = channel.ExportRegistration(0)
} else {
chinfo, err = server.channelRegistry.LoadChannel(chname)
if err != nil && !(err == errNoSuchChannel || err == errFeatureDisabled) {
service.Notice(rb, client.t("An error occurred"))
return
}
chinfo = channel.exportSummary()
}
// channel exists but is unregistered, or doesn't exist:
@ -826,12 +825,12 @@ func csGetHandler(service *ircService, server *Server, client *Client, command s
service.Notice(rb, client.t("No such channel"))
return
}
info := channel.ExportRegistration(IncludeSettings)
info := channel.exportSummary()
if !csPrivsCheck(service, info, client, rb) {
return
}
displayChannelSetting(service, setting, info.Settings, client, rb)
displayChannelSetting(service, setting, channel.Settings(), client, rb)
}
func csSetHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
@ -841,12 +840,12 @@ func csSetHandler(service *ircService, server *Server, client *Client, command s
service.Notice(rb, client.t("No such channel"))
return
}
info := channel.ExportRegistration(IncludeSettings)
settings := info.Settings
info := channel.exportSummary()
if !csPrivsCheck(service, info, client, rb) {
return
}
settings := channel.Settings()
var err error
switch strings.ToLower(setting) {
case "history":
@ -952,7 +951,7 @@ func csHowToBanHandler(service *ircService, server *Server, client *Client, comm
success = true
if len(collateralDamage) != 0 {
service.Notice(rb, fmt.Sprintf(client.t("Warning: this ban will affect %d other users:"), len(collateralDamage)))
for _, line := range utils.BuildTokenLines(400, collateralDamage, " ") {
for _, line := range utils.BuildTokenLines(maxLastArgLength, collateralDamage, " ") {
service.Notice(rb, line)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -84,7 +84,7 @@ func (clients *ClientManager) Remove(client *Client) error {
// SetNick sets a client's nickname, validating it against nicknames in use
// XXX: dryRun validates a client's ability to claim a nick, without
// actually claiming it
func (clients *ClientManager) SetNick(client *Client, session *Session, newNick string, dryRun bool) (setNick string, err error, returnedFromAway bool) {
func (clients *ClientManager) SetNick(client *Client, session *Session, newNick string, dryRun bool) (setNick string, err error, awayChanged bool) {
config := client.server.Config()
var newCfNick, newSkeleton string
@ -94,15 +94,16 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick
accountName := client.accountName
settings := client.accountSettings
registered := client.registered
realname := client.realname
client.stateMutex.RUnlock()
// these restrictions have grandfather exceptions for nicknames registered
// on previous versions of Ergo:
if newNick != accountName {
// can't contain "disfavored" characters like <, or start with a $ because
// it collides with the massmessage mask syntax:
if strings.ContainsAny(newNick, disfavoredNameCharacters) || strings.HasPrefix(newNick, "$") {
// it collides with the massmessage mask syntax. '0' conflicts with the use of 0
// as a placeholder in WHOX (#1896):
if strings.ContainsAny(newNick, disfavoredNameCharacters) || strings.HasPrefix(newNick, "$") ||
newNick == "0" {
return "", errNicknameInvalid, false
}
}
@ -114,8 +115,10 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick
useAccountName = alwaysOn || config.Accounts.NickReservation.ForceNickEqualsAccount
}
nickIsReserved := false
if useAccountName {
if registered && newNick != accountName && newNick != "" {
if registered && newNick != accountName {
return "", errNickAccountMismatch, false
}
newNick = accountName
@ -165,7 +168,9 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick
reservedAccount, method := client.server.accounts.EnforcementStatus(newCfNick, newSkeleton)
if method == NickEnforcementStrict && reservedAccount != "" && reservedAccount != account {
return "", errNicknameReserved, false
// see #2135: we want to enter the critical section, see if the nick is actually in use,
// and return errNicknameInUse in that case
nickIsReserved = true
}
}
@ -193,16 +198,7 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick
dryRun || session == nil {
return "", errNicknameInUse, false
}
// check TLS modes
if client.HasMode(modes.TLS) != currentClient.HasMode(modes.TLS) {
if useAccountName {
// #955: this is fatal because they can't fix it by trying a different nick
return "", errInsecureReattach, false
} else {
return "", errNicknameInUse, false
}
}
reattachSuccessful, numSessions, lastSeen, back := currentClient.AddSession(session)
reattachSuccessful, numSessions, lastSeen, wasAway, nowAway := currentClient.AddSession(session)
if !reattachSuccessful {
return "", errNicknameInUse, false
}
@ -212,12 +208,8 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick
client.server.stats.AddRegistered(invisible, operator)
}
session.autoreplayMissedSince = lastSeen
// TODO: transition mechanism for #1065, clean this up eventually:
if currentClient.Realname() == "" {
currentClient.SetRealname(realname)
}
// successful reattach!
return newNick, nil, back
return newNick, nil, wasAway != nowAway
} else if currentClient == client && currentClient.Nick() == newNick {
return "", errNoop, false
}
@ -226,6 +218,9 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick
if skeletonHolder != nil && skeletonHolder != client {
return "", errNicknameInUse, false
}
if nickIsReserved {
return "", errNicknameReserved, false
}
if dryRun {
return "", nil, false
@ -253,15 +248,14 @@ func (clients *ClientManager) AllClients() (result []*Client) {
return
}
// AllWithCapsNotify returns all clients with the given capabilities, and that support cap-notify.
func (clients *ClientManager) AllWithCapsNotify(capabs ...caps.Capability) (sessions []*Session) {
capabs = append(capabs, caps.CapNotify)
// AllWithCapsNotify returns all sessions that support cap-notify.
func (clients *ClientManager) AllWithCapsNotify() (sessions []*Session) {
clients.RLock()
defer clients.RUnlock()
for _, client := range clients.byNick {
for _, session := range client.Sessions() {
// cap-notify is implicit in cap version 302 and above
if session.capabilities.HasAll(capabs...) || 302 <= session.capVersion {
if session.capabilities.Has(caps.CapNotify) || 302 <= session.capVersion {
sessions = append(sessions, session)
}
}
@ -270,6 +264,18 @@ func (clients *ClientManager) AllWithCapsNotify(capabs ...caps.Capability) (sess
return
}
// AllWithPushSubscriptions returns all clients that are always-on with an active push subscription.
func (clients *ClientManager) AllWithPushSubscriptions() (result []*Client) {
clients.RLock()
defer clients.RUnlock()
for _, client := range clients.byNick {
if client.hasPushSubscriptions() && client.AlwaysOn() {
result = append(result, client)
}
}
return result
}
// FindAll returns all clients that match the given userhost mask.
func (clients *ClientManager) FindAll(userhost string) (set ClientSet) {
set = make(ClientSet)

View File

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

View File

@ -6,7 +6,7 @@ import (
"fmt"
"net"
"golang.org/x/crypto/sha3"
"crypto/sha3"
"github.com/ergochat/ergo/irc/utils"
)

View File

@ -18,6 +18,24 @@ type Command struct {
capabs []string
}
// resolveCommand returns the command to execute in response to a user input line.
// some invalid commands (unknown command verb, invalid UTF8) get a fake handler
// to ensure that labeled-response still works as expected.
func (server *Server) resolveCommand(command string, invalidUTF8 bool) (canonicalName string, result Command) {
if invalidUTF8 {
return command, invalidUtf8Command
}
if cmd, ok := Commands[command]; ok {
return command, cmd
}
if target, ok := server.Config().Server.CommandAliases[command]; ok {
if cmd, ok := Commands[target]; ok {
return target, cmd
}
}
return command, unknownCommand
}
// Run runs this command with the given client/message.
func (cmd *Command) Run(server *Server, client *Client, session *Session, msg ircmsg.Message) (exiting bool) {
rb := NewResponseBuffer(session)
@ -53,7 +71,7 @@ func (cmd *Command) Run(server *Server, client *Client, session *Session, msg ir
}
if client.registered {
client.Touch(session)
client.Touch(session) // even if `exiting`, we bump the lastSeen timestamp
}
return exiting
@ -75,6 +93,10 @@ var Commands map[string]Command
func init() {
Commands = map[string]Command{
"ACCEPT": {
handler: acceptHandler,
minParams: 1,
},
"AMBIANCE": {
handler: sceneHandler,
minParams: 2,
@ -85,8 +107,9 @@ func init() {
minParams: 1,
},
"AWAY": {
handler: awayHandler,
minParams: 0,
handler: awayHandler,
usablePreReg: true,
minParams: 0,
},
"BATCH": {
handler: batchHandler,
@ -147,6 +170,10 @@ func init() {
handler: isonHandler,
minParams: 1,
},
"ISUPPORT": {
handler: isupportHandler,
usablePreReg: true,
},
"JOIN": {
handler: joinHandler,
minParams: 1,
@ -178,6 +205,15 @@ func init() {
handler: lusersHandler,
minParams: 0,
},
"MARKREAD": {
handler: markReadHandler,
minParams: 0, // send FAIL instead of ERR_NEEDMOREPARAMS
},
"METADATA": {
handler: metadataHandler,
minParams: 2,
usablePreReg: true,
},
"MODE": {
handler: modeHandler,
minParams: 1,
@ -225,6 +261,10 @@ func init() {
usablePreReg: true,
minParams: 1,
},
"PERSISTENCE": {
handler: persistenceHandler,
minParams: 1,
},
"PING": {
handler: pingHandler,
usablePreReg: true,
@ -288,6 +328,10 @@ func init() {
usablePreReg: true,
minParams: 0,
},
"REDACT": {
handler: redactHandler,
minParams: 2,
},
"REHASH": {
handler: rehashHandler,
minParams: 0,
@ -346,6 +390,10 @@ func init() {
usablePreReg: true,
minParams: 4,
},
"WEBPUSH": {
handler: webpushHandler,
minParams: 2,
},
"WHO": {
handler: whoHandler,
minParams: 1,

View File

@ -22,6 +22,7 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
"code.cloudfoundry.org/bytefmt"
"github.com/ergochat/irc-go/ircfmt"
@ -38,8 +39,14 @@ import (
"github.com/ergochat/ergo/irc/logger"
"github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/mysql"
"github.com/ergochat/ergo/irc/oauth2"
"github.com/ergochat/ergo/irc/passwd"
"github.com/ergochat/ergo/irc/utils"
"github.com/ergochat/ergo/irc/webpush"
)
const (
defaultProxyDeadline = time.Minute
)
// here's how this works: exported (capitalized) members of the config structs
@ -303,7 +310,7 @@ func (t *ThrottleConfig) UnmarshalYAML(unmarshal func(interface{}) error) (err e
type AccountConfig struct {
Registration AccountRegistrationConfig
AuthenticationEnabled bool `yaml:"authentication-enabled"`
AdvertiseSCRAM bool `yaml:"advertise-scram"` // undocumented, see #1782
AdvertiseSCRAM bool `yaml:"advertise-scram"`
RequireSasl struct {
Enabled bool
Exempted []string
@ -331,7 +338,9 @@ type AccountConfig struct {
Multiclient MulticlientConfig
Bouncer *MulticlientConfig // # handle old name for 'multiclient'
VHosts VHostConfig
AuthScript AuthScriptConfig `yaml:"auth-script"`
AuthScript AuthScriptConfig `yaml:"auth-script"`
OAuth2 oauth2.OAuth2BearerConfig `yaml:"oauth2"`
JWTAuth jwt.JWTAuthConfig `yaml:"jwt-auth"`
}
type ScriptConfig struct {
@ -450,6 +459,10 @@ func (cm *Casemapping) UnmarshalYAML(unmarshal func(interface{}) error) (err err
result = CasemappingPRECIS
case "permissive", "fun":
result = CasemappingPermissive
case "rfc1459":
result = CasemappingRFC1459
case "rfc1459-strict":
result = CasemappingRFC1459Strict
default:
return fmt.Errorf("invalid casemapping value: %s", orig)
}
@ -484,6 +497,7 @@ type Limits struct {
ChanListModes int `yaml:"chan-list-modes"`
ChannelLen int `yaml:"channellen"`
IdentLen int `yaml:"identlen"`
RealnameLen int `yaml:"realnamelen"`
KickLen int `yaml:"kicklen"`
MonitorEntries int `yaml:"monitor-entries"`
NickLen int `yaml:"nicklen"`
@ -524,6 +538,7 @@ type FakelagConfig struct {
BurstLimit uint `yaml:"burst-limit"`
MessagesPerWindow uint `yaml:"messages-per-window"`
Cooldown time.Duration
CommandBudgets map[string]int `yaml:"command-budgets"`
}
type TorListenersConfig struct {
@ -566,7 +581,12 @@ type Config struct {
MOTD string
motdLines []string
MOTDFormatting bool `yaml:"motd-formatting"`
Relaymsg struct {
IdleTimeouts struct {
Registration time.Duration
Ping time.Duration
Disconnect time.Duration
} `yaml:"idle-timeouts"`
Relaymsg struct {
Enabled bool
Separators string
AvailableToChanops bool `yaml:"available-to-chanops"`
@ -588,6 +608,7 @@ type Config struct {
Cloaks cloaks.CloakConfig `yaml:"ip-cloaking"`
SecureNetDefs []string `yaml:"secure-nets"`
secureNets []net.IPNet
OperThrottle time.Duration `yaml:"oper-throttle"`
supportedCaps *caps.Set
supportedCapsWithoutSTS *caps.Set
capValues caps.Values
@ -598,14 +619,27 @@ type Config struct {
OverrideServicesHostname string `yaml:"override-services-hostname"`
MaxLineLen int `yaml:"max-line-len"`
SuppressLusers bool `yaml:"suppress-lusers"`
AdditionalISupport map[string]string `yaml:"additional-isupport"`
CommandAliases map[string]string `yaml:"command-aliases"`
}
API struct {
Enabled bool
Listener string
TLS TLSListenConfig
tlsConfig *tls.Config
BearerTokens []string `yaml:"bearer-tokens"`
bearerTokenBytes [][]byte
} `yaml:"api"`
Roleplay struct {
Enabled bool
RequireChanops bool `yaml:"require-chanops"`
RequireOper bool `yaml:"require-oper"`
AddSuffix *bool `yaml:"add-suffix"`
addSuffix bool
NPCNickMask string `yaml:"npc-nick-mask"`
SceneNickMask string `yaml:"scene-nick-mask"`
}
Extjwt struct {
@ -643,6 +677,7 @@ type Config struct {
}
ListDelay time.Duration `yaml:"list-delay"`
InviteExpiration custime.Duration `yaml:"invite-expiration"`
AutoJoin []string `yaml:"auto-join"`
}
OperClasses map[string]*OperClassConfig `yaml:"oper-classes"`
@ -698,14 +733,32 @@ type Config struct {
} `yaml:"tagmsg-storage"`
}
Metadata struct {
Enabled bool
MaxSubs int `yaml:"max-subs"`
MaxKeys int `yaml:"max-keys"`
MaxValueBytes int `yaml:"max-value-length"`
ClientThrottle ThrottleConfig `yaml:"client-throttle"`
}
WebPush struct {
Enabled bool
Timeout time.Duration
Delay time.Duration
Subscriber string
MaxSubscriptions int `yaml:"max-subscriptions"`
Expiration custime.Duration
vapidKeys *webpush.VAPIDKeys
} `yaml:"webpush"`
Filename string
}
// OperClass defines an assembled operator class.
type OperClass struct {
Title string
WhoisLine string `yaml:"whois-line"`
Capabilities utils.StringSet // map to make lookups much easier
WhoisLine string `yaml:"whois-line"`
Capabilities utils.HashSet[string] // map to make lookups much easier
}
// OperatorClasses returns a map of assembled operator classes from the given config.
@ -743,7 +796,7 @@ func (conf *Config) OperatorClasses() (map[string]*OperClass, error) {
// create new operclass
var oc OperClass
oc.Capabilities = make(utils.StringSet)
oc.Capabilities = make(utils.HashSet[string])
// get inhereted info from other operclasses
if len(info.Extends) > 0 {
@ -944,7 +997,7 @@ func (conf *Config) prepareListeners() (err error) {
conf.Server.trueListeners = make(map[string]utils.ListenerConfig)
for addr, block := range conf.Server.Listeners {
var lconf utils.ListenerConfig
lconf.ProxyDeadline = RegisterTimeout
lconf.ProxyDeadline = defaultProxyDeadline
lconf.Tor = block.Tor
lconf.STSOnly = block.STSOnly
if lconf.STSOnly && !conf.Server.STS.Enabled {
@ -988,6 +1041,40 @@ func (config *Config) processExtjwt() (err error) {
return nil
}
func (config *Config) processAPI() (err error) {
if !config.API.Enabled {
return nil
}
if config.API.Listener == "" {
return errors.New("config.api.enabled is true, but listener address is empty")
}
config.API.bearerTokenBytes = make([][]byte, len(config.API.BearerTokens))
for i, tok := range config.API.BearerTokens {
if tok == "" || tok == "example" {
continue
}
config.API.bearerTokenBytes[i] = []byte(tok)
}
var tlsConfig *tls.Config
if config.API.TLS.Cert != "" {
cert, err := loadCertWithLeaf(config.API.TLS.Cert, config.API.TLS.Key)
if err != nil {
return err
}
tlsConfig = &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
// TODO consider supporting client certificates
}
}
config.API.tlsConfig = tlsConfig
return nil
}
// LoadRawConfig loads the config without doing any consistency checks or postprocessing
func LoadRawConfig(filename string) (config *Config, err error) {
data, err := os.ReadFile(filename)
@ -1037,7 +1124,7 @@ func (ce *configPathError) Error() string {
return fmt.Sprintf("Couldn't apply config override `%s`: %s", ce.name, ce.desc)
}
func mungeFromEnvironment(config *Config, envPair string) (applied bool, err *configPathError) {
func mungeFromEnvironment(config *Config, envPair string) (applied bool, name string, err *configPathError) {
equalIdx := strings.IndexByte(envPair, '=')
name, value := envPair[:equalIdx], envPair[equalIdx+1:]
if strings.HasPrefix(name, "ERGO__") {
@ -1045,7 +1132,7 @@ func mungeFromEnvironment(config *Config, envPair string) (applied bool, err *co
} else if strings.HasPrefix(name, "ORAGONO__") {
name = strings.TrimPrefix(name, "ORAGONO__")
} else {
return false, nil
return false, "", nil
}
pathComponents := strings.Split(name, "__")
for i, pathComponent := range pathComponents {
@ -1056,10 +1143,10 @@ func mungeFromEnvironment(config *Config, envPair string) (applied bool, err *co
t := v.Type()
for _, component := range pathComponents {
if component == "" {
return false, &configPathError{name, "invalid", nil}
return false, "", &configPathError{name, "invalid", nil}
}
if v.Kind() != reflect.Struct {
return false, &configPathError{name, "index into non-struct", nil}
return false, "", &configPathError{name, "index into non-struct", nil}
}
var nextField reflect.StructField
success := false
@ -1085,7 +1172,7 @@ func mungeFromEnvironment(config *Config, envPair string) (applied bool, err *co
}
}
if !success {
return false, &configPathError{name, fmt.Sprintf("couldn't resolve path component: `%s`", component), nil}
return false, "", &configPathError{name, fmt.Sprintf("couldn't resolve path component: `%s`", component), nil}
}
v = v.FieldByName(nextField.Name)
// dereference pointer field if necessary, initialize new value if necessary
@ -1099,9 +1186,9 @@ func mungeFromEnvironment(config *Config, envPair string) (applied bool, err *co
}
yamlErr := yaml.Unmarshal([]byte(value), v.Addr().Interface())
if yamlErr != nil {
return false, &configPathError{name, "couldn't deserialize YAML", yamlErr}
return false, "", &configPathError{name, "couldn't deserialize YAML", yamlErr}
}
return true, nil
return true, name, nil
}
// LoadConfig loads the given YAML configuration file.
@ -1113,7 +1200,7 @@ func LoadConfig(filename string) (config *Config, err error) {
if config.AllowEnvironmentOverrides {
for _, envPair := range os.Environ() {
applied, envErr := mungeFromEnvironment(config, envPair)
applied, name, envErr := mungeFromEnvironment(config, envPair)
if envErr != nil {
if envErr.fatalErr != nil {
return nil, envErr
@ -1121,7 +1208,7 @@ func LoadConfig(filename string) (config *Config, err error) {
log.Println(envErr.Error())
}
} else if applied {
log.Printf("applied environment override: %s\n", envPair)
log.Printf("applied environment override: %s\n", name)
}
}
}
@ -1160,6 +1247,23 @@ func LoadConfig(filename string) (config *Config, err error) {
}
}
if config.Server.IdleTimeouts.Registration <= 0 {
config.Server.IdleTimeouts.Registration = time.Minute
}
if config.Server.IdleTimeouts.Ping <= 0 {
config.Server.IdleTimeouts.Ping = time.Minute + 30*time.Second
}
if config.Server.IdleTimeouts.Disconnect <= 0 {
config.Server.IdleTimeouts.Disconnect = 2*time.Minute + 30*time.Second
}
if !(config.Server.IdleTimeouts.Ping < config.Server.IdleTimeouts.Disconnect) {
return nil, fmt.Errorf(
"ping timeout %v must be strictly less than disconnect timeout %v, to give the client time to respond",
config.Server.IdleTimeouts.Ping, config.Server.IdleTimeouts.Disconnect,
)
}
if config.Server.CoerceIdent != "" {
if config.Server.CheckIdent {
return nil, errors.New("Can't configure both check-ident and coerce-ident")
@ -1388,16 +1492,38 @@ func LoadConfig(filename string) (config *Config, err error) {
config.Accounts.VHosts.validRegexp = defaultValidVhostRegex
}
saslCapValue := "PLAIN,EXTERNAL,SCRAM-SHA-256"
// TODO(#1782) clean this up:
if !config.Accounts.AdvertiseSCRAM {
saslCapValue = "PLAIN,EXTERNAL"
}
config.Server.capValues[caps.SASL] = saslCapValue
if !config.Accounts.AuthenticationEnabled {
if config.Accounts.AuthenticationEnabled {
saslCapValues := []string{"PLAIN", "EXTERNAL"}
if config.Accounts.AdvertiseSCRAM {
saslCapValues = append(saslCapValues, "SCRAM-SHA-256")
}
if config.Accounts.OAuth2.Enabled {
saslCapValues = append(saslCapValues, "OAUTHBEARER")
}
if config.Accounts.OAuth2.Enabled || config.Accounts.JWTAuth.Enabled {
saslCapValues = append(saslCapValues, "IRCV3BEARER")
}
config.Server.capValues[caps.SASL] = strings.Join(saslCapValues, ",")
} else {
config.Server.supportedCaps.Disable(caps.SASL)
}
if config.Server.OperThrottle <= 0 {
config.Server.OperThrottle = 10 * time.Second
}
if err := config.Accounts.OAuth2.Postprocess(); err != nil {
return nil, err
}
if err := config.Accounts.JWTAuth.Postprocess(); err != nil {
return nil, err
}
if config.Accounts.OAuth2.Enabled && config.Accounts.OAuth2.AuthScript && !config.Accounts.AuthScript.Enabled {
return nil, fmt.Errorf("oauth2 is enabled with auth-script, but no auth-script is enabled")
}
if !config.Accounts.Registration.Enabled {
config.Server.supportedCaps.Disable(caps.AccountRegistration)
} else {
@ -1428,6 +1554,17 @@ func LoadConfig(filename string) (config *Config, err error) {
}
config.Server.capValues[caps.Languages] = config.languageManager.CapValue()
if len(config.Fakelag.CommandBudgets) != 0 {
// normalize command names to uppercase:
commandBudgets := make(map[string]int, len(config.Fakelag.CommandBudgets))
for command, budget := range config.Fakelag.CommandBudgets {
commandBudgets[strings.ToUpper(command)] = budget
}
config.Fakelag.CommandBudgets = commandBudgets
} else {
config.Fakelag.CommandBudgets = nil
}
if config.Server.Relaymsg.Enabled {
for _, char := range protocolBreakingNameCharacters {
if strings.ContainsRune(config.Server.Relaymsg.Separators, char) {
@ -1474,12 +1611,13 @@ func LoadConfig(filename string) (config *Config, err error) {
// in the current implementation, we disable history by creating a history buffer
// with zero capacity. but the `enabled` config option MUST be respected regardless
// of this detail
if !config.History.Enabled {
if !config.History.Enabled || config.History.ChathistoryMax == 0 {
config.History.ChannelLength = 0
config.History.ClientLength = 0
config.Server.supportedCaps.Disable(caps.Chathistory)
config.Server.supportedCaps.Disable(caps.EventPlayback)
config.Server.supportedCaps.Disable(caps.ZNCPlayback)
config.Server.supportedCaps.Disable(caps.MessageRedaction)
}
if !config.History.Enabled || !config.History.Persistent.Enabled {
@ -1510,7 +1648,17 @@ func LoadConfig(filename string) (config *Config, err error) {
}
}
if !config.History.Retention.AllowIndividualDelete {
config.Server.supportedCaps.Disable(caps.MessageRedaction) // #2215
}
config.Roleplay.addSuffix = utils.BoolDefaultTrue(config.Roleplay.AddSuffix)
if config.Roleplay.NPCNickMask == "" {
config.Roleplay.NPCNickMask = defaultNPCNickMask
}
if config.Roleplay.SceneNickMask == "" {
config.Roleplay.SceneNickMask = defaultSceneNickMask
}
config.Datastore.MySQL.ExpireTime = time.Duration(config.History.Restrictions.ExpireTime)
config.Datastore.MySQL.TrackAccountMessages = config.History.Retention.EnableAccountIndexing
@ -1528,11 +1676,65 @@ func LoadConfig(filename string) (config *Config, err error) {
}
}
if !config.Metadata.Enabled {
config.Server.supportedCaps.Disable(caps.Metadata)
} else {
metadataValues := make([]string, 0, 4)
metadataValues = append(metadataValues, "before-connect")
// these are required for normal operation, so set sane defaults:
if config.Metadata.MaxSubs == 0 {
config.Metadata.MaxSubs = 10
}
metadataValues = append(metadataValues, fmt.Sprintf("max-subs=%d", config.Metadata.MaxSubs))
if config.Metadata.MaxKeys == 0 {
config.Metadata.MaxKeys = 10
}
metadataValues = append(metadataValues, fmt.Sprintf("max-keys=%d", config.Metadata.MaxKeys))
// this is not required since we enforce a hardcoded upper bound on key+value
if config.Metadata.MaxValueBytes > 0 {
metadataValues = append(metadataValues, fmt.Sprintf("max-value-bytes=%d", config.Metadata.MaxValueBytes))
}
config.Server.capValues[caps.Metadata] = strings.Join(metadataValues, ",")
}
err = config.processExtjwt()
if err != nil {
return nil, err
}
if config.WebPush.Enabled {
if config.Accounts.Multiclient.AlwaysOn == PersistentDisabled {
return nil, fmt.Errorf("Cannot enable webpush if always-on is disabled")
}
if config.WebPush.Timeout == 0 {
config.WebPush.Timeout = 10 * time.Second
}
if config.WebPush.Subscriber == "" {
config.WebPush.Subscriber = "https://ergo.chat/about"
}
if config.WebPush.MaxSubscriptions <= 0 {
config.WebPush.MaxSubscriptions = 1
}
if config.WebPush.Expiration == 0 {
config.WebPush.Expiration = custime.Duration(14 * 24 * time.Hour)
} else if config.WebPush.Expiration < custime.Duration(3*24*time.Hour) {
return nil, fmt.Errorf("webpush.expiration is too short (should be several days)")
}
} else {
config.Server.supportedCaps.Disable(caps.WebPush)
config.Server.supportedCaps.Disable(caps.SojuWebPush)
}
err = config.processAPI()
if err != nil {
return nil, err
}
config.Server.CommandAliases, err = normalizeCommandAliases(config.Server.CommandAliases)
if err != nil {
return nil, err
}
// now that all postprocessing is complete, regenerate ISUPPORT:
err = config.generateISupport()
if err != nil {
@ -1556,6 +1758,10 @@ func (config *Config) isRelaymsgIdentifier(nick string) bool {
return false
}
if strings.HasPrefix(nick, "#") {
return false // #2114
}
for _, char := range config.Server.Relaymsg.Separators {
if strings.ContainsRune(nick, char) {
return true
@ -1573,10 +1779,21 @@ func (config *Config) generateISupport() (err error) {
isupport.Initialize()
isupport.Add("AWAYLEN", strconv.Itoa(config.Limits.AwayLen))
isupport.Add("BOT", "B")
isupport.Add("CASEMAPPING", "ascii")
var casemappingToken string
switch config.Server.Casemapping {
default:
casemappingToken = "ascii" // this is published for ascii, precis, or permissive
case CasemappingRFC1459:
casemappingToken = "rfc1459"
case CasemappingRFC1459Strict:
casemappingToken = "rfc1459-strict"
}
isupport.Add("CASEMAPPING", casemappingToken)
isupport.Add("CHANLIMIT", fmt.Sprintf("%s:%d", chanTypes, config.Channels.MaxChannelsPerClient))
isupport.Add("CHANMODES", chanmodesToken)
isupport.Add("CHANMODES", modes.ChanmodesToken())
if config.History.Enabled && config.History.ChathistoryMax > 0 {
isupport.Add("CHATHISTORY", strconv.Itoa(config.History.ChathistoryMax))
// Kiwi expects this legacy token name:
isupport.Add("draft/CHATHISTORY", strconv.Itoa(config.History.ChathistoryMax))
}
isupport.Add("CHANNELLEN", strconv.Itoa(config.Limits.ChannelLen))
@ -1592,6 +1809,7 @@ func (config *Config) generateISupport() (err error) {
isupport.Add("KICKLEN", strconv.Itoa(config.Limits.KickLen))
isupport.Add("MAXLIST", fmt.Sprintf("beI:%s", strconv.Itoa(config.Limits.ChanListModes)))
isupport.Add("MAXTARGETS", maxTargetsString)
isupport.Add("MSGREFTYPES", "msgid,timestamp")
isupport.Add("MODES", "")
isupport.Add("MONITOR", strconv.Itoa(config.Limits.MonitorEntries))
isupport.Add("NETWORK", config.Network.Name)
@ -1601,6 +1819,8 @@ func (config *Config) generateISupport() (err error) {
isupport.Add("RPCHAN", "E")
isupport.Add("RPUSER", "E")
}
isupport.Add("SAFELIST", "")
isupport.Add("SAFERATE", "")
isupport.Add("STATUSMSG", "~&@%+")
isupport.Add("TARGMAX", fmt.Sprintf("NAMES:1,LIST:1,KICK:,WHOIS:1,USERHOST:10,PRIVMSG:%s,TAGMSG:%s,NOTICE:%s,MONITOR:%d", maxTargetsString, maxTargetsString, maxTargetsString, config.Limits.MonitorEntries))
isupport.Add("TOPICLEN", strconv.Itoa(config.Limits.TopicLen))
@ -1610,8 +1830,21 @@ func (config *Config) generateISupport() (err error) {
if config.Server.EnforceUtf8 {
isupport.Add("UTF8ONLY", "")
}
if config.WebPush.Enabled {
// XXX we typically don't have this at config parse time, so we'll have to regenerate
// the cached reply later
if config.WebPush.vapidKeys != nil {
isupport.Add("VAPID", config.WebPush.vapidKeys.PublicKeyString())
}
}
isupport.Add("WHOX", "")
for key, value := range config.Server.AdditionalISupport {
if !isupport.Contains(key) {
isupport.Add(key, value)
}
}
err = isupport.RegenerateCachedReply()
return
}
@ -1713,6 +1946,9 @@ func (config *Config) loadMOTD() error {
if config.Server.MOTDFormatting {
lineToSend = ircfmt.Unescape(lineToSend)
}
if config.Server.EnforceUtf8 && !utf8.ValidString(lineToSend) {
return fmt.Errorf("Line %d of MOTD contains invalid UTF8", i+1)
}
// "- " is the required prefix for MOTD
lineToSend = fmt.Sprintf("- %s", lineToSend)
config.Server.motdLines = append(config.Server.motdLines, lineToSend)
@ -1720,3 +1956,22 @@ func (config *Config) loadMOTD() error {
}
return nil
}
func normalizeCommandAliases(aliases map[string]string) (normalizedAliases map[string]string, err error) {
if len(aliases) == 0 {
return nil, nil
}
normalizedAliases = make(map[string]string, len(aliases))
for alias, command := range aliases {
alias = strings.ToUpper(alias)
command = strings.ToUpper(command)
if _, found := Commands[alias]; found {
return nil, fmt.Errorf("Command alias `%s` collides with a real Ergo command", alias)
}
if _, found := Commands[command]; !found {
return nil, fmt.Errorf("Command alias `%s` mapped to non-existent Ergo command `%s`", alias, command)
}
normalizedAliases[alias] = command
}
return normalizedAliases, nil
}

View File

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

View File

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

View File

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

View File

@ -4,9 +4,18 @@
package email
import (
"bytes"
"crypto"
"crypto/ed25519"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
dkim "github.com/toorop/go-dkim"
"fmt"
"os"
dkim "github.com/emersion/go-msgauth/dkim"
)
var (
@ -17,38 +26,77 @@ type DKIMConfig struct {
Domain string
Selector string
KeyFile string `yaml:"key-file"`
keyBytes []byte
privKey crypto.Signer
}
func (dkim *DKIMConfig) Enabled() bool {
return dkim.Domain != ""
}
func (dkim *DKIMConfig) Postprocess() (err error) {
if dkim.Domain != "" {
if dkim.Selector == "" || dkim.KeyFile == "" {
return ErrMissingFields
}
dkim.keyBytes, err = os.ReadFile(dkim.KeyFile)
if err != nil {
return err
}
if !dkim.Enabled() {
return nil
}
if dkim.Selector == "" || dkim.KeyFile == "" {
return ErrMissingFields
}
keyBytes, err := os.ReadFile(dkim.KeyFile)
if err != nil {
return fmt.Errorf("Could not read DKIM key file: %w", err)
}
dkim.privKey, err = parseDKIMPrivKey(keyBytes)
if err != nil {
return fmt.Errorf("Could not parse DKIM key file: %w", err)
}
return nil
}
var defaultOptions = dkim.SigOptions{
Version: 1,
Canonicalization: "relaxed/relaxed",
Algo: "rsa-sha256",
Headers: []string{"from", "to", "subject", "message-id", "date"},
BodyLength: 0,
QueryMethods: []string{"dns/txt"},
AddSignatureTimestamp: true,
SignatureExpireIn: 0,
func parseDKIMPrivKey(input []byte) (crypto.Signer, error) {
if len(input) == 0 {
return nil, errors.New("DKIM private key is empty")
}
// raw ed25519 private key format
if len(input) == ed25519.PrivateKeySize {
return ed25519.PrivateKey(input), nil
}
d, _ := pem.Decode(input)
if d == nil {
return nil, errors.New("Invalid PEM data for DKIM private key")
}
if rsaKey, err := x509.ParsePKCS1PrivateKey(d.Bytes); err == nil {
return rsaKey, nil
}
if k, err := x509.ParsePKCS8PrivateKey(d.Bytes); err == nil {
switch key := k.(type) {
case *rsa.PrivateKey:
return key, nil
case ed25519.PrivateKey:
return key, nil
default:
return nil, fmt.Errorf("Unacceptable type for DKIM private key: %T", k)
}
}
return nil, errors.New("No acceptable format for DKIM private key")
}
func DKIMSign(message []byte, dkimConfig DKIMConfig) (result []byte, err error) {
options := defaultOptions
options.PrivateKey = dkimConfig.keyBytes
options.Domain = dkimConfig.Domain
options.Selector = dkimConfig.Selector
err = dkim.Sign(&message, options)
return message, err
options := dkim.SignOptions{
Domain: dkimConfig.Domain,
Selector: dkimConfig.Selector,
Signer: dkimConfig.privKey,
HeaderCanonicalization: dkim.CanonicalizationRelaxed,
BodyCanonicalization: dkim.CanonicalizationRelaxed,
}
input := bytes.NewBuffer(message)
output := bytes.NewBuffer(make([]byte, 0, len(message)+1024))
err = dkim.Sign(output, input, &options)
return output.Bytes(), err
}

View File

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

View File

@ -33,6 +33,7 @@ var (
errAccountVerificationInvalidCode = errors.New("Invalid account verification code")
errAccountUpdateFailed = errors.New(`Error while updating your account information`)
errAccountMustHoldNick = errors.New(`You must hold that nickname in order to register it`)
errAuthRequired = errors.New("You must be logged into an account to do this")
errAuthzidAuthcidMismatch = errors.New(`authcid and authzid must be the same`)
errCertfpAlreadyExists = errors.New(`An account already exists for your certificate fingerprint`)
errChannelNotOwnedByAccount = errors.New("Channel not owned by the specified account")
@ -51,6 +52,7 @@ var (
errNoExistingBan = errors.New("Ban does not exist")
errNoSuchChannel = errors.New(`No such channel`)
errChannelPurged = errors.New(`This channel was purged by the server operators and cannot be used`)
errChannelPurgedAlready = errors.New(`This channel was already purged and cannot be purged again`)
errConfusableIdentifier = errors.New("This identifier is confusable with one already in use")
errInsufficientPrivs = errors.New("Insufficient privileges")
errInvalidUsername = errors.New("Invalid username")
@ -74,6 +76,8 @@ var (
errRegisteredOnly = errors.New("Cannot join registered-only channel without an account")
errValidEmailRequired = errors.New("A valid email address is required for account registration")
errInvalidAccountRename = errors.New("Account renames can only change the casefolding of the account name")
errNameReserved = errors.New(`Name reserved due to a prior registration`)
errInvalidBearerTokenType = errors.New("invalid bearer token type")
)
// String Errors
@ -96,5 +100,5 @@ type ThrottleError struct {
}
func (te *ThrottleError) Error() string {
return fmt.Sprintf(`Please wait at least %v and try again`, te.Duration)
return fmt.Sprintf(`Please wait at least %v and try again`, te.Duration.Round(time.Millisecond))
}

View File

@ -4,6 +4,7 @@
package irc
import (
"maps"
"time"
)
@ -36,6 +37,10 @@ type Fakelag struct {
func (fl *Fakelag) Initialize(config FakelagConfig) {
fl.config = config
// XXX don't share mutable member CommandBudgets:
if config.CommandBudgets != nil {
fl.config.CommandBudgets = maps.Clone(config.CommandBudgets)
}
fl.nowFunc = time.Now
fl.sleepFunc = time.Sleep
fl.state = FakelagBursting
@ -58,11 +63,16 @@ func (fl *Fakelag) Unsuspend() {
}
// register a new command, sleep if necessary to delay it
func (fl *Fakelag) Touch() {
func (fl *Fakelag) Touch(command string) {
if !fl.config.Enabled {
return
}
if budget, ok := fl.config.CommandBudgets[command]; ok && budget > 0 {
fl.config.CommandBudgets[command] = budget - 1
return
}
now := fl.nowFunc()
// XXX if lastTouch.IsZero(), treat it as "very far in the past", which is fine
elapsed := now.Sub(fl.lastTouch)

View File

@ -60,7 +60,7 @@ func TestFakelag(t *testing.T) {
window, _ := time.ParseDuration("1s")
fl, mt := newFakelagForTesting(window, 3, 2, window)
fl.Touch()
fl.Touch("")
slept, _ := mt.lastSleep()
if slept {
t.Fatalf("should not have slept")
@ -69,7 +69,7 @@ func TestFakelag(t *testing.T) {
interval, _ := time.ParseDuration("100ms")
for i := 0; i < 2; i++ {
mt.pause(interval)
fl.Touch()
fl.Touch("")
slept, _ := mt.lastSleep()
if slept {
t.Fatalf("should not have slept")
@ -77,7 +77,7 @@ func TestFakelag(t *testing.T) {
}
mt.pause(interval)
fl.Touch()
fl.Touch("")
if fl.state != FakelagThrottled {
t.Fatalf("should be throttled")
}
@ -91,7 +91,7 @@ func TestFakelag(t *testing.T) {
}
// send another message without a pause; we should have to sleep for 500 msec
fl.Touch()
fl.Touch("")
if fl.state != FakelagThrottled {
t.Fatalf("should be throttled")
}
@ -102,7 +102,7 @@ func TestFakelag(t *testing.T) {
}
mt.pause(interval * 6)
fl.Touch()
fl.Touch("")
if fl.state != FakelagThrottled {
t.Fatalf("should still be throttled")
}
@ -112,7 +112,7 @@ func TestFakelag(t *testing.T) {
}
mt.pause(window * 2)
fl.Touch()
fl.Touch("")
if fl.state != FakelagBursting {
t.Fatalf("should be bursting again")
}
@ -125,31 +125,31 @@ func TestFakelag(t *testing.T) {
func TestSuspend(t *testing.T) {
window, _ := time.ParseDuration("1s")
fl, _ := newFakelagForTesting(window, 3, 2, window)
assertEqual(fl.config.Enabled, true, t)
assertEqual(fl.config.Enabled, true)
// suspend idempotently disables
fl.Suspend()
assertEqual(fl.config.Enabled, false, t)
assertEqual(fl.config.Enabled, false)
fl.Suspend()
assertEqual(fl.config.Enabled, false, t)
assertEqual(fl.config.Enabled, false)
// unsuspend idempotently enables
fl.Unsuspend()
assertEqual(fl.config.Enabled, true, t)
assertEqual(fl.config.Enabled, true)
fl.Unsuspend()
assertEqual(fl.config.Enabled, true, t)
assertEqual(fl.config.Enabled, true)
fl.Suspend()
assertEqual(fl.config.Enabled, false, t)
assertEqual(fl.config.Enabled, false)
fl2, _ := newFakelagForTesting(window, 3, 2, window)
fl2.config.Enabled = false
// if we were never enabled, suspend and unsuspend are both no-ops
fl2.Suspend()
assertEqual(fl2.config.Enabled, false, t)
assertEqual(fl2.config.Enabled, false)
fl2.Suspend()
assertEqual(fl2.config.Enabled, false, t)
assertEqual(fl2.config.Enabled, false)
fl2.Unsuspend()
assertEqual(fl2.config.Enabled, false, t)
assertEqual(fl2.config.Enabled, false)
fl2.Unsuspend()
assertEqual(fl2.config.Enabled, false, t)
assertEqual(fl2.config.Enabled, false)
}

View File

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

View File

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

View File

@ -32,6 +32,7 @@ type webircConfig struct {
Fingerprint *string // legacy name for certfp, #1050
Certfp string
Hosts []string
AcceptHostname bool `yaml:"accept-hostname"`
allowedNets []net.IPNet
}
@ -91,7 +92,7 @@ func (client *Client) ApplyProxiedIP(session *Session, proxiedIP net.IP, tls boo
client.server.connectionLimiter.RemoveClient(flatip.FromNetIP(session.realIP))
// given IP is sane! override the client's current IP
client.server.logger.Info("connect-ip", "Accepted proxy IP for client", proxiedIP.String())
client.server.logger.Info("connect-ip", session.connID, "Accepted proxy IP for client", proxiedIP.String())
client.stateMutex.Lock()
defer client.stateMutex.Unlock()

View File

@ -4,27 +4,22 @@
package irc
import (
"fmt"
"maps"
"net"
"sync/atomic"
"slices"
"time"
"unsafe"
"github.com/ergochat/ergo/irc/caps"
"github.com/ergochat/ergo/irc/connection_limits"
"github.com/ergochat/ergo/irc/languages"
"github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/utils"
"github.com/ergochat/ergo/irc/webpush"
)
func (server *Server) Config() (config *Config) {
return (*Config)(atomic.LoadPointer(&server.config))
}
func (server *Server) SetConfig(config *Config) {
atomic.StorePointer(&server.config, unsafe.Pointer(config))
}
func (server *Server) ChannelRegistrationEnabled() bool {
return server.Config().Channels.Registration.Enabled
return server.config.Load()
}
func (server *Server) GetOperator(name string) (oper *Oper) {
@ -40,11 +35,11 @@ func (server *Server) Languages() (lm *languages.Manager) {
}
func (server *Server) Defcon() uint32 {
return atomic.LoadUint32(&server.defcon)
return server.defcon.Load()
}
func (server *Server) SetDefcon(defcon uint32) {
atomic.StoreUint32(&server.defcon, defcon)
server.defcon.Store(defcon)
}
func (client *Client) Sessions() (sessions []*Session) {
@ -62,6 +57,7 @@ type SessionData struct {
certfp string
deviceID string
connInfo string
connID string
sessionID int64
caps []string
}
@ -82,6 +78,7 @@ func (client *Client) AllSessionData(currentSession *Session, hasPrivs bool) (da
hostname: session.rawHostname,
certfp: session.certfp,
deviceID: session.deviceID,
connID: session.connID,
sessionID: session.sessionID,
}
if session.proxiedIP != nil {
@ -97,7 +94,7 @@ func (client *Client) AllSessionData(currentSession *Session, hasPrivs bool) (da
return
}
func (client *Client) AddSession(session *Session) (success bool, numSessions int, lastSeen time.Time, back bool) {
func (client *Client) AddSession(session *Session) (success bool, numSessions int, lastSeen time.Time, wasAway, nowAway string) {
config := client.server.Config()
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
@ -115,17 +112,25 @@ func (client *Client) AddSession(session *Session) (success bool, numSessions in
newSessions[len(newSessions)-1] = session
if client.accountSettings.AutoreplayMissed || session.deviceID != "" {
lastSeen = client.lastSeen[session.deviceID]
client.setLastSeen(time.Now().UTC(), session.deviceID)
}
client.setLastSeen(time.Now().UTC(), session.deviceID)
client.sessions = newSessions
// TODO(#1551) there should be a cap to opt out of this behavior on a session
if persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway) {
client.awayMessage = ""
if len(client.sessions) == 1 {
back = true
wasAway = client.awayMessage
if client.autoAwayEnabledNoMutex(config) {
client.setAutoAwayNoMutex(config)
} else {
if session.awayMessage != "" && session.awayMessage != "*" {
// set the away message
client.awayMessage = session.awayMessage
} else if session.awayMessage == "" && !session.awayAt.IsZero() {
// weird edge case: explicit `AWAY` or `AWAY :` during pre-registration makes the client back
client.awayMessage = ""
}
// else: the client sent no AWAY command at all, no-op
// or: the client sent `AWAY *`, which should not modify the publicly visible away state
}
return true, len(client.sessions), lastSeen, back
nowAway = client.awayMessage
return true, len(client.sessions), lastSeen, wasAway, nowAway
}
func (client *Client) removeSession(session *Session) (success bool, length int) {
@ -200,7 +205,7 @@ func (client *Client) Away() (result bool, message string) {
return
}
func (session *Session) SetAway(awayMessage string) {
func (session *Session) SetAway(awayMessage string) (wasAway, nowAway string) {
client := session.client
config := client.server.Config()
@ -210,15 +215,28 @@ func (session *Session) SetAway(awayMessage string) {
session.awayMessage = awayMessage
session.awayAt = time.Now().UTC()
autoAway := client.registered && client.alwaysOn && persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway)
if autoAway {
wasAway = client.awayMessage
if client.autoAwayEnabledNoMutex(config) {
client.setAutoAwayNoMutex(config)
} else {
} else if awayMessage != "*" {
client.awayMessage = awayMessage
}
} // else: `AWAY *`, should not modify publicly visible away state
nowAway = client.awayMessage
return
}
func (session *Session) ConnID() string {
if session == nil {
return "*"
}
return session.connID
}
func (client *Client) autoAwayEnabledNoMutex(config *Config) bool {
return client.registered && client.alwaysOn &&
persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway)
}
func (client *Client) setAutoAwayNoMutex(config *Config) {
// aggregate the away statuses of the individual sessions:
var globalAwayState string
@ -228,8 +246,8 @@ func (client *Client) setAutoAwayNoMutex(config *Config) {
// a session is active, we are not auto-away
client.awayMessage = ""
return
} else if cSession.awayAt.After(awaySetAt) {
// choose the latest available away message from any session
} else if cSession.awayAt.After(awaySetAt) && cSession.awayMessage != "*" {
// choose the latest valid away message from any session
globalAwayState = cSession.awayMessage
awaySetAt = cSession.awayAt
}
@ -480,6 +498,9 @@ func (client *Client) checkAlwaysOnExpirationNoMutex(config *Config, ignoreRegis
if !((client.registered || ignoreRegistration) && client.alwaysOn) {
return false
}
if len(client.lastSeen) == 0 {
return true // #2252: do not precreate the client if it was never logged into at all
}
deadline := time.Duration(config.Accounts.Multiclient.AlwaysOnExpiration)
if deadline == 0 {
return false
@ -493,6 +514,226 @@ func (client *Client) checkAlwaysOnExpirationNoMutex(config *Config, ignoreRegis
return true
}
func (client *Client) GetReadMarker(cfname string) (result string) {
client.stateMutex.RLock()
t, ok := client.readMarkers[cfname]
client.stateMutex.RUnlock()
if ok {
return fmt.Sprintf("timestamp=%s", t.Format(utils.IRCv3TimestampFormat))
}
return "*"
}
func (client *Client) getMarkreadTime(cfname string) (timestamp time.Time, ok bool) {
client.stateMutex.RLock()
timestamp, ok = client.readMarkers[cfname]
client.stateMutex.RUnlock()
return
}
func (client *Client) copyReadMarkers() (result map[string]time.Time) {
client.stateMutex.RLock()
defer client.stateMutex.RUnlock()
return maps.Clone(client.readMarkers)
}
func (client *Client) SetReadMarker(cfname string, now time.Time) (result time.Time) {
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
if client.readMarkers == nil {
client.readMarkers = make(map[string]time.Time)
}
result = updateLRUMap(client.readMarkers, cfname, now, maxReadMarkers)
client.dirtyTimestamps = true
return
}
func updateLRUMap(lru map[string]time.Time, key string, val time.Time, maxItems int) (result time.Time) {
if currentVal := lru[key]; currentVal.After(val) {
return currentVal
}
lru[key] = val
// evict the least-recently-used entry if necessary
if maxItems < len(lru) {
var minKey string
var minVal time.Time
for key, val := range lru {
if minVal.IsZero() || val.Before(minVal) {
minKey, minVal = key, val
}
}
delete(lru, minKey)
}
return val
}
func (client *Client) addClearablePushMessage(cftarget string, messageTime time.Time) {
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
if client.clearablePushMessages == nil {
client.clearablePushMessages = make(map[string]time.Time)
}
updateLRUMap(client.clearablePushMessages, cftarget, messageTime, maxReadMarkers)
}
func (client *Client) clearClearablePushMessage(cftarget string, readTimestamp time.Time) (ok bool) {
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
pushMessageTime, ok := client.clearablePushMessages[cftarget]
if ok && utils.ReadMarkerLessThanOrEqual(pushMessageTime, readTimestamp) {
delete(client.clearablePushMessages, cftarget)
return true
}
return false
}
func (client *Client) shouldFlushTimestamps() (result bool) {
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
result = client.dirtyTimestamps && client.registered && client.alwaysOn
client.dirtyTimestamps = false
return
}
func (client *Client) setKlined() {
client.stateMutex.Lock()
client.isKlined = true
client.stateMutex.Unlock()
}
func (client *Client) refreshPushSubscription(endpoint string, keys webpush.Keys) bool {
// do not mark dirty --- defer the write to periodic maintenance
now := time.Now().UTC()
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
sub, ok := client.pushSubscriptions[endpoint]
if ok && sub.Keys.Equal(keys) {
sub.LastRefresh = now
return true
}
return false // subscription doesn't exist, we need to send a test message
}
func (client *Client) addPushSubscription(endpoint string, keys webpush.Keys) error {
changed := false
defer func() {
if changed {
client.markDirty(IncludeAllAttrs)
}
}()
config := client.server.Config()
now := time.Now().UTC()
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
if client.pushSubscriptions == nil {
client.pushSubscriptions = make(map[string]*pushSubscription)
}
sub, ok := client.pushSubscriptions[endpoint]
if ok {
changed = !sub.Keys.Equal(keys)
sub.Keys = keys
sub.LastRefresh = now
} else {
if len(client.pushSubscriptions) >= config.WebPush.MaxSubscriptions {
return errLimitExceeded
}
changed = true
sub = newPushSubscription(storedPushSubscription{
Endpoint: endpoint,
Keys: keys,
LastRefresh: now,
LastSuccess: now, // assume we just sent a successful message to confirm the sub
})
client.pushSubscriptions[endpoint] = sub
}
if changed {
client.rebuildPushSubscriptionCache()
}
return nil
}
func (client *Client) hasPushSubscriptions() bool {
return client.pushSubscriptionsExist.Load() != 0
}
func (client *Client) getPushSubscriptions(refresh bool) []storedPushSubscription {
if refresh {
func() {
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
client.rebuildPushSubscriptionCache()
}()
}
client.stateMutex.RLock()
defer client.stateMutex.RUnlock()
return client.cachedPushSubscriptions
}
func (client *Client) rebuildPushSubscriptionCache() {
// must hold write lock
if len(client.pushSubscriptions) == 0 {
client.cachedPushSubscriptions = nil
client.pushSubscriptionsExist.Store(0)
return
}
client.cachedPushSubscriptions = make([]storedPushSubscription, 0, len(client.pushSubscriptions))
for _, subscription := range client.pushSubscriptions {
client.cachedPushSubscriptions = append(client.cachedPushSubscriptions, subscription.storedPushSubscription)
}
client.pushSubscriptionsExist.Store(1)
}
func (client *Client) deletePushSubscription(endpoint string, writeback bool) (changed bool) {
defer func() {
if writeback && changed {
client.markDirty(IncludeAllAttrs)
}
}()
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
_, ok := client.pushSubscriptions[endpoint]
if ok {
changed = true
delete(client.pushSubscriptions, endpoint)
client.rebuildPushSubscriptionCache()
}
return
}
func (client *Client) recordPush(endpoint string, success bool) {
now := time.Now().UTC()
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
subscription, ok := client.pushSubscriptions[endpoint]
if !ok {
return
}
if success {
subscription.LastSuccess = now
}
// TODO we may want to track failures in some way in the future
}
func (channel *Channel) Name() string {
channel.stateMutex.RLock()
defer channel.stateMutex.RUnlock()
@ -543,9 +784,11 @@ func (channel *Channel) Founder() string {
func (channel *Channel) HighestUserMode(client *Client) (result modes.Mode) {
channel.stateMutex.RLock()
clientModes := channel.members[client].modes
channel.stateMutex.RUnlock()
return clientModes.HighestChannelUserMode()
defer channel.stateMutex.RUnlock()
if clientData, ok := channel.members[client]; ok {
return clientData.modes.HighestChannelUserMode()
}
return
}
func (channel *Channel) Settings() (result ChannelSettings) {
@ -556,10 +799,12 @@ func (channel *Channel) Settings() (result ChannelSettings) {
}
func (channel *Channel) SetSettings(settings ChannelSettings) {
defer channel.MarkDirty(IncludeSettings)
channel.stateMutex.Lock()
defer channel.stateMutex.Unlock()
channel.settings = settings
channel.stateMutex.Unlock()
channel.MarkDirty(IncludeSettings)
}
func (channel *Channel) setForward(forward string) {
@ -580,3 +825,268 @@ func (channel *Channel) getAmode(cfaccount string) (result modes.Mode) {
defer channel.stateMutex.RUnlock()
return channel.accountToUMode[cfaccount]
}
func (channel *Channel) UUID() utils.UUID {
channel.stateMutex.RLock()
defer channel.stateMutex.RUnlock()
return channel.uuid
}
func (session *Session) isSubscribedTo(key string) bool {
session.client.stateMutex.RLock()
defer session.client.stateMutex.RUnlock()
return session.metadataSubscriptions.Has(key)
}
func (session *Session) SubscribeTo(keys ...string) ([]string, error) {
maxSubs := session.client.server.Config().Metadata.MaxSubs
session.client.stateMutex.Lock()
defer session.client.stateMutex.Unlock()
if session.metadataSubscriptions == nil {
session.metadataSubscriptions = make(utils.HashSet[string])
}
var added []string
for _, k := range keys {
if !session.metadataSubscriptions.Has(k) {
if len(session.metadataSubscriptions) > maxSubs {
return added, errMetadataTooManySubs
}
added = append(added, k)
session.metadataSubscriptions.Add(k)
}
}
return added, nil
}
func (session *Session) UnsubscribeFrom(keys ...string) []string {
session.client.stateMutex.Lock()
defer session.client.stateMutex.Unlock()
var removed []string
for k := range session.metadataSubscriptions {
if slices.Contains(keys, k) {
removed = append(removed, k)
session.metadataSubscriptions.Remove(k)
}
}
return removed
}
func (session *Session) MetadataSubscriptions() utils.HashSet[string] {
session.client.stateMutex.Lock()
defer session.client.stateMutex.Unlock()
return maps.Clone(session.metadataSubscriptions)
}
func (channel *Channel) GetMetadata(key string) (string, bool) {
channel.stateMutex.RLock()
defer channel.stateMutex.RUnlock()
val, ok := channel.metadata[key]
return val, ok
}
func (channel *Channel) SetMetadata(key string, value string, limit int) (updated bool, err error) {
defer channel.MarkDirty(IncludeAllAttrs)
channel.stateMutex.Lock()
defer channel.stateMutex.Unlock()
if channel.metadata == nil {
channel.metadata = make(map[string]string)
}
existing, ok := channel.metadata[key]
if !ok && len(channel.metadata) >= limit {
return false, errLimitExceeded
}
updated = !ok || value != existing
if updated {
channel.metadata[key] = value
}
return updated, nil
}
func (channel *Channel) ListMetadata() map[string]string {
channel.stateMutex.RLock()
defer channel.stateMutex.RUnlock()
return maps.Clone(channel.metadata)
}
func (channel *Channel) DeleteMetadata(key string) (updated bool) {
defer channel.MarkDirty(IncludeAllAttrs)
channel.stateMutex.Lock()
defer channel.stateMutex.Unlock()
_, updated = channel.metadata[key]
if updated {
delete(channel.metadata, key)
}
return updated
}
func (channel *Channel) ClearMetadata() map[string]string {
defer channel.MarkDirty(IncludeAllAttrs)
channel.stateMutex.Lock()
defer channel.stateMutex.Unlock()
oldMap := channel.metadata
channel.metadata = nil
return oldMap
}
func (channel *Channel) CountMetadata() int {
channel.stateMutex.RLock()
defer channel.stateMutex.RUnlock()
return len(channel.metadata)
}
func (client *Client) GetMetadata(key string) (string, bool) {
client.stateMutex.RLock()
defer client.stateMutex.RUnlock()
val, ok := client.metadata[key]
return val, ok
}
func (client *Client) SetMetadata(key string, value string, limit int) (updated bool, err error) {
var alwaysOn bool
defer func() {
if alwaysOn && updated {
client.markDirty(IncludeMetadata)
}
}()
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
alwaysOn = client.registered && client.alwaysOn
if client.metadata == nil {
client.metadata = make(map[string]string)
}
existing, ok := client.metadata[key]
if !ok && len(client.metadata) >= limit {
return false, errLimitExceeded
}
updated = !ok || value != existing
if updated {
client.metadata[key] = value
}
return updated, nil
}
func (client *Client) UpdateMetadataFromPrereg(preregData map[string]string, limit int) (updates map[string]string) {
var alwaysOn bool
defer func() {
if alwaysOn && len(updates) > 0 {
client.markDirty(IncludeMetadata)
}
}()
updates = make(map[string]string, len(preregData))
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
alwaysOn = client.registered && client.alwaysOn
if client.metadata == nil {
client.metadata = make(map[string]string)
}
for k, v := range preregData {
// do not overwrite any existing keys
_, ok := client.metadata[k]
if ok {
continue
}
if len(client.metadata) >= limit {
return // we know this is a new key
}
client.metadata[k] = v
updates[k] = v
}
return
}
func (client *Client) ListMetadata() map[string]string {
client.stateMutex.RLock()
defer client.stateMutex.RUnlock()
return maps.Clone(client.metadata)
}
func (client *Client) DeleteMetadata(key string) (updated bool) {
defer func() {
if updated {
client.markDirty(IncludeMetadata)
}
}()
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
_, updated = client.metadata[key]
if updated {
delete(client.metadata, key)
}
return updated
}
func (client *Client) ClearMetadata() (oldMap map[string]string) {
defer func() {
if len(oldMap) > 0 {
client.markDirty(IncludeMetadata)
}
}()
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
oldMap = client.metadata
client.metadata = nil
return oldMap
}
func (client *Client) CountMetadata() int {
client.stateMutex.RLock()
defer client.stateMutex.RUnlock()
return len(client.metadata)
}
func (client *Client) checkMetadataThrottle() (throttled bool, remainingTime time.Duration) {
config := client.server.Config()
if !config.Metadata.ClientThrottle.Enabled {
return false, 0
}
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
// copy client.metadataThrottle locally and then back for processing
var throttle connection_limits.GenericThrottle
throttle.ThrottleDetails = client.metadataThrottle
throttle.Duration = config.Metadata.ClientThrottle.Duration
throttle.Limit = config.Metadata.ClientThrottle.MaxAttempts
throttled, remainingTime = throttle.Touch()
client.metadataThrottle = throttle.ThrottleDetails
return
}

File diff suppressed because it is too large Load Diff

View File

@ -110,6 +110,13 @@ For instance, this would set the kill, oper, account and xline snomasks on dan:
// Help contains the help strings distributed with the IRCd.
var Help = map[string]HelpEntry{
// Commands
"accept": {
text: `ACCEPT <target>
ACCEPT allows the target user to send you direct messages, overriding any
restrictions that might otherwise prevent this. Currently, the only
applicable restriction is the +R registered-only mode.`,
},
"ambiance": {
text: `AMBIANCE <target> <text to be sent>
@ -231,11 +238,10 @@ Get an explanation of <argument>, or "index" for a list of help topics.`,
"history": {
text: `HISTORY <target> [limit]
Replay message history. <target> can be a channel name, "me" to replay direct
message history, or a nickname to replay another client's direct message
history (they must be logged into the same account as you). [limit] can be
either an integer (the maximum number of messages to replay), or a time
duration like 10m or 1h (the time window within which to replay messages).`,
Replay message history. <target> can be a channel name or a nickname you have
direct message history with. [limit] can be either an integer (the maximum
number of messages to replay), or a time duration like 10m or 1h (the time
window within which to replay messages).`,
},
"info": {
text: `INFO
@ -252,6 +258,11 @@ appropriate channel privs.`,
text: `ISON <nickname>{ <nickname>}
Returns whether the given nicks exist on the network.`,
},
"isupport": {
text: `ISUPPORT
Returns RPL_ISUPPORT lines describing the server's capabilities.`,
},
"join": {
text: `JOIN <channel>{,<channel>} [<key>{,<key>}]
@ -320,6 +331,19 @@ channels). <elistcond>s modify how the channels are selected.`,
Shows statistics about the size of the network. If <mask> is given, only
returns stats for servers matching the given mask. If <server> is given, the
command is processed by that server.`,
},
"markread": {
text: `MARKREAD <target> [timestamp]
MARKREAD updates an IRCv3 read message marker. It is not intended for use by
end users. For more details, see the latest draft of the read-marker
specification.`,
},
"metadata": {
text: `METADATA <target> <subcommand> [<everything else>...]
Retrieve and meddle with metadata for the given target.
Have a look at https://ircv3.net/specs/extensions/metadata for interesting technical information.`,
},
"mode": {
text: `MODE <target> [<modestring> [<mode arguments>...]]
@ -399,6 +423,13 @@ Leaves the given channels and shows people the given reason.`,
When the server requires a connection password to join, used to send us the
password.`,
},
"persistence": {
text: `PERSISTENCE [params]
PERSISTENCE is a command associated with an IRC protocol extension for
persistent connections. End users should probably use /NS GET ALWAYS-ON
and /NS SET ALWAYS-ON instead.`,
},
"ping": {
text: `PING <args>...
@ -414,6 +445,12 @@ Replies to a PING. Used to check link connectivity.`,
text: `PRIVMSG <target>{,<target>} <text to be sent>
Sends the text to the given targets as a PRIVMSG.`,
},
"redact": {
text: `REDACT <target> <targetmsgid> [<reason>]
Removes the message of the target msgid from the chat history of a channel
or target user.`,
},
"relaymsg": {
text: `RELAYMSG <channel> <spoofed nick> :<message>
@ -481,9 +518,9 @@ specs for more info: http://ircv3.net/specs/core/message-tags-3.3.html`,
Indicates that you're leaving the server, and shows everyone the given reason.`,
},
"register": {
text: `REGISTER <email | *> <password>
text: `REGISTER <account> <email | *> <password>
Registers an account in accordance with the draft/register capability.`,
Registers an account in accordance with the draft/account-registration capability.`,
},
"rehash": {
oper: true,
@ -557,9 +594,9 @@ The USERS command is not implemented.`,
Shows information about the given users. Takes up to 10 nicknames.`,
},
"verify": {
text: `VERIFY <account> <password>
text: `VERIFY <account> <code>
Verifies an account in accordance with the draft/register capability.`,
Verifies an account in accordance with the draft/account-registration capability.`,
},
"version": {
text: `VERSION [server]
@ -578,6 +615,11 @@ ircv3.net/specs/extensions/webirc.html
the connection from the client to the gateway, such as:
- tls: this flag indicates that the client->gateway connection is secure`,
},
"webpush": {
text: `WEBPUSH <subcommand> [arguments]
Configures web push settings. Not for direct use by end users.`,
},
"who": {
text: `WHO <name> [o]

View File

@ -4,9 +4,11 @@
package history
import (
"github.com/ergochat/ergo/irc/utils"
"slices"
"sync"
"time"
"github.com/ergochat/ergo/irc/utils"
)
type ItemType uint
@ -55,12 +57,6 @@ func (item *Item) HasMsgid(msgid string) bool {
type Predicate func(item *Item) (matches bool)
func Reverse(results []Item) {
for i, j := 0, len(results)-1; i < j; i, j = i+1, j-1 {
results[i], results[j] = results[j], results[i]
}
}
// Buffer is a ring buffer holding message/event history for a channel or user
type Buffer struct {
sync.RWMutex
@ -160,7 +156,7 @@ func (list *Buffer) betweenHelper(start, end Selector, cutoff time.Time, pred Pr
defer func() {
if !ascending {
Reverse(results)
slices.Reverse(results)
}
}()
@ -203,7 +199,7 @@ func (list *Buffer) betweenHelper(start, end Selector, cutoff time.Time, pred Pr
// returns all correspondents, in reverse time order
func (list *Buffer) allCorrespondents() (results []TargetListing) {
seen := make(utils.StringSet)
seen := make(utils.HashSet[string])
list.RLock()
defer list.RUnlock()
@ -267,7 +263,7 @@ func (list *Buffer) listCorrespondents(start, end Selector, cutoff time.Time, li
}
if !ascending {
ReverseCorrespondents(results)
slices.Reverse(results)
}
return

View File

@ -4,6 +4,7 @@
package history
import (
"slices"
"sort"
"time"
)
@ -33,8 +34,8 @@ func MergeTargets(base []TargetListing, extra []TargetListing, start, end time.T
results = make([]TargetListing, 0, prealloc)
if !ascending {
ReverseCorrespondents(base)
ReverseCorrespondents(extra)
slices.Reverse(base)
slices.Reverse(extra)
}
for len(results) < limit {
@ -64,18 +65,11 @@ func MergeTargets(base []TargetListing, extra []TargetListing, start, end time.T
}
if !ascending {
ReverseCorrespondents(results)
slices.Reverse(results)
}
return
}
func ReverseCorrespondents(results []TargetListing) {
// lol, generics when?
for i, j := 0, len(results)-1; i < j; i, j = i+1, j-1 {
results[i], results[j] = results[j], results[i]
}
}
func SortCorrespondents(list []TargetListing) {
sort.Slice(list, func(i, j int) bool {
return list[i].Time.Before(list[j].Time)

View File

@ -15,6 +15,14 @@ import (
"github.com/ergochat/ergo/irc/utils"
)
type CanDelete uint
const (
canDeleteAny CanDelete = iota // User is allowed to delete any message (for a given channel/PM)
canDeleteSelf // User is allowed to delete their own messages (ditto)
canDeleteNone // User is not allowed to delete any message (ditto)
)
const (
histservHelp = `HistServ provides commands related to history.`
)
@ -42,14 +50,13 @@ FORGET deletes all history messages sent by an account.`,
},
"delete": {
handler: histservDeleteHandler,
help: `Syntax: $bDELETE [target] <msgid>$b
help: `Syntax: $bDELETE <target> <msgid>$b
DELETE deletes an individual message by its msgid. The target is a channel
name or nickname; depending on the history implementation, this may or may not
be necessary to locate the message.`,
helpShort: `$bDELETE$b deletes an individual message by its msgid.`,
DELETE deletes an individual message by its msgid. The target is the channel
name. The msgid is the ID as can be found in the tags of that message.`,
helpShort: `$bDELETE$b deletes an individual message by its target and msgid.`,
enabled: histservEnabled,
minParams: 1,
minParams: 2,
maxParams: 2,
},
"export": {
@ -93,38 +100,53 @@ func histservForgetHandler(service *ircService, server *Server, client *Client,
service.Notice(rb, fmt.Sprintf(client.t("Enqueued account %s for message deletion"), accountName))
}
func histservDeleteHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
var target, msgid string
if len(params) == 1 {
msgid = params[0]
} else {
target, msgid = params[0], params[1]
}
// operators can delete; if individual delete is allowed, a chanop or
// the message author can delete
accountName := "*"
isChanop := false
// Returns:
//
// 1. `canDeleteAny` if the client allowed to delete other users' messages from the target, ie.:
// - the client is a channel operator, or
// - the client is an operator with "history" capability
//
// 2. `canDeleteSelf` if the client is allowed to delete their own messages from the target
// 3. `canDeleteNone` otherwise
func deletionPolicy(server *Server, client *Client, target string) CanDelete {
isOper := client.HasRoleCapabs("history")
if !isOper {
if isOper {
return canDeleteAny
} else {
if server.Config().History.Retention.AllowIndividualDelete {
channel := server.channels.Get(target)
if channel != nil && channel.ClientIsAtLeast(client, modes.Operator) {
isChanop = true
return canDeleteAny
} else {
accountName = client.AccountName()
return canDeleteSelf
}
} else {
return canDeleteNone
}
}
if !isOper && !isChanop && accountName == "*" {
}
func histservDeleteHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
target, msgid := params[0], params[1] // Fix #1881 2 params are required
canDelete := deletionPolicy(server, client, target)
accountName := "*"
if canDelete == canDeleteNone {
service.Notice(rb, client.t("Insufficient privileges"))
return
} else if canDelete == canDeleteSelf {
accountName = client.AccountName()
if accountName == "*" {
service.Notice(rb, client.t("Insufficient privileges"))
return
}
}
err := server.DeleteMessage(target, msgid, accountName)
if err == nil {
service.Notice(rb, client.t("Successfully deleted message"))
} else {
isOper := client.HasRoleCapabs("history")
if isOper {
service.Notice(rb, fmt.Sprintf(client.t("Error deleting message: %v"), err))
} else {
@ -142,7 +164,7 @@ func histservExportHandler(service *ircService, server *Server, client *Client,
config := server.Config()
// don't include the account name in the filename because of escaping concerns
filename := fmt.Sprintf("%s-%s.json", utils.GenerateSecretToken(), time.Now().UTC().Format(IRCv3TimestampFormat))
filename := fmt.Sprintf("%s-%s.json", utils.GenerateSecretToken(), time.Now().UTC().Format(utils.IRCv3TimestampFormat))
pathname := config.getOutputPath(filename)
outfile, err := os.Create(pathname)
if err != nil {
@ -155,7 +177,7 @@ func histservExportHandler(service *ircService, server *Server, client *Client,
}
func histservExportAndNotify(service *ircService, server *Server, cfAccount string, outfile *os.File, filename, alertNick string) {
defer server.HandlePanic()
defer server.HandlePanic(nil)
defer outfile.Close()
writer := bufio.NewWriter(outfile)

View File

@ -63,7 +63,7 @@ disabled. A server operator can view someone else's status.`,
handler: hsSetHandler,
help: `Syntax: $bSET <user> <vhost>$b
SET sets a user's vhost, bypassing the request system.`,
SET sets a user's vhost.`,
helpShort: `$bSET$b sets a user's vhost.`,
capabs: []string{"vhosts"},
enabled: hostservEnabled,
@ -193,6 +193,6 @@ func hsSetCloakSecretHandler(service *ircService, server *Server, client *Client
service.Notice(rb, fmt.Sprintf(client.t("To confirm, run this command: %s"), fmt.Sprintf("/HS SETCLOAKSECRET %s %s", secret, expectedCode)))
return
}
StoreCloakSecret(server.store, secret)
StoreCloakSecret(server.dstore, secret)
service.Notice(rb, client.t("Rotated the cloak secret; you must rehash or restart the server for it to take effect"))
}

View File

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

View File

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

View File

@ -5,12 +5,18 @@ package isupport
import (
"fmt"
"sort"
"slices"
"strings"
)
const (
maxLastArgLength = 400
maxPayloadLength = 380
/* 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
@ -41,6 +47,12 @@ func (il *List) AddNoValue(name string) {
il.Tokens[name] = ""
}
// Contains returns whether the list already contains a token
func (il *List) Contains(name string) bool {
_, ok := il.Tokens[name]
return ok
}
// getTokenString gets the appropriate string for a token+value.
func getTokenString(name string, value string) string {
if len(value) == 0 {
@ -52,7 +64,7 @@ func getTokenString(name string, value string) string {
// GetDifference returns the difference between two token lists.
func (il *List) GetDifference(newil *List) [][]string {
var outTokens sort.StringSlice
var outTokens []string
// append removed tokens
for name := range il.Tokens {
@ -78,7 +90,7 @@ func (il *List) GetDifference(newil *List) [][]string {
outTokens = append(outTokens, token)
}
sort.Sort(outTokens)
slices.Sort(outTokens)
// create output list
replies := make([][]string, 0)
@ -86,7 +98,7 @@ func (il *List) GetDifference(newil *List) [][]string {
var cache []string // Token list cache
for _, token := range outTokens {
if len(token)+length <= maxLastArgLength {
if len(token)+length <= maxPayloadLength {
// account for the space separating tokens
if len(cache) > 0 {
length++
@ -95,7 +107,7 @@ func (il *List) GetDifference(newil *List) [][]string {
length += len(token)
}
if len(cache) == 13 || len(token)+length >= maxLastArgLength {
if len(cache) == maxParameters || len(token)+length >= maxPayloadLength {
replies = append(replies, cache)
cache = make([]string, 0)
length = 0
@ -109,40 +121,54 @@ func (il *List) GetDifference(newil *List) [][]string {
return replies
}
func validateToken(token string) error {
if len(token) == 0 || token[0] == ':' || strings.Contains(token, " ") {
return fmt.Errorf("bad isupport token (cannot be sent as IRC parameter): `%s`", token)
}
if strings.ContainsAny(token, "\n\r\x00") {
return fmt.Errorf("bad isupport token (contains forbidden octets)")
}
// technically a token can be maxPayloadLength if it occurs alone,
// but fail it just to be safe
if len(token) >= maxPayloadLength {
return fmt.Errorf("bad isupport token (too long): `%s`", token)
}
return nil
}
// RegenerateCachedReply regenerates the cached RPL_ISUPPORT reply
func (il *List) RegenerateCachedReply() (err error) {
il.CachedReply = make([][]string, 0)
var length int // Length of the current cache
var cache []string // Token list cache
// make sure we get a sorted list of tokens, needed for tests and looks nice
var tokens sort.StringSlice
for name := range il.Tokens {
tokens = append(tokens, name)
var tokens []string
for name, value := range il.Tokens {
token := getTokenString(name, value)
if tokenErr := validateToken(token); tokenErr == nil {
tokens = append(tokens, token)
} else {
err = tokenErr
}
}
sort.Sort(tokens)
// make sure we get a sorted list of tokens, needed for tests and looks nice
slices.Sort(tokens)
for _, name := range tokens {
token := getTokenString(name, il.Tokens[name])
if token[0] == ':' || strings.Contains(token, " ") {
err = fmt.Errorf("bad isupport token (cannot contain spaces or start with :): %s", token)
continue
}
var cache []string // Tokens in current line
var length int // Length of the current line
if len(token)+length <= maxLastArgLength {
// account for the space separating tokens
if len(cache) > 0 {
length++
}
cache = append(cache, token)
length += len(token)
}
if len(cache) == 13 || len(token)+length >= maxLastArgLength {
for _, token := range tokens {
// account for the space separating tokens
if len(cache) == maxParameters || (len(token)+1)+length > maxPayloadLength {
il.CachedReply = append(il.CachedReply, cache)
cache = make([]string, 0)
cache = nil
length = 0
}
if len(cache) > 0 {
length++
}
length += len(token)
cache = append(cache, token)
}
if len(cache) > 0 {

View File

@ -37,7 +37,7 @@ func TestISUPPORT(t *testing.T) {
}
if !reflect.DeepEqual(tListLong.CachedReply, longReplies) {
t.Errorf("Multiple output replies did not match, got [%v]", longReplies)
t.Errorf("Multiple output replies did not match, got [%v]", tListLong.CachedReply)
}
// create first list

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

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

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

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

View File

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

View File

@ -66,11 +66,12 @@ func (km *KLineManager) AllBans() map[string]IPBanInfo {
}
// AddMask adds to the blocked list.
func (km *KLineManager) AddMask(mask string, duration time.Duration, reason, operReason, operName string) error {
func (km *KLineManager) AddMask(mask string, duration time.Duration, requireSASL bool, reason, operReason, operName string) error {
km.persistenceMutex.Lock()
defer km.persistenceMutex.Unlock()
info := IPBanInfo{
RequireSASL: requireSASL,
Reason: reason,
OperReason: operReason,
OperName: operName,
@ -208,13 +209,14 @@ func (km *KLineManager) CheckMasks(masks ...string) (isBanned bool, info IPBanIn
for _, entryInfo := range km.entries {
for _, mask := range masks {
if entryInfo.Matcher.MatchString(mask) {
return true, entryInfo.Info
// apply the most stringent ban (unconditional bans override require-sasl)
if !isBanned || info.RequireSASL {
isBanned, info = true, entryInfo.Info
}
}
}
}
// no matches!
isBanned = false
return
}

View File

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

View File

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

View File

@ -69,7 +69,7 @@ type Manager struct {
loggers []singleLogger
stdoutWriteLock sync.Mutex // use one lock for both stdout and stderr
fileWriteLock sync.Mutex
loggingRawIO uint32
loggingRawIO atomic.Uint32
}
// LoggingConfig represents the configuration of a single logger.
@ -107,7 +107,7 @@ func (logger *Manager) ApplyConfig(config []LoggingConfig) error {
}
logger.loggers = nil
atomic.StoreUint32(&logger.loggingRawIO, 0)
logger.loggingRawIO.Store(0)
// for safety, this deep-copies all mutable data in `config`
// XXX let's keep it that way
@ -138,7 +138,7 @@ func (logger *Manager) ApplyConfig(config []LoggingConfig) error {
ioEnabled := typeMap["userinput"] || typeMap["useroutput"] || (typeMap["*"] && !(excludedTypeMap["userinput"] && excludedTypeMap["useroutput"]))
// raw I/O is only logged at level debug;
if ioEnabled && logConfig.Level == LogDebug {
atomic.StoreUint32(&logger.loggingRawIO, 1)
logger.loggingRawIO.Store(1)
}
if sLogger.MethodFile.Enabled {
file, err := os.OpenFile(sLogger.MethodFile.Filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0666)
@ -157,7 +157,7 @@ func (logger *Manager) ApplyConfig(config []LoggingConfig) error {
// IsLoggingRawIO returns true if raw user input and output is being logged.
func (logger *Manager) IsLoggingRawIO() bool {
return atomic.LoadUint32(&logger.loggingRawIO) == 1
return logger.loggingRawIO.Load() == 1
}
// Log logs the given message with the given details.

View File

@ -45,7 +45,7 @@ type MessageCache struct {
func addAllTags(msg *ircmsg.Message, tags map[string]string, serverTime time.Time, msgid, accountName string, isBot bool) {
msg.UpdateTags(tags)
msg.SetTag("time", serverTime.Format(IRCv3TimestampFormat))
msg.SetTag("time", serverTime.Format(utils.IRCv3TimestampFormat))
if accountName != "*" {
msg.SetTag("account", accountName)
}
@ -79,11 +79,10 @@ func (m *MessageCache) Initialize(server *Server, serverTime time.Time, msgid st
m.params = params
var msg ircmsg.Message
config := server.Config()
if config.Server.Compatibility.forceTrailing && commandsThatMustUseTrailing[command] {
if forceTrailing(server.Config(), command) {
msg.ForceTrailing()
}
msg.Prefix = nickmask
msg.Source = nickmask
msg.Command = command
msg.Params = make([]string, len(params))
copy(msg.Params, params)
@ -111,8 +110,7 @@ func (m *MessageCache) InitializeSplitMessage(server *Server, nickmask, accountN
m.target = target
m.splitMessage = message
config := server.Config()
forceTrailing := config.Server.Compatibility.forceTrailing && commandsThatMustUseTrailing[command]
forceTrailing := forceTrailing(server.Config(), command)
if message.Is512() {
isTagmsg := command == "TAGMSG"
@ -121,7 +119,7 @@ func (m *MessageCache) InitializeSplitMessage(server *Server, nickmask, accountN
msg.ForceTrailing()
}
msg.Prefix = nickmask
msg.Source = nickmask
msg.Command = command
if isTagmsg {
msg.Params = []string{target}
@ -146,7 +144,7 @@ func (m *MessageCache) InitializeSplitMessage(server *Server, nickmask, accountN
if forceTrailing {
msg.ForceTrailing()
}
msg.Prefix = nickmask
msg.Source = nickmask
msg.Command = command
msg.Params = make([]string, 2)
msg.Params[0] = target

174
irc/metadata.go Normal file
View File

@ -0,0 +1,174 @@
package irc
import (
"errors"
"iter"
"maps"
"regexp"
"unicode/utf8"
"github.com/ergochat/ergo/irc/caps"
"github.com/ergochat/ergo/irc/modes"
)
const (
// metadata key + value need to be relayable on a single IRC RPL_KEYVALUE line
maxCombinedMetadataLenBytes = 350
)
var (
errMetadataTooManySubs = errors.New("too many subscriptions")
errMetadataNotFound = errors.New("key not found")
)
type MetadataHaver interface {
SetMetadata(key string, value string, limit int) (updated bool, err error)
GetMetadata(key string) (string, bool)
DeleteMetadata(key string) (updated bool)
ListMetadata() map[string]string
ClearMetadata() map[string]string
CountMetadata() int
}
func notifySubscribers(server *Server, session *Session, targetObj MetadataHaver, targetName, key, value string, set bool) {
var recipientSessions iter.Seq[*Session]
switch target := targetObj.(type) {
case *Client:
// TODO this case is expensive and might warrant rate-limiting
friends := target.FriendsMonitors(caps.Metadata)
// broadcast metadata update to other connected sessions
for _, s := range target.Sessions() {
friends.Add(s)
}
recipientSessions = maps.Keys(friends)
case *Channel:
recipientSessions = target.sessionsWithCaps(caps.Metadata)
default:
return // impossible
}
broadcastMetadataUpdate(server, recipientSessions, session, targetName, key, value, set)
}
func broadcastMetadataUpdate(server *Server, sessions iter.Seq[*Session], originator *Session, target, key, value string, set bool) {
for s := range sessions {
// don't notify the session that made the change
if s == originator || !s.isSubscribedTo(key) {
continue
}
if set {
s.Send(nil, server.name, "METADATA", target, key, "*", value)
} else {
s.Send(nil, server.name, "METADATA", target, key, "*")
}
}
}
func syncClientMetadata(server *Server, rb *ResponseBuffer, target *Client) {
batchId := rb.StartNestedBatch("metadata", target.Nick())
defer rb.EndNestedBatch(batchId)
subs := rb.session.MetadataSubscriptions()
values := target.ListMetadata()
for k, v := range values {
if subs.Has(k) {
visibility := "*"
rb.Add(nil, server.name, "METADATA", target.Nick(), k, visibility, v)
}
}
}
func syncChannelMetadata(server *Server, rb *ResponseBuffer, channel *Channel) {
batchId := rb.StartNestedBatch("metadata", channel.Name())
defer rb.EndNestedBatch(batchId)
subs := rb.session.MetadataSubscriptions()
chname := channel.Name()
values := channel.ListMetadata()
for k, v := range values {
if subs.Has(k) {
visibility := "*"
rb.Add(nil, server.name, "METADATA", chname, k, visibility, v)
}
}
for _, client := range channel.Members() {
values := client.ListMetadata()
for k, v := range values {
if subs.Has(k) {
visibility := "*"
rb.Add(nil, server.name, "METADATA", client.Nick(), k, visibility, v)
}
}
}
}
func playMetadataList(rb *ResponseBuffer, nick, target string, values map[string]string) {
batchId := rb.StartNestedBatch("metadata", target)
defer rb.EndNestedBatch(batchId)
for key, val := range values {
visibility := "*"
rb.Add(nil, rb.session.client.server.name, RPL_KEYVALUE, nick, target, key, visibility, val)
}
}
func playMetadataVerbBatch(rb *ResponseBuffer, target string, values map[string]string) {
batchId := rb.StartNestedBatch("metadata", target)
defer rb.EndNestedBatch(batchId)
for key, val := range values {
visibility := "*"
rb.Add(nil, rb.session.client.server.name, "METADATA", target, key, visibility, val)
}
}
var validMetadataKeyRegexp = regexp.MustCompile("^[a-z0-9_./-]+$")
func metadataKeyIsEvil(key string) bool {
return !validMetadataKeyRegexp.MatchString(key)
}
func metadataValueIsEvil(config *Config, key, value string) (failMsg string) {
if !globalUtf8EnforcementSetting && !utf8.ValidString(value) {
return `METADATA values must be UTF-8`
}
if len(key)+len(value) > maxCombinedMetadataLenBytes ||
(config.Metadata.MaxValueBytes > 0 && len(value) > config.Metadata.MaxValueBytes) {
return `Value is too long`
}
return "" // success
}
func metadataCanIEditThisKey(client *Client, targetObj MetadataHaver, key string) bool {
// no key-specific logic as yet
return metadataCanIEditThisTarget(client, targetObj)
}
func metadataCanIEditThisTarget(client *Client, targetObj MetadataHaver) bool {
switch target := targetObj.(type) {
case *Client:
return client == target || client.HasRoleCapabs("metadata")
case *Channel:
return target.ClientIsAtLeast(client, modes.Operator) || client.HasRoleCapabs("metadata")
default:
return false // impossible
}
}
func metadataCanISeeThisTarget(client *Client, targetObj MetadataHaver) bool {
switch target := targetObj.(type) {
case *Client:
return true
case *Channel:
return target.hasClient(client) || client.HasRoleCapabs("metadata")
default:
return false // impossible
}
}

25
irc/metadata_test.go Normal file
View File

@ -0,0 +1,25 @@
package irc
import "testing"
func TestKeyCheck(t *testing.T) {
cases := []struct {
input string
isEvil bool
}{
{"ImNormalButIHaveCaps", true},
{"imnormalandidonthavecaps", false},
{"ergo.chat/vendor-extension", false},
{"", true},
{":imevil", true},
{"im:evil", true},
{"key£with$not%allowed^chars", true},
{"key.thats_completely/normal-and.fine", false},
}
for _, c := range cases {
if metadataKeyIsEvil(c.input) != c.isEvil {
t.Errorf("%s should have returned %v. but it didn't. so that's not great", c.input, c.isEvil)
}
}
}

View File

@ -9,9 +9,14 @@ import (
)
func TestZncTimestampParser(t *testing.T) {
assertEqual(zncWireTimeToTime("1558338348.988"), time.Unix(1558338348, 988000000).UTC(), t)
assertEqual(zncWireTimeToTime("1558338348.9"), time.Unix(1558338348, 900000000).UTC(), t)
assertEqual(zncWireTimeToTime("1558338348"), time.Unix(1558338348, 0).UTC(), t)
assertEqual(zncWireTimeToTime(".988"), time.Unix(0, 988000000).UTC(), t)
assertEqual(zncWireTimeToTime("garbage"), time.Unix(0, 0).UTC(), t)
assertEqual(zncWireTimeToTime("1558338348.988"), time.Unix(1558338348, 988000000).UTC())
assertEqual(zncWireTimeToTime("1558338348.9"), time.Unix(1558338348, 900000000).UTC())
assertEqual(zncWireTimeToTime("1558338348"), time.Unix(1558338348, 0).UTC())
assertEqual(zncWireTimeToTime("1558338348.99999999999999999999999999999"), time.Unix(1558338348, 999999999).UTC())
assertEqual(zncWireTimeToTime("1558338348.999999999111111111"), time.Unix(1558338348, 999999999).UTC())
assertEqual(zncWireTimeToTime("1558338348.999999991111111111"), time.Unix(1558338348, 999999991).UTC())
assertEqual(zncWireTimeToTime(".988"), time.Unix(0, 988000000).UTC())
assertEqual(zncWireTimeToTime("0"), time.Unix(0, 0).UTC())
assertEqual(zncWireTimeToTime("garbage"), time.Unix(0, 0).UTC())
assertEqual(zncWireTimeToTime(""), time.Unix(0, 0).UTC())
}

View File

@ -116,7 +116,7 @@ func ApplyUserModeChanges(client *Client, changes modes.ModeChanges, force bool,
}
// parseDefaultModes uses the provided mode change parser to parse the rawModes.
func parseDefaultModes(rawModes string, parser func(params ...string) (modes.ModeChanges, map[rune]bool)) modes.Modes {
func parseDefaultModes(rawModes string, parser func(params ...string) (modes.ModeChanges, []rune)) modes.Modes {
modeChangeStrings := strings.Fields(rawModes)
modeChanges, _ := parser(modeChangeStrings...)
defaultModes := make(modes.Modes, 0)
@ -158,7 +158,6 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c
var alreadySentPrivError bool
maskOpCount := 0
chname := channel.Name()
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 {
if !hasPrivs(change) {
if !alreadySentPrivError {
@ -203,7 +207,6 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c
switch change.Mode {
case modes.BanMask, modes.ExceptMask, modes.InviteMask:
maskOpCount += 1
if change.Op == modes.List {
channel.ShowMaskList(client, change.Mode, rb)
continue
@ -212,7 +215,7 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c
mask := change.Arg
switch change.Op {
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] {
rb.Add(nil, client.server.name, ERR_BANLISTFULL, details.nick, chname, change.Mode.String(), client.t("Channel list is full"))
listFullWarned[change.Mode] = true
@ -263,9 +266,9 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c
case modes.Add:
ch := client.server.channels.Get(change.Arg)
if ch == nil {
rb.Add(nil, client.server.name, ERR_INVALIDMODEPARAM, details.nick, chname, string(change.Mode), utils.SafeErrorParam(change.Arg), fmt.Sprintf(client.t("No such channel")))
rb.Add(nil, client.server.name, ERR_INVALIDMODEPARAM, details.nick, chname, string(change.Mode), utils.SafeErrorParam(change.Arg), client.t("No such channel"))
} else if ch == channel {
rb.Add(nil, client.server.name, ERR_INVALIDMODEPARAM, details.nick, chname, string(change.Mode), utils.SafeErrorParam(change.Arg), fmt.Sprintf(client.t("You can't forward a channel to itself")))
rb.Add(nil, client.server.name, ERR_INVALIDMODEPARAM, details.nick, chname, string(change.Mode), utils.SafeErrorParam(change.Arg), client.t("You can't forward a channel to itself"))
} else {
if isSamode || ch.ClientIsAtLeast(client, modes.ChannelOperator) {
change.Arg = ch.Name()
@ -313,11 +316,14 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c
default:
// all channel modes with no args, e.g., InviteOnly, Secret
if change.Op == modes.List {
shouldSendModeIsLine = true
continue
}
if channel.flags.SetMode(change.Mode, change.Op == modes.Add) {
applied = append(applied, change)
} else {
shouldSendModeIsLine = true
}
}
}
@ -337,8 +343,7 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c
channel.MarkDirty(includeFlags)
}
// #649: don't send 324 RPL_CHANNELMODEIS if we were only working with mask lists
if len(applied) == 0 && !alreadySentPrivError && (maskOpCount == 0 || maskOpCount < len(changes)) {
if len(applied) == 0 && !alreadySentPrivError && shouldSendModeIsLine {
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_CREATIONTIME, details.nick, chname, strconv.FormatInt(channel.createdTime.Unix(), 10))

View File

@ -7,7 +7,7 @@ package modes
import (
"fmt"
"sort"
"slices"
"strings"
"github.com/ergochat/ergo/irc/utils"
@ -189,10 +189,7 @@ func GetLowestChannelModePrefix(prefixes string) (lowest Mode) {
//
// ParseUserModeChanges returns the valid changes, and the list of unknown chars.
func ParseUserModeChanges(params ...string) (ModeChanges, map[rune]bool) {
changes := make(ModeChanges, 0)
unknown := make(map[rune]bool)
func ParseUserModeChanges(params ...string) (changes ModeChanges, unknown []rune) {
op := List
if 0 < len(params) {
@ -219,19 +216,11 @@ func ParseUserModeChanges(params ...string) (ModeChanges, map[rune]bool) {
}
}
var isKnown bool
for _, supportedMode := range SupportedUserModes {
if rune(supportedMode) == mode {
isKnown = true
break
}
if slices.Contains(SupportedUserModes, Mode(mode)) {
changes = append(changes, change)
} else {
unknown = append(unknown, mode)
}
if !isKnown {
unknown[mode] = true
continue
}
changes = append(changes, change)
}
}
@ -239,10 +228,7 @@ func ParseUserModeChanges(params ...string) (ModeChanges, map[rune]bool) {
}
// ParseChannelModeChanges returns the valid changes, and the list of unknown chars.
func ParseChannelModeChanges(params ...string) (ModeChanges, map[rune]bool) {
changes := make(ModeChanges, 0)
unknown := make(map[rune]bool)
func ParseChannelModeChanges(params ...string) (changes ModeChanges, unknown []rune) {
op := List
if 0 < len(params) {
@ -304,25 +290,11 @@ func ParseChannelModeChanges(params ...string) (ModeChanges, map[rune]bool) {
}
}
var isKnown bool
for _, supportedMode := range SupportedChannelModes {
if rune(supportedMode) == mode {
isKnown = true
break
}
if slices.Contains(SupportedChannelModes, Mode(mode)) || slices.Contains(ChannelUserModes, Mode(mode)) {
changes = append(changes, change)
} else {
unknown = append(unknown, mode)
}
for _, supportedMode := range ChannelUserModes {
if rune(supportedMode) == mode {
isKnown = true
break
}
}
if !isKnown {
unknown[mode] = true
continue
}
changes = append(changes, change)
}
}
@ -345,6 +317,10 @@ func NewModeSet() *ModeSet {
return &set
}
func (set *ModeSet) Clear() {
utils.BitsetClear(set[:])
}
// test whether `mode` is set
func (set *ModeSet) HasMode(mode Mode) bool {
if set == nil {
@ -424,33 +400,37 @@ func (set *ModeSet) HighestChannelUserMode() (result Mode) {
return
}
type ByCodepoint Modes
var (
rplMyInfo1, rplMyInfo2, rplMyInfo3, chanmodesToken string
)
func (a ByCodepoint) Len() int { return len(a) }
func (a ByCodepoint) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByCodepoint) Less(i, j int) bool { return a[i] < a[j] }
func init() {
initRplMyInfo()
initChanmodesToken()
}
func RplMyInfo() (param1, param2, param3 string) {
func initRplMyInfo() {
// initialize constant strings published in initial numerics
userModes := make(Modes, len(SupportedUserModes), len(SupportedUserModes)+1)
copy(userModes, SupportedUserModes)
// TLS is not in SupportedUserModes because it can't be modified
userModes = append(userModes, TLS)
sort.Sort(ByCodepoint(userModes))
slices.Sort(userModes)
channelModes := make(Modes, len(SupportedChannelModes)+len(ChannelUserModes))
copy(channelModes, SupportedChannelModes)
copy(channelModes[len(SupportedChannelModes):], ChannelUserModes)
sort.Sort(ByCodepoint(channelModes))
slices.Sort(channelModes)
// XXX enumerate these by hand, i can't see any way to DRY this
channelParametrizedModes := Modes{BanMask, ExceptMask, InviteMask, Key, UserLimit, Forward}
channelParametrizedModes = append(channelParametrizedModes, ChannelUserModes...)
sort.Sort(ByCodepoint(channelParametrizedModes))
slices.Sort(channelParametrizedModes)
return userModes.String(), channelModes.String(), channelParametrizedModes.String()
rplMyInfo1, rplMyInfo2, rplMyInfo3 = userModes.String(), channelModes.String(), channelParametrizedModes.String()
}
func ChanmodesToken() (result string) {
func initChanmodesToken() {
// https://modern.ircdocs.horse#chanmodes-parameter
// type A: listable modes with parameters
A := Modes{BanMask, ExceptMask, InviteMask}
@ -461,10 +441,18 @@ func ChanmodesToken() (result string) {
// type D: modes without parameters
D := Modes{InviteOnly, Moderated, NoOutside, OpOnlyTopic, ChanRoleplaying, Secret, NoCTCP, RegisteredOnly, RegisteredOnlySpeak, Auditorium, OpModerated}
sort.Sort(ByCodepoint(A))
sort.Sort(ByCodepoint(B))
sort.Sort(ByCodepoint(C))
sort.Sort(ByCodepoint(D))
slices.Sort(A)
slices.Sort(B)
slices.Sort(C)
slices.Sort(D)
return fmt.Sprintf("%s,%s,%s,%s", A.String(), B.String(), C.String(), D.String())
chanmodesToken = fmt.Sprintf("%s,%s,%s,%s", A.String(), B.String(), C.String(), D.String())
}
func RplMyInfo() (param1, param2, param3 string) {
return rplMyInfo1, rplMyInfo2, rplMyInfo3
}
func ChanmodesToken() (result string) {
return chanmodesToken
}

View File

@ -5,6 +5,7 @@ package modes
import (
"reflect"
"slices"
"strings"
"testing"
)
@ -16,7 +17,7 @@ func assertEqual(supplied, expected interface{}, t *testing.T) {
}
func TestParseUserModeChanges(t *testing.T) {
emptyUnknown := make(map[rune]bool)
var emptyUnknown []rune
changes, unknown := ParseUserModeChanges("+i")
assertEqual(unknown, emptyUnknown, t)
assertEqual(changes, ModeChanges{ModeChange{Op: Add, Mode: Invisible}}, t)
@ -48,10 +49,11 @@ func TestParseUserModeChanges(t *testing.T) {
}
func TestIssue874(t *testing.T) {
emptyUnknown := make(map[rune]bool)
var emptyModeChanges ModeChanges
var emptyUnknown []rune
modes, unknown := ParseChannelModeChanges("+k")
assertEqual(unknown, emptyUnknown, t)
assertEqual(modes, ModeChanges{}, t)
assertEqual(modes, emptyModeChanges, t)
modes, unknown = ParseChannelModeChanges("+k", "beer")
assertEqual(unknown, emptyUnknown, t)
@ -151,7 +153,7 @@ func TestParseChannelModeChanges(t *testing.T) {
}
modes, unknown = ParseChannelModeChanges("+tx")
if len(unknown) != 1 || !unknown['x'] {
if len(unknown) != 1 || !slices.Contains(unknown, 'x') {
t.Errorf("expected that x is an unknown mode, instead: %v", unknown)
}
expected = ModeChange{

View File

@ -4,6 +4,7 @@
package irc
import (
"fmt"
"reflect"
"testing"
@ -74,21 +75,21 @@ func TestUmodeGreaterThan(t *testing.T) {
}
}
func assertEqual(supplied, expected interface{}, t *testing.T) {
if !reflect.DeepEqual(supplied, expected) {
t.Errorf("expected %v but got %v", expected, supplied)
func assertEqual(found, expected interface{}) {
if !reflect.DeepEqual(found, expected) {
panic(fmt.Sprintf("found %#v, expected %#v", found, expected))
}
}
func TestChannelUserModeHasPrivsOver(t *testing.T) {
assertEqual(channelUserModeHasPrivsOver(modes.Voice, modes.Halfop), false, t)
assertEqual(channelUserModeHasPrivsOver(modes.Mode(0), modes.Halfop), false, t)
assertEqual(channelUserModeHasPrivsOver(modes.Voice, modes.Mode(0)), false, t)
assertEqual(channelUserModeHasPrivsOver(modes.ChannelAdmin, modes.ChannelAdmin), false, t)
assertEqual(channelUserModeHasPrivsOver(modes.Halfop, modes.Halfop), false, t)
assertEqual(channelUserModeHasPrivsOver(modes.Voice, modes.Voice), false, t)
assertEqual(channelUserModeHasPrivsOver(modes.Voice, modes.Halfop), false)
assertEqual(channelUserModeHasPrivsOver(modes.Mode(0), modes.Halfop), false)
assertEqual(channelUserModeHasPrivsOver(modes.Voice, modes.Mode(0)), false)
assertEqual(channelUserModeHasPrivsOver(modes.ChannelAdmin, modes.ChannelAdmin), false)
assertEqual(channelUserModeHasPrivsOver(modes.Halfop, modes.Halfop), false)
assertEqual(channelUserModeHasPrivsOver(modes.Voice, modes.Voice), false)
assertEqual(channelUserModeHasPrivsOver(modes.Halfop, modes.Voice), true, t)
assertEqual(channelUserModeHasPrivsOver(modes.ChannelFounder, modes.ChannelAdmin), true, t)
assertEqual(channelUserModeHasPrivsOver(modes.ChannelOperator, modes.ChannelOperator), true, t)
assertEqual(channelUserModeHasPrivsOver(modes.Halfop, modes.Voice), true)
assertEqual(channelUserModeHasPrivsOver(modes.ChannelFounder, modes.ChannelAdmin), true)
assertEqual(channelUserModeHasPrivsOver(modes.ChannelOperator, modes.ChannelOperator), true)
}

View File

@ -7,6 +7,7 @@ import (
"sync"
"github.com/ergochat/ergo/irc/caps"
"github.com/ergochat/ergo/irc/utils"
"github.com/ergochat/irc-go/ircmsg"
)
@ -17,27 +18,27 @@ type MonitorManager struct {
// client -> (casefolded nick it's watching -> uncasefolded nick)
watching map[*Session]map[string]string
// casefolded nick -> clients watching it
watchedby map[string]map[*Session]empty
watchedby map[string]utils.HashSet[*Session]
}
func (mm *MonitorManager) Initialize() {
mm.watching = make(map[*Session]map[string]string)
mm.watchedby = make(map[string]map[*Session]empty)
mm.watchedby = make(map[string]utils.HashSet[*Session])
}
// AddMonitors adds clients using extended-monitor monitoring `client`'s nick to the passed user set.
func (manager *MonitorManager) AddMonitors(users map[*Session]empty, cfnick string, capabs ...caps.Capability) {
func (manager *MonitorManager) AddMonitors(users utils.HashSet[*Session], cfnick string, capabs ...caps.Capability) {
manager.RLock()
defer manager.RUnlock()
for session := range manager.watchedby[cfnick] {
if session.capabilities.Has(caps.ExtendedMonitor) && session.capabilities.HasAll(capabs...) {
users[session] = empty{}
users.Add(session)
}
}
}
// AlertAbout alerts everyone monitoring `client`'s nick that `client` is now {on,off}line.
func (manager *MonitorManager) AlertAbout(nick, cfnick string, online bool) {
func (manager *MonitorManager) AlertAbout(nick, cfnick string, online bool, client *Client) {
var watchers []*Session
// safely copy the list of clients watching our nick
manager.RLock()
@ -51,8 +52,21 @@ func (manager *MonitorManager) AlertAbout(nick, cfnick string, online bool) {
command = RPL_MONONLINE
}
var metadata map[string]string
if online && client != nil {
metadata = client.ListMetadata()
}
for _, session := range watchers {
session.Send(nil, session.client.server.name, command, session.client.Nick(), nick)
if metadata != nil && session.capabilities.Has(caps.Metadata) {
for key := range session.MetadataSubscriptions() {
if val, ok := metadata[key]; ok {
session.Send(nil, client.server.name, "METADATA", nick, key, "*", val)
}
}
}
}
}
@ -70,7 +84,7 @@ func (manager *MonitorManager) Add(session *Session, nick string, limit int) err
manager.watching[session] = make(map[string]string)
}
if manager.watchedby[cfnick] == nil {
manager.watchedby[cfnick] = make(map[*Session]empty)
manager.watchedby[cfnick] = make(utils.HashSet[*Session])
}
if len(manager.watching[session]) >= limit {
@ -78,7 +92,7 @@ func (manager *MonitorManager) Add(session *Session, nick string, limit int) err
}
manager.watching[session][cfnick] = nick
manager.watchedby[cfnick][session] = empty{}
manager.watchedby[cfnick].Add(session)
return nil
}
@ -92,7 +106,7 @@ func (manager *MonitorManager) Remove(session *Session, nick string) (err error)
manager.Lock()
defer manager.Unlock()
delete(manager.watching[session], cfnick)
delete(manager.watchedby[cfnick], session)
manager.watchedby[cfnick].Remove(session)
return nil
}
@ -102,7 +116,7 @@ func (manager *MonitorManager) RemoveAll(session *Session) {
defer manager.Unlock()
for cfnick := range manager.watching[session] {
delete(manager.watchedby[cfnick], session)
manager.watchedby[cfnick].Remove(session)
}
delete(manager.watching, session)
}

View File

@ -11,6 +11,7 @@ import (
"fmt"
"io"
"runtime/debug"
"slices"
"strings"
"sync"
"sync/atomic"
@ -45,10 +46,8 @@ const (
type e struct{}
type MySQL struct {
timeout int64
trackAccountMessages uint32
db *sql.DB
logger *logger.Manager
db *sql.DB
logger *logger.Manager
insertHistory *sql.Stmt
insertSequence *sql.Stmt
@ -60,6 +59,9 @@ type MySQL struct {
config Config
wakeForgetter chan e
timeout atomic.Uint64
trackAccountMessages atomic.Uint32
}
func (mysql *MySQL) Initialize(logger *logger.Manager, config Config) {
@ -69,12 +71,12 @@ func (mysql *MySQL) Initialize(logger *logger.Manager, config Config) {
}
func (mysql *MySQL) SetConfig(config Config) {
atomic.StoreInt64(&mysql.timeout, int64(config.Timeout))
mysql.timeout.Store(uint64(config.Timeout))
var trackAccountMessages uint32
if config.TrackAccountMessages {
trackAccountMessages = 1
}
atomic.StoreUint32(&mysql.trackAccountMessages, trackAccountMessages)
mysql.trackAccountMessages.Store(trackAccountMessages)
mysql.stateMutex.Lock()
mysql.config = config
mysql.stateMutex.Unlock()
@ -554,11 +556,11 @@ func (mysql *MySQL) prepareStatements() (err error) {
}
func (mysql *MySQL) getTimeout() time.Duration {
return time.Duration(atomic.LoadInt64(&mysql.timeout))
return time.Duration(mysql.timeout.Load())
}
func (mysql *MySQL) isTrackingAccountMessages() bool {
return atomic.LoadUint32(&mysql.trackAccountMessages) != 0
return mysql.trackAccountMessages.Load() != 0
}
func (mysql *MySQL) logError(context string, err error) (quit bool) {
@ -916,7 +918,7 @@ func (mysql *MySQL) betweenTimestamps(ctx context.Context, target, correspondent
results, err = mysql.selectItems(ctx, queryBuf.String(), args...)
if err == nil && !ascending {
history.Reverse(results)
slices.Reverse(results)
}
return
}
@ -959,12 +961,12 @@ func (mysql *MySQL) listCorrespondentsInternal(ctx context.Context, target strin
}
results = append(results, history.TargetListing{
CfName: correspondent,
Time: time.Unix(0, nanotime),
Time: time.Unix(0, nanotime).UTC(),
})
}
if !ascending {
history.ReverseCorrespondents(results)
slices.Reverse(results)
}
return
@ -1012,7 +1014,7 @@ func (mysql *MySQL) ListChannels(cfchannels []string) (results []history.TargetL
}
results = append(results, history.TargetListing{
CfName: target,
Time: time.Unix(0, nanotime),
Time: time.Unix(0, nanotime).UTC(),
})
}
return

View File

@ -24,8 +24,8 @@ var (
"MemoServ", "BotServ", "OperServ",
}
restrictedCasefoldedNicks = make(utils.StringSet)
restrictedSkeletons = make(utils.StringSet)
restrictedCasefoldedNicks = make(utils.HashSet[string])
restrictedSkeletons = make(utils.HashSet[string])
)
func performNickChange(server *Server, client *Client, target *Client, session *Session, nickname string, rb *ResponseBuffer) error {
@ -34,7 +34,7 @@ func performNickChange(server *Server, client *Client, target *Client, session *
origNickMask := details.nickMask
isSanick := client != target
assignedNickname, err, back := client.server.clients.SetNick(target, session, nickname, false)
assignedNickname, err, awayChanged := client.server.clients.SetNick(target, session, nickname, false)
if err == errNicknameInUse {
if !isSanick {
rb.Add(nil, server.name, ERR_NICKNAMEINUSE, details.nick, utils.SafeErrorParam(nickname), client.t("Nickname is already in use"))
@ -43,6 +43,8 @@ func performNickChange(server *Server, client *Client, target *Client, session *
}
} else if err == errNicknameReserved {
if !isSanick {
// see #1594 for context: ERR_NICKNAMEINUSE can confuse clients if the nickname is not
// literally in use:
if !client.registered {
rb.Add(nil, server.name, ERR_NICKNAMEINUSE, details.nick, utils.SafeErrorParam(nickname), client.t("Nickname is reserved by a different account"))
}
@ -115,18 +117,22 @@ func performNickChange(server *Server, client *Client, target *Client, session *
}
}
if back {
dispatchAwayNotify(session.client, false, "")
if awayChanged {
dispatchAwayNotify(session.client, session.client.AwayMessage())
}
for _, channel := range target.Channels() {
channel.AddHistoryItem(histItem, details.account)
if channel.memberIsVisible(client) {
channel.AddHistoryItem(histItem, details.account)
}
}
newCfnick := target.NickCasefolded()
if newCfnick != details.nickCasefolded {
client.server.monitorManager.AlertAbout(details.nick, details.nickCasefolded, false)
client.server.monitorManager.AlertAbout(assignedNickname, newCfnick, true)
// send MONITOR updates only for nick changes, not for new connection registration;
// defer MONITOR for new connection registration until pre-registration metadata is applied
if hadNick && newCfnick != details.nickCasefolded {
client.server.monitorManager.AlertAbout(details.nick, details.nickCasefolded, false, nil)
client.server.monitorManager.AlertAbout(assignedNickname, newCfnick, true, target)
}
return nil
}

View File

@ -13,6 +13,7 @@ import (
"github.com/ergochat/irc-go/ircfmt"
"github.com/ergochat/ergo/irc/caps"
"github.com/ergochat/ergo/irc/custime"
"github.com/ergochat/ergo/irc/passwd"
"github.com/ergochat/ergo/irc/sno"
@ -164,7 +165,17 @@ SAREGISTER registers an account on someone else's behalf.
This is for use in configurations that require SASL for all connections;
an administrator can set use this command to set up user accounts.`,
helpShort: `$bSAREGISTER$b registers an account on someone else's behalf.`,
enabled: servCmdRequiresAuthEnabled,
enabled: servCmdRequiresAuthEnabled, // deliberate
capabs: []string{"accreg"},
minParams: 1,
},
"saverify": {
handler: nsSaverifyHandler,
help: `Syntax: $bSAVERIFY <username>$b
SAVERIFY manually verifies an account that is pending verification.`,
helpShort: `$bSAVERIFY$b manually verifies an account that is pending verification.`,
enabled: servCmdRequiresAuthEnabled, // deliberate
capabs: []string{"accreg"},
minParams: 1,
},
@ -230,6 +241,18 @@ indicate an empty password, use * instead.`,
"password": {
aliasOf: "passwd",
},
"push": {
handler: nsPushHandler,
help: `Syntax: $bPUSH LIST$b
Or: $bPUSH DELETE <endpoint>$b
PUSH lets you view or modify the state of your push subscriptions.`,
helpShort: `$bPUSH$b lets you view or modify your push subscriptions.`,
enabled: func(config *Config) bool {
return config.WebPush.Enabled
},
minParams: 1,
},
"get": {
handler: nsGetHandler,
help: `Syntax: $bGET <setting>$b
@ -800,7 +823,7 @@ func nsGroupHandler(service *ircService, server *Server, client *Client, command
func nsLoginThrottleCheck(service *ircService, client *Client, rb *ResponseBuffer) (success bool) {
throttled, remainingTime := client.checkLoginThrottle()
if throttled {
service.Notice(rb, fmt.Sprintf(client.t("Please wait at least %v and try again"), remainingTime))
service.Notice(rb, fmt.Sprintf(client.t("Please wait at least %v and try again"), remainingTime.Round(time.Millisecond)))
}
return !throttled
}
@ -913,7 +936,11 @@ func nsInfoHandler(service *ircService, server *Server, client *Client, command
account, err := server.accounts.LoadAccount(accountName)
if err != nil || !account.Verified {
service.Notice(rb, client.t("Account does not exist"))
if server.accounts.accountWasUnregistered(accountName) {
service.Notice(rb, client.t("Name reserved due to a prior registration"))
} else {
service.Notice(rb, client.t("Account does not exist"))
}
return
}
@ -939,9 +966,9 @@ func nsInfoHandler(service *ircService, server *Server, client *Client, command
func listRegisteredChannels(service *ircService, accountName string, rb *ResponseBuffer) {
client := rb.session.client
channels := client.server.accounts.ChannelsForAccount(accountName)
channels := client.server.channels.ChannelsForAccount(accountName)
service.Notice(rb, fmt.Sprintf(client.t("Account %s has %d registered channel(s)."), accountName, len(channels)))
for _, channel := range rb.session.client.server.accounts.ChannelsForAccount(accountName) {
for _, channel := range channels {
service.Notice(rb, fmt.Sprintf(client.t("Registered channel: %s"), channel))
}
}
@ -996,7 +1023,7 @@ func nsRegisterHandler(service *ircService, server *Server, client *Client, comm
err := server.accounts.Register(client, account, callbackNamespace, callbackValue, passphrase, rb.session.certfp)
if err == nil {
if callbackNamespace == "*" {
err = server.accounts.Verify(client, account, "")
err = server.accounts.Verify(client, account, "", true)
if err == nil && fixupNickEqualsAccount(client, rb, config, service.prefix) {
sendSuccessfulRegResponse(service, client, rb)
}
@ -1014,6 +1041,8 @@ func nsRegisterHandler(service *ircService, server *Server, client *Client, comm
}
func nsSaregisterHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
registerCap := rb.session.capabilities.Has(caps.AccountRegistration)
var account, passphrase string
account = params[0]
if 1 < len(params) && params[1] != "*" {
@ -1023,21 +1052,44 @@ func nsSaregisterHandler(service *ircService, server *Server, client *Client, co
if err != nil {
var errMsg string
var failCode string
if err == errAccountAlreadyRegistered || err == errAccountAlreadyVerified {
errMsg = client.t("Account already exists")
failCode = "ACCOUNT_EXISTS"
} else if err == errNameReserved {
errMsg = client.t(err.Error())
failCode = "ACCOUNT_EXISTS"
} else if err == errAccountBadPassphrase {
errMsg = client.t("Passphrase contains forbidden characters or is otherwise invalid")
failCode = "INVALID_PASSWORD"
} else {
server.logger.Error("services", "unknown error from saregister", err.Error())
errMsg = client.t("Could not register")
errMsg = fmt.Sprintf(client.t("Could not register: %v"), err)
failCode = "UNKNOWN_ERROR"
}
service.Notice(rb, errMsg)
if registerCap {
rb.Add(nil, server.name, "FAIL", "REGISTER", failCode, utils.SafeErrorParam(account), err.Error())
}
} else {
service.Notice(rb, fmt.Sprintf(client.t("Successfully registered account %s"), account))
if registerCap {
rb.Add(nil, server.name, "REGISTER", "SUCCESS", account, client.t("Account successfully registered"))
}
server.snomasks.Send(sno.LocalAccounts, fmt.Sprintf(ircfmt.Unescape("Operator $c[grey][$r%s$c[grey]] registered account $c[grey][$r%s$c[grey]] with SAREGISTER"), client.Oper().Name, account))
}
}
func nsSaverifyHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
account := params[0]
err := server.accounts.Verify(nil, account, "", true)
if err == nil {
service.Notice(rb, fmt.Sprintf(client.t("Successfully verified account %s"), account))
} else {
service.Notice(rb, fmt.Sprintf(client.t("Failed to verify account %s: %v"), account, err.Error()))
}
}
func nsUnregisterHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
erase := command == "erase"
@ -1106,7 +1158,7 @@ func nsUnregisterHandler(service *ircService, server *Server, client *Client, co
func nsVerifyHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
username, code := params[0], params[1]
err := server.accounts.Verify(client, username, code)
err := server.accounts.Verify(client, username, code, false)
var errorMessage string
if err != nil {
@ -1270,21 +1322,24 @@ func nsClientsListHandler(service *ircService, server *Server, client *Client, p
service.Notice(rb, fmt.Sprintf(client.t("Client %d:"), session.sessionID))
}
if session.deviceID != "" {
service.Notice(rb, fmt.Sprintf(client.t("Device ID: %s"), session.deviceID))
service.Notice(rb, fmt.Sprintf(client.t("Device ID: %s"), session.deviceID))
}
service.Notice(rb, fmt.Sprintf(client.t("IP address: %s"), session.ip.String()))
service.Notice(rb, fmt.Sprintf(client.t("Hostname: %s"), session.hostname))
if hasPrivs {
service.Notice(rb, fmt.Sprintf(client.t("Connection: %s"), session.connInfo))
service.Notice(rb, fmt.Sprintf(client.t("Debug log ID: %s"), session.connID))
}
service.Notice(rb, fmt.Sprintf(client.t("Created at: %s"), session.ctime.Format(time.RFC1123)))
service.Notice(rb, fmt.Sprintf(client.t("Last active: %s"), session.atime.Format(time.RFC1123)))
service.Notice(rb, fmt.Sprintf(client.t("IP address: %s"), session.ip.String()))
service.Notice(rb, fmt.Sprintf(client.t("Hostname: %s"), session.hostname))
if hasPrivs {
service.Notice(rb, fmt.Sprintf(client.t("Connection: %s"), session.connInfo))
}
service.Notice(rb, fmt.Sprintf(client.t("Created at: %s"), session.ctime.Format(time.RFC1123)))
service.Notice(rb, fmt.Sprintf(client.t("Last active: %s"), session.atime.Format(time.RFC1123)))
if session.certfp != "" {
service.Notice(rb, fmt.Sprintf(client.t("Certfp: %s"), session.certfp))
service.Notice(rb, fmt.Sprintf(client.t("Certfp: %s"), session.certfp))
}
for _, capStr := range session.caps {
if capStr != "" {
service.Notice(rb, fmt.Sprintf(client.t("IRCv3 CAPs: %s"), capStr))
service.Notice(rb, fmt.Sprintf(client.t("IRCv3 CAPs: %s"), capStr))
}
}
}
@ -1358,6 +1413,11 @@ func nsCertHandler(service *ircService, server *Server, client *Client, command
case "add", "del":
if 2 <= len(params) {
target, certfp = params[0], params[1]
if cftarget, err := CasefoldName(target); err == nil && client.Account() == cftarget {
// If the target is equal to the account, then the user accidentally invoked operator
// syntax (cert add mynick <fp>) instead of self syntax (cert add <fp>).
target = ""
}
} else if len(params) == 1 {
certfp = params[0]
} else if len(params) == 0 && verb == "add" && rb.session.certfp != "" {
@ -1611,3 +1671,48 @@ func nsRenameHandler(service *ircService, server *Server, client *Client, comman
}
}
}
func nsPushHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
switch strings.ToUpper(params[0]) {
case "LIST":
target := client
if len(params) > 1 && client.HasRoleCapabs("accreg") {
target = server.clients.Get(params[1])
if target == nil {
service.Notice(rb, client.t("No such nick"))
return
}
}
subscriptions := target.getPushSubscriptions(true)
service.Notice(rb, fmt.Sprintf(client.t("Nickname %[1]s has %[2]d push subscription(s)"), target.Nick(), len(subscriptions)))
for i, subscription := range subscriptions {
service.Notice(rb, fmt.Sprintf(client.t("Subscription %d:"), i+1))
service.Notice(rb, fmt.Sprintf(client.t("Endpoint: %s"), subscription.Endpoint))
service.Notice(rb, fmt.Sprintf(client.t("Last renewal: %s"), subscription.LastRefresh.Format(time.RFC1123)))
service.Notice(rb, fmt.Sprintf(client.t("Last push: %s"), subscription.LastSuccess.Format(time.RFC1123)))
}
case "DELETE":
if len(params) < 2 {
service.Notice(rb, client.t("Invalid parameters"))
return
}
target := client
endpoint := params[1]
if len(params) > 2 && client.HasRoleCapabs("accreg") {
target = server.clients.Get(params[1])
if target == nil {
service.Notice(rb, client.t("No such nick"))
return
}
endpoint = params[2]
}
changed := target.deletePushSubscription(endpoint, true)
if changed {
service.Notice(rb, client.t("Successfully deleted push subscription"))
} else {
service.Notice(rb, client.t("Push subscription not found"))
}
default:
service.Notice(rb, client.t("Invalid parameters"))
}
}

View File

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

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

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

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

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

View File

@ -6,14 +6,19 @@ package irc
import (
"fmt"
"runtime/debug"
"time"
)
// HandlePanic is a general-purpose panic handler for ad-hoc goroutines.
// Because of the semantics of `recover`, it must be called directly
// from the routine on whose call stack the panic would occur, with `defer`,
// e.g. `defer server.HandlePanic()`
func (server *Server) HandlePanic() {
func (server *Server) HandlePanic(restartable func()) {
if r := recover(); r != nil {
server.logger.Error("internal", fmt.Sprintf("Panic encountered: %v\n%s", r, debug.Stack()))
if restartable != nil {
time.Sleep(time.Second)
go restartable()
}
}
}

View File

@ -3,8 +3,11 @@
package passwd
import "golang.org/x/crypto/bcrypt"
import "golang.org/x/crypto/sha3"
import (
"crypto/sha3"
"golang.org/x/crypto/bcrypt"
)
const (
MinCost = bcrypt.MinCost

View File

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

View File

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

View File

@ -13,8 +13,8 @@ import (
)
const (
npcNickMask = "*%s*!%s@npc.fakeuser.invalid"
sceneNickMask = "=Scene=!%s@npc.fakeuser.invalid"
defaultNPCNickMask = "*%s*!%s@npc.fakeuser.invalid"
defaultSceneNickMask = "=Scene=!%s@npc.fakeuser.invalid"
)
func sendRoleplayMessage(server *Server, client *Client, source string, targetString string, isScene, isAction bool, messageParts []string, rb *ResponseBuffer) {
@ -30,7 +30,7 @@ func sendRoleplayMessage(server *Server, client *Client, source string, targetSt
var sourceMask string
if isScene {
sourceMask = fmt.Sprintf(sceneNickMask, client.Nick())
sourceMask = fmt.Sprintf(server.Config().Roleplay.SceneNickMask, client.Nick())
} else {
cfSource, cfSourceErr := CasefoldName(source)
skelSource, skelErr := Skeleton(source)
@ -39,7 +39,7 @@ func sendRoleplayMessage(server *Server, client *Client, source string, targetSt
rb.Add(nil, client.server.name, ERR_CANNOTSENDRP, targetString, client.t("Invalid roleplay name"))
return
}
sourceMask = fmt.Sprintf(npcNickMask, source, client.Nick())
sourceMask = fmt.Sprintf(server.Config().Roleplay.NPCNickMask, source, client.Nick())
}
// block attempts to send CTCP messages to Tor clients

37
irc/serde.go Normal file
View File

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

View File

@ -12,18 +12,22 @@ import (
_ "net/http/pprof"
"os"
"os/signal"
"runtime/pprof"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"unsafe"
"github.com/ergochat/irc-go/ircfmt"
"github.com/okzk/sdnotify"
"github.com/tidwall/buntdb"
"github.com/ergochat/ergo/irc/bunt"
"github.com/ergochat/ergo/irc/caps"
"github.com/ergochat/ergo/irc/connection_limits"
"github.com/ergochat/ergo/irc/datastore"
"github.com/ergochat/ergo/irc/flatip"
"github.com/ergochat/ergo/irc/flock"
"github.com/ergochat/ergo/irc/history"
@ -32,27 +36,16 @@ import (
"github.com/ergochat/ergo/irc/mysql"
"github.com/ergochat/ergo/irc/sno"
"github.com/ergochat/ergo/irc/utils"
"github.com/tidwall/buntdb"
"github.com/ergochat/ergo/irc/webpush"
)
const (
alwaysOnExpirationPollPeriod = time.Hour
)
alwaysOnMaintenanceInterval = 30 * time.Minute
pushMaintenanceInterval = 24 * time.Hour
var (
// common error line to sub values into
errorMsg = "ERROR :%s\r\n"
// three final parameters of 004 RPL_MYINFO, enumerating our supported modes
rplMyInfo1, rplMyInfo2, rplMyInfo3 = modes.RplMyInfo()
// CHANMODES isupport token
chanmodesToken = modes.ChanmodesToken()
// whitelist of caps to serve on the STS-only listener. In particular,
// never advertise SASL, to discourage people from sending their passwords:
stsOnlyCaps = caps.NewSet(caps.STS, caps.MessageTags, caps.ServerTime, caps.Batch, caps.LabeledResponse, caps.EchoMessage, caps.Nope)
// we only have standard channels for now. TODO: any updates to this
// will also need to be reflected in CasefoldChannel
chanTypes = "#"
@ -60,13 +53,24 @@ var (
throttleMessage = "You have attempted to connect too many times within a short duration. Wait a while, and you will be able to connect."
)
var (
// whitelist of caps to serve on the STS-only listener. In particular,
// never advertise SASL, to discourage people from sending their passwords:
stsOnlyCaps = caps.NewSet(caps.STS, caps.MessageTags, caps.ServerTime, caps.Batch, caps.LabeledResponse, caps.EchoMessage, caps.Nope)
httpVerbs = utils.SetLiteral("CONNECT", "DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "TRACE")
unixEpoch = time.Unix(0, 0).UTC()
year2262Problem = time.Unix(0, 1<<63-1).UTC() // this is the maximum time for which (*time.Time).UnixNano() is well-defined
)
// Server is the main Oragono server.
type Server struct {
accepts AcceptManager
accounts AccountManager
channels ChannelManager
channelRegistry ChannelRegistry
clients ClientManager
config unsafe.Pointer
config atomic.Pointer[Config]
configFilename string
connectionLimiter connection_limits.Limiter
ctime time.Time
@ -82,35 +86,51 @@ type Server struct {
rehashSignal chan os.Signal
pprofServer *http.Server
exitSignals chan os.Signal
tracebackSignal chan os.Signal
snomasks SnoManager
store *buntdb.DB
dstore datastore.Datastore
historyDB mysql.MySQL
torLimiter connection_limits.TorLimiter
whoWas WhoWasList
stats Stats
semaphores ServerSemaphores
flock flock.Flocker
defcon uint32
connIDCounter atomic.Uint64
defcon atomic.Uint32
// API stuff
apiHandler http.Handler // always initialized
apiListener *utils.ReloadableListener
apiServer *http.Server // nil if API is not enabled
}
// NewServer returns a new Oragono server.
func NewServer(config *Config, logger *logger.Manager) (*Server, error) {
// sanity check that kernel randomness is available; on modern Linux,
// this will block until it is, on other platforms it may panic:
utils.GenerateUUIDv4()
// initialize data structures
server := &Server{
ctime: time.Now().UTC(),
listeners: make(map[string]IRCListener),
logger: logger,
rehashSignal: make(chan os.Signal, 1),
exitSignals: make(chan os.Signal, len(utils.ServerExitSignals)),
defcon: 5,
ctime: time.Now().UTC(),
listeners: make(map[string]IRCListener),
logger: logger,
rehashSignal: make(chan os.Signal, 1),
exitSignals: make(chan os.Signal, len(utils.ServerExitSignals)),
tracebackSignal: make(chan os.Signal, len(utils.ServerTracebackSignals)),
}
server.defcon.Store(5)
server.accepts.Initialize()
server.clients.Initialize()
server.semaphores.Initialize()
server.whoWas.Initialize(config.Limits.WhowasEntries)
server.monitorManager.Initialize()
server.snomasks.Initialize()
server.apiHandler = newAPIHandler(server)
if err := server.applyConfig(config); err != nil {
return nil, err
}
@ -118,8 +138,12 @@ func NewServer(config *Config, logger *logger.Manager) (*Server, error) {
// Attempt to clean up when receiving these signals.
signal.Notify(server.exitSignals, utils.ServerExitSignals...)
signal.Notify(server.rehashSignal, syscall.SIGHUP)
if len(utils.ServerTracebackSignals) != 0 {
signal.Notify(server.tracebackSignal, utils.ServerTracebackSignals...)
}
time.AfterFunc(alwaysOnExpirationPollPeriod, server.handleAlwaysOnExpirations)
time.AfterFunc(alwaysOnMaintenanceInterval, server.periodicAlwaysOnMaintenance)
time.AfterFunc(pushMaintenanceInterval, server.periodicPushMaintenance)
return server, nil
}
@ -132,11 +156,11 @@ func (server *Server) Shutdown() {
//TODO(dan): Make sure we disallow new nicks
for _, client := range server.clients.AllClients() {
client.Notice("Server is shutting down")
if client.AlwaysOn() {
client.Store(IncludeLastSeen)
}
}
// flush data associated with always-on clients:
server.performAlwaysOnMaintenance(false, true)
if err := server.store.Close(); err != nil {
server.logger.Error("shutdown", fmt.Sprintln("Could not close datastore:", err))
}
@ -156,6 +180,8 @@ func (server *Server) Run() {
case <-server.rehashSignal:
server.logger.Info("server", "Rehashing due to SIGHUP")
go server.rehash()
case <-server.tracebackSignal:
go server.dumpStacks()
}
}
}
@ -244,26 +270,74 @@ func (server *Server) checkTorLimits() (banned bool, message string) {
}
}
func (server *Server) handleAlwaysOnExpirations() {
func (server *Server) periodicAlwaysOnMaintenance() {
defer func() {
// reschedule whether or not there was a panic
time.AfterFunc(alwaysOnExpirationPollPeriod, server.handleAlwaysOnExpirations)
time.AfterFunc(alwaysOnMaintenanceInterval, server.periodicAlwaysOnMaintenance)
}()
defer server.HandlePanic()
defer server.HandlePanic(nil)
server.logger.Info("accounts", "Performing periodic always-on client checks")
server.performAlwaysOnMaintenance(true, true)
}
func (server *Server) performAlwaysOnMaintenance(checkExpiration, flushTimestamps bool) {
config := server.Config()
deadline := time.Duration(config.Accounts.Multiclient.AlwaysOnExpiration)
if deadline == 0 {
return
}
server.logger.Info("accounts", "Checking always-on clients for expiration")
for _, client := range server.clients.AllClients() {
if client.IsExpiredAlwaysOn(config) {
if checkExpiration && client.IsExpiredAlwaysOn(config) {
// TODO save the channels list, use it for autojoin if/when they return?
server.logger.Info("accounts", "Expiring always-on client", client.AccountName())
client.destroy(nil)
continue
}
if flushTimestamps && client.shouldFlushTimestamps() {
account := client.Account()
server.accounts.saveLastSeen(account, client.copyLastSeen())
server.accounts.saveReadMarkers(account, client.copyReadMarkers())
}
}
}
func (server *Server) periodicPushMaintenance() {
defer func() {
// reschedule whether or not there was a panic
time.AfterFunc(pushMaintenanceInterval, server.periodicPushMaintenance)
}()
defer server.HandlePanic(nil)
if server.Config().WebPush.Enabled {
server.logger.Info("webpush", "Performing periodic push subscription maintenance")
server.performPushMaintenance()
} // else: reschedule and check again later, the operator may enable it via rehash
}
func (server *Server) performPushMaintenance() {
expiration := time.Duration(server.Config().WebPush.Expiration)
for _, client := range server.clients.AllWithPushSubscriptions() {
for _, sub := range client.getPushSubscriptions(true) {
now := time.Now()
// require both periodic successful push messages and renewal of the subscription via WEBPUSH REGISTER
if now.Sub(sub.LastSuccess) > expiration || now.Sub(sub.LastRefresh) > expiration {
server.logger.Debug("webpush", "expiring push subscription for client", client.Nick(), sub.Endpoint)
client.deletePushSubscription(sub.Endpoint, false)
} else if now.Sub(sub.LastSuccess) > expiration/2 {
// we haven't pushed to them recently, make an attempt
server.logger.Debug("webpush", "pinging push subscription for client", client.Nick(), sub.Endpoint)
client.sendAndTrackPush(
sub.Endpoint, sub.Keys,
pushMessage{
msg: webpush.PingMessage,
urgency: webpush.UrgencyNormal,
},
false,
)
}
}
// persist all push subscriptions on the assumption that the timestamps have changed
client.Store(IncludePushSubscriptions)
}
}
@ -279,7 +353,7 @@ func (server *Server) checkBanScriptExemptSASL(config *Config, session *Session)
return authSuccess
}
if output.Result == IPBanned || output.Result == IPRequireSASL {
server.logger.Info("connect-ip", "Rejecting unauthenticated client due to ip-check-script", ipaddr.String())
server.logger.Info("connect-ip", session.connID, "Rejecting unauthenticated client due to ip-check-script", ipaddr.String())
if output.BanMessage != "" {
session.client.requireSASLMessage = output.BanMessage
}
@ -291,9 +365,7 @@ func (server *Server) checkBanScriptExemptSASL(config *Config, session *Session)
func (server *Server) tryRegister(c *Client, session *Session) (exiting bool) {
// XXX PROXY or WEBIRC MUST be sent as the first line of the session;
// if we are here at all that means we have the final value of the IP
if session.rawHostname == "" {
session.client.lookupHostname(session, false)
}
c.finalizeHostname(session)
// try to complete registration normally
// XXX(#1057) username can be filled in by an ident query without the client
@ -336,10 +408,7 @@ func (server *Server) tryRegister(c *Client, session *Session) (exiting bool) {
rb := NewResponseBuffer(session)
nickError := performNickChange(server, c, c, session, c.preregNick, rb)
rb.Send(true)
if nickError == errInsecureReattach {
c.Quit(c.t("You can't mix secure and insecure connections to this account"), nil)
return true
} else if nickError != nil {
if nickError != nil {
c.preregNick = ""
return false
}
@ -359,19 +428,38 @@ func (server *Server) tryRegister(c *Client, session *Session) (exiting bool) {
c.SetMode(defaultMode, true)
}
c.applyPreregMetadata(session)
c.server.monitorManager.AlertAbout(c.Nick(), c.NickCasefolded(), true, c)
// this is not a reattach, so if the client is always-on, this is the first time
// the Client object was created during the current server uptime. mark dirty in
// order to persist the realname and the user modes:
if c.AlwaysOn() {
c.markDirty(IncludeAllAttrs)
}
// count new user in statistics (before checking KLINEs, see #1303)
server.stats.Register(c.HasMode(modes.Invisible))
// check KLINEs (#671: ignore KLINEs for loopback connections)
if !session.IP().IsLoopback() || session.isTor {
isBanned, info := server.klines.CheckMasks(c.AllNickmasks()...)
if isBanned {
if isBanned && !(info.RequireSASL && session.client.Account() != "") {
c.setKlined()
c.Quit(info.BanMessage(c.t("You are banned from this server (%s)")), nil)
server.logger.Info("connect", session.connID, "Client rejected by k-line", c.NickMaskString())
return true
}
}
server.playRegistrationBurst(session)
if len(config.Channels.AutoJoin) > 0 {
// only applicable to new clients, not reattaches:
server.handleAutojoins(session, config.Channels.AutoJoin)
}
return false
}
@ -392,7 +480,7 @@ func (server *Server) playRegistrationBurst(session *Session) {
c := session.client
// continue registration
d := c.Details()
server.logger.Info("connect", fmt.Sprintf("Client connected [%s] [u:%s] [r:%s]", d.nick, d.username, d.realname))
server.logger.Info("connect", session.connID, fmt.Sprintf("Client connected [%s] [u:%s] [r:%s]", d.nick, d.username, d.realname))
server.snomasks.Send(sno.LocalConnects, fmt.Sprintf("Client connected [%s] [u:%s] [h:%s] [ip:%s] [r:%s]", d.nick, d.username, session.rawHostname, session.IP().String(), d.realname))
if d.account != "" {
server.sendLoginSnomask(d.nickMask, d.accountName)
@ -405,10 +493,19 @@ func (server *Server) playRegistrationBurst(session *Session) {
session.Send(nil, server.name, RPL_WELCOME, d.nick, fmt.Sprintf(c.t("Welcome to the %s IRC Network %s"), config.Network.Name, d.nick))
session.Send(nil, server.name, RPL_YOURHOST, d.nick, fmt.Sprintf(c.t("Your host is %[1]s, running version %[2]s"), server.name, Ver))
session.Send(nil, server.name, RPL_CREATED, d.nick, fmt.Sprintf(c.t("This server was created %s"), server.ctime.Format(time.RFC1123)))
rplMyInfo1, rplMyInfo2, rplMyInfo3 := modes.RplMyInfo()
session.Send(nil, server.name, RPL_MYINFO, d.nick, server.name, Ver, rplMyInfo1, rplMyInfo2, rplMyInfo3)
rb := NewResponseBuffer(session)
server.RplISupport(c, rb)
if !(rb.session.capabilities.Has(caps.ExtendedISupport) && rb.session.isupportSentPrereg) {
server.RplISupport(c, rb)
}
if session.capabilities.Has(caps.Metadata) {
playMetadataVerbBatch(rb, d.nick, c.ListMetadata())
}
if d.account != "" && session.capabilities.Has(caps.Persistence) {
reportPersistenceStatus(c, rb, false)
}
server.Lusers(c, rb)
server.MOTD(c, rb)
rb.Send(true)
@ -427,15 +524,22 @@ func (server *Server) playRegistrationBurst(session *Session) {
// 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) {
translatedISupport := client.t("are supported by this server")
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)
}
finalText := "are supported by this server"
nick := client.Nick()
config := server.Config()
for _, cachedTokenLine := range config.Server.isupport.CachedReply {
for _, cachedTokenLine := range lines {
length := len(cachedTokenLine) + 2
tokenline := make([]string, length)
tokenline[0] = nick
copy(tokenline[1:], cachedTokenLine)
tokenline[length-1] = translatedISupport
tokenline[length-1] = finalText
rb.Add(nil, server.name, RPL_ISUPPORT, tokenline...)
}
}
@ -477,6 +581,14 @@ func (server *Server) MOTD(client *Client, rb *ResponseBuffer) {
rb.Add(nil, server.name, RPL_ENDOFMOTD, client.nick, client.t("End of MOTD command"))
}
func (server *Server) handleAutojoins(session *Session, channelNames []string) {
rb := NewResponseBuffer(session)
for _, chname := range channelNames {
server.channels.Join(session.client, chname, "", false, rb)
}
rb.Send(true)
}
func (client *Client) whoisChannelsNames(target *Client, multiPrefix bool, hasPrivs bool) []string {
var chstrs []string
targetInvis := target.HasMode(modes.Invisible)
@ -499,7 +611,9 @@ func (client *Client) getWhoisOf(target *Client, hasPrivs bool, rb *ResponseBuff
whoischannels := client.whoisChannelsNames(target, rb.session.capabilities.Has(caps.MultiPrefix), oper.HasRoleCapab("sajoin"))
if whoischannels != nil {
rb.Add(nil, client.server.name, RPL_WHOISCHANNELS, cnick, tnick, strings.Join(whoischannels, " "))
for _, line := range utils.BuildTokenLines(maxLastArgLength, whoischannels, " ") {
rb.Add(nil, client.server.name, RPL_WHOISCHANNELS, cnick, tnick, line)
}
}
if target.HasMode(modes.Operator) && operStatusVisible(client, target, oper != nil) {
tOper := target.Oper()
@ -523,7 +637,6 @@ func (client *Client) getWhoisOf(target *Client, hasPrivs bool, rb *ResponseBuff
if target.HasMode(modes.Bot) {
rb.Add(nil, client.server.name, RPL_WHOISBOT, cnick, tnick, fmt.Sprintf(ircfmt.Unescape(client.t("is a $bBot$b on %s")), client.server.Config().Network.Name))
}
if client == target || oper.HasRoleCapab("ban") {
for _, session := range target.Sessions() {
if session.certfp != "" {
@ -535,12 +648,17 @@ func (client *Client) getWhoisOf(target *Client, hasPrivs bool, rb *ResponseBuff
if away, awayMessage := target.Away(); away {
rb.Add(nil, client.server.name, RPL_AWAY, cnick, tnick, awayMessage)
}
if rb.session.capabilities.Has(caps.Metadata) {
for key, value := range target.ListMetadata() {
rb.Add(nil, client.server.name, RPL_WHOISKEYVALUE, cnick, tnick, key, "*", value)
}
}
}
// rehash reloads the config and applies the changes from the config file.
func (server *Server) rehash() error {
// #1570; this needs its own panic handling because it can be invoked via SIGHUP
defer server.HandlePanic()
defer server.HandlePanic(nil)
server.logger.Info("server", "Attempting rehash")
@ -578,6 +696,9 @@ func (server *Server) applyConfig(config *Config) (err error) {
globalCasemappingSetting = config.Server.Casemapping
globalUtf8EnforcementSetting = config.Server.EnforceUtf8
MaxLineLen = config.Server.MaxLineLen
RegisterTimeout = config.Server.IdleTimeouts.Registration
PingTimeout = config.Server.IdleTimeouts.Ping
DisconnectTimeout = config.Server.IdleTimeouts.Disconnect
} else {
// enforce configs that can't be changed after launch:
if server.name != config.Server.Name {
@ -603,6 +724,8 @@ func (server *Server) applyConfig(config *Config) (err error) {
return fmt.Errorf("Cannot enable MySQL after launching the server, rehash aborted")
} else if oldConfig.Server.MaxLineLen != config.Server.MaxLineLen {
return fmt.Errorf("Cannot change max-line-len after launching the server, rehash aborted")
} else if oldConfig.Server.IdleTimeouts != config.Server.IdleTimeouts {
return fmt.Errorf("Cannot change idle-timeouts after launching the server, rehash aborted")
}
}
@ -661,9 +784,6 @@ func (server *Server) applyConfig(config *Config) (err error) {
if !oldConfig.Accounts.NickReservation.Enabled {
server.accounts.buildNickToAccountIndex(config)
}
if !oldConfig.Channels.Registration.Enabled {
server.channels.loadRegisteredChannels(config)
}
// resize history buffers as needed
if config.historyChangedFrom(oldConfig) {
for _, channel := range server.channels.Channels() {
@ -692,10 +812,24 @@ func (server *Server) applyConfig(config *Config) (err error) {
// now that the datastore is initialized, we can load the cloak secret from it
// XXX this modifies config after the initial load, which is naughty,
// but there's no data race because we haven't done SetConfig yet
config.Server.Cloaks.SetSecret(LoadCloakSecret(server.store))
cloakSecret, err := LoadCloakSecret(server.dstore)
if err != nil {
return fmt.Errorf("Could not load cloak secret: %w", err)
}
config.Server.Cloaks.SetSecret(cloakSecret)
// similarly bring the VAPID keys into the config, which requires regenerating the 005
if config.WebPush.Enabled {
config.WebPush.vapidKeys, err = LoadVAPIDKeys(server.dstore)
if err != nil {
return fmt.Errorf("Could not load VAPID keys: %w", err)
}
if err = config.generateISupport(); err != nil {
return fmt.Errorf("Could not regenerate cached 005 for VAPID: %w", err)
}
}
// activate the new config
server.SetConfig(config)
server.config.Store(config)
// load [dk]-lines, registered users and channels, etc.
if initial {
@ -735,6 +869,8 @@ func (server *Server) applyConfig(config *Config) (err error) {
server.setupPprofListener(config)
server.setupAPIListener(config)
// set RPL_ISUPPORT
var newISupportReplies [][]string
if oldConfig != nil {
@ -754,13 +890,19 @@ func (server *Server) applyConfig(config *Config) (err error) {
}
if !initial {
// push new info to all of our clients
for _, sClient := range server.clients.AllClients() {
for _, tokenline := range newISupportReplies {
sClient.Send(nil, server.name, RPL_ISUPPORT, append([]string{sClient.nick}, tokenline...)...)
// send 005 updates (somewhat rare)
if len(newISupportReplies) != 0 {
for _, sClient := range server.clients.AllClients() {
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."))
}
}
@ -770,6 +912,9 @@ func (server *Server) applyConfig(config *Config) (err error) {
if config.Accounts.RequireSasl.Enabled && config.Accounts.Registration.Enabled {
server.logger.Warning("server", "Warning: although require-sasl is enabled, users can still register accounts. If your server is not intended to be public, you must set accounts.registration.enabled to false.")
}
if config.History.Enabled && config.History.ChathistoryMax == 0 {
server.logger.Warning("server", "Warning: for history to work correctly, you must set history.chathistory-maxmessages (see default.yaml for a recommendation).")
}
return err
}
@ -797,6 +942,46 @@ func (server *Server) setupPprofListener(config *Config) {
}
}
func (server *Server) setupAPIListener(config *Config) {
if server.apiServer != nil {
if !config.API.Enabled || (config.API.Listener != server.apiServer.Addr) {
server.logger.Info("server", "Stopping API listener", server.apiServer.Addr)
server.apiServer.Close()
server.apiListener = nil
server.apiServer = nil
}
}
if !config.API.Enabled {
return
}
listenerConfig := utils.ListenerConfig{
TLSConfig: config.API.tlsConfig,
}
if server.apiListener != nil {
server.apiListener.Reload(listenerConfig)
return
}
listener, err := net.Listen("tcp", config.API.Listener)
if err != nil {
server.logger.Error("server", "Couldn't create API listener", config.API.Listener, err.Error())
return
}
server.apiListener = utils.NewReloadableListener(listener, listenerConfig)
server.apiServer = &http.Server{
Addr: config.API.Listener, // just informational since we created the listener ourselves
Handler: server.apiHandler,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
MaxHeaderBytes: 16384,
}
go func(hs *http.Server, listener net.Listener) {
if err := hs.Serve(listener); err != nil {
server.logger.Error("server", "API listener failed", err.Error())
}
}(server.apiServer, server.apiListener)
server.logger.Info("server", "Started API listener", server.apiServer.Addr)
}
func (server *Server) loadDatastore(config *Config) error {
// open the datastore and load server state for which it (rather than config)
// is the source of truth
@ -813,6 +998,7 @@ func (server *Server) loadDatastore(config *Config) error {
db, err := OpenDatabase(config)
if err == nil {
server.store = db
server.dstore = bunt.NewBuntdbDatastore(db, server.logger)
return nil
} else {
return fmt.Errorf("Failed to open datastore: %s", err.Error())
@ -825,8 +1011,7 @@ func (server *Server) loadFromDatastore(config *Config) (err error) {
server.loadDLines()
server.loadKLines()
server.channelRegistry.Initialize(server)
server.channels.Initialize(server)
server.channels.Initialize(server, config)
server.accounts.Initialize(server)
if config.Datastore.MySQL.Enabled {
@ -960,25 +1145,28 @@ func (server *Server) GetHistorySequence(providedChannel *Channel, client *Clien
}
var cutoff time.Time
if config.History.Restrictions.ExpireTime != 0 {
cutoff = time.Now().UTC().Add(-time.Duration(config.History.Restrictions.ExpireTime))
}
// #836: registration date cutoff is always enforced for DMs
// either way, take the later of the two cutoffs
if restriction == HistoryCutoffRegistrationTime || channel == nil {
regCutoff := client.historyCutoff()
if regCutoff.After(cutoff) {
cutoff = regCutoff
// #1593: cutoff is ignored for operators
if !client.HasRoleCapabs("history") {
if config.History.Restrictions.ExpireTime != 0 {
cutoff = time.Now().UTC().Add(-time.Duration(config.History.Restrictions.ExpireTime))
}
} else if restriction == HistoryCutoffJoinTime {
if joinTimeCutoff.After(cutoff) {
cutoff = joinTimeCutoff
// #836: registration date cutoff is always enforced for DMs
// either way, take the later of the two cutoffs
if restriction == HistoryCutoffRegistrationTime || channel == nil {
regCutoff := client.historyCutoff()
if regCutoff.After(cutoff) {
cutoff = regCutoff
}
} else if restriction == HistoryCutoffJoinTime {
if joinTimeCutoff.After(cutoff) {
cutoff = joinTimeCutoff
}
}
}
// #836 again: grace period is never applied to DMs
if !cutoff.IsZero() && channel != nil && restriction != HistoryCutoffJoinTime {
cutoff = cutoff.Add(-time.Duration(config.History.Restrictions.GracePeriod))
// #836 again: grace period is never applied to DMs
if !cutoff.IsZero() && channel != nil && restriction != HistoryCutoffJoinTime {
cutoff = cutoff.Add(-time.Duration(config.History.Restrictions.GracePeriod))
}
}
if hist != nil {
@ -1066,6 +1254,16 @@ func (server *Server) UnfoldName(cfname string) (name string) {
return server.clients.UnfoldNick(cfname)
}
// generateConnectionID generates a unique string identifier for an incoming connection.
// this identifier is only used for debug logging.
func (server *Server) generateConnectionID() string {
id := server.connIDCounter.Add(1)
// pad with leading zeroes to a minimum length of 5 hex digits. this enhances greppability;
// the identifier length will be 6 for the first 1048576 connections, which is less important
// but makes the log slightly easier to read
return fmt.Sprintf("s%05x", id)
}
// elistMatcher takes and matches ELIST conditions
type elistMatcher struct {
MinClientsActive bool
@ -1083,7 +1281,7 @@ func (matcher *elistMatcher) Matches(channel *Channel) bool {
}
if matcher.MaxClientsActive {
if len(channel.Members()) < len(channel.members) {
if len(channel.Members()) > matcher.MaxClients {
return false
}
}
@ -1109,3 +1307,11 @@ var (
Edmund Huber, edmund-huber
`, "\n")
)
func (server *Server) dumpStacks() {
if gprof := pprof.Lookup("goroutine"); gprof != nil {
gprof.WriteTo(os.Stderr, 2)
} else {
server.logger.Error("internal", "unable to dump goroutine stacks")
}
}

View File

@ -7,7 +7,7 @@ import (
"bytes"
"fmt"
"log"
"sort"
"slices"
"strings"
"time"
@ -26,6 +26,10 @@ type ircService struct {
HelpBanner string
}
func (service *ircService) Realname(client *Client) string {
return fmt.Sprintf(client.t("Network service, for more info /msg %s HELP"), service.Name)
}
// defines a command associated with a service, e.g., NICKSERV IDENTIFY
type serviceCommand struct {
aliasOf string // marks this command as an alias of another
@ -92,7 +96,7 @@ var (
)
// all services, by lowercase name
var OragonoServices = map[string]*ircService{
var ErgoServices = map[string]*ircService{
"nickserv": nickservService,
"chanserv": chanservService,
"hostserv": hostservService,
@ -105,7 +109,7 @@ func (service *ircService) Notice(rb *ResponseBuffer, text string) {
// all service commands at the protocol level, by uppercase command name
// e.g., NICKSERV, NS
var oragonoServicesByCommandAlias map[string]*ircService
var ergoServicesByCommandAlias map[string]*ircService
// special-cased command shared by all services
var servHelpCmd serviceCommand = serviceCommand{
@ -117,7 +121,7 @@ HELP returns information on the given command.`,
// generic handler for IRC commands like `/NICKSERV INFO`
func serviceCmdHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
service, ok := oragonoServicesByCommandAlias[msg.Command]
service, ok := ergoServicesByCommandAlias[msg.Command]
if !ok {
server.logger.Warning("internal", "can't handle unrecognized service", msg.Command)
return false
@ -219,7 +223,6 @@ func serviceRunCommand(service *ircService, server *Server, client *Client, cmd
return
}
server.logger.Debug("services", fmt.Sprintf("Client %s ran %s command %s", client.Nick(), service.Name, commandName))
if commandName == "help" {
serviceHelpHandler(service, server, client, params, rb)
} else {
@ -247,7 +250,7 @@ func serviceHelpHandler(service *ircService, server *Server, client *Client, par
client.t("Here are the commands you can use:"),
}...)
// show general help
var shownHelpLines sort.StringSlice
var shownHelpLines []string
var disabledCommands bool
for _, commandInfo := range service.Commands {
// skip commands user can't access
@ -265,13 +268,13 @@ func serviceHelpHandler(service *ircService, server *Server, client *Client, par
shownHelpLines = append(shownHelpLines, " "+ircfmt.Unescape(client.t(commandInfo.helpShort)))
}
// sort help lines
slices.Sort(shownHelpLines)
if disabledCommands {
shownHelpLines = append(shownHelpLines, " "+client.t("... and other commands which have been disabled"))
}
// sort help lines
sort.Sort(shownHelpLines)
// push out help text
for _, line := range helpBannerLines {
sendNotice(line)
@ -323,7 +326,7 @@ func overrideServicePrefixes(hostname string) error {
if !utils.IsHostname(hostname) {
return fmt.Errorf("`%s` is an invalid services hostname", hostname)
}
for _, serv := range OragonoServices {
for _, serv := range ErgoServices {
serv.prefix = fmt.Sprintf("%s!%s@%s", serv.Name, serv.Name, hostname)
}
return nil
@ -332,9 +335,9 @@ func overrideServicePrefixes(hostname string) error {
func initializeServices() {
// this modifies the global Commands map,
// so it must be called from irc/commands.go's init()
oragonoServicesByCommandAlias = make(map[string]*ircService)
ergoServicesByCommandAlias = make(map[string]*ircService)
for serviceName, service := range OragonoServices {
for serviceName, service := range ErgoServices {
service.prefix = fmt.Sprintf("%s!%s@localhost", service.Name, service.Name)
// make `/MSG ServiceName HELP` work correctly
@ -349,7 +352,7 @@ func initializeServices() {
ircCmdDef.handler = serviceCmdHandler
for _, ircCmd := range service.CommandAliases {
Commands[ircCmd] = ircCmdDef
oragonoServicesByCommandAlias[ircCmd] = service
ergoServicesByCommandAlias[ircCmd] = service
Help[strings.ToLower(ircCmd)] = HelpEntry{
textGenerator: makeServiceHelpTextGenerator(ircCmd, service.HelpBanner),
}

View File

@ -4,15 +4,17 @@
// Package smtp implements the Simple Mail Transfer Protocol as defined in RFC 5321.
// It also implements the following extensions:
//
// 8BITMIME RFC 1652
// AUTH RFC 2554
// STARTTLS RFC 3207
//
// Additional extensions may be handled by clients.
//
// The smtp package is frozen and is not accepting new features.
// Some external packages provide more functionality. See:
//
// https://godoc.org/?q=smtp
// https://godoc.org/?q=smtp
package smtp
import (
@ -53,14 +55,18 @@ type Client struct {
// Dial returns a new Client connected to an SMTP server at addr.
// The addr must include a port, as in "mail.example.com:smtp".
func Dial(addr string, timeout time.Duration) (*Client, error) {
func Dial(protocol, addr string, localAddress net.Addr, timeout time.Duration, implicitTLS bool) (*Client, error) {
var conn net.Conn
var err error
dialer := net.Dialer{
Timeout: timeout,
LocalAddr: localAddress,
}
start := time.Now()
if timeout == 0 {
conn, err = net.Dial("tcp", addr)
if !implicitTLS {
conn, err = dialer.Dial(protocol, addr)
} else {
conn, err = net.DialTimeout("tcp", addr, timeout)
conn, err = tls.DialWithDialer(&dialer, protocol, addr, nil)
}
if err != nil {
return nil, err
@ -227,7 +233,7 @@ func (c *Client) Auth(a Auth) error {
}
resp64 := make([]byte, encoding.EncodedLen(len(resp)))
encoding.Encode(resp64, resp)
code, msg64, err := c.cmd(0, strings.TrimSpace(fmt.Sprintf("AUTH %s %s", mech, resp64)))
code, msg64, err := c.cmd(0, "%s", strings.TrimSpace(fmt.Sprintf("AUTH %s %s", mech, resp64)))
for err == nil {
var msg []byte
switch code {
@ -253,7 +259,7 @@ func (c *Client) Auth(a Auth) error {
}
resp64 = make([]byte, encoding.EncodedLen(len(resp)))
encoding.Encode(resp64, resp)
code, msg64, err = c.cmd(0, string(resp64))
code, msg64, err = c.cmd(0, "%s", resp64)
}
return err
}
@ -336,7 +342,7 @@ var testHookStartTLS func(*tls.Config) // nil, except for tests
// functionality. Higher-level packages exist outside of the standard
// library.
// XXX: modified in Ergo to add `requireTLS`, `heloDomain`, and `timeout` arguments
func SendMail(addr string, a Auth, heloDomain string, from string, to []string, msg []byte, requireTLS bool, timeout time.Duration) error {
func SendMail(addr string, a Auth, heloDomain string, from string, to []string, msg []byte, requireTLS, implicitTLS bool, protocol string, localAddress net.Addr, timeout time.Duration) error {
if err := validateLine(from); err != nil {
return err
}
@ -345,7 +351,7 @@ func SendMail(addr string, a Auth, heloDomain string, from string, to []string,
return err
}
}
c, err := Dial(addr, timeout)
c, err := Dial(protocol, addr, localAddress, timeout, implicitTLS)
if err != nil {
return err
}
@ -353,23 +359,25 @@ func SendMail(addr string, a Auth, heloDomain string, from string, to []string,
if err = c.Hello(heloDomain); err != nil {
return err
}
if ok, _ := c.Extension("STARTTLS"); ok {
var config *tls.Config
if requireTLS {
config = &tls.Config{ServerName: c.serverName}
} else {
// if TLS isn't a hard requirement, don't verify the certificate either,
// since a MITM attacker could just remove the STARTTLS advertisement
config = &tls.Config{InsecureSkipVerify: true}
if !implicitTLS {
if ok, _ := c.Extension("STARTTLS"); ok {
var config *tls.Config
if requireTLS {
config = &tls.Config{ServerName: c.serverName}
} else {
// if TLS isn't a hard requirement, don't verify the certificate either,
// since a MITM attacker could just remove the STARTTLS advertisement
config = &tls.Config{InsecureSkipVerify: true}
}
if testHookStartTLS != nil {
testHookStartTLS(config)
}
if err = c.StartTLS(config); err != nil {
return err
}
} else if requireTLS {
return errors.New("TLS required, but not negotiated")
}
if testHookStartTLS != nil {
testHookStartTLS(config)
}
if err = c.StartTLS(config); err != nil {
return err
}
} else if requireTLS {
return errors.New("TLS required, but not negotiated")
}
if a != nil && c.ext != nil {
if _, ok := c.ext["AUTH"]; !ok {

View File

@ -8,8 +8,6 @@ import (
"errors"
"io"
"sync"
"github.com/ergochat/ergo/irc/utils"
)
var (
@ -27,7 +25,7 @@ type Socket struct {
maxSendQBytes int
// this is a trylock enforcing that only one goroutine can write to `conn` at a time
writerSemaphore utils.Semaphore
writeLock sync.Mutex
buffers [][]byte
totalLength int
@ -40,9 +38,8 @@ type Socket struct {
// NewSocket returns a new Socket.
func NewSocket(conn IRCConn, maxSendQBytes int) *Socket {
result := Socket{
conn: conn,
maxSendQBytes: maxSendQBytes,
writerSemaphore: utils.NewSemaphore(1),
conn: conn,
maxSendQBytes: maxSendQBytes,
}
return &result
}
@ -105,12 +102,13 @@ func (socket *Socket) Write(data []byte) (err error) {
}
// BlockingWrite sends the given string out of Socket. Requirements:
// 1. MUST block until the message is sent
// 2. MUST bypass sendq (calls to BlockingWrite cannot, on their own, cause a sendq overflow)
// 3. MUST provide mutual exclusion for socket.conn.Write
// 4. MUST respect the same ordering guarantees as Write (i.e., if a call to Write that sends
// message m1 happens-before a call to BlockingWrite that sends message m2,
// m1 must be sent on the wire before m2
// 1. MUST block until the message is sent
// 2. MUST bypass sendq (calls to BlockingWrite cannot, on their own, cause a sendq overflow)
// 3. MUST provide mutual exclusion for socket.conn.Write
// 4. MUST respect the same ordering guarantees as Write (i.e., if a call to Write that sends
// message m1 happens-before a call to BlockingWrite that sends message m2,
// m1 must be sent on the wire before m2
//
// Callers MUST be writing to the client's socket from the client's own goroutine;
// other callers must use the nonblocking Write call instead. Otherwise, a client
// with a slow/unreliable connection risks stalling the progress of the system as a whole.
@ -127,8 +125,8 @@ func (socket *Socket) BlockingWrite(data []byte) (err error) {
}()
// blocking acquire of the trylock
socket.writerSemaphore.Acquire()
defer socket.writerSemaphore.Release()
socket.writeLock.Lock()
defer socket.writeLock.Unlock()
// first, flush any buffered data, to preserve the ordering guarantees
closed := socket.performWrite()
@ -145,7 +143,7 @@ func (socket *Socket) BlockingWrite(data []byte) (err error) {
// wakeWriter starts the goroutine that actually performs the write, without blocking
func (socket *Socket) wakeWriter() {
if socket.writerSemaphore.TryAcquire() {
if socket.writeLock.TryLock() {
// acquired the trylock; send() will release it
go socket.send()
}
@ -181,12 +179,12 @@ func (socket *Socket) send() {
socket.performWrite()
// surrender the trylock, avoiding a race where a write comes in after we've
// checked readyToWrite() and it returned false, but while we still hold the trylock:
socket.writerSemaphore.Release()
socket.writeLock.Unlock()
// check if more data came in while we held the trylock:
if !socket.readyToWrite() {
return
}
if !socket.writerSemaphore.TryAcquire() {
if !socket.writeLock.TryLock() {
// failed to acquire; exit and wait for the holder to observe readyToWrite()
// after releasing it
return

View File

@ -60,6 +60,10 @@ const (
// confusables detection: standard skeleton algorithm (which may be ineffective
// over the larger set of permitted identifiers)
CasemappingPermissive
// rfc1459 is a legacy mapping as defined here: https://modern.ircdocs.horse/#casemapping-parameter
CasemappingRFC1459
// rfc1459-strict is a legacy mapping as defined here: https://modern.ircdocs.horse/#casemapping-parameter
CasemappingRFC1459Strict
)
// XXX this is a global variable without explicit synchronization.
@ -69,7 +73,7 @@ var globalCasemappingSetting Casemapping = CasemappingPRECIS
// XXX analogous unsynchronized global variable controlling utf8 validation
// if this is off, you get the traditional IRC behavior (relaying any valid RFC1459
// octets) and invalid utf8 messages are silently dropped for websocket clients only.
// octets), and websocket listeners are disabled.
// if this is on, invalid utf8 inputs get a FAIL reply.
var globalUtf8EnforcementSetting bool
@ -110,6 +114,10 @@ func casefoldWithSetting(str string, setting Casemapping) (string, error) {
return foldASCII(str)
case CasemappingPermissive:
return foldPermissive(str)
case CasemappingRFC1459:
return foldRFC1459(str, false)
case CasemappingRFC1459Strict:
return foldRFC1459(str, true)
}
}
@ -165,6 +173,17 @@ func CasefoldName(name string) (string, error) {
return lowered, err
}
// CasefoldTarget returns a casefolded version of an IRC target, i.e.
// it determines whether the target is a channel name or nickname and
// applies the appropriate casefolding rules.
func CasefoldTarget(name string) (string, error) {
if strings.HasPrefix(name, "#") {
return CasefoldChannel(name)
} else {
return CasefoldName(name)
}
}
// returns true if the given name is a valid ident, using a mix of Insp and
// Chary's ident restrictions.
func isIdent(name string) bool {
@ -203,7 +222,7 @@ func Skeleton(name string) (string, error) {
switch globalCasemappingSetting {
default:
return realSkeleton(name)
case CasemappingASCII:
case CasemappingASCII, CasemappingRFC1459, CasemappingRFC1459Strict:
// identity function is fine because we independently case-normalize in Casefold
return name, nil
}
@ -291,6 +310,23 @@ func foldASCII(str string) (result string, err error) {
return strings.ToLower(str), nil
}
var (
rfc1459Replacer = strings.NewReplacer("[", "{", "]", "}", "\\", "|", "~", "^")
rfc1459StrictReplacer = strings.NewReplacer("[", "{", "]", "}", "\\", "|")
)
func foldRFC1459(str string, strict bool) (result string, err error) {
asciiFold, err := foldASCII(str)
if err != nil {
return "", err
}
replacer := rfc1459Replacer
if strict {
replacer = rfc1459StrictReplacer
}
return replacer.Replace(asciiFold), nil
}
func IsPrintableASCII(str string) bool {
for i := 0; i < len(str); i++ {
// allow space here because it's technically printable;

View File

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

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