3
0
mirror of https://github.com/ergochat/ergo.git synced 2026-04-05 06:58:03 +02:00

Compare commits

...

194 Commits

Author SHA1 Message Date
Shivaram Lingamneni
44cd51ef36 new development version 2026-03-22 01:38:47 -04:00
Shivaram Lingamneni
4fabfb8ee3
release v2.18.0 2026-03-22 00:55:45 -04:00
Shivaram Lingamneni
ef28345fd5
Merge pull request #2371 from slingamn/sqlite_fix
fix (*SQLite).ListChannels
2026-03-19 22:41:40 -07:00
Shivaram Lingamneni
f9a11db153 fix (*SQLite).ListChannels
If the final channel we attempted to list had no messages, sql.ErrNoRows
would be returned as the error for the entire function, which would
prevent any channels from appearing in the targets list.
2026-03-20 04:15:29 +00:00
Shivaram Lingamneni
73276d8685 changelog for build tags 2026-03-19 22:54:23 -04:00
Shivaram Lingamneni
56f0691bef fix 2026-03-19 17:50:25 -04:00
Shivaram Lingamneni
9ea307c92e add more build documentation 2026-03-19 17:49:22 -04:00
Shivaram Lingamneni
e6b55903f0 tweaks to changelog 2026-03-17 14:42:06 -04:00
Shivaram Lingamneni
45548138c3 update changelog 2026-03-17 14:34:56 -04:00
Shivaram Lingamneni
937bad6717
fix #2287 (#2369)
Allow restricting metadata modification to server operators
2026-03-17 14:33:11 -04:00
Shivaram Lingamneni
9e61528711
update documentation for persistent history (#2368) 2026-03-17 03:23:59 -04:00
Shivaram Lingamneni
2a114dc78a
release v2.18.0-rc1 (#2364) 2026-03-16 00:53:01 -04:00
Shivaram Lingamneni
da037c0162 clean up config tests 2026-03-15 05:56:31 -04:00
Shivaram Lingamneni
0e76470249
allow testing irc/ with i18n compiled out (#2363)
Add to `make test` as well
2026-03-15 03:47:33 -04:00
Shivaram Lingamneni
93495e2cf5
update FAQ entry for OPER issues (#2230) 2026-03-15 03:38:25 -04:00
Shivaram Lingamneni
620e7d13d5
Merge pull request #2362 from slingamn/issue2275.6
fix #2275
2026-03-15 00:28:09 -07:00
Shivaram Lingamneni
218cac3037 fix environment overrides against map fields 2026-03-15 07:16:44 +00:00
Shivaram Lingamneni
3bc010a6d9 document limitations of environment overrides 2026-03-15 04:40:26 +00:00
Shivaram Lingamneni
e4e9e20986 bump irctest 2026-03-13 16:38:07 -04:00
Shivaram Lingamneni
52fc77433e
api: defcon endpoint (#2359) 2026-03-13 03:18:23 -04:00
Shivaram Lingamneni
05c37122fc
api: add /v1/list to list channels (#2358) 2026-03-13 01:53:17 -04:00
Shivaram Lingamneni
ca4c3c09df
goreleaser updates for v2.18 (#2357)
* Full build is default
* sqlite is only supported on Linux, MacOS, and FreeBSD;
  override the sqlite build tag based on OS where unsupported
2026-03-12 22:37:56 -04:00
Shivaram Lingamneni
e7558f292c
build changes (#2356)
* default build includes everything
* allow compiling out PRECIS and Skeleton (still included by default)
* consistently use `postgresql` in identifiers
2026-03-12 21:48:37 -04:00
Shivaram Lingamneni
768c01c17b
add a sqlite history backend (#2352) 2026-03-12 21:32:13 -04:00
Shivaram Lingamneni
1b673f049f
remove defer in AuthenticateByCertificate (#2355)
Makes the control flow unnecessarily confusing now that the check
is its own function.
2026-03-12 12:58:29 -04:00
Shivaram Lingamneni
4e65b76c67
fix #2353 (#2354)
Check certfp in API /v1/check_auth
2026-03-12 01:41:49 -04:00
Shivaram Lingamneni
c61859927f
fix WEBPUSH FAIL responses (#2351)
* All FAIL messages take the endpoint as a parameter when available
* Use MAX_REGISTRATIONS instead of FORBIDDEN when appropriate
* Add handler for unknown subcommand
2026-03-11 04:21:36 -04:00
Shivaram Lingamneni
ec7db5a02b
fix #2349 (#2350)
Add msgid to push messages when available, as required by the spec
2026-03-10 17:50:01 -04:00
Shivaram Lingamneni
3c41a81921 bump irctest 2026-03-10 11:43:32 -04:00
Shivaram Lingamneni
69efae2c2e
postgres: accept libpq URIs (#2347) 2026-03-05 14:58:09 -05:00
Shivaram Lingamneni
5fd8ed8ed6
add a postgres history backend (#2322)
Postgres support is behind a build tag; use `make build_full`
or `make install_full` to compile it in.
2026-03-05 01:18:28 -05:00
Shivaram Lingamneni
ee0071b37a bump irctest 2026-03-02 23:19:50 -05:00
Shivaram Lingamneni
2e6fd75525
support draft/ACCOUNTREQUIRED in 005 (#2341)
https://github.com/ircv3/ircv3-specifications/pull/585
2026-03-02 04:10:58 -05:00
Shivaram Lingamneni
76e8e61705
fix #2227 (#2334)
* fix #2227

Instead of returning an error from Accept(), force the caller to process
errors from trying to read and parse the PROXY protocol.

The advantage here is that we don't have to rely on (net.Error).Temporary
or incur timed backoff from net/http when hitting these errors. However,
we still risk stalling processing of new incoming connections if someone
opens a connection to the proxy listener and doesn't send anything.
This is hard to fix while maintaining the net.Listener abstraction in
cooperation with http.Server.

* reduce proxy deadline to 5 seconds
2026-03-02 04:10:26 -05:00
Shivaram Lingamneni
b120806fd5
fix #2345 (#2346)
Nil dereference from CS DEOP and EXTJWT; no security implications
2026-03-01 12:45:21 -05:00
Shivaram Lingamneni
23e1ef384c mysql: fix channels allocation 2026-03-01 06:04:07 -05:00
Shivaram Lingamneni
3e83f52f3a
Merge pull request #2338 from ergochat/dependabot/go_modules/filippo.io/edwards25519-1.1.1
Bump filippo.io/edwards25519 from 1.1.0 to 1.1.1
2026-02-19 12:15:13 -08:00
dependabot[bot]
1e00095055
Bump filippo.io/edwards25519 from 1.1.0 to 1.1.1
Bumps [filippo.io/edwards25519](https://github.com/FiloSottile/edwards25519) from 1.1.0 to 1.1.1.
- [Commits](https://github.com/FiloSottile/edwards25519/compare/v1.1.0...v1.1.1)

---
updated-dependencies:
- dependency-name: filippo.io/edwards25519
  dependency-version: 1.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-19 18:51:35 +00:00
Shivaram Lingamneni
11a38b7119
bump irctest to test websockets (#2331)
Install python3-websockets for irctest in CI. This won't actually work
as expected until Ubuntu 26.04.
2026-02-16 18:19:04 -05:00
Shivaram Lingamneni
c79b65cf8a bump irc-go 2026-02-11 04:01:40 -05:00
Shivaram Lingamneni
8fe491156c
upgrade to go 1.26 (#2330)
Also upgrade actions for compatibility with 1.26
2026-02-10 19:19:08 -05:00
Shivaram Lingamneni
5fa9757b43
fix #2328 (#2329)
* Add API endpoint /v1/ns/passwd to change passwords
* New naming scheme for API endpoints under /v1/ns/
* /v1/ns/list no longer returns error responses
2026-02-08 01:27:13 -05:00
Shivaram Lingamneni
2b1dad982f
clarify when history.channel-length applies (#2323) 2026-01-27 08:13:21 -08:00
Shivaram Lingamneni
3ff0341944 bump irctest 2026-01-26 12:22:31 -05:00
Shivaram Lingamneni
2152000312
use correct name for RPL_INVEXLIST (#2326)
Rename the constants only; no functional change
2026-01-11 02:27:37 -05:00
Shivaram Lingamneni
2da19a0760
fix #2324 (#2325)
Validate user limit parameter
2026-01-10 18:57:49 -05:00
Shivaram Lingamneni
5c0af196da fix (noopSequence).Ephemeral() 2026-01-01 15:06:53 -05:00
Shivaram Lingamneni
63a04a5ff0 bump irctest 2026-01-01 12:33:12 -05:00
Shivaram Lingamneni
f28b10986e
redact: fix error line for missing channel (#2321) 2025-12-31 14:56:23 -05:00
Shivaram Lingamneni
d2c9c80cc1 fix mysql logline 2025-12-31 02:37:00 -05:00
Shivaram Lingamneni
6386b9ef70
fix some redact bugs (#2320)
* Consistently return UNKNOWN_MSGID for unknown or invalid msgids
* If both client's DMs are stored in persistent history, a single
  server.DeleteMessage will delete the single canonical copy of the message.
  So the second call will fail, which is fine.
2025-12-31 02:15:11 -05:00
Shivaram Lingamneni
6ba60c89c4 move mysql serialization tools to shared pkgs 2025-12-30 23:34:05 -05:00
Shivaram Lingamneni
0b7be24f80 propagate mysql close error 2025-12-30 23:13:55 -05:00
Shivaram Lingamneni
9f54ea07b7
prep for alternative history databases (#2316)
* abstract history DB interface

* make mysql error logging consistent

Consistently propagate database errors to the client, making the client
responsible for logging them.

* move ListCorrespondents from Sequence to Database/Buffer
2025-12-30 23:12:30 -05:00
Shivaram Lingamneni
3179fdffb0
REDACT: fix reason handling (#2319)
REDACT without a reason parameter was being relayed with an
empty reason parameter instead; fix this.
2025-12-30 18:41:24 -05:00
Shivaram Lingamneni
13653b5202
fix #2284 (#2317)
Send a configurable NOTICE on first connection that hopm can
detect via target_string
2025-12-30 02:27:09 -05:00
Shivaram Lingamneni
b26571e891 bump irctest 2025-12-29 12:41:20 -05:00
Shivaram Lingamneni
b7ede0730f
consistent casing for SQL queries (#2315)
Makes it easier to read diffs with other database backends
2025-12-29 00:03:58 -05:00
Shivaram Lingamneni
05b41e18af bump irctest 2025-12-28 21:47:27 -05:00
Shivaram Lingamneni
748700877e
dependency upgrades for v2.18 release cycle (#2314) 2025-12-23 00:07:39 -05:00
Shivaram Lingamneni
462e568f00
fix #2311 (#2312)
Validate bcrypt-cost config value to prevent silent errors
2025-12-22 03:26:09 -05:00
Shivaram Lingamneni
3c4c5dde4d
fix #2309 (#2310)
If bob is monitoring alice, bob should get METADATA lines for alice
even if bob doesn't have extended-monitor.

This implementation also removes the check for extended-monitor
when sending account-notify, away-notify, chghost, setname
(which are explicitly mentioned in the extended-monitor spec)
on the grounds that nothing bad will happen if clients who support
the cap receive notifications for users they're not explicitly tracking.
2025-12-22 03:25:27 -05:00
Shivaram Lingamneni
3e9fa38f6b set up new development version 2025-12-22 03:22:05 -05:00
Shivaram Lingamneni
16186d677d bump version and changelog for v2.17.0 2025-12-22 02:34:40 -05:00
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
1961 changed files with 6267079 additions and 12476 deletions

View File

@ -12,16 +12,20 @@ on:
jobs: jobs:
build: build:
runs-on: "ubuntu-22.04" runs-on: "ubuntu-24.04"
steps: steps:
- name: "checkout repository" - name: "checkout repository"
uses: "actions/checkout@v3" uses: "actions/checkout@v6"
- name: "setup go" - name: "setup go"
uses: "actions/setup-go@v3" uses: "actions/setup-go@v6"
with: with:
go-version: "1.22" go-version: "1.26"
- name: "install python3-pytest" - name: "install python3-pytest"
run: "sudo apt install -y python3-pytest" run: "sudo apt install -y python3-pytest python3-websockets"
- name: "make minimal"
run: "make minimal"
- name: "make build"
run: "make build"
- name: "make install" - name: "make install"
run: "make install" run: "make install"
- name: "make test" - name: "make test"

View File

@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout Git repository - name: Checkout Git repository
uses: actions/checkout@v3 uses: actions/checkout@v6
- name: Authenticate to container registry - name: Authenticate to container registry
uses: docker/login-action@v2 uses: docker/login-action@v2

View File

@ -1,5 +1,6 @@
# .goreleaser.yml # .goreleaser.yml
# Build customization # Build customization
version: 2
project_name: ergo project_name: ergo
builds: builds:
- main: ergo.go - main: ergo.go
@ -17,6 +18,7 @@ builds:
- amd64 - amd64
- arm - arm
- arm64 - arm64
- riscv64
goarm: goarm:
- 6 - 6
ignore: ignore:
@ -24,30 +26,42 @@ builds:
goarch: arm goarch: arm
- goos: windows - goos: windows
goarch: arm64 goarch: arm64
- goos: windows
goarch: riscv64
- goos: darwin - goos: darwin
goarch: arm goarch: arm
- goos: darwin
goarch: riscv64
- goos: freebsd - goos: freebsd
goarch: arm goarch: arm
- goos: freebsd - goos: freebsd
goarch: arm64 goarch: arm64
- goos: freebsd
goarch: riscv64
- goos: openbsd - goos: openbsd
goarch: arm goarch: arm
- goos: openbsd - goos: openbsd
goarch: arm64 goarch: arm64
- goos: openbsd
goarch: riscv64
- goos: plan9 - goos: plan9
goarch: arm goarch: arm
- goos: plan9 - goos: plan9
goarch: arm64 goarch: arm64
- goos: plan9
goarch: riscv64
flags: flags:
- -trimpath - -trimpath
- -tags={{.Env.ERGO_BUILD_TAGS}}
archives: archives:
- -
name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" name_template: >-
{{ .ProjectName }}-{{ .Version }}-
{{- if eq .Os "darwin" }}macos{{- else }}{{ .Os }}{{ end -}}-
{{- if eq .Arch "amd64" }}x86_64{{- else }}{{ .Arch }}{{ end -}}
{{ if .Arm }}v{{ .Arm }}{{ end -}}
format: tar.gz format: tar.gz
replacements:
amd64: x86_64
darwin: macos
format_overrides: format_overrides:
- goos: windows - goos: windows
format: zip format: zip
@ -58,6 +72,8 @@ archives:
- ergo.motd - ergo.motd
- default.yaml - default.yaml
- traditional.yaml - traditional.yaml
- docs/API.md
- docs/BUILD.md
- docs/MANUAL.md - docs/MANUAL.md
- docs/USERGUIDE.md - docs/USERGUIDE.md
- languages/*.yaml - languages/*.yaml

View File

@ -1,20 +1,155 @@
# Changelog # Changelog
All notable changes to Ergo will be documented in this file. All notable changes to Ergo will be documented in this file.
## [2.14.0-rc2] - 2024-06-09 ## [2.18.0] - 2026-03-22
Due to the discovery of a bug (predating v2.14.0-rc1), we are releasing a new release candidate for v2.14.0 (the official release should follow within a week or so). We're pleased to be publishing v2.18.0, a new stable release. This release adds support for PostgreSQL and SQLite as history backends, expands the HTTP API, and includes bug fixes and minor improvements.
### 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)
## [2.14.0-rc1] - 2024-06-09
We're pleased to be publishing the release candidate for v2.14.0 (the official release should follow within two weeks or so). 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. 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), 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. Due to the additional database drivers included in the default build, the size of the Ergo executable binary has increased since v2.17.0 (for example, the Linux binary for x86-64 has increased from 16.2 MiB to 26.1 MiB). See [docs/BUILD.md](https://github.com/ergochat/ergo/blob/stable/docs/BUILD.md) if you need to build a smaller binary. Conversely, if you were already building from source, you may need to adjust your build commands in order to maintain parity; consult that file for details.
Many thanks to [@clbm87](https://github.com/clbm87), [@emersion](https://github.com/emersion), [@felix](https://github.com/felix), flurry, [@furudean](https://github.com/furudean), [@k4ct0](https://github.com/k4ct0), [@mauropcorrea](https://github.com/mauropcorrea), [@NyaaaWhatsUpDoc](https://github.com/NyaaaWhatsUpDoc), [@poVoq](https://github.com/poVoq), [@progval](https://github.com/progval), [@rys](https://github.com/rys), Stryker, and th0th for helpful discussions, contributing patches, reporting issues, and helping test.
### Config changes
* Added `server.postgresql` block to configure a PostgreSQL history backend. (#2322, #2347)
* Added `server.sqlite` block to configure a SQLite history backend. (#2352)
* Added `metadata.operator-only-modification` to restrict metadata changes to IRC operators with the `metadata` capability (#2287, #2369, thanks [@clbm87](https://github.com/clbm87)!)
* Added `server.initial-notice` to send a configurable notice to clients immediately after they connect, which can be used for open proxy detection (e.g., with HOPM) (#2317, thanks Stryker!)
### Added
* Added support for PostgreSQL and SQLite as history backends (#2322, #2352, thanks [@felix](https://github.com/felix), [@NyaaaWhatsUpDoc](https://github.com/NyaaaWhatsUpDoc), [@poVoq](https://github.com/poVoq)!)
* Added new HTTP API endpoints (thanks [@clbm87](https://github.com/clbm87), flurry, [@furudean](https://github.com/furudean), [@mauropcorrea](https://github.com/mauropcorrea)!):
* `/v1/list` to list channels (#2358)
* `/v1/defcon` to view or modify the DEFCON level (#2359)
* `/v1/ns/passwd` to change account passwords (#2329)
* Added support for `draft/ACCOUNTREQUIRED` in `005` ISUPPORT tokens when `accounts.require-sasl` is enabled (#2341)
### Fixed
* Push notifications now include the `msgid` tag, as required by the specification (#2350)
* Fixed some cases where environment variable config overrides did not apply as expected (e.g., individual oper entries can now be overridden without replacing the entire `opers` block) (#2275, #2362, thanks th0th!)
* Fixed error cases in `CS DEOP` and `EXTJWT` causing client disconnection (#2345, #2346, thanks [@k4ct0](https://github.com/k4ct0)!)
* Fixed some `REDACT` responses (#2319, #2320)
* Fixed some `FAIL` responses to `WEBPUSH` (#2351)
* The `+l` (user limit) channel mode now rejects non-positive values with an appropriate error (#2325, thanks [@progval](https://github.com/progval)!)
* Clients monitoring a user now receive `METADATA` notifications for that user even without the `extended-monitor` capability (#2309, #2310)
* Improved handling of PROXY protocol errors on `proxy: true` listeners (#2334)
* The `accounts.bcrypt-cost` config value is now validated at config load time (#2311, #2312, thanks [@rys](https://github.com/rys)!)
### Changed
* HTTP API: reorganized NickServ-related endpoints under a `/v1/ns/` prefix (`/v1/ns/info`, `/v1/ns/list`, `/v1/ns/passwd`). The previous endpoint names (`/v1/account_details`, `/v1/account_list`) are retained as aliases for backwards compatibility. (#2329)
* The `/v1/check_auth` API endpoint now supports certificate fingerprint authentication (#2354, thanks flurry!)
* Reduced the deadline for `proxy: true` listeners to read the PROXY protocol header from 1 minute to 5 seconds (#2334)
### Internal
* Added build tags to control which optional features are built; see [docs/BUILD.md](https://github.com/ergochat/ergo/blob/stable/docs/BUILD.md) for details (#2356)
* Official release builds use Go 1.26.1 (#2330)
## [2.17.0] - 2025-12-22
We're pleased to be publishing v2.17.0, a new stable release. 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 ### Config changes
* Added `accounts.oauth2` and `accounts.jwt-auth` blocks for configuring OAuth2 and JWT authentication (#2004) * Added `accounts.oauth2` and `accounts.jwt-auth` blocks for configuring OAuth2 and JWT authentication (#2004)
@ -30,6 +165,7 @@ Many thanks to [@al3xandros](https://github.com/al3xandros), donio, [@eeeeeta](h
* The new `ergo defaultconfig` subcommand prints a copy of the default config file to standard output (#2157, #2160, thanks [@al3xandros](https://github.com/al3xandros)!) * 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 ### 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)!) * 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)!) * 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!) * Attempts to use unrecognized SASL mechanisms no longer count against the login throttle, improving compatibility with Pidgin (#2156, thanks donio and pathof!)
@ -47,6 +183,7 @@ Many thanks to [@al3xandros](https://github.com/al3xandros), donio, [@eeeeeta](h
### Internal ### Internal
* Official release builds use Go 1.22.4 * 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 ## [2.13.1] - 2024-05-06

View File

@ -1,5 +1,5 @@
## build ergo binary ## build ergo binary
FROM docker.io/golang:1.22-alpine AS build-env FROM docker.io/golang:1.26-alpine3.22 AS build-env
RUN apk upgrade -U --force-refresh --no-cache && apk add --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
@ -16,7 +16,7 @@ RUN sed -i 's/^\(\s*\)\"127.0.0.1:6667\":.*$/\1":6667":/' /go/src/github.com/erg
RUN make install RUN make install
## build ergo container ## build ergo container
FROM docker.io/alpine:3.19 FROM docker.io/alpine:3.22
# metadata # metadata
LABEL maintainer="Daniel Oaks <daniel@danieloaks.net>,Daniel Thamdrup <dallemon@protonmail.com>" \ LABEL maintainer="Daniel Oaks <daniel@danieloaks.net>,Daniel Thamdrup <dallemon@protonmail.com>" \

View File

@ -1,5 +1,3 @@
.PHONY: all install build release capdefs test smoke gofmt irctest
GIT_COMMIT := $(shell git rev-parse HEAD 2> /dev/null) GIT_COMMIT := $(shell git rev-parse HEAD 2> /dev/null)
GIT_TAG := $(shell git tag --points-at HEAD 2> /dev/null | head -n 1) GIT_TAG := $(shell git tag --points-at HEAD 2> /dev/null | head -n 1)
@ -7,35 +5,56 @@ GIT_TAG := $(shell git tag --points-at HEAD 2> /dev/null | head -n 1)
# this can be overridden by passing CGO_ENABLED=1 to make # this can be overridden by passing CGO_ENABLED=1 to make
export CGO_ENABLED ?= 0 export CGO_ENABLED ?= 0
# build tags for the maximalist build with everything included
full_tags = i18n mysql postgresql sqlite
# build everything by default; override by passing, e.g. ERGO_BUILD_TAGS="mysql postgresql"
ERGO_BUILD_TAGS ?= $(full_tags)
capdef_file = ./irc/caps/defs.go capdef_file = ./irc/caps/defs.go
.PHONY: all
all: build all: build
install: .PHONY: build
go install -v -ldflags "-X main.commit=$(GIT_COMMIT) -X main.version=$(GIT_TAG)"
build: build:
go build -v -ldflags "-X main.commit=$(GIT_COMMIT) -X main.version=$(GIT_TAG)" go build -v -tags "$(ERGO_BUILD_TAGS)" -ldflags "-X main.commit=$(GIT_COMMIT) -X main.version=$(GIT_TAG)"
.PHONY: install
install:
go install -v -tags "$(ERGO_BUILD_TAGS)" -ldflags "-X main.commit=$(GIT_COMMIT) -X main.version=$(GIT_TAG)"
.PHONY: release
release: release:
goreleaser --skip-publish --rm-dist ERGO_BUILD_TAGS="$(ERGO_BUILD_TAGS)" goreleaser --skip=publish --clean
.PHONY: minimal
minimal:
go build -v -tags "" -ldflags "-X main.commit=$(GIT_COMMIT) -X main.version=$(GIT_TAG)"
.PHONY: capdefs
capdefs: capdefs:
python3 ./gencapdefs.py > ${capdef_file} python3 ./gencapdefs.py > ${capdef_file}
.PHONY: test
test: test:
python3 ./gencapdefs.py | diff - ${capdef_file} python3 ./gencapdefs.py | diff - ${capdef_file}
go test ./... go test -tags "$(full_tags)" ./...
go vet ./... go vet -tags "$(full_tags)" ./...
go test -tags "" ./...
go vet -tags "" ./...
./.check-gofmt.sh ./.check-gofmt.sh
.PHONY: smoke
smoke: install smoke: install
ergo mkcerts --conf ./default.yaml || true ergo mkcerts --conf ./default.yaml || true
ergo run --conf ./default.yaml --smoke ergo run --conf ./default.yaml --smoke
.PHONY: gofmt
gofmt: gofmt:
./.check-gofmt.sh --fix ./.check-gofmt.sh --fix
.PHONY: irctest
irctest: install irctest: install
git submodule update --init git submodule update --init
cd irctest && make ergo cd irctest && make ergo

View File

@ -76,16 +76,18 @@ to the GitHub Container Registry at [ghcr.io/ergochat/ergo](https://ghcr.io/ergo
### From Source ### From Source
You can also clone this repository and build from source. Typical deployments should use the `stable` branch, which points to the latest stable release. In general, `stable` should coincide with the latest published tag that is not designated as a beta or release candidate (for example, `v2.7.0-rc1` was an unstable release candidate and `v2.7.0` was the corresponding stable release), so you can also identify the latest stable release tag on the [releases page](https://github.com/ergochat/ergo/releases) and build that. You can also clone this repository and build from source. A quick start guide:
The `master` branch is not recommended for production use since it may contain bugs, and because the forwards compatibility guarantees for the config file and the database that apply to releases do not apply to master. That is to say, running master may result in changes to your database that end up being incompatible with future versions of Ergo. 1. Obtain an [up-to-date distribution of the Go language for your OS and architecture](https://golang.org/dl/). Check the output of `go version` to ensure it was installed correctly.
1. Clone the repository.
1. `git checkout stable`
1. `make`
1. You should now have a binary named `ergo` in the working directory.
Ergo vendors all its dependencies, so you will not need to fetch any dependencies remotely. For more information, including on build customization, see [docs/BUILD.md](https://github.com/ergochat/ergo/blob/stable/docs/BUILD.md).
For information on contributing to Ergo, see [DEVELOPING.md](https://github.com/ergochat/ergo/blob/master/DEVELOPING.md). For information on contributing to Ergo, see [DEVELOPING.md](https://github.com/ergochat/ergo/blob/master/DEVELOPING.md).
#### Building
You'll need an [up-to-date distribution of the Go language for your OS and architecture](https://golang.org/dl/). Once that's installed (check the output of `go version`), just check out your desired branch or tag and run `make`. This will produce an executable binary named `ergo` in the base directory of the project. (Ergo vendors all its dependencies, so you will not need to fetch any dependencies remotely.)
## Configuration ## Configuration
The default config file [`default.yaml`](default.yaml) helps walk you through what each option means and changes. The default config file [`default.yaml`](default.yaml) helps walk you through what each option means and changes.

View File

@ -100,6 +100,7 @@ server:
max-connections-per-duration: 64 max-connections-per-duration: 64
# strict transport security, to get clients to automagically use TLS # strict transport security, to get clients to automagically use TLS
# (irrelevant in the recommended configuration, with no public plaintext listener)
sts: sts:
# whether to advertise STS # whether to advertise STS
# #
@ -179,6 +180,21 @@ server:
# if this is true, the motd is escaped using formatting codes like $c, $b, and $i # if this is true, the motd is escaped using formatting codes like $c, $b, and $i
motd-formatting: true motd-formatting: true
# send a configurable notice to clients immediately after they connect,
# as a way of detecting open proxies
#initial-notice: "*** Welcome to the Ergo IRC server"
# 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 # relaying using the RELAYMSG command
relaymsg: relaymsg:
# is relaymsg enabled at all? # is relaymsg enabled at all?
@ -357,6 +373,10 @@ server:
secure-nets: secure-nets:
# - "10.0.0.0/8" # - "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., # Ergo will write files to disk under certain circumstances, e.g.,
# CPU profiling or data export. by default, these files will be written # CPU profiling or data export. by default, these files will be written
# to the working directory. set this to customize: # to the working directory. set this to customize:
@ -375,6 +395,17 @@ server:
# if you don't want to publicize how popular the server is # if you don't want to publicize how popular the server is
suppress-lusers: false 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 # account options
accounts: accounts:
# is account authentication enabled, i.e., can users log into existing accounts? # is account authentication enabled, i.e., can users log into existing accounts?
@ -510,7 +541,7 @@ accounts:
# 1. these nicknames cannot be registered or reserved # 1. these nicknames cannot be registered or reserved
# 2. if a client is automatically renamed by the server, # 2. if a client is automatically renamed by the server,
# this is the template that will be used (e.g., Guest-nccj6rgmt97cg) # 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 # a registered account will have this template applied to their
# nicknames (e.g., 'katie' will become 'Guest-katie') # nicknames (e.g., 'katie' will become 'Guest-katie')
guest-nickname-format: "Guest-*" guest-nickname-format: "Guest-*"
@ -712,6 +743,7 @@ oper-classes:
- "history" # modify or delete history messages - "history" # modify or delete history messages
- "defcon" # use the DEFCON command (restrict server capabilities) - "defcon" # use the DEFCON command (restrict server capabilities)
- "massmessage" # message all users on the server - "massmessage" # message all users on the server
- "metadata" # modify arbitrary metadata on channels and users
# ircd operators # ircd operators
opers: opers:
@ -776,7 +808,7 @@ logging:
# be logged, even if you explicitly include it # be logged, even if you explicitly include it
# #
# useful types include: # 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 # server server startup, rehash, and shutdown events
# accounts account registration and authentication # accounts account registration and authentication
# channels channel creation and operations # channels channel creation and operations
@ -820,7 +852,7 @@ lock-file: "ircd.lock"
# datastore configuration # datastore configuration
datastore: datastore:
# path to the datastore # path to the database file (used to store account and channel registrations):
path: ircd.db path: ircd.db
# if the database schema requires an upgrade, `autoupgrade` will attempt to # if the database schema requires an upgrade, `autoupgrade` will attempt to
@ -843,6 +875,43 @@ datastore:
# this may be necessary to prevent middleware from closing your connections: # this may be necessary to prevent middleware from closing your connections:
#conn-max-lifetime: 180s #conn-max-lifetime: 180s
# connection information for PostgreSQL (currently only used for persistent history)
postgresql:
enabled: false
host: "localhost"
port: 5432
# if socket-path is set, it will be used instead of host:port
# PostgreSQL uses the socket directory, not the socket file path
#socket-path: "/var/run/postgresql"
# PostgreSQL SSL/TLS configuration:
ssl-mode: "disable" # options: disable, require, verify-ca, verify-full
#ssl-cert: "/path/to/client-cert.pem"
#ssl-key: "/path/to/client-key.pem"
#ssl-root-cert: "/path/to/ca-cert.pem"
user: "ergo"
password: "hunter2"
history-database: "ergo_history"
# uri takes a postgresql:// (libpq) URI, overriding the above parameters if present:
# uri: "postgresql://ergo:hunter2@localhost/ergo_history"
timeout: 3s
max-conns: 4
# this may be necessary to prevent middleware from closing your connections:
#conn-max-lifetime: 180s
# application name shown in pg_stat_activity for operational visibility:
#application-name: "ergo"
# timeout for establishing initial connections to PostgreSQL:
#connect-timeout: 10s
# connection information for SQLite (currently only used for persistent history)
sqlite:
enabled: false
# path to the SQLite database file
database-path: "ergo_history.db"
# timeout when waiting for write lock
busy-timeout: 5s
# maximum concurrent connections
max-conns: 1
# languages config # languages config
languages: languages:
# whether to load languages # whether to load languages
@ -922,6 +991,7 @@ fakelag:
"MARKREAD": 16 "MARKREAD": 16
"MONITOR": 1 "MONITOR": 1
"WHO": 4 "WHO": 4
"WEBPUSH": 1
# the roleplay commands are semi-standardized extensions to IRC that allow # the roleplay commands are semi-standardized extensions to IRC that allow
# sending and receiving messages from pseudo-nicknames. this can be used either # sending and receiving messages from pseudo-nicknames. this can be used either
@ -940,6 +1010,12 @@ roleplay:
# add the real nickname, in parentheses, to the end of every roleplay message? # add the real nickname, in parentheses, to the end of every roleplay message?
add-suffix: true 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). # 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 # 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. # the server, is a member of a particular channel, etc.
@ -968,10 +1044,12 @@ history:
# in your country and the countries of your users. # in your country and the countries of your users.
enabled: true enabled: true
# how many channel-specific events (messages, joins, parts) should be tracked per channel? # if the in-memory backend is enabled for a channel, how many channel-specific events
# (messages, joins, parts) should be retained?
channel-length: 2048 channel-length: 2048
# how many direct messages and notices should be tracked per user? # if the in-memory backend is enabled for a user, how many direct messages
# and notices should be retained?
client-length: 256 client-length: 256
# how long should we try to preserve messages? # how long should we try to preserve messages?
@ -1067,3 +1145,58 @@ history:
# whether to allow customization of the config at runtime using environment variables, # whether to allow customization of the config at runtime using environment variables,
# e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details. # e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details.
allow-environment-overrides: true allow-environment-overrides: true
# metadata support for setting key/value data on channels and nicknames.
metadata:
# can clients store metadata?
enabled: true
# if this is true, only server operators with the `metadata` capability can edit metadata:
operator-only-modification: false
# 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"

View File

@ -53,14 +53,14 @@ For example, to create a new docker volume and then mount it:
```shell ```shell
docker volume create ergo-data docker volume create ergo-data
docker run --init -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: Or to mount a folder from your host machine:
```shell ```shell
mkdir ergo-data mkdir ergo-data
docker run --init -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 ## Customising the config
@ -85,8 +85,8 @@ docker kill -s SIGHUP ergo
## Using custom TLS certificates ## Using custom TLS certificates
TLS certs will by default be read from /ircd/tls.crt, with a private key TLS certs will by default be read from /ircd/fullchain.pem, with a private key
in /ircd/tls.key. You can customise this path in the ircd.yaml file if 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 you wish to mount the certificates from another volume. For information
on using Let's Encrypt certificates, see on using Let's Encrypt certificates, see
[this manual entry](https://github.com/ergochat/ergo/blob/master/docs/MANUAL.md#using-valid-tls-certificates). [this manual entry](https://github.com/ergochat/ergo/blob/master/docs/MANUAL.md#using-valid-tls-certificates).

181
docs/API.md Normal file
View File

@ -0,0 +1,181 @@
__ __ ______ ___ ______ ___
__/ // /_/ ____/ __ \/ ____/ __ \
/_ // __/ __/ / /_/ / / __/ / / /
/_ // __/ /___/ _, _/ /_/ / /_/ /
/_//_/ /_____/_/ |_|\____/\____/
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/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
* `certfp`: string, alleged certificate fingerprint (hex-encoded SHA-256 checksum of the decoded raw certificate) associated with the account
Each individual field is optional, since a user may be authenticated either by account-passphrase pair or by certificate.
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/defcon`
------------
This endpoint can be used to view or modify the DEFCON level (see `/helpop defcon` for details). If the request is empty, the existing level is returned. To change the level, send a JSON object with fields:
* `defcon`: integer, desired new value of the DEFCON setting (between 5 for normal operation and 1 for the most restrictive)
The response is a JSON object with fields:
* `defcon`: integer, current (or new) value of the DEFCON setting
`/v1/list`
----------
This endpoint returns a list of channels that exist on the network. The request body is ignored and can be empty.
The response is a JSON object with fields:
* `success`: whether the request was successful
* `channels`: a list of channel objects, as described below
Each channel object has fields:
* `name`: canonical name of the channel without case-normalization
* `hasKey`: boolean, whether the channel has a key set with the `+k` mode
* `inviteOnly`: boolean, whether the channel has the `+i` invite-only mode set
* `secret`: boolean, whether the channel has the `+s` secret mode set (and would be hidden from an unprivileged `LIST` command)
* `userCount`: integer, number of users in the channel
* `topic`: string, channel topic
* `topicSetAt`: string, time the topic was last updated (in ISO8601 format)
* `createdAt`: string, time the channel was created (in ISO8601 format)
* `registered`: boolean, whether the channel is registered
* `owner`: string, account name of the registered owner if the channel is registered
* `registeredAt`: string, registration date/time of the channel (in ISO8601 format) if it is registered
`/v1/ns/info`
-------------
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
Note: this endpoint was previously named `/v1/account_details`. The old name is still accepted for backwards compatibility.
`/v1/ns/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
Note: this endpoint was previously named `/v1/account_list`. The old name is still accepted for backwards compatibility.
`/v1/ns/passwd`
---------------
This endpoint changes the password of an existing NickServ account. The request is a JSON object with fields:
* `accountName`: string, name of the account
* `passphrase`: string, new passphrase for the account
The response is a JSON object with fields:
* `success`: whether the password change succeeded
* `errorCode`: string, optional, machine-readable description of the error. Possible values include: `ACCOUNT_DOES_NOT_EXIST`, `INVALID_PASSPHRASE`, `CREDENTIALS_EXTERNALLY_MANAGED`, `UNKNOWN_ERROR`.
`/v1/ns/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.
Note: this endpoint was previously named `/v1/saregister`. The old name is still accepted for backwards compatibility.
`/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/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

45
docs/BUILD.md Normal file
View File

@ -0,0 +1,45 @@
__ __ ______ ___ ______ ___
__/ // /_/ ____/ __ \/ ____/ __ \
/_ // __/ __/ / /_/ / / __/ / / /
/_ // __/ /___/ _, _/ /_/ / /_/ /
/_//_/ /_____/_/ |_|\____/\____/
Ergo Build Guide
https://ergo.chat/
_Copyright © Daniel Oaks <daniel@danieloaks.net>, Shivaram Lingamneni <slingamn@cs.stanford.edu>_
--------------------------------------------------------------------------------------------
This guide is for building Ergo from source. You can also obtain a pre-built release binary from our [GitHub page](https://github.com/ergochat/ergo/releases).
# Prerequisites
You will need an [up-to-date distribution of the Go language for your OS and architecture](https://golang.org/dl/). Use the latest version available. (As of this writing, only Google's Go distribution is supported, since `gccgo` lacks support for current language features.) Check the output of `go version` to ensure it was installed correctly.
You will need to either clone the repository from GitHub at [https://github.com/ergochat/ergo], or obtain a source tarball from our releases page on GitHub.
# What to build
Typical deployments should build the `stable` branch, which points to the latest stable release. In general, `stable` should coincide with the latest published tag that is not designated as a beta or release candidate (for example, `v2.7.0-rc1` was an unstable release candidate and `v2.7.0` was the corresponding stable release), so you can also identify the latest stable release tag on the [releases page](https://github.com/ergochat/ergo/releases) and build that.
The `master` branch is not recommended for production use since it may contain bugs, and because the forwards compatibility guarantees for the config file and the database that apply to releases do not apply to master. That is to say, running master may result in changes to your database that end up being incompatible with future versions of Ergo.
# Build tags and options
By default, Ergo is built with cgo disabled, producing a fully statically linked binary. You can disable this with `export CGO_ENABLED=1` before running `make`.
Ergo can be cross-compiled using [standard Go environment variables](https://go.dev/doc/install/source#environment), e.g. `GOOS=linux GOARCH=arm GOARM=v6 make build` will build an `ergo` binary suitable for a 32-bit Raspberry Pi.
The default Ergo binary (built with `make` or `make build`) includes support for all optional features. Each optional feature is controlled via a separate build tag; to override the build tags, pass the environment variable `ERGO_BUILD_TAGS` with a space-separated list of tags. (For example, for parity with v2.17.0 and earlier, you can run `ERGO_BUILD_TAGS="i18n mysql" make`. Passing the empty string disables all optional features.)
The supported build tags are:
* `i18n` enables support for non-ASCII casemappings (allowing Unicode in nicknames and channel names). (This was a default feature in Ergo v2.17.0 and earlier, but was not enabled by default at runtime. See the `server.casemapping` value of the config file.)
* `mysql` enables support for MySQL as a persistent history backend. (This was a default feature in v2.17.0 and earlier.)
* `postgresql` enables support for PostgreSQL as a persistent history backend.
* `sqlite` enables support for SQLite as a persistent history backend.
`sqlite` is particularly memory-intensive to compile (but not to run), so if you're building Ergo for a memory-constrained environment, you may want to consider cross-compilation.

View File

@ -41,9 +41,10 @@ _Copyright © Daniel Oaks <daniel@danieloaks.net>, Shivaram Lingamneni <slingamn
- [Language](#language) - [Language](#language)
- [Multiclient ("Bouncer")](#multiclient-bouncer) - [Multiclient ("Bouncer")](#multiclient-bouncer)
- [History](#history) - [History](#history)
- [Persistent history with MySQL](#persistent-history-with-mysql) - [Persistent history](#persistent-history)
- [IP cloaking](#ip-cloaking) - [IP cloaking](#ip-cloaking)
- [Moderation](#moderation) - [Moderation](#moderation)
- [Push notifications](#push-notifications)
- [Frequently Asked Questions](#frequently-asked-questions) - [Frequently Asked Questions](#frequently-asked-questions)
- [IRC over TLS](#irc-over-tls) - [IRC over TLS](#irc-over-tls)
- [Redirect from plaintext to TLS](#how-can-i-redirect-users-from-plaintext-to-tls) - [Redirect from plaintext to TLS](#how-can-i-redirect-users-from-plaintext-to-tls)
@ -62,6 +63,7 @@ _Copyright © Daniel Oaks <daniel@danieloaks.net>, Shivaram Lingamneni <slingamn
- [Tor](#tor) - [Tor](#tor)
- [I2P](#i2p) - [I2P](#i2p)
- [ZNC](#znc) - [ZNC](#znc)
- [API](#api)
- [External authentication systems](#external-authentication-systems) - [External authentication systems](#external-authentication-systems)
- [DNSBLs and other IP checking systems](#dnsbls-and-other-ip-checking-systems) - [DNSBLs and other IP checking systems](#dnsbls-and-other-ip-checking-systems)
- [Acknowledgements](#acknowledgements) - [Acknowledgements](#acknowledgements)
@ -169,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: 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. 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. Convert each path component from "kebab case" to "screaming snake case", e.g., `SERVER`, `WEBSOCKETS`, and `ALLOWED_ORIGINS`.
1. Prepend `ERGO` to the components, then join them all together using `__` as the separator, e.g., `ERGO__SERVER__WEBSOCKETS__ALLOWED_ORIGINS`. 1. Prepend `ERGO` to the components, then join them all together using `__` as the separator, e.g., `ERGO__SERVER__WEBSOCKETS__ALLOWED_ORIGINS`.
@ -176,6 +179,9 @@ Ergo can also be configured using environment variables, using the following tec
However, settings that were overridden using this technique cannot be rehashed --- changing them will require restarting the server. However, settings that were overridden using this technique cannot be rehashed --- changing them will require restarting the server.
Due to implementation details, this technique has some limitations. Here are the known issues:
1. `accounts.auth-script` and `server.ip-check-script` do not work as expected (see [#2275](https://github.com/ergochat/ergo/issues/2275) for workarounds).
## Productionizing with systemd ## Productionizing with systemd
@ -423,9 +429,32 @@ Unfortunately, client support for history playback is still patchy. In descendin
1. You can autoreplay a fixed number of lines (e.g., 25) each time you join a channel using `/msg NickServ set autoreplay-lines 25`. 1. You can autoreplay a fixed number of lines (e.g., 25) each time you join a channel using `/msg NickServ set autoreplay-lines 25`.
## Persistent history with MySQL ## Persistent history
On most Linux and POSIX systems, it's straightforward to set up MySQL (or MariaDB) as a backend for persistent history. This increases the amount of history that can be stored, and ensures that message data will be retained on server restart (you can still use the configuration options to set a time limit for retention). Here's a quick start guide for Ubuntu based on [Digital Ocean's documentation](https://www.digitalocean.com/community/tutorials/how-to-install-mysql-on-ubuntu-20-04): Persistent history means storing chat history (messages, but also events like JOINs and PARTs) on disk. This increases the amount of history that can be stored, and also ensures that message data will be retained on server restart (you can still use the configuration options to set a time limit for retention). Ergo supports three backends for persistent history: MySQL, PostgreSQL, and SQLite. If you have a default build of Ergo (for example, a release build from our GitHub page, or our official Docker image), all three backends are available.
To configure persistent history, you must set `history.persistent.enabled` to `true` in the Ergo config file. You may want to adjust other options in the `history` section at this time. Then you must additionally enable and configure one of the backends. Here are per-backend instructions:
### SQLite
SQLite is the easiest backend to enable; it's an embedded database that runs inside the Ergo process, without needing to talk to an external database server. Find `datastore.sqlite` in your config (or add it, following an up-to-date `default.yaml` as a guide):
```yaml
sqlite:
enabled: true
# path to the SQLite database file
database-path: "ergo_history.db"
# timeout when waiting for write lock
busy-timeout: 5s
# maximum concurrent connections
max-conns: 1
```
This creates an on-disk file `ergo_history.db` for the history storage, by default in the same working directory as the Ergo process. We believe SQLite should scale to the needs of most Ergo deployments (in our initial benchmarks, there is a write bottleneck of approximately 1K messages/events per second).
### MySQL
Here's a quick start guide for MySQL on Ubuntu based on [Digital Ocean's documentation](https://www.digitalocean.com/community/tutorials/how-to-install-mysql-on-ubuntu-20-04). (Ergo is also compatible with MariaDB; a compatible implementation is available on most Linux and POSIX platforms.)
1. Install the `mysql-server` package 1. Install the `mysql-server` package
1. Run `mysql_secure_installation` as root; this corrects some insecure package defaults 1. Run `mysql_secure_installation` as root; this corrects some insecure package defaults
@ -446,6 +475,10 @@ On most Linux and POSIX systems, it's straightforward to set up MySQL (or MariaD
timeout: 3s timeout: 3s
``` ```
### PostgreSQL
If you don't already have a PostgreSQL database, follow [Digital Ocean's quick start guide](https://www.digitalocean.com/community/tutorials/how-to-install-postgresql-on-ubuntu-22-04-quickstart) to set one up, then edit `datastore.postgresql` in the Ergo config file with `enabled: true` and your database parameters.
## IP cloaking ## IP cloaking
@ -483,6 +516,19 @@ These techniques require operator privileges: `UBAN` requires the `ban` operator
For channel operators, `/msg ChanServ HOWTOBAN #channel nickname` will provide similar information about the best way to ban a user from a channel. 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.
------------------------------------------------------------------------------------------- -------------------------------------------------------------------------------------------
@ -511,18 +557,23 @@ If your client or bot is failing to connect to Ergo, here are some things to che
## Why can't I oper? ## 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 correctly generate the hashed password with `ergo genpasswd`?
1. Did you add the password hash to the correct config file, then save the file? 1. Did you add the password hash to the correct config file, then save the file?
1. Did you rehash or restart Ergo after saving the file? 1. Did you rehash or restart Ergo after saving the file?
1. Does your password contain spaces or non-ASCII characters? Although such passwords are theoretically compatible with Ergo, they are likely to cause problems with your client. (The period character `.` is an acceptable alternative separator if your password is based on randomly chosen words.)
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. 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. 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.
If you're still having problems, your client or bouncer may be mangling the OPER command. You can try connecting to Ergo directly via the `nc` ("netcat") command to test this:
1. From the machine where Ergo is running, run `nc -v 127.0.0.1 6667`. (If you are using Docker, you will first need to get a shell inside the Docker container, e.g. with `docker exec -it $CONTAINER_ID /bin/sh`.)
1. Type `NICK unique_nickname`, press enter, type `USER u s e r`, and press enter. You may need to retry with a different nickname if the first one is in use.
1. Once you see a burst of lines starting with an `001` command, indicating a successful connection, type: `OPER <opername> <password>` and press enter.
1. If you see a successful response including the `381` command, this indicates that your password was accepted by Ergo and the problem is with your client or bouncer setup. If you see an error response, then there is an issue with your password or configuration file.
## Why is Ergo ignoring my ident response / USER command? ## Why is Ergo ignoring my ident response / USER command?
@ -1056,9 +1107,12 @@ You can import user and channel registrations from an Anope or Atheme database i
## Hybrid Open Proxy Monitor (HOPM) ## Hybrid Open Proxy Monitor (HOPM)
[hopm](https://github.com/ircd-hybrid/hopm) can be used to monitor your server for connections from open proxies, then automatically ban them. To configure hopm to work with Ergo, add operator blocks like this to your Ergo config file, which grant hopm the necessary privileges: [hopm](https://github.com/ircd-hybrid/hopm) can be used to monitor your server for connections from open proxies, then automatically ban them. To configure hopm to work with Ergo, configure `server.initial_notice` and add operator blocks like this to your Ergo config file, which grant hopm the necessary privileges:
````yaml ````yaml
server:
initial-notice: "Welcome to the Ergo IRC server"
# operator classes # operator classes
oper-classes: oper-classes:
# hopm # hopm
@ -1093,6 +1147,9 @@ opers:
Then configure hopm like this: Then configure hopm like this:
```` ````
/* replace with the exact notice your server sends on first connection */
target_string = ":ergo.test NOTICE * :*** Welcome to the Ergo IRC server"
/* ergo */ /* ergo */
connregex = ".+-.+CONNECT.+-.+ Client Connected \\[([^ ]+)\\] \\[u:([^ ]+)\\] \\[h:([^ ]+)\\] \\[ip:([^ ]+)\\] .+"; connregex = ".+-.+CONNECT.+-.+ Client Connected \\[([^ ]+)\\] \\[u:([^ ]+)\\] \\[h:([^ ]+)\\] \\[ip:([^ ]+)\\] .+";
@ -1121,6 +1178,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: 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`. * 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). * 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: * 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. * 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.
@ -1160,6 +1218,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. 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 ## 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: 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:

View File

@ -23,6 +23,7 @@ _Copyright © Daniel Oaks <daniel@danieloaks.net>, Shivaram Lingamneni <slingamn
- [Always-on](#always-on) - [Always-on](#always-on)
- [Multiclient](#multiclient) - [Multiclient](#multiclient)
- [History](#history) - [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 /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 # Always-on
@ -121,3 +122,7 @@ If you have registered a channel, you can make it private. The best way to do th
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. Identify the users you want to be able to access the channel. Ensure that they have registered their accounts (you should be able to see their registration status if you `/WHOIS` their nicknames).
1. Add the desired nick/account names to the invite exception list (`/mode #example +I alice`) or give them persistent voice (`/msg ChanServ AMODE #example +v alice`) 1. 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`) 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).

View File

@ -21,6 +21,7 @@ import (
"github.com/ergochat/ergo/irc" "github.com/ergochat/ergo/irc"
"github.com/ergochat/ergo/irc/logger" "github.com/ergochat/ergo/irc/logger"
"github.com/ergochat/ergo/irc/mkcerts" "github.com/ergochat/ergo/irc/mkcerts"
"github.com/ergochat/ergo/irc/utils"
) )
// set via linker flags, either by make or by goreleaser: // set via linker flags, either by make or by goreleaser:
@ -99,6 +100,7 @@ Usage:
ergo genpasswd [--conf <filename>] [--quiet] ergo genpasswd [--conf <filename>] [--quiet]
ergo mkcerts [--conf <filename>] [--quiet] ergo mkcerts [--conf <filename>] [--quiet]
ergo defaultconfig ergo defaultconfig
ergo gentoken
ergo run [--conf <filename>] [--quiet] [--smoke] ergo run [--conf <filename>] [--quiet] [--smoke]
ergo -h | --help ergo -h | --help
ergo --version ergo --version
@ -141,6 +143,9 @@ Options:
} else if arguments["defaultconfig"].(bool) { } else if arguments["defaultconfig"].(bool) {
fmt.Print(defaultConfig) fmt.Print(defaultConfig)
return return
} else if arguments["gentoken"].(bool) {
fmt.Println(utils.GenerateSecretKey())
return
} else if arguments["mkcerts"].(bool) { } else if arguments["mkcerts"].(bool) {
doMkcerts(arguments["--conf"].(string), arguments["--quiet"].(bool)) doMkcerts(arguments["--conf"].(string), arguments["--quiet"].(bool))
return return
@ -188,7 +193,7 @@ Options:
// warning if running a non-final version // warning if running a non-final version
if strings.Contains(irc.Ver, "unreleased") { 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) server, err := irc.NewServer(config, logman)

View File

@ -219,6 +219,31 @@ CAPDEFS = [
url="https://github.com/ircv3/ircv3-specifications/pull/527", url="https://github.com/ircv3/ircv3-specifications/pull/527",
standard="proposed IRCv3", 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(): def validate_defs():

43
go.mod
View File

@ -1,6 +1,6 @@
module github.com/ergochat/ergo module github.com/ergochat/ergo
go 1.22 go 1.26
require ( require (
code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48 code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48
@ -8,27 +8,41 @@ require (
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815
github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1 github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1
github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881 github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881
github.com/ergochat/irc-go v0.5.0-rc1 github.com/ergochat/irc-go v0.5.0
github.com/go-sql-driver/mysql v1.7.0 github.com/go-sql-driver/mysql v1.9.3
github.com/go-test/deep v1.0.6 // indirect
github.com/gofrs/flock v0.8.1 github.com/gofrs/flock v0.8.1
github.com/gorilla/websocket v1.4.2 github.com/gorilla/websocket v1.4.2
github.com/okzk/sdnotify v0.0.0-20180710141335-d9becc38acbd github.com/okzk/sdnotify v0.0.0-20180710141335-d9becc38acbd
github.com/onsi/ginkgo v1.12.0 // indirect github.com/onsi/ginkgo v1.12.0 // indirect
github.com/onsi/gomega v1.9.0 // indirect github.com/onsi/gomega v1.9.0 // indirect
github.com/stretchr/testify v1.4.0 // indirect github.com/tidwall/buntdb v1.3.2
github.com/tidwall/buntdb v1.2.10
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208
github.com/xdg-go/scram v1.0.2 github.com/xdg-go/scram v1.0.2
golang.org/x/crypto v0.17.0 golang.org/x/crypto v0.46.0
golang.org/x/term v0.15.0 golang.org/x/term v0.38.0
golang.org/x/text v0.14.0 golang.org/x/text v0.32.0
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
) )
require github.com/golang-jwt/jwt/v5 v5.2.0 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.3.0
github.com/jackc/pgx/v5 v5.8.0
modernc.org/sqlite v1.42.2
)
require ( require (
filippo.io/edwards25519 v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.6.1 // indirect
github.com/tidwall/btree v1.4.2 // indirect github.com/tidwall/btree v1.4.2 // indirect
github.com/tidwall/gjson v1.14.3 // indirect github.com/tidwall/gjson v1.14.3 // indirect
github.com/tidwall/grect v0.1.4 // indirect github.com/tidwall/grect v0.1.4 // indirect
@ -37,7 +51,12 @@ require (
github.com/tidwall/rtred v0.1.2 // indirect github.com/tidwall/rtred v0.1.2 // indirect
github.com/tidwall/tinyqueue v0.1.1 // indirect github.com/tidwall/tinyqueue v0.1.1 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect
golang.org/x/sys v0.15.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
) )
replace github.com/gorilla/websocket => github.com/ergochat/websocket v1.4.2-oragono1 replace github.com/gorilla/websocket => github.com/ergochat/websocket v1.4.2-oragono1

131
go.sum
View File

@ -1,35 +1,64 @@
code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48 h1:/EMHruHCFXR9xClkGV/t0rmHrdhX4+trQUcBqjwc9xE= code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48 h1:/EMHruHCFXR9xClkGV/t0rmHrdhX4+trQUcBqjwc9xE=
code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48/go.mod h1:wN/zk7mhREp/oviagqUXY3EwuHhWyOvAdsn5Y4CzOrc= code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48/go.mod h1:wN/zk7mhREp/oviagqUXY3EwuHhWyOvAdsn5Y4CzOrc=
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 h1:KeNholpO2xKjgaaSyd+DyQRrsQjhbSeS7qe4nEw8aQw= github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 h1:KeNholpO2xKjgaaSyd+DyQRrsQjhbSeS7qe4nEw8aQw=
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962/go.mod h1:kC29dT1vFpj7py2OvG1khBdQpo3kInWP+6QipLbdngo= github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962/go.mod h1:kC29dT1vFpj7py2OvG1khBdQpo3kInWP+6QipLbdngo=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ= github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
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 h1:WLHTOodthVyv5NvYLIvWl112kSFv5IInKKrRN2qpons=
github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1/go.mod h1:mov+uh1DPWsltdQnOdzn08UO9GsJ3MEvhtu0Ci37fdk= github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1/go.mod h1:mov+uh1DPWsltdQnOdzn08UO9GsJ3MEvhtu0Ci37fdk=
github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881 h1:+J5m88nvybxB5AnBVGzTXM/yHVytt48rXBGcJGzSbms= 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/go-ident v0.0.0-20230911071154-8c30606d6881/go.mod h1:ASYJtQujNitna6cVHsNQTGrfWvMPJ5Sa2lZlmsH65uM=
github.com/ergochat/irc-go v0.4.0 h1:0YibCKfAAtwxQdNjLQd9xpIEPisLcJ45f8FNsMHAuZc= github.com/ergochat/irc-go v0.5.0 h1:woQ1RS9YbfgqPgSpPBBQeczXGIGzR0aC7dEgk469fTw=
github.com/ergochat/irc-go v0.4.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0= github.com/ergochat/irc-go v0.5.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
github.com/ergochat/irc-go v0.5.0-rc1 h1:kFoIHExoNFQ2CV+iShAVna/H4xrXQB4t4jK5Sep2j9k=
github.com/ergochat/irc-go v0.5.0-rc1/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
github.com/ergochat/scram v1.0.2-ergo1 h1:2bYXiRFQH636pT0msOG39fmEYl4Eq+OuutcyDsCix/g= github.com/ergochat/scram v1.0.2-ergo1 h1:2bYXiRFQH636pT0msOG39fmEYl4Eq+OuutcyDsCix/g=
github.com/ergochat/scram v1.0.2-ergo1/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs= github.com/ergochat/scram v1.0.2-ergo1/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
github.com/ergochat/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 h1:plMUunFBM6UoSCIYCKKclTdy/TkkHfUslhOfJQzfueM=
github.com/ergochat/websocket v1.4.2-oragono1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-test/deep v1.0.6 h1:UHSEyLZUwX9Qoi99vVwvewiMC8mM2bf7XEM2nqvzEn8=
github.com/go-test/deep v1.0.6/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/okzk/sdnotify v0.0.0-20180710141335-d9becc38acbd h1:+iAPaTbi1gZpcpDwe/BW1fx7Xoesv69hLNGPheoyhBs= github.com/okzk/sdnotify v0.0.0-20180710141335-d9becc38acbd h1:+iAPaTbi1gZpcpDwe/BW1fx7Xoesv69hLNGPheoyhBs=
github.com/okzk/sdnotify v0.0.0-20180710141335-d9becc38acbd/go.mod h1:4soZNh0zW0LtYGdQ416i0jO0EIqMGcbtaspRS4BDvRQ= github.com/okzk/sdnotify v0.0.0-20180710141335-d9becc38acbd/go.mod h1:4soZNh0zW0LtYGdQ416i0jO0EIqMGcbtaspRS4BDvRQ=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
@ -40,15 +69,21 @@ github.com/onsi/gomega v1.9.0 h1:R1uwffexN6Pr340GtYRIdZmAiN4J+iw6WG4wog1DUXg=
github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA= github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/assert v0.1.0 h1:aWcKyRBUAdLoVebxo95N7+YZVTFF/ASTr7BN4sLP6XI= github.com/tidwall/assert v0.1.0 h1:aWcKyRBUAdLoVebxo95N7+YZVTFF/ASTr7BN4sLP6XI=
github.com/tidwall/assert v0.1.0/go.mod h1:QLYtGyeqse53vuELQheYl9dngGCJQ+mTtlxcktb+Kj8= github.com/tidwall/assert v0.1.0/go.mod h1:QLYtGyeqse53vuELQheYl9dngGCJQ+mTtlxcktb+Kj8=
github.com/tidwall/btree v1.4.2 h1:PpkaieETJMUxYNADsjgtNRcERX7mGc/GP2zp/r5FM3g= github.com/tidwall/btree v1.4.2 h1:PpkaieETJMUxYNADsjgtNRcERX7mGc/GP2zp/r5FM3g=
github.com/tidwall/btree v1.4.2/go.mod h1:LGm8L/DZjPLmeWGjv5kFrY8dL4uVhMmzmmLYmsObdKE= github.com/tidwall/btree v1.4.2/go.mod h1:LGm8L/DZjPLmeWGjv5kFrY8dL4uVhMmzmmLYmsObdKE=
github.com/tidwall/buntdb v1.2.10 h1:U/ebfkmYPBnyiNZIirUiWFcxA/mgzjbKlyPynFsPtyM= github.com/tidwall/buntdb v1.3.2 h1:qd+IpdEGs0pZci37G4jF51+fSKlkuUTMXuHhXL1AkKg=
github.com/tidwall/buntdb v1.2.10/go.mod h1:lZZrZUWzlyDJKlLQ6DKAy53LnG7m5kHyrEHvvcDmBpU= 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.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw= github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw=
github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@ -64,38 +99,76 @@ 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/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 h1:SpNEvEggbpyN5DIReaJ2/1ndroY8iyEGxPYxoSaymYE=
github.com/tidwall/tinyqueue v0.1.1/go.mod h1:O/QNHwrnjqr6IHItYrzoHAKYhBkLI67Q096fQP5zMYw= 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 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/stringprep v1.0.2 h1:6iq84/ryjjeRmMJwxutI51F2GIPlP5BfTvXHeYjyhBc= github.com/xdg-go/stringprep v1.0.2 h1:6iq84/ryjjeRmMJwxutI51F2GIPlP5BfTvXHeYjyhBc=
github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM= github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@ -9,6 +9,7 @@ import (
"crypto/x509" "crypto/x509"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@ -50,7 +51,9 @@ const (
keyAccountEmailChange = "account.emailchange %s" keyAccountEmailChange = "account.emailchange %s"
// for an always-on client, a map of channel names they're in to their current modes // 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): // (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 maxCertfpsPerAccount = 5
) )
@ -135,6 +138,8 @@ func (am *AccountManager) createAlwaysOnClients(config *Config) {
am.loadTimeMap(keyAccountReadMarkers, accountName), am.loadTimeMap(keyAccountReadMarkers, accountName),
am.loadModes(accountName), am.loadModes(accountName),
am.loadRealname(accountName), am.loadRealname(accountName),
am.loadPushSubscriptions(accountName),
am.loadMetadata(accountName),
) )
} }
} }
@ -715,6 +720,74 @@ func (am *AccountManager) loadRealname(account string) (realname string) {
return 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) { func (am *AccountManager) addRemoveCertfp(account, certfp string, add bool, hasPrivs bool) (err error) {
certfp, err = utils.NormalizeCertfp(certfp) certfp, err = utils.NormalizeCertfp(certfp)
if err != nil { if err != nil {
@ -950,7 +1023,7 @@ func (am *AccountManager) Verify(client *Client, account string, code string, ad
if client != nil { if client != nil {
am.Login(client, clientAccount) am.Login(client, clientAccount)
if client.AlwaysOn() { if client.AlwaysOn() {
client.markDirty(IncludeRealname) client.markDirty(IncludeAllAttrs)
} }
} }
// we may need to do nick enforcement here: // we may need to do nick enforcement here:
@ -1121,7 +1194,7 @@ func (am *AccountManager) NsSendpass(client *Client, accountName string) (err er
message := email.ComposeMail(config.Accounts.Registration.EmailVerification, account.Settings.Email, subject) 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) 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") 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("\r\n") message.WriteString("\r\n")
message.WriteString(client.t("Otherwise, to reset your password, issue the following command (replace `new_password` with your desired password):")) message.WriteString(client.t("Otherwise, to reset your password, issue the following command (replace `new_password` with your desired password):"))
@ -1843,6 +1916,8 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
suspendedKey := fmt.Sprintf(keyAccountSuspended, casefoldedAccount) suspendedKey := fmt.Sprintf(keyAccountSuspended, casefoldedAccount)
pwResetKey := fmt.Sprintf(keyAccountPwReset, casefoldedAccount) pwResetKey := fmt.Sprintf(keyAccountPwReset, casefoldedAccount)
emailChangeKey := fmt.Sprintf(keyAccountEmailChange, casefoldedAccount) emailChangeKey := fmt.Sprintf(keyAccountEmailChange, casefoldedAccount)
pushSubscriptionsKey := fmt.Sprintf(keyAccountPushSubscriptions, casefoldedAccount)
metadataKey := fmt.Sprintf(keyAccountMetadata, casefoldedAccount)
var clients []*Client var clients []*Client
defer func() { defer func() {
@ -1901,6 +1976,8 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
tx.Delete(suspendedKey) tx.Delete(suspendedKey)
tx.Delete(pwResetKey) tx.Delete(pwResetKey)
tx.Delete(emailChangeKey) tx.Delete(emailChangeKey)
tx.Delete(pushSubscriptionsKey)
tx.Delete(metadataKey)
return nil return nil
}) })
@ -1958,8 +2035,21 @@ func (am *AccountManager) AuthenticateByCertificate(client *Client, certfp strin
return errAccountInvalidCredentials return errAccountInvalidCredentials
} }
var clientAccount ClientAccount clientAccount, err := am.checkCertAuth(client.IP(), certfp, peerCerts, authzid)
if err != nil {
return
}
if client.registered {
if clientAlready := am.server.clients.Get(clientAccount.Name); clientAlready != nil && clientAlready.AlwaysOn() {
err = errNickAccountMismatch
return
}
}
am.Login(client, clientAccount)
return
}
func (am *AccountManager) checkCertAuth(ip net.IP, certfp string, peerCerts []*x509.Certificate, authzid string) (clientAccount ClientAccount, err error) {
defer func() { defer func() {
if err != nil { if err != nil {
return return
@ -1970,22 +2060,19 @@ func (am *AccountManager) AuthenticateByCertificate(client *Client, certfp strin
err = errAccountSuspended err = errAccountSuspended
return return
} }
// TODO(#1109) clean this check up?
if client.registered {
if clientAlready := am.server.clients.Get(clientAccount.Name); clientAlready != nil && clientAlready.AlwaysOn() {
err = errNickAccountMismatch
return
}
}
am.Login(client, clientAccount)
return
}() }()
config := am.server.Config() config := am.server.Config()
if config.Accounts.AuthScript.Enabled { if config.Accounts.AuthScript.Enabled {
var output AuthScriptOutput var output AuthScriptOutput
output, err = CheckAuthScript(am.server.semaphores.AuthScript, config.Accounts.AuthScript.ScriptConfig, var ipString string
AuthScriptInput{Certfp: certfp, IP: client.IP().String(), peerCerts: peerCerts}) if ip != nil {
ipString = ip.String()
}
output, err = CheckAuthScript(
am.server.semaphores.AuthScript, config.Accounts.AuthScript.ScriptConfig,
AuthScriptInput{Certfp: certfp, IP: ipString, peerCerts: peerCerts},
)
if err != nil { if err != nil {
am.server.logger.Error("internal", "failed shell auth invocation", err.Error()) am.server.logger.Error("internal", "failed shell auth invocation", err.Error())
} else if output.Success && output.AccountName != "" { } else if output.Success && output.AccountName != "" {
@ -2006,18 +2093,19 @@ func (am *AccountManager) AuthenticateByCertificate(client *Client, certfp strin
}) })
if err != nil { if err != nil {
return err return
} }
if authzid != "" { if authzid != "" {
if cfAuthzid, err := CasefoldName(authzid); err != nil || cfAuthzid != account { if cfAuthzid, cErr := CasefoldName(authzid); cErr != nil || cfAuthzid != account {
return errAuthzidAuthcidMismatch err = errAuthzidAuthcidMismatch
return
} }
} }
// ok, we found an account corresponding to their certificate // ok, we found an account corresponding to their certificate
clientAccount, err = am.LoadAccount(account) clientAccount, err = am.LoadAccount(account)
return err return
} }
type settingsMunger func(input AccountSettings) (output AccountSettings, err error) type settingsMunger func(input AccountSettings) (output AccountSettings, err error)
@ -2261,7 +2349,7 @@ func (ac *AccountCredentials) Serialize() (result string, err error) {
return string(credText), nil return string(credText), nil
} }
func (ac *AccountCredentials) SetPassphrase(passphrase string, bcryptCost uint) (err error) { func (ac *AccountCredentials) SetPassphrase(passphrase string, bcryptCost int) (err error) {
if passphrase == "" { if passphrase == "" {
ac.PassphraseHash = nil ac.PassphraseHash = nil
ac.SCRAMCreds = SCRAMCreds{} ac.SCRAMCreds = SCRAMCreds{}
@ -2272,7 +2360,7 @@ func (ac *AccountCredentials) SetPassphrase(passphrase string, bcryptCost uint)
return errAccountBadPassphrase return errAccountBadPassphrase
} }
ac.PassphraseHash, err = passwd.GenerateFromPassword([]byte(passphrase), int(bcryptCost)) ac.PassphraseHash, err = passwd.GenerateFromPassword([]byte(passphrase), bcryptCost)
if err != nil { if err != nil {
return errAccountBadPassphrase return errAccountBadPassphrase
} }

435
irc/api.go Normal file
View File

@ -0,0 +1,435 @@
package irc
import (
"crypto/subtle"
"encoding/json"
"fmt"
"net/http"
"runtime"
"strings"
"github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/sno"
"github.com/ergochat/ergo/irc/utils"
)
func newAPIHandler(server *Server) http.Handler {
api := &ergoAPI{
server: server,
mux: http.NewServeMux(),
}
// server-level functionality:
api.mux.HandleFunc("POST /v1/rehash", api.handleRehash)
api.mux.HandleFunc("POST /v1/status", api.handleStatus)
api.mux.HandleFunc("POST /v1/list", api.handleList)
api.mux.HandleFunc("POST /v1/defcon", api.handleDefcon)
// use Ergo as a source of truth for authentication in other services:
api.mux.HandleFunc("POST /v1/check_auth", api.handleCheckAuth)
// legacy names for /v1/ns endpoints:
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)
// /v1/ns: nickserv functionality
api.mux.HandleFunc("POST /v1/ns/info", api.handleAccountDetails)
api.mux.HandleFunc("POST /v1/ns/list", api.handleAccountList)
api.mux.HandleFunc("POST /v1/ns/passwd", api.handleNsPasswd)
api.mux.HandleFunc("POST /v1/ns/saregister", api.handleSaregister)
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 defconRequestResponse struct {
apiGenericResponse
Defcon int `json:"defcon"`
}
func (a *ergoAPI) handleDefcon(w http.ResponseWriter, r *http.Request) {
var changeRequested uint32
var request defconRequestResponse
// ignore errors or invalid values
if err := json.NewDecoder(r.Body).Decode(&request); err == nil {
if 1 <= request.Defcon && request.Defcon <= 5 {
changeRequested = uint32(request.Defcon)
}
}
if changeRequested != 0 {
a.server.SetDefcon(changeRequested)
message := fmt.Sprintf("API set DEFCON level to %d", changeRequested)
a.server.logger.Info("server", message)
a.server.snomasks.Send(sno.LocalAnnouncements, message)
}
a.writeJSONResponse(
defconRequestResponse{
apiGenericResponse: apiGenericResponse{Success: true},
Defcon: int(a.server.Defcon()),
}, 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
var account ClientAccount
var err error
// try whatever credentials are present
if request.AccountName != "" && request.Passphrase != "" {
account, err = a.server.accounts.checkPassphrase(request.AccountName, request.Passphrase)
} else if request.Certfp != "" {
account, err = a.server.accounts.checkCertAuth(nil, request.Certfp, nil, "")
} else {
err = errAccountInvalidCredentials
}
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()
}
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)
}
func (a *ergoAPI) handleNsPasswd(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.setPassword(request.AccountName, request.Passphrase, true)
switch err {
case nil:
response.Success = true
case errAccountDoesNotExist:
response.ErrorCode = "ACCOUNT_DOES_NOT_EXIST"
case errAccountBadPassphrase, errEmptyCredentials:
response.ErrorCode = "INVALID_PASSPHRASE"
case errCredsExternallyManaged:
response.ErrorCode = "CREDENTIALS_EXTERNALLY_MANAGED"
default:
a.server.logger.Error("api", "could not change user password:", err.Error())
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, 0, len(accounts))
for _, account := range accounts {
accountData, err := a.server.accounts.LoadAccount(account)
if err != nil {
// shouldn't happen
continue
}
response.Accounts = append(
response.Accounts,
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)
}
type apiChannelData struct {
Name string `json:"name"`
HasKey bool `json:"hasKey"`
InviteOnly bool `json:"inviteOnly"`
Secret bool `json:"secret"`
UserCount int `json:"userCount"`
Topic string `json:"topic"`
TopicSetAt string `json:"topicSetAt,omitempty"`
CreatedAt string `json:"createdAt"`
Registered bool `json:"registered"`
Owner string `json:"owner,omitempty"`
RegisteredAt string `json:"registeredAt,omitempty"`
}
func (channel *Channel) apiData() (result apiChannelData) {
channel.stateMutex.RLock()
defer channel.stateMutex.RUnlock()
result.Name = channel.name
result.HasKey = channel.key != ""
result.InviteOnly = channel.flags.HasMode(modes.InviteOnly)
result.Secret = channel.flags.HasMode(modes.Secret)
result.UserCount = len(channel.members)
result.Topic = channel.topic
if !channel.topicSetTime.IsZero() {
result.TopicSetAt = channel.topicSetTime.UTC().Format(utils.IRCv3TimestampFormat)
}
result.CreatedAt = channel.createdTime.UTC().Format(utils.IRCv3TimestampFormat)
result.Registered = channel.registeredFounder != ""
if result.Registered {
result.Owner = channel.registeredFounder
if !channel.registeredTime.IsZero() {
result.RegisteredAt = channel.registeredTime.UTC().Format(utils.IRCv3TimestampFormat)
}
}
return
}
type apiListResponse struct {
apiGenericResponse
Channels []apiChannelData `json:"channels"`
}
func (a *ergoAPI) handleList(w http.ResponseWriter, r *http.Request) {
channels := a.server.channels.ListableChannels()
response := apiListResponse{
apiGenericResponse: apiGenericResponse{Success: true},
Channels: make([]apiChannelData, 0, len(channels)),
}
for _, channel := range channels {
response.Channels = append(response.Channels, channel.apiData())
}
a.writeJSONResponse(response, w, r)
}

View File

@ -44,10 +44,11 @@ func (b *buntdbDatastore) GetAll(table datastore.Table) (result []datastore.KV,
tablePrefix := fmt.Sprintf("%x ", table) tablePrefix := fmt.Sprintf("%x ", table)
err = b.db.View(func(tx *buntdb.Tx) error { err = b.db.View(func(tx *buntdb.Tx) error {
err := tx.AscendGreaterOrEqual("", tablePrefix, func(key, value string) bool { err := tx.AscendGreaterOrEqual("", tablePrefix, func(key, value string) bool {
if !strings.HasPrefix(key, tablePrefix) { encUUID, ok := strings.CutPrefix(key, tablePrefix)
if !ok {
return false return false
} }
uuid, err := utils.DecodeUUID(strings.TrimPrefix(key, tablePrefix)) uuid, err := utils.DecodeUUID(encUUID)
if err == nil { if err == nil {
result = append(result, datastore.KV{UUID: uuid, Value: []byte(value)}) result = append(result, datastore.KV{UUID: uuid, Value: []byte(value)})
} else { } else {

View File

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

View File

@ -7,7 +7,7 @@ package caps
const ( const (
// number of recognized capabilities: // number of recognized capabilities:
numCapabs = 34 numCapabs = 38
// length of the uint32 array that represents the bitset: // length of the uint32 array that represents the bitset:
bitsetLen = 2 bitsetLen = 2
) )
@ -53,6 +53,10 @@ const (
// https://github.com/ircv3/ircv3-specifications/pull/362 // https://github.com/ircv3/ircv3-specifications/pull/362
EventPlayback Capability = iota EventPlayback Capability = iota
// ExtendedISupport is the proposed IRCv3 capability named "draft/extended-isupport":
// https://github.com/ircv3/ircv3-specifications/pull/543
ExtendedISupport Capability = iota
// Languages is the proposed IRCv3 capability named "draft/languages": // Languages is the proposed IRCv3 capability named "draft/languages":
// https://gist.github.com/DanielOaks/8126122f74b26012a3de37db80e4e0c6 // https://gist.github.com/DanielOaks/8126122f74b26012a3de37db80e4e0c6
Languages Capability = iota Languages Capability = iota
@ -61,6 +65,10 @@ const (
// https://github.com/progval/ircv3-specifications/blob/redaction/extensions/message-redaction.md // https://github.com/progval/ircv3-specifications/blob/redaction/extensions/message-redaction.md
MessageRedaction Capability = iota 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": // Multiline is the proposed IRCv3 capability named "draft/multiline":
// https://github.com/ircv3/ircv3-specifications/pull/398 // https://github.com/ircv3/ircv3-specifications/pull/398
Multiline Capability = iota Multiline Capability = iota
@ -85,6 +93,10 @@ const (
// https://github.com/ircv3/ircv3-specifications/pull/417 // https://github.com/ircv3/ircv3-specifications/pull/417
Relaymsg Capability = iota 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": // EchoMessage is the IRCv3 capability named "echo-message":
// https://ircv3.net/specs/extensions/echo-message-3.2.html // https://ircv3.net/specs/extensions/echo-message-3.2.html
EchoMessage Capability = iota EchoMessage Capability = iota
@ -129,6 +141,10 @@ const (
// https://ircv3.net/specs/extensions/setname.html // https://ircv3.net/specs/extensions/setname.html
SetName Capability = iota 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": // StandardReplies is the IRCv3 capability named "standard-replies":
// https://github.com/ircv3/ircv3-specifications/pull/506 // https://github.com/ircv3/ircv3-specifications/pull/506
StandardReplies Capability = iota StandardReplies Capability = iota
@ -163,14 +179,17 @@ var (
"draft/channel-rename", "draft/channel-rename",
"draft/chathistory", "draft/chathistory",
"draft/event-playback", "draft/event-playback",
"draft/extended-isupport",
"draft/languages", "draft/languages",
"draft/message-redaction", "draft/message-redaction",
"draft/metadata-2",
"draft/multiline", "draft/multiline",
"draft/no-implicit-names", "draft/no-implicit-names",
"draft/persistence", "draft/persistence",
"draft/pre-away", "draft/pre-away",
"draft/read-marker", "draft/read-marker",
"draft/relaymsg", "draft/relaymsg",
"draft/webpush",
"echo-message", "echo-message",
"ergo.chat/nope", "ergo.chat/nope",
"extended-join", "extended-join",
@ -182,6 +201,7 @@ var (
"sasl", "sasl",
"server-time", "server-time",
"setname", "setname",
"soju.im/webpush",
"standard-replies", "standard-replies",
"sts", "sts",
"userhost-in-names", "userhost-in-names",

View File

@ -7,6 +7,7 @@ package irc
import ( import (
"fmt" "fmt"
"iter"
"maps" "maps"
"strconv" "strconv"
"strings" "strings"
@ -21,6 +22,7 @@ import (
"github.com/ergochat/ergo/irc/history" "github.com/ergochat/ergo/irc/history"
"github.com/ergochat/ergo/irc/modes" "github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/utils" "github.com/ergochat/ergo/irc/utils"
"github.com/ergochat/ergo/irc/webpush"
) )
type ChannelSettings struct { type ChannelSettings struct {
@ -54,6 +56,7 @@ type Channel struct {
dirtyBits uint dirtyBits uint
settings ChannelSettings settings ChannelSettings
uuid utils.UUID uuid utils.UUID
metadata map[string]string
// these caches are paired to allow iteration over channel members without holding the lock // these caches are paired to allow iteration over channel members without holding the lock
membersCache []*Client membersCache []*Client
memberDataCache []*memberData memberDataCache []*memberData
@ -125,6 +128,7 @@ func (channel *Channel) applyRegInfo(chanReg RegisteredChannel) {
channel.userLimit = chanReg.UserLimit channel.userLimit = chanReg.UserLimit
channel.settings = chanReg.Settings channel.settings = chanReg.Settings
channel.forward = chanReg.Forward channel.forward = chanReg.Forward
channel.metadata = chanReg.Metadata
for _, mode := range chanReg.Modes { for _, mode := range chanReg.Modes {
channel.flags.SetMode(mode, true) channel.flags.SetMode(mode, true)
@ -162,6 +166,7 @@ func (channel *Channel) ExportRegistration() (info RegisteredChannel) {
info.AccountToUMode = maps.Clone(channel.accountToUMode) info.AccountToUMode = maps.Clone(channel.accountToUMode)
info.Settings = channel.settings info.Settings = channel.settings
info.Metadata = channel.metadata
return return
} }
@ -222,7 +227,7 @@ func (channel *Channel) wakeWriter() {
// equivalent of Socket.send() // equivalent of Socket.send()
func (channel *Channel) writeLoop() { func (channel *Channel) writeLoop() {
defer channel.server.HandlePanic() defer channel.server.HandlePanic(nil)
for { for {
// TODO(#357) check the error value of this and implement timed backoff // TODO(#357) check the error value of this and implement timed backoff
@ -546,7 +551,11 @@ func (channel *Channel) ClientStatus(client *Client) (present bool, joinTimeSecs
channel.stateMutex.RLock() channel.stateMutex.RLock()
defer channel.stateMutex.RUnlock() defer channel.stateMutex.RUnlock()
memberData, present := channel.members[client] memberData, present := channel.members[client]
return present, time.Unix(0, memberData.joinTime).Unix(), memberData.modes.AllModes() if present {
return present, time.Unix(0, memberData.joinTime).Unix(), memberData.modes.AllModes()
} else {
return
}
} }
// helper for persisting channel-user modes for always-on clients; // helper for persisting channel-user modes for always-on clients;
@ -725,6 +734,9 @@ func (channel *Channel) AddHistoryItem(item history.Item, account string) (err e
status, target, _ := channel.historyStatus(channel.server.Config()) status, target, _ := channel.historyStatus(channel.server.Config())
if status == HistoryPersistent { if status == HistoryPersistent {
err = channel.server.historyDB.AddChannelItem(target, item, account) err = channel.server.historyDB.AddChannelItem(target, item, account)
if err != nil {
channel.server.logger.Error("history", "could not add channel message to history", err.Error())
}
} else if status == HistoryEphemeral { } else if status == HistoryEphemeral {
channel.history.Add(item) channel.history.Add(item)
} }
@ -891,6 +903,10 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
rb.Add(nil, client.server.name, "MARKREAD", chname, client.GetReadMarker(chcfname)) 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 { if rb.session.client == client {
// don't send topic and names for a SAJOIN of a different client // don't send topic and names for a SAJOIN of a different client
channel.SendTopic(client, rb, false) channel.SendTopic(client, rb, false)
@ -1325,7 +1341,10 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
chname = fmt.Sprintf("%s%s", modes.ChannelModePrefixes[minPrefixMode], chname) chname = fmt.Sprintf("%s%s", modes.ChannelModePrefixes[minPrefixMode], chname)
} }
if !client.server.Config().Server.Compatibility.allowTruncation { config := client.server.Config()
dispatchWebPush := false
if !config.Server.Compatibility.allowTruncation {
if !validateSplitMessageLen(histType, details.nickMask, chname, message) { if !validateSplitMessageLen(histType, details.nickMask, chname, message) {
rb.Add(nil, client.server.name, ERR_INPUTTOOLONG, details.nick, client.t("Line too long to be relayed without truncation")) rb.Add(nil, client.server.name, ERR_INPUTTOOLONG, details.nick, client.t("Line too long to be relayed without truncation"))
return return
@ -1355,6 +1374,9 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
continue 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() { for _, session := range member.Sessions() {
if session == rb.session { if session == rb.session {
continue // we already sent echo-message, if applicable continue // we already sent echo-message, if applicable
@ -1378,6 +1400,42 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
Tags: clientOnlyTags, Tags: clientOnlyTags,
IsBot: isBot, IsBot: isBot,
}, details.account) }, 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,
})
} }
} }
@ -1419,8 +1477,8 @@ func (channel *Channel) ShowMaskList(client *Client, mode modes.Mode, rb *Respon
rpllist = RPL_EXCEPTLIST rpllist = RPL_EXCEPTLIST
rplendoflist = RPL_ENDOFEXCEPTLIST rplendoflist = RPL_ENDOFEXCEPTLIST
} else if mode == modes.InviteMask { } else if mode == modes.InviteMask {
rpllist = RPL_INVITELIST rpllist = RPL_INVEXLIST
rplendoflist = RPL_ENDOFINVITELIST rplendoflist = RPL_ENDOFINVEXLIST
} }
nick := client.Nick() nick := client.Nick()
@ -1626,6 +1684,20 @@ func (channel *Channel) auditoriumFriends(client *Client) (friends []*Client) {
return 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 // returns whether the client is visible to unprivileged users in the channel
// (i.e., respecting auditorium mode). note that this assumes that the client // (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 // is a member; if the client is not, it may return true anyway

View File

@ -206,6 +206,10 @@ func (cm *ChannelManager) Cleanup(channel *Channel) {
} }
func (cm *ChannelManager) SetRegistered(channelName string, account string) (err error) { 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 { if cm.server.Defcon() <= 4 {
return errFeatureDisabled return errFeatureDisabled
} }

View File

@ -63,6 +63,8 @@ type RegisteredChannel struct {
Invites map[string]MaskInfo Invites map[string]MaskInfo
// Settings are the chanserv-modifiable settings // Settings are the chanserv-modifiable settings
Settings ChannelSettings Settings ChannelSettings
// Metadata set using the METADATA command
Metadata map[string]string
} }
func (r *RegisteredChannel) Serialize() ([]byte, error) { func (r *RegisteredChannel) Serialize() ([]byte, error) {

View File

@ -6,6 +6,7 @@
package irc package irc
import ( import (
"context"
"crypto/x509" "crypto/x509"
"fmt" "fmt"
"maps" "maps"
@ -32,6 +33,7 @@ import (
"github.com/ergochat/ergo/irc/oauth2" "github.com/ergochat/ergo/irc/oauth2"
"github.com/ergochat/ergo/irc/sno" "github.com/ergochat/ergo/irc/sno"
"github.com/ergochat/ergo/irc/utils" "github.com/ergochat/ergo/irc/utils"
"github.com/ergochat/ergo/irc/webpush"
) )
const ( const (
@ -39,84 +41,96 @@ const (
DefaultMaxLineLen = 512 DefaultMaxLineLen = 512
// IdentTimeout is how long before our ident (username) check times out. // IdentTimeout is how long before our ident (username) check times out.
IdentTimeout = time.Second + 500*time.Millisecond IdentTimeout = time.Second + 500*time.Millisecond
IRCv3TimestampFormat = utils.IRCv3TimestampFormat
// limit the number of device IDs a client can use, as a DoS mitigation // limit the number of device IDs a client can use, as a DoS mitigation
maxDeviceIDsPerClient = 64 maxDeviceIDsPerClient = 64
// maximum total read markers that can be stored // maximum total read markers that can be stored
// (writeback of read markers is controlled by lastSeen logic) // (writeback of read markers is controlled by lastSeen logic)
maxReadMarkers = 256 maxReadMarkers = 256
// should be long enough to handle multiple notifications in rapid succession,
// short enough that it doesn't waste a lot of RAM per client
pushQueueLengthPerClient = 16
)
var (
// idle timeouts for client connections, set from the config
RegisterTimeout, PingTimeout, DisconnectTimeout time.Duration
) )
const ( const (
// RegisterTimeout is how long clients have to register before we disconnect them
RegisterTimeout = time.Minute
// DefaultIdleTimeout is how long without traffic before we send the client a PING
DefaultIdleTimeout = time.Minute + 30*time.Second
// For Tor clients, we send a PING at least every 30 seconds, as a workaround for this bug // For Tor clients, we send a PING at least every 30 seconds, as a workaround for this bug
// (single-onion circuits will close unless the client sends data once every 60 seconds): // (single-onion circuits will close unless the client sends data once every 60 seconds):
// https://bugs.torproject.org/29665 // https://bugs.torproject.org/29665
TorIdleTimeout = time.Second * 30 TorPingTimeout = time.Second * 30
// This is how long a client gets without sending any message, including the PONG to our
// PING, before we disconnect them:
DefaultTotalTimeout = 2*time.Minute + 30*time.Second
// round off the ping interval by this much, see below: // round off the ping interval by this much, see below:
PingCoalesceThreshold = time.Second PingCoalesceThreshold = time.Second
) )
const (
utf8BOM = "\xef\xbb\xbf"
)
var ( var (
MaxLineLen = DefaultMaxLineLen MaxLineLen = DefaultMaxLineLen
) )
// Client is an IRC client. // Client is an IRC client.
type Client struct { type Client struct {
account string account string
accountName string // display name of the account: uncasefolded, '*' if not logged in accountName string // display name of the account: uncasefolded, '*' if not logged in
accountRegDate time.Time accountRegDate time.Time
accountSettings AccountSettings accountSettings AccountSettings
awayMessage string awayMessage string
channels ChannelSet channels ChannelSet
ctime time.Time ctime time.Time
destroyed bool destroyed bool
modes modes.ModeSet modes modes.ModeSet
hostname string hostname string
invitedTo map[string]channelInvite invitedTo map[string]channelInvite
isSTSOnly bool isSTSOnly bool
isKlined bool // #1941: k-line kills are special-cased to suppress some triggered notices/events isKlined bool // #1941: k-line kills are special-cased to suppress some triggered notices/events
languages []string languages []string
lastActive time.Time // last time they sent a command that wasn't PONG or similar lastActive time.Time // last time they sent a command that wasn't PONG or similar
lastSeen map[string]time.Time // maps device ID (including "") to time of last received command lastSeen map[string]time.Time // maps device ID (including "") to time of last received command
readMarkers map[string]time.Time // maps casefolded target to time of last read marker readMarkers map[string]time.Time // maps casefolded target to time of last read marker
loginThrottle connection_limits.GenericThrottle loginThrottle connection_limits.GenericThrottle
nextSessionID int64 // Incremented when a new session is established nextSessionID int64 // Incremented when a new session is established
nick string nick string
nickCasefolded string nickCasefolded string
nickMaskCasefolded string nickMaskCasefolded string
nickMaskString string // cache for nickmask string since it's used with lots of replies nickMaskString string // cache for nickmask string since it's used with lots of replies
oper *Oper oper *Oper
preregNick string preregNick string
proxiedIP net.IP // actual remote IP if using the PROXY protocol proxiedIP net.IP // actual remote IP if using the PROXY protocol
rawHostname string rawHostname string
cloakedHostname string cloakedHostname string
realname string realname string
realIP net.IP realIP net.IP
requireSASLMessage string requireSASLMessage string
requireSASL bool requireSASL bool
registered bool registered bool
registerCmdSent bool // already sent the draft/register command, can't send it again registerCmdSent bool // already sent the draft/register command, can't send it again
dirtyTimestamps bool // lastSeen or readMarkers is dirty dirtyTimestamps bool // lastSeen or readMarkers is dirty
registrationTimer *time.Timer registrationTimer *time.Timer
server *Server server *Server
skeleton string skeleton string
sessions []*Session sessions []*Session
stateMutex sync.RWMutex // tier 1 stateMutex sync.RWMutex // tier 1
alwaysOn bool alwaysOn bool
username string username string
vhost string vhost string
history history.Buffer history history.Buffer
dirtyBits uint dirtyBits uint
writebackLock sync.Mutex // tier 1.5 writebackLock sync.Mutex // tier 1.5
pushSubscriptions map[string]*pushSubscription
cachedPushSubscriptions []storedPushSubscription
clearablePushMessages map[string]time.Time
pushSubscriptionsExist atomic.Uint32 // this is a cache on len(pushSubscriptions) != 0
pushQueue pushQueue
metadata map[string]string
metadataThrottle connection_limits.ThrottleDetails
} }
type saslStatus struct { type saslStatus struct {
@ -152,6 +166,8 @@ const (
type Session struct { type Session struct {
client *Client client *Client
connID string // identifies the connection in debug logs
deviceID string deviceID string
ctime time.Time ctime time.Time
@ -172,6 +188,8 @@ type Session struct {
fakelag Fakelag fakelag Fakelag
deferredFakelagCount int deferredFakelagCount int
lastOperAttempt time.Time
certfp string certfp string
peerCerts []*x509.Certificate peerCerts []*x509.Certificate
sasl saslStatus sasl saslStatus
@ -179,6 +197,8 @@ type Session struct {
batchCounter atomic.Uint32 batchCounter atomic.Uint32
isupportSentPrereg bool
quitMessage string quitMessage string
awayMessage string awayMessage string
@ -194,6 +214,11 @@ type Session struct {
autoreplayMissedSince time.Time autoreplayMissedSince time.Time
batch MultilineBatch batch MultilineBatch
webPushEndpoint string // goroutine-local: web push endpoint registered by the current session
metadataSubscriptions utils.HashSet[string]
metadataPreregVals map[string]string
} }
// MultilineBatch tracks the state of a client-to-server multiline batch. // MultilineBatch tracks the state of a client-to-server multiline batch.
@ -332,7 +357,8 @@ func (server *Server) RunClient(conn IRCConn) {
return return
} }
server.logger.Info("connect-ip", fmt.Sprintf("Client connecting: real IP %v, proxied IP %v", realIP, proxiedIP)) connID := server.generateConnectionID()
server.logger.Info("connect-ip", connID, fmt.Sprintf("Client connecting: real IP %v, proxied IP %v", realIP, proxiedIP))
now := time.Now().UTC() now := time.Now().UTC()
// give them 1k of grace over the limit: // give them 1k of grace over the limit:
@ -372,6 +398,7 @@ func (server *Server) RunClient(conn IRCConn) {
proxiedIP: proxiedIP, proxiedIP: proxiedIP,
isTor: wConn.Tor, isTor: wConn.Tor,
hideSTS: wConn.Tor || wConn.HideSTS, hideSTS: wConn.Tor || wConn.HideSTS,
connID: connID,
} }
session.sasl.Initialize() session.sasl.Initialize()
client.sessions = []*Session{session} client.sessions = []*Session{session}
@ -387,6 +414,11 @@ func (server *Server) RunClient(conn IRCConn) {
session.certfp, session.peerCerts, _ = utils.GetCertFP(wConn.Conn, RegisterTimeout) session.certfp, session.peerCerts, _ = utils.GetCertFP(wConn.Conn, RegisterTimeout)
} }
if config.Server.InitialNotice != "" {
// send initial notice for HOPM to recognize
client.Send(nil, client.server.name, "NOTICE", "*", config.Server.InitialNotice)
}
if session.isTor { if session.isTor {
session.rawHostname = config.Server.TorListeners.Vhost session.rawHostname = config.Server.TorListeners.Vhost
client.rawHostname = session.rawHostname client.rawHostname = session.rawHostname
@ -401,7 +433,7 @@ func (server *Server) RunClient(conn IRCConn) {
client.run(session) client.run(session)
} }
func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToStatus map[string]alwaysOnChannelStatus, lastSeen, readMarkers map[string]time.Time, uModes modes.Modes, realname string) { func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToStatus map[string]alwaysOnChannelStatus, lastSeen, readMarkers map[string]time.Time, uModes modes.Modes, realname string, pushSubscriptions []storedPushSubscription, metadata map[string]string) {
now := time.Now().UTC() now := time.Now().UTC()
config := server.Config() config := server.Config()
if lastSeen == nil && account.Settings.AutoreplayMissed { if lastSeen == nil && account.Settings.AutoreplayMissed {
@ -478,6 +510,18 @@ func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToStatus m
if persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway) { if persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway) {
client.setAutoAwayNoMutex(config) client.setAutoAwayNoMutex(config)
} }
if len(pushSubscriptions) != 0 {
client.pushSubscriptions = make(map[string]*pushSubscription, len(pushSubscriptions))
for _, sub := range pushSubscriptions {
client.pushSubscriptions[sub.Endpoint] = newPushSubscription(sub)
}
}
client.rebuildPushSubscriptionCache()
if len(metadata) != 0 {
client.metadata = metadata
}
} }
func (client *Client) resizeHistory(config *Config) { func (client *Client) resizeHistory(config *Config) {
@ -644,7 +688,7 @@ func (client *Client) run(session *Session) {
isReattach := client.Registered() isReattach := client.Registered()
if isReattach { if isReattach {
client.Touch(session) client.Touch(session)
client.playReattachMessages(session) client.performReattach(session)
} }
firstLine := !isReattach firstLine := !isReattach
@ -655,7 +699,7 @@ func (client *Client) run(session *Session) {
if err == errInvalidUtf8 { if err == errInvalidUtf8 {
invalidUtf8 = true // handle as normal, including labeling invalidUtf8 = true // handle as normal, including labeling
} else if err != nil { } else if err != nil {
client.server.logger.Debug("connect-ip", "read error from client", err.Error()) client.server.logger.Debug("connect-ip", session.connID, "read error from client", err.Error())
var quitMessage string var quitMessage string
switch err { switch err {
case ircreader.ErrReadQ: case ircreader.ErrReadQ:
@ -668,7 +712,7 @@ func (client *Client) run(session *Session) {
} }
if client.server.logger.IsLoggingRawIO() { if client.server.logger.IsLoggingRawIO() {
client.server.logger.Debug("userinput", client.nick, "<- ", line) client.server.logger.Debug("userinput", session.connID, client.nick, "<-", line)
} }
// special-cased handling of PROXY protocol, see `handleProxyCommand` for details: // special-cased handling of PROXY protocol, see `handleProxyCommand` for details:
@ -700,8 +744,12 @@ func (client *Client) run(session *Session) {
} }
session.fakelag.Touch(command) session.fakelag.Touch(command)
} else { } else {
// DoS hardening, #505 if session.registrationMessages == 0 && httpVerbs.Has(msg.Command) {
client.Send(nil, client.server.name, ERR_UNKNOWNERROR, msg.Command, "This is not an HTTP server")
break
}
session.registrationMessages++ session.registrationMessages++
// DoS hardening, #505
if client.server.Config().Limits.RegistrationMessages < session.registrationMessages { if client.server.Config().Limits.RegistrationMessages < session.registrationMessages {
client.Send(nil, client.server.name, ERR_UNKNOWNERROR, "*", client.t("You have sent too many registration messages")) client.Send(nil, client.server.name, ERR_UNKNOWNERROR, "*", client.t("You have sent too many registration messages"))
break break
@ -719,17 +767,16 @@ func (client *Client) run(session *Session) {
continue continue
} // else: proceed with the truncated line } // else: proceed with the truncated line
} else if err != nil { } else if err != nil {
client.Quit(client.t("Received malformed line"), session) message := "Received malformed line"
if strings.HasPrefix(line, utf8BOM) {
message = "Received UTF-8 byte-order mark, which is invalid at the start of an IRC protocol message"
}
client.Quit(message, session)
break break
} }
cmd, exists := Commands[msg.Command] var cmd Command
if !exists { msg.Command, cmd = client.server.resolveCommand(msg.Command, invalidUtf8)
cmd = unknownCommand
} else if invalidUtf8 {
cmd = invalidUtf8Command
}
isExiting := cmd.Run(client.server, client, session, msg) isExiting := cmd.Run(client.server, client, session, msg)
if isExiting { if isExiting {
break break
@ -741,7 +788,9 @@ func (client *Client) run(session *Session) {
} }
} }
func (client *Client) playReattachMessages(session *Session) { func (client *Client) performReattach(session *Session) {
client.applyPreregMetadata(session)
client.server.playRegistrationBurst(session) client.server.playRegistrationBurst(session)
hasHistoryCaps := session.HasHistoryCaps() hasHistoryCaps := session.HasHistoryCaps()
for _, channel := range session.client.Channels() { for _, channel := range session.client.Channels() {
@ -765,6 +814,34 @@ func (client *Client) playReattachMessages(session *Session) {
session.autoreplayMissedSince = time.Time{} session.autoreplayMissedSince = time.Time{}
} }
func (client *Client) applyPreregMetadata(session *Session) {
if session.metadataPreregVals == nil {
return
}
defer func() {
session.metadataPreregVals = nil
}()
updates := client.UpdateMetadataFromPrereg(session.metadataPreregVals, client.server.Config().Metadata.MaxKeys)
if len(updates) == 0 {
return
}
// TODO this is expensive
friends := client.FriendsMonitors(caps.Metadata)
for _, s := range client.Sessions() {
if s != session {
friends.Add(s)
}
}
target := client.Nick()
for k, v := range updates {
broadcastMetadataUpdate(client.server, maps.Keys(friends), session, target, k, v, true)
}
}
// //
// idle, quit, timers and timeouts // idle, quit, timers and timeouts
// //
@ -796,19 +873,19 @@ func (client *Client) updateIdleTimer(session *Session, now time.Time) {
session.pingSent = false session.pingSent = false
if session.idleTimer == nil { if session.idleTimer == nil {
pingTimeout := DefaultIdleTimeout pingTimeout := PingTimeout
if session.isTor { if session.isTor && TorPingTimeout < pingTimeout {
pingTimeout = TorIdleTimeout pingTimeout = TorPingTimeout
} }
session.idleTimer = time.AfterFunc(pingTimeout, session.handleIdleTimeout) session.idleTimer = time.AfterFunc(pingTimeout, session.handleIdleTimeout)
} }
} }
func (session *Session) handleIdleTimeout() { func (session *Session) handleIdleTimeout() {
totalTimeout := DefaultTotalTimeout totalTimeout := DisconnectTimeout
pingTimeout := DefaultIdleTimeout pingTimeout := PingTimeout
if session.isTor { if session.isTor && TorPingTimeout < pingTimeout {
pingTimeout = TorIdleTimeout pingTimeout = TorPingTimeout
} }
session.client.stateMutex.Lock() session.client.stateMutex.Lock()
@ -1096,6 +1173,7 @@ func (client *Client) SetNick(nick, nickCasefolded, skeleton string) (success bo
client.nickCasefolded = nickCasefolded client.nickCasefolded = nickCasefolded
client.skeleton = skeleton client.skeleton = skeleton
client.updateNickMaskNoMutex() client.updateNickMaskNoMutex()
return true return true
} }
@ -1160,12 +1238,18 @@ func (client *Client) LoggedIntoAccount() bool {
// (You must ensure separately that destroy() is called, e.g., by returning `true` from // (You must ensure separately that destroy() is called, e.g., by returning `true` from
// the command handler or calling it yourself.) // the command handler or calling it yourself.)
func (client *Client) Quit(message string, session *Session) { func (client *Client) Quit(message string, session *Session) {
nuh := client.NickMaskString()
now := time.Now().UTC()
setFinalData := func(sess *Session) { setFinalData := func(sess *Session) {
message := sess.quitMessage message := sess.quitMessage
var finalData []byte var finalData []byte
// #364: don't send QUIT lines to unregistered clients // #364: don't send QUIT lines to unregistered clients
if client.registered { if client.registered {
quitMsg := ircmsg.MakeMessage(nil, client.nickMaskString, "QUIT", message) quitMsg := ircmsg.MakeMessage(nil, nuh, "QUIT", message)
if sess.capabilities.Has(caps.ServerTime) {
quitMsg.SetTag("time", now.Format(utils.IRCv3TimestampFormat))
}
finalData, _ = quitMsg.LineBytesStrict(false, MaxLineLen) finalData, _ = quitMsg.LineBytesStrict(false, MaxLineLen)
} }
@ -1285,7 +1369,7 @@ func (client *Client) destroy(session *Session) {
if !shouldDestroy { if !shouldDestroy {
client.server.snomasks.Send(sno.LocalDisconnects, fmt.Sprintf(ircfmt.Unescape("Client session disconnected for [a:%s] [h:%s] [ip:%s]"), details.accountName, session.rawHostname, source)) client.server.snomasks.Send(sno.LocalDisconnects, fmt.Sprintf(ircfmt.Unescape("Client session disconnected for [a:%s] [h:%s] [ip:%s]"), details.accountName, session.rawHostname, source))
} }
client.server.logger.Info("connect-ip", fmt.Sprintf("disconnecting session of %s from %s", details.nick, source)) client.server.logger.Info("connect-ip", session.connID, fmt.Sprintf("Disconnecting session of %s from %s", details.nick, source))
} }
// decrement stats if we have no more sessions, even if the client will not be destroyed // decrement stats if we have no more sessions, even if the client will not be destroyed
@ -1323,7 +1407,7 @@ func (client *Client) destroy(session *Session) {
// alert monitors // alert monitors
if registered { if registered {
client.server.monitorManager.AlertAbout(details.nick, details.nickCasefolded, false) client.server.monitorManager.AlertAbout(details.nick, details.nickCasefolded, false, nil)
} }
// clean up channels // clean up channels
@ -1423,7 +1507,7 @@ func (session *Session) sendFromClientInternal(blocking bool, serverTime time.Ti
func composeMultilineBatch(batchID, fromNickMask, fromAccount string, isBot bool, tags map[string]string, command, target string, message utils.SplitMessage) (result []ircmsg.Message) { func composeMultilineBatch(batchID, fromNickMask, fromAccount string, isBot bool, tags map[string]string, command, target string, message utils.SplitMessage) (result []ircmsg.Message) {
batchStart := ircmsg.MakeMessage(tags, fromNickMask, "BATCH", "+"+batchID, caps.MultilineBatchType, target) batchStart := ircmsg.MakeMessage(tags, fromNickMask, "BATCH", "+"+batchID, caps.MultilineBatchType, target)
batchStart.SetTag("time", message.Time.Format(IRCv3TimestampFormat)) batchStart.SetTag("time", message.Time.Format(utils.IRCv3TimestampFormat))
batchStart.SetTag("msgid", message.Msgid) batchStart.SetTag("msgid", message.Msgid)
if fromAccount != "*" { if fromAccount != "*" {
batchStart.SetTag("account", fromAccount) batchStart.SetTag("account", fromAccount)
@ -1495,7 +1579,7 @@ func (session *Session) SendRawMessage(message ircmsg.Message, blocking bool) er
func (session *Session) sendBytes(line []byte, blocking bool) (err error) { func (session *Session) sendBytes(line []byte, blocking bool) (err error) {
if session.client.server.logger.IsLoggingRawIO() { if session.client.server.logger.IsLoggingRawIO() {
logline := string(line[:len(line)-2]) // strip "\r\n" logline := string(line[:len(line)-2]) // strip "\r\n"
session.client.server.logger.Debug("useroutput", session.client.Nick(), " ->", logline) session.client.server.logger.Debug("useroutput", session.connID, session.client.Nick(), "->", logline)
} }
if blocking { if blocking {
@ -1504,7 +1588,7 @@ func (session *Session) sendBytes(line []byte, blocking bool) (err error) {
err = session.socket.Write(line) err = session.socket.Write(line)
} }
if err != nil { if err != nil {
session.client.server.logger.Info("quit", "send error to client", fmt.Sprintf("%s [%d]", session.client.Nick(), session.sessionID), err.Error()) session.client.server.logger.Info("quit", session.connID, "send error to client", session.client.Nick(), err.Error())
} }
return err return err
} }
@ -1531,7 +1615,7 @@ func (session *Session) setTimeTag(msg *ircmsg.Message, serverTime time.Time) {
if serverTime.IsZero() { if serverTime.IsZero() {
serverTime = time.Now() serverTime = time.Now()
} }
msg.SetTag("time", serverTime.UTC().Format(IRCv3TimestampFormat)) msg.SetTag("time", serverTime.UTC().Format(utils.IRCv3TimestampFormat))
} }
} }
@ -1690,12 +1774,15 @@ func (client *Client) addHistoryItem(target *Client, item history.Item, details,
} }
if cStatus == HistoryPersistent || tStatus == HistoryPersistent { if cStatus == HistoryPersistent || tStatus == HistoryPersistent {
targetedItem.CfCorrespondent = "" targetedItem.CfCorrespondent = ""
client.server.historyDB.AddDirectMessage(details.nickCasefolded, details.account, tDetails.nickCasefolded, tDetails.account, targetedItem) err = client.server.historyDB.AddDirectMessage(details.nickCasefolded, details.account, tDetails.nickCasefolded, tDetails.account, targetedItem)
if err != nil {
client.server.logger.Error("history", "could not add direct message to history", err.Error())
}
} }
return nil return nil
} }
func (client *Client) listTargets(start, end history.Selector, limit int) (results []history.TargetListing, err error) { func (client *Client) listTargets(start, end time.Time, limit int) (results []history.TargetListing, err error) {
var base, extras []history.TargetListing var base, extras []history.TargetListing
var chcfnames []string var chcfnames []string
for _, channel := range client.Channels() { for _, channel := range client.Channels() {
@ -1716,27 +1803,35 @@ func (client *Client) listTargets(start, end history.Selector, limit int) (resul
} }
} }
persistentExtras, err := client.server.historyDB.ListChannels(chcfnames) persistentExtras, err := client.server.historyDB.ListChannels(chcfnames)
if err == nil && len(persistentExtras) != 0 { if err != nil {
client.server.logger.Error("history", "could not list persistent channels", err.Error())
} else if len(persistentExtras) != 0 {
extras = append(extras, persistentExtras...) extras = append(extras, persistentExtras...)
} }
_, cSeq, err := client.server.GetHistorySequence(nil, client, "") // get DM correspondents from the in-memory buffer or the database, as applicable
if err == nil && cSeq != nil { var cErr error
correspondents, err := cSeq.ListCorrespondents(start, end, limit) status, target := client.historyStatus(client.server.Config())
if err == nil { switch status {
base = correspondents case HistoryEphemeral:
} base, cErr = client.history.ListCorrespondents(start, end, limit)
case HistoryPersistent:
base, cErr = client.server.historyDB.ListCorrespondents(target, start, end, limit)
default:
// nothing to do
}
if cErr != nil {
base = nil
client.server.logger.Error("history", "could not list correspondents", cErr.Error())
} }
results = history.MergeTargets(base, extras, start.Time, end.Time, limit) results = history.MergeTargets(base, extras, start, end, limit)
return results, nil return results, nil
} }
// latest PRIVMSG from all DM targets // latest PRIVMSG from all DM targets
func (client *Client) privmsgsBetween(startTime, endTime time.Time, targetLimit, messageLimit int) (results []history.Item, err error) { func (client *Client) privmsgsBetween(startTime, endTime time.Time, targetLimit, messageLimit int) (results []history.Item, err error) {
start := history.Selector{Time: startTime} targets, err := client.listTargets(startTime, endTime, targetLimit)
end := history.Selector{Time: endTime}
targets, err := client.listTargets(start, end, targetLimit)
if err != nil { if err != nil {
return return
} }
@ -1746,7 +1841,7 @@ func (client *Client) privmsgsBetween(startTime, endTime time.Time, targetLimit,
} }
_, seq, err := client.server.GetHistorySequence(nil, client, target.CfName) _, seq, err := client.server.GetHistorySequence(nil, client, target.CfName)
if err == nil && seq != nil { if err == nil && seq != nil {
items, err := seq.Between(start, end, messageLimit) items, err := seq.Between(history.Selector{Time: startTime}, history.Selector{Time: endTime}, messageLimit)
if err == nil { if err == nil {
results = append(results, items...) results = append(results, items...)
} else { } else {
@ -1774,6 +1869,8 @@ const (
IncludeChannels uint = 1 << iota IncludeChannels uint = 1 << iota
IncludeUserModes IncludeUserModes
IncludeRealname IncludeRealname
IncludePushSubscriptions
IncludeMetadata
) )
func (client *Client) markDirty(dirtyBits uint) { func (client *Client) markDirty(dirtyBits uint) {
@ -1794,7 +1891,7 @@ func (client *Client) wakeWriter() {
} }
func (client *Client) writeLoop() { func (client *Client) writeLoop() {
defer client.server.HandlePanic() defer client.server.HandlePanic(nil)
for { for {
client.performWrite(0) client.performWrite(0)
@ -1852,6 +1949,12 @@ func (client *Client) performWrite(additionalDirtyBits uint) {
if (dirtyBits & IncludeRealname) != 0 { if (dirtyBits & IncludeRealname) != 0 {
client.server.accounts.saveRealname(account, client.realname) client.server.accounts.saveRealname(account, client.realname)
} }
if (dirtyBits & IncludePushSubscriptions) != 0 {
client.server.accounts.savePushSubscriptions(account, client.getPushSubscriptions(true))
}
if (dirtyBits & IncludeMetadata) != 0 {
client.server.accounts.saveMetadata(account, client.ListMetadata())
}
} }
// Blocking store; see Channel.Store and Socket.BlockingWrite // Blocking store; see Channel.Store and Socket.BlockingWrite
@ -1871,3 +1974,134 @@ func (client *Client) Store(dirtyBits uint) (err error) {
client.performWrite(dirtyBits) client.performWrite(dirtyBits)
return nil return nil
} }
// pushSubscription represents all the data we track about the state of a push subscription;
// right now every field is persisted, but we may want to persist only a subset in future
type pushSubscription struct {
storedPushSubscription
}
// storedPushSubscription represents a subscription as stored in the database
type storedPushSubscription struct {
Endpoint string
Keys webpush.Keys
LastRefresh time.Time // last time the client sent WEBPUSH REGISTER for this endpoint
LastSuccess time.Time // last time we successfully pushed to this endpoint
}
func newPushSubscription(sub storedPushSubscription) *pushSubscription {
return &pushSubscription{
storedPushSubscription: sub,
// TODO any other initialization here, like rate limiting
}
}
type pushMessage struct {
msg []byte
urgency webpush.Urgency
originatingEndpoint string
cftarget string
time time.Time
}
type pushQueue struct {
workerLock sync.Mutex
queue chan pushMessage
once sync.Once
dropped atomic.Uint64
}
func (c *Client) ensurePushInitialized() {
c.pushQueue.once.Do(c.initializePush)
}
func (c *Client) initializePush() {
// allocate the queue
c.pushQueue.queue = make(chan pushMessage, pushQueueLengthPerClient)
}
func (client *Client) dispatchPushMessage(msg pushMessage) {
client.ensurePushInitialized()
select {
case client.pushQueue.queue <- msg:
if client.pushQueue.workerLock.TryLock() {
go client.pushWorker()
}
default:
client.pushQueue.dropped.Add(1)
}
}
func (client *Client) pushWorker() {
defer client.server.HandlePanic(nil)
defer client.pushQueue.workerLock.Unlock()
for {
select {
case msg := <-client.pushQueue.queue:
for _, sub := range client.getPushSubscriptions(false) {
if !client.skipPushMessage(msg) {
client.sendAndTrackPush(sub.Endpoint, sub.Keys, msg, true)
}
}
default:
// no more messages, end the goroutine and release the trylock
return
}
}
}
// skipPushMessage waits up to the configured delay for the client to send MARKREAD;
// it returns whether the message has been read
func (client *Client) skipPushMessage(msg pushMessage) bool {
if msg.cftarget == "" || msg.time.IsZero() {
return false
}
config := client.server.Config()
if config.WebPush.Delay == 0 {
return false
}
deadline := msg.time.Add(config.WebPush.Delay)
pause := time.Until(deadline)
if pause > 0 {
time.Sleep(pause)
}
readTimestamp, ok := client.getMarkreadTime(msg.cftarget)
return ok && utils.ReadMarkerLessThanOrEqual(msg.time, readTimestamp)
}
func (client *Client) sendAndTrackPush(endpoint string, keys webpush.Keys, msg pushMessage, updateDB bool) {
if endpoint == msg.originatingEndpoint {
return
}
if msg.cftarget != "" && !msg.time.IsZero() {
client.addClearablePushMessage(msg.cftarget, msg.time)
}
switch client.sendPush(endpoint, keys, msg.urgency, msg.msg) {
case nil:
client.recordPush(endpoint, true)
case webpush.Err404:
client.deletePushSubscription(endpoint, updateDB)
default:
client.recordPush(endpoint, false)
}
}
func (client *Client) sendPush(endpoint string, keys webpush.Keys, urgency webpush.Urgency, msg []byte) error {
config := client.server.Config()
// final sanity check
if !config.WebPush.Enabled {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), config.WebPush.Timeout)
defer cancel()
err := webpush.SendWebPush(ctx, endpoint, keys, config.WebPush.vapidKeys, webpush.UrgencyHigh, config.WebPush.Subscriber, msg)
if err == nil {
client.server.logger.Debug("webpush", "dispatched push to client", client.Nick(), endpoint)
} else {
client.server.logger.Debug("webpush", "failed to dispatch push to client", client.Nick(), endpoint, err.Error())
}
return err
}

View File

@ -94,7 +94,6 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick
accountName := client.accountName accountName := client.accountName
settings := client.accountSettings settings := client.accountSettings
registered := client.registered registered := client.registered
realname := client.realname
client.stateMutex.RUnlock() client.stateMutex.RUnlock()
// these restrictions have grandfather exceptions for nicknames registered // these restrictions have grandfather exceptions for nicknames registered
@ -209,10 +208,6 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick
client.server.stats.AddRegistered(invisible, operator) client.server.stats.AddRegistered(invisible, operator)
} }
session.autoreplayMissedSince = lastSeen session.autoreplayMissedSince = lastSeen
// TODO: transition mechanism for #1065, clean this up eventually:
if currentClient.Realname() == "" {
currentClient.SetRealname(realname)
}
// successful reattach! // successful reattach!
return newNick, nil, wasAway != nowAway return newNick, nil, wasAway != nowAway
} else if currentClient == client && currentClient.Nick() == newNick { } else if currentClient == client && currentClient.Nick() == newNick {
@ -253,15 +248,14 @@ func (clients *ClientManager) AllClients() (result []*Client) {
return return
} }
// AllWithCapsNotify returns all clients with the given capabilities, and that support cap-notify. // AllWithCapsNotify returns all sessions that support cap-notify.
func (clients *ClientManager) AllWithCapsNotify(capabs ...caps.Capability) (sessions []*Session) { func (clients *ClientManager) AllWithCapsNotify() (sessions []*Session) {
capabs = append(capabs, caps.CapNotify)
clients.RLock() clients.RLock()
defer clients.RUnlock() defer clients.RUnlock()
for _, client := range clients.byNick { for _, client := range clients.byNick {
for _, session := range client.Sessions() { for _, session := range client.Sessions() {
// cap-notify is implicit in cap version 302 and above // 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) sessions = append(sessions, session)
} }
} }
@ -270,6 +264,18 @@ func (clients *ClientManager) AllWithCapsNotify(capabs ...caps.Capability) (sess
return 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. // FindAll returns all clients that match the given userhost mask.
func (clients *ClientManager) FindAll(userhost string) (set ClientSet) { func (clients *ClientManager) FindAll(userhost string) (set ClientSet) {
set = make(ClientSet) set = make(ClientSet)

View File

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

View File

@ -18,6 +18,24 @@ type Command struct {
capabs []string 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. // Run runs this command with the given client/message.
func (cmd *Command) Run(server *Server, client *Client, session *Session, msg ircmsg.Message) (exiting bool) { func (cmd *Command) Run(server *Server, client *Client, session *Session, msg ircmsg.Message) (exiting bool) {
rb := NewResponseBuffer(session) rb := NewResponseBuffer(session)
@ -152,6 +170,10 @@ func init() {
handler: isonHandler, handler: isonHandler,
minParams: 1, minParams: 1,
}, },
"ISUPPORT": {
handler: isupportHandler,
usablePreReg: true,
},
"JOIN": { "JOIN": {
handler: joinHandler, handler: joinHandler,
minParams: 1, minParams: 1,
@ -187,6 +209,11 @@ func init() {
handler: markReadHandler, handler: markReadHandler,
minParams: 0, // send FAIL instead of ERR_NEEDMOREPARAMS minParams: 0, // send FAIL instead of ERR_NEEDMOREPARAMS
}, },
"METADATA": {
handler: metadataHandler,
minParams: 2,
usablePreReg: true,
},
"MODE": { "MODE": {
handler: modeHandler, handler: modeHandler,
minParams: 1, minParams: 1,
@ -363,6 +390,10 @@ func init() {
usablePreReg: true, usablePreReg: true,
minParams: 4, minParams: 4,
}, },
"WEBPUSH": {
handler: webpushHandler,
minParams: 2,
},
"WHO": { "WHO": {
handler: whoHandler, handler: whoHandler,
minParams: 1, minParams: 1,

View File

@ -22,6 +22,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode/utf8"
"code.cloudfoundry.org/bytefmt" "code.cloudfoundry.org/bytefmt"
"github.com/ergochat/irc-go/ircfmt" "github.com/ergochat/irc-go/ircfmt"
@ -32,6 +33,7 @@ import (
"github.com/ergochat/ergo/irc/connection_limits" "github.com/ergochat/ergo/irc/connection_limits"
"github.com/ergochat/ergo/irc/custime" "github.com/ergochat/ergo/irc/custime"
"github.com/ergochat/ergo/irc/email" "github.com/ergochat/ergo/irc/email"
"github.com/ergochat/ergo/irc/i18n"
"github.com/ergochat/ergo/irc/isupport" "github.com/ergochat/ergo/irc/isupport"
"github.com/ergochat/ergo/irc/jwt" "github.com/ergochat/ergo/irc/jwt"
"github.com/ergochat/ergo/irc/languages" "github.com/ergochat/ergo/irc/languages"
@ -40,7 +42,14 @@ import (
"github.com/ergochat/ergo/irc/mysql" "github.com/ergochat/ergo/irc/mysql"
"github.com/ergochat/ergo/irc/oauth2" "github.com/ergochat/ergo/irc/oauth2"
"github.com/ergochat/ergo/irc/passwd" "github.com/ergochat/ergo/irc/passwd"
"github.com/ergochat/ergo/irc/postgresql"
"github.com/ergochat/ergo/irc/sqlite"
"github.com/ergochat/ergo/irc/utils" "github.com/ergochat/ergo/irc/utils"
"github.com/ergochat/ergo/irc/webpush"
)
const (
defaultProxyDeadline = 5 * time.Second
) )
// here's how this works: exported (capitalized) members of the config structs // here's how this works: exported (capitalized) members of the config structs
@ -369,7 +378,7 @@ type AccountRegistrationConfig struct {
Mailto email.MailtoConfig Mailto email.MailtoConfig
} `yaml:"callbacks"` } `yaml:"callbacks"`
VerifyTimeout custime.Duration `yaml:"verify-timeout"` VerifyTimeout custime.Duration `yaml:"verify-timeout"`
BcryptCost uint `yaml:"bcrypt-cost"` BcryptCost int `yaml:"bcrypt-cost"`
} }
type VHostConfig struct { type VHostConfig struct {
@ -439,31 +448,6 @@ func (nr *NickEnforcementMethod) UnmarshalYAML(unmarshal func(interface{}) error
return err return err
} }
func (cm *Casemapping) UnmarshalYAML(unmarshal func(interface{}) error) (err error) {
var orig string
if err = unmarshal(&orig); err != nil {
return err
}
var result Casemapping
switch strings.ToLower(orig) {
case "ascii":
result = CasemappingASCII
case "precis", "rfc7613", "rfc8265":
result = CasemappingPRECIS
case "permissive", "fun":
result = CasemappingPermissive
case "rfc1459":
result = CasemappingRFC1459
case "rfc1459-strict":
result = CasemappingRFC1459Strict
default:
return fmt.Errorf("invalid casemapping value: %s", orig)
}
*cm = result
return nil
}
// OperClassConfig defines a specific operator class. // OperClassConfig defines a specific operator class.
type OperClassConfig struct { type OperClassConfig struct {
Title string Title string
@ -574,8 +558,14 @@ type Config struct {
CoerceIdent string `yaml:"coerce-ident"` CoerceIdent string `yaml:"coerce-ident"`
MOTD string MOTD string
motdLines []string motdLines []string
MOTDFormatting bool `yaml:"motd-formatting"` MOTDFormatting bool `yaml:"motd-formatting"`
Relaymsg struct { InitialNotice string `yaml:"initial-notice"`
IdleTimeouts struct {
Registration time.Duration
Ping time.Duration
Disconnect time.Duration
} `yaml:"idle-timeouts"`
Relaymsg struct {
Enabled bool Enabled bool
Separators string Separators string
AvailableToChanops bool `yaml:"available-to-chanops"` AvailableToChanops bool `yaml:"available-to-chanops"`
@ -597,24 +587,38 @@ type Config struct {
Cloaks cloaks.CloakConfig `yaml:"ip-cloaking"` Cloaks cloaks.CloakConfig `yaml:"ip-cloaking"`
SecureNetDefs []string `yaml:"secure-nets"` SecureNetDefs []string `yaml:"secure-nets"`
secureNets []net.IPNet secureNets []net.IPNet
OperThrottle time.Duration `yaml:"oper-throttle"`
supportedCaps *caps.Set supportedCaps *caps.Set
supportedCapsWithoutSTS *caps.Set supportedCapsWithoutSTS *caps.Set
capValues caps.Values capValues caps.Values
Casemapping Casemapping Casemapping i18n.Casemapping
EnforceUtf8 bool `yaml:"enforce-utf8"` EnforceUtf8 bool `yaml:"enforce-utf8"`
OutputPath string `yaml:"output-path"` OutputPath string `yaml:"output-path"`
IPCheckScript IPCheckScriptConfig `yaml:"ip-check-script"` IPCheckScript IPCheckScriptConfig `yaml:"ip-check-script"`
OverrideServicesHostname string `yaml:"override-services-hostname"` OverrideServicesHostname string `yaml:"override-services-hostname"`
MaxLineLen int `yaml:"max-line-len"` MaxLineLen int `yaml:"max-line-len"`
SuppressLusers bool `yaml:"suppress-lusers"` 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 { Roleplay struct {
Enabled bool Enabled bool
RequireChanops bool `yaml:"require-chanops"` RequireChanops bool `yaml:"require-chanops"`
RequireOper bool `yaml:"require-oper"` RequireOper bool `yaml:"require-oper"`
AddSuffix *bool `yaml:"add-suffix"` AddSuffix *bool `yaml:"add-suffix"`
addSuffix bool addSuffix bool
NPCNickMask string `yaml:"npc-nick-mask"`
SceneNickMask string `yaml:"scene-nick-mask"`
} }
Extjwt struct { Extjwt struct {
@ -636,6 +640,8 @@ type Config struct {
Path string Path string
AutoUpgrade bool AutoUpgrade bool
MySQL mysql.Config MySQL mysql.Config
PostgreSQL postgresql.Config
SQLite sqlite.Config
} }
Accounts AccountConfig Accounts AccountConfig
@ -708,6 +714,25 @@ type Config struct {
} `yaml:"tagmsg-storage"` } `yaml:"tagmsg-storage"`
} }
Metadata struct {
Enabled bool
OperatorOnlyModification bool `yaml:"operator-only-modification"`
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 Filename string
} }
@ -954,7 +979,7 @@ func (conf *Config) prepareListeners() (err error) {
conf.Server.trueListeners = make(map[string]utils.ListenerConfig) conf.Server.trueListeners = make(map[string]utils.ListenerConfig)
for addr, block := range conf.Server.Listeners { for addr, block := range conf.Server.Listeners {
var lconf utils.ListenerConfig var lconf utils.ListenerConfig
lconf.ProxyDeadline = RegisterTimeout lconf.ProxyDeadline = defaultProxyDeadline
lconf.Tor = block.Tor lconf.Tor = block.Tor
lconf.STSOnly = block.STSOnly lconf.STSOnly = block.STSOnly
if lconf.STSOnly && !conf.Server.STS.Enabled { if lconf.STSOnly && !conf.Server.STS.Enabled {
@ -998,6 +1023,40 @@ func (config *Config) processExtjwt() (err error) {
return nil 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 // LoadRawConfig loads the config without doing any consistency checks or postprocessing
func LoadRawConfig(filename string) (config *Config, err error) { func LoadRawConfig(filename string) (config *Config, err error) {
data, err := os.ReadFile(filename) data, err := os.ReadFile(filename)
@ -1062,55 +1121,104 @@ func mungeFromEnvironment(config *Config, envPair string) (applied bool, name st
pathComponents[i] = screamingSnakeToKebab(pathComponent) pathComponents[i] = screamingSnakeToKebab(pathComponent)
} }
type mapInsertion struct {
m reflect.Value
k reflect.Value
v reflect.Value
}
var mapStack []mapInsertion
v := reflect.Indirect(reflect.ValueOf(config)) v := reflect.Indirect(reflect.ValueOf(config))
t := v.Type() t := v.Type()
for _, component := range pathComponents { for _, component := range pathComponents {
if component == "" { if component == "" {
return false, "", &configPathError{name, "invalid", nil} return false, "", &configPathError{name, "invalid", nil}
} }
if v.Kind() != reflect.Struct { if v.Kind() == reflect.Struct {
return false, "", &configPathError{name, "index into non-struct", nil} var nextField reflect.StructField
} success := false
var nextField reflect.StructField n := t.NumField()
success := false // preferentially get a field with an exact yaml tag match,
n := t.NumField() // then fall back to case-insensitive comparison of field names
// preferentially get a field with an exact yaml tag match,
// then fall back to case-insensitive comparison of field names
for i := 0; i < n; i++ {
field := t.Field(i)
if isExported(field) && field.Tag.Get("yaml") == component {
nextField = field
success = true
break
}
}
if !success {
for i := 0; i < n; i++ { for i := 0; i < n; i++ {
field := t.Field(i) field := t.Field(i)
if isExported(field) && strings.ToLower(field.Name) == component { if isExported(field) && field.Tag.Get("yaml") == component {
nextField = field nextField = field
success = true success = true
break break
} }
} }
} if !success {
if !success { for i := 0; i < n; i++ {
return false, "", &configPathError{name, fmt.Sprintf("couldn't resolve path component: `%s`", component), nil} field := t.Field(i)
} if isExported(field) && strings.ToLower(field.Name) == component {
v = v.FieldByName(nextField.Name) nextField = field
// dereference pointer field if necessary, initialize new value if necessary success = true
if v.Kind() == reflect.Ptr { break
if v.IsNil() { }
v.Set(reflect.New(v.Type().Elem())) }
} }
v = reflect.Indirect(v) if !success {
return false, "", &configPathError{name, fmt.Sprintf("couldn't resolve path component: `%s`", component), nil}
}
v = v.FieldByName(nextField.Name)
// dereference pointer field if necessary, initialize new value if necessary
switch v.Kind() {
case reflect.Ptr:
if v.IsNil() {
v.Set(reflect.New(v.Type().Elem()))
}
v = reflect.Indirect(v)
case reflect.Map:
if v.IsNil() {
v.Set(reflect.MakeMap(v.Type()))
}
}
t = v.Type()
} else if v.Kind() == reflect.Map {
keyType := v.Type().Key()
valueType := v.Type().Elem()
if keyType.Kind() != reflect.String {
return false, "", &configPathError{name, "can't index into map unless its keys are strings", nil}
}
// index into the map, returns the zero value (invalid) if not found
key := reflect.ValueOf(component)
v2 := v.MapIndex(key)
if v2.IsValid() {
// make an addressable copy of the existing value:
v3 := reflect.New(valueType).Elem()
v3.Set(v2)
v2 = v3
} else {
// make an addressable value of the map value type:
v2 = reflect.New(valueType).Elem()
// if the map value type is *Baz, set it to a new(Baz):
if valueType.Kind() == reflect.Pointer {
v2.Set(reflect.New(valueType.Elem()))
}
}
// we are not operating directly on the current map member,
// we need to go back later and insert v2 into the map:
mapStack = append(mapStack, mapInsertion{m: v, k: key, v: v2})
if valueType.Kind() != reflect.Pointer {
v = v2
} else {
v = reflect.Indirect(v2)
}
t = v.Type()
} else {
return false, "", &configPathError{name, "can't index into fields other than struct or map", nil}
} }
t = v.Type()
} }
yamlErr := yaml.Unmarshal([]byte(value), v.Addr().Interface()) yamlErr := yaml.Unmarshal([]byte(value), v.Addr().Interface())
if yamlErr != nil { if yamlErr != nil {
return false, "", &configPathError{name, "couldn't deserialize YAML", yamlErr} return false, "", &configPathError{name, "couldn't deserialize YAML", yamlErr}
} }
// go back and do all map assignments
for i := len(mapStack) - 1; i >= 0; i-- {
elem := mapStack[i]
elem.m.SetMapIndex(elem.k, elem.v)
}
return true, name, nil return true, name, nil
} }
@ -1165,10 +1273,59 @@ func LoadConfig(filename string) (config *Config, err error) {
config.Server.MaxLineLen = DefaultMaxLineLen config.Server.MaxLineLen = DefaultMaxLineLen
} }
if config.Datastore.MySQL.Enabled { if config.Datastore.MySQL.Enabled {
if !mysql.Enabled {
return nil, fmt.Errorf("MySQL is enabled in the config, but this binary was not built with MySQL support. Rebuild with `make build_full` to enable")
}
if config.Limits.NickLen > mysql.MaxTargetLength || config.Limits.ChannelLen > mysql.MaxTargetLength { if config.Limits.NickLen > mysql.MaxTargetLength || config.Limits.ChannelLen > mysql.MaxTargetLength {
return nil, fmt.Errorf("to use MySQL, nick and channel length limits must be %d or lower", mysql.MaxTargetLength) return nil, fmt.Errorf("to use MySQL, nick and channel length limits must be %d or lower", mysql.MaxTargetLength)
} }
} }
if config.Datastore.PostgreSQL.Enabled {
if !postgresql.Enabled {
return nil, fmt.Errorf("PostgreSQL is enabled in the config, but this binary was not built with PostgreSQL support. Rebuild with `make build_full` to enable")
}
if config.Limits.NickLen > postgresql.MaxTargetLength || config.Limits.ChannelLen > postgresql.MaxTargetLength {
return nil, fmt.Errorf("to use PostgreSQL, nick and channel length limits must be %d or lower", postgresql.MaxTargetLength)
}
}
if config.Datastore.SQLite.Enabled {
if !sqlite.Enabled {
return nil, fmt.Errorf("SQLite is enabled in the config, but this binary was not built with SQLite support. Rebuild with `make build_full` to enable")
}
if config.Limits.NickLen > sqlite.MaxTargetLength || config.Limits.ChannelLen > sqlite.MaxTargetLength {
return nil, fmt.Errorf("to use SQLite, nick and channel length limits must be %d or lower", sqlite.MaxTargetLength)
}
}
enabledBackends := 0
if config.Datastore.MySQL.Enabled {
enabledBackends++
}
if config.Datastore.PostgreSQL.Enabled {
enabledBackends++
}
if config.Datastore.SQLite.Enabled {
enabledBackends++
}
if enabledBackends > 1 {
return nil, fmt.Errorf("cannot enable multiple history database backends simultaneously")
}
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.CoerceIdent != "" {
if config.Server.CheckIdent { if config.Server.CheckIdent {
@ -1246,6 +1403,12 @@ func LoadConfig(filename string) (config *Config, err error) {
config.Server.capValues[caps.Multiline] = multilineCapValue config.Server.capValues[caps.Multiline] = multilineCapValue
} }
if !i18n.Enabled {
if config.Server.Casemapping != i18n.CasemappingASCII {
return nil, fmt.Errorf("i18n support was compiled out; set casemapping to 'ascii' or recompile")
}
}
// handle legacy name 'bouncer' for 'multiclient' section: // handle legacy name 'bouncer' for 'multiclient' section:
if config.Accounts.Bouncer != nil { if config.Accounts.Bouncer != nil {
config.Accounts.Multiclient = *config.Accounts.Bouncer config.Accounts.Multiclient = *config.Accounts.Bouncer
@ -1414,6 +1577,10 @@ func LoadConfig(filename string) (config *Config, err error) {
config.Server.supportedCaps.Disable(caps.SASL) 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 { if err := config.Accounts.OAuth2.Postprocess(); err != nil {
return nil, err return nil, err
} }
@ -1497,6 +1664,12 @@ func LoadConfig(filename string) (config *Config, err error) {
if config.Accounts.Registration.BcryptCost == 0 { if config.Accounts.Registration.BcryptCost == 0 {
config.Accounts.Registration.BcryptCost = passwd.DefaultCost config.Accounts.Registration.BcryptCost = passwd.DefaultCost
} }
if config.Accounts.Registration.BcryptCost < passwd.MinCost || config.Accounts.Registration.BcryptCost > passwd.MaxCost {
return nil, fmt.Errorf(
"invalid bcrypt-cost %d (require %d <= cost <= %d)",
config.Accounts.Registration.BcryptCost, passwd.MinCost, passwd.MaxCost,
)
}
if config.Channels.MaxChannelsPerClient == 0 { if config.Channels.MaxChannelsPerClient == 0 {
config.Channels.MaxChannelsPerClient = 100 config.Channels.MaxChannelsPerClient = 100
@ -1513,12 +1686,13 @@ func LoadConfig(filename string) (config *Config, err error) {
// in the current implementation, we disable history by creating a history buffer // in the current implementation, we disable history by creating a history buffer
// with zero capacity. but the `enabled` config option MUST be respected regardless // with zero capacity. but the `enabled` config option MUST be respected regardless
// of this detail // of this detail
if !config.History.Enabled { if !config.History.Enabled || config.History.ChathistoryMax == 0 {
config.History.ChannelLength = 0 config.History.ChannelLength = 0
config.History.ClientLength = 0 config.History.ClientLength = 0
config.Server.supportedCaps.Disable(caps.Chathistory) config.Server.supportedCaps.Disable(caps.Chathistory)
config.Server.supportedCaps.Disable(caps.EventPlayback) config.Server.supportedCaps.Disable(caps.EventPlayback)
config.Server.supportedCaps.Disable(caps.ZNCPlayback) config.Server.supportedCaps.Disable(caps.ZNCPlayback)
config.Server.supportedCaps.Disable(caps.MessageRedaction)
} }
if !config.History.Enabled || !config.History.Persistent.Enabled { if !config.History.Enabled || !config.History.Persistent.Enabled {
@ -1528,8 +1702,8 @@ func LoadConfig(filename string) (config *Config, err error) {
config.History.Persistent.DirectMessages = PersistentDisabled config.History.Persistent.DirectMessages = PersistentDisabled
} }
if config.History.Persistent.Enabled && !config.Datastore.MySQL.Enabled { if config.History.Persistent.Enabled && !config.Datastore.MySQL.Enabled && !config.Datastore.PostgreSQL.Enabled && !config.Datastore.SQLite.Enabled {
return nil, fmt.Errorf("You must configure a MySQL server in order to enable persistent history") return nil, fmt.Errorf("You must configure a MySQL, PostgreSQL, or SQLite database in order to enable persistent history")
} }
if config.History.ZNCMax == 0 { if config.History.ZNCMax == 0 {
@ -1549,7 +1723,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) 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.ExpireTime = time.Duration(config.History.Restrictions.ExpireTime)
config.Datastore.MySQL.TrackAccountMessages = config.History.Retention.EnableAccountIndexing config.Datastore.MySQL.TrackAccountMessages = config.History.Retention.EnableAccountIndexing
@ -1559,6 +1743,15 @@ func LoadConfig(filename string) (config *Config, err error) {
// same machine: // same machine:
config.Datastore.MySQL.MaxConns = runtime.NumCPU() config.Datastore.MySQL.MaxConns = runtime.NumCPU()
} }
// do the same for postgresql
config.Datastore.PostgreSQL.ExpireTime = time.Duration(config.History.Restrictions.ExpireTime)
config.Datastore.PostgreSQL.TrackAccountMessages = config.History.Retention.EnableAccountIndexing
if config.Datastore.PostgreSQL.MaxConns == 0 {
config.Datastore.PostgreSQL.MaxConns = runtime.NumCPU()
}
// and for sqlite
config.Datastore.SQLite.ExpireTime = time.Duration(config.History.Restrictions.ExpireTime)
config.Datastore.SQLite.TrackAccountMessages = config.History.Retention.EnableAccountIndexing
config.Server.Cloaks.Initialize() config.Server.Cloaks.Initialize()
if config.Server.Cloaks.Enabled { if config.Server.Cloaks.Enabled {
@ -1567,11 +1760,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() err = config.processExtjwt()
if err != nil { if err != nil {
return nil, err 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: // now that all postprocessing is complete, regenerate ISUPPORT:
err = config.generateISupport() err = config.generateISupport()
if err != nil { if err != nil {
@ -1620,14 +1867,14 @@ func (config *Config) generateISupport() (err error) {
switch config.Server.Casemapping { switch config.Server.Casemapping {
default: default:
casemappingToken = "ascii" // this is published for ascii, precis, or permissive casemappingToken = "ascii" // this is published for ascii, precis, or permissive
case CasemappingRFC1459: case i18n.CasemappingRFC1459:
casemappingToken = "rfc1459" casemappingToken = "rfc1459"
case CasemappingRFC1459Strict: case i18n.CasemappingRFC1459Strict:
casemappingToken = "rfc1459-strict" casemappingToken = "rfc1459-strict"
} }
isupport.Add("CASEMAPPING", casemappingToken) isupport.Add("CASEMAPPING", casemappingToken)
isupport.Add("CHANLIMIT", fmt.Sprintf("%s:%d", chanTypes, config.Channels.MaxChannelsPerClient)) isupport.Add("CHANLIMIT", fmt.Sprintf("%s:%d", chanTypes, config.Channels.MaxChannelsPerClient))
isupport.Add("CHANMODES", chanmodesToken) isupport.Add("CHANMODES", modes.ChanmodesToken())
if config.History.Enabled && config.History.ChathistoryMax > 0 { if config.History.Enabled && config.History.ChathistoryMax > 0 {
isupport.Add("CHATHISTORY", strconv.Itoa(config.History.ChathistoryMax)) isupport.Add("CHATHISTORY", strconv.Itoa(config.History.ChathistoryMax))
// Kiwi expects this legacy token name: // Kiwi expects this legacy token name:
@ -1656,17 +1903,36 @@ func (config *Config) generateISupport() (err error) {
isupport.Add("RPCHAN", "E") isupport.Add("RPCHAN", "E")
isupport.Add("RPUSER", "E") isupport.Add("RPUSER", "E")
} }
isupport.Add("SAFELIST", "")
isupport.Add("SAFERATE", "")
isupport.Add("STATUSMSG", "~&@%+") isupport.Add("STATUSMSG", "~&@%+")
isupport.Add("TARGMAX", fmt.Sprintf("NAMES:1,LIST:1,KICK:,WHOIS:1,USERHOST:10,PRIVMSG:%s,TAGMSG:%s,NOTICE:%s,MONITOR:%d", maxTargetsString, maxTargetsString, maxTargetsString, config.Limits.MonitorEntries)) isupport.Add("TARGMAX", fmt.Sprintf("NAMES:1,LIST:1,KICK:,WHOIS:1,USERHOST:10,PRIVMSG:%s,TAGMSG:%s,NOTICE:%s,MONITOR:%d", maxTargetsString, maxTargetsString, maxTargetsString, config.Limits.MonitorEntries))
isupport.Add("TOPICLEN", strconv.Itoa(config.Limits.TopicLen)) isupport.Add("TOPICLEN", strconv.Itoa(config.Limits.TopicLen))
if config.Server.Casemapping == CasemappingPRECIS { if config.Server.Casemapping == i18n.CasemappingPRECIS {
isupport.Add("UTF8MAPPING", precisUTF8MappingToken) isupport.Add("UTF8MAPPING", precisUTF8MappingToken)
} }
if config.Server.EnforceUtf8 { if config.Server.EnforceUtf8 {
isupport.Add("UTF8ONLY", "") 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", "") isupport.Add("WHOX", "")
if config.Accounts.RequireSasl.Enabled {
isupport.Add("draft/ACCOUNTREQUIRED", "")
}
for key, value := range config.Server.AdditionalISupport {
if !isupport.Contains(key) {
isupport.Add(key, value)
}
}
err = isupport.RegenerateCachedReply() err = isupport.RegenerateCachedReply()
return return
} }
@ -1719,7 +1985,7 @@ func (config *Config) historyChangedFrom(oldConfig *Config) bool {
config.History.Persistent != oldConfig.History.Persistent config.History.Persistent != oldConfig.History.Persistent
} }
func compileGuestRegexp(guestFormat string, casemapping Casemapping) (standard, folded *regexp.Regexp, err error) { func compileGuestRegexp(guestFormat string, casemapping i18n.Casemapping) (standard, folded *regexp.Regexp, err error) {
if strings.Count(guestFormat, "?") != 0 || strings.Count(guestFormat, "*") != 1 { if strings.Count(guestFormat, "?") != 0 || strings.Count(guestFormat, "*") != 1 {
err = errors.New("guest format must contain 1 '*' and no '?'s") err = errors.New("guest format must contain 1 '*' and no '?'s")
return return
@ -1733,11 +1999,11 @@ func compileGuestRegexp(guestFormat string, casemapping Casemapping) (standard,
starIndex := strings.IndexByte(guestFormat, '*') starIndex := strings.IndexByte(guestFormat, '*')
initial := guestFormat[:starIndex] initial := guestFormat[:starIndex]
final := guestFormat[starIndex+1:] final := guestFormat[starIndex+1:]
initialFolded, err := casefoldWithSetting(initial, casemapping) initialFolded, err := i18n.CasefoldWithSetting(initial, casemapping)
if err != nil { if err != nil {
return return
} }
finalFolded, err := casefoldWithSetting(final, casemapping) finalFolded, err := i18n.CasefoldWithSetting(final, casemapping)
if err != nil { if err != nil {
return return
} }
@ -1768,6 +2034,9 @@ func (config *Config) loadMOTD() error {
if config.Server.MOTDFormatting { if config.Server.MOTDFormatting {
lineToSend = ircfmt.Unescape(lineToSend) 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 // "- " is the required prefix for MOTD
lineToSend = fmt.Sprintf("- %s", lineToSend) lineToSend = fmt.Sprintf("- %s", lineToSend)
config.Server.motdLines = append(config.Server.motdLines, lineToSend) config.Server.motdLines = append(config.Server.motdLines, lineToSend)
@ -1775,3 +2044,22 @@ func (config *Config) loadMOTD() error {
} }
return nil 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

@ -8,6 +8,15 @@ import (
"testing" "testing"
) )
func mungeEnvForTesting(config *Config, env []string, t *testing.T) {
for _, envPair := range env {
_, _, err := mungeFromEnvironment(config, envPair)
if err != nil {
t.Errorf("couldn't apply override `%s`: %v", envPair, err)
}
}
}
func TestEnvironmentOverrides(t *testing.T) { func TestEnvironmentOverrides(t *testing.T) {
var config Config var config Config
config.Server.Compatibility.SendUnprefixedSasl = true config.Server.Compatibility.SendUnprefixedSasl = true
@ -16,6 +25,12 @@ func TestEnvironmentOverrides(t *testing.T) {
config.Accounts.DefaultUserModes = &defaultUserModes config.Accounts.DefaultUserModes = &defaultUserModes
config.Server.WebSockets.AllowedOrigins = []string{"https://www.ircv3.net"} config.Server.WebSockets.AllowedOrigins = []string{"https://www.ircv3.net"}
config.Server.MOTD = "long.motd.txt" // overwrite this config.Server.MOTD = "long.motd.txt" // overwrite this
config.Opers = map[string]*OperConfig{
"admin": {
Class: "server-admin",
Password: "adminpassword",
},
}
env := []string{ env := []string{
`USER=shivaram`, // unrelated var `USER=shivaram`, // unrelated var
`ORAGONO_USER=oragono`, // this should be ignored as well `ORAGONO_USER=oragono`, // this should be ignored as well
@ -26,13 +41,11 @@ func TestEnvironmentOverrides(t *testing.T) {
`ORAGONO__ACCOUNTS__NICK_RESERVATION__ENABLED=true`, `ORAGONO__ACCOUNTS__NICK_RESERVATION__ENABLED=true`,
`ERGO__ACCOUNTS__DEFAULT_USER_MODES="+iR"`, `ERGO__ACCOUNTS__DEFAULT_USER_MODES="+iR"`,
`ORAGONO__SERVER__IP_CLOAKING={"enabled": true, "enabled-for-always-on": true, "netname": "irc", "cidr-len-ipv4": 32, "cidr-len-ipv6": 64, "num-bits": 64}`, `ORAGONO__SERVER__IP_CLOAKING={"enabled": true, "enabled-for-always-on": true, "netname": "irc", "cidr-len-ipv4": 32, "cidr-len-ipv6": 64, "num-bits": 64}`,
`ERGO__OPERS__ADMIN__PASSWORD="newadminpassword"`,
`ERGO__OPERS__OPERUSER={"class": "server-admin", "whois-line": "is a server admin", "password": "operpassword"}`,
} }
for _, envPair := range env {
_, _, err := mungeFromEnvironment(&config, envPair) mungeEnvForTesting(&config, env, t)
if err != nil {
t.Errorf("couldn't apply override `%s`: %v", envPair, err)
}
}
if config.Network.Name != "example.com" { if config.Network.Name != "example.com" {
t.Errorf("unexpected value of network.name: %s", config.Network.Name) t.Errorf("unexpected value of network.name: %s", config.Network.Name)
@ -68,6 +81,56 @@ func TestEnvironmentOverrides(t *testing.T) {
if *config.Accounts.DefaultUserModes != "+iR" { if *config.Accounts.DefaultUserModes != "+iR" {
t.Errorf("couldn't override pre-set ptr field") t.Errorf("couldn't override pre-set ptr field")
} }
if (*config.Opers["admin"]).Password != "newadminpassword" {
t.Errorf("couldn't index into map and then overwrite")
}
if (*config.Opers["operuser"]).Password != "operpassword" {
t.Errorf("couldn't create new entry in map")
}
}
func TestEnvironmentInitializeNilMap(t *testing.T) {
var config Config
env := []string{
`ERGO__OPERS__OPERUSER={"class": "server-admin", "whois-line": "is a server admin", "password": "operpassword"}`,
}
mungeEnvForTesting(&config, env, t)
assertEqual((*config.Opers["operuser"]).Password, "operpassword")
// try with an initialized but empty map:
config.Opers = make(map[string]*OperConfig)
mungeEnvForTesting(&config, env, t)
assertEqual((*config.Opers["operuser"]).Password, "operpassword")
}
func TestEnvironmentCreateNewMap(t *testing.T) {
var config Config
env := []string{
`ERGO__OPERS={"operuser": {"class": "server-admin", "whois-line": "is a server admin", "password": "operpassword"}}`,
}
mungeEnvForTesting(&config, env, t)
assertEqual((*config.Opers["operuser"]).Password, "operpassword")
// try with an initialized but empty map:
config.Opers = make(map[string]*OperConfig)
mungeEnvForTesting(&config, env, t)
assertEqual((*config.Opers["operuser"]).Password, "operpassword")
}
func TestEnvironmentNonPointerMap(t *testing.T) {
// edge cases that should not panic, even though the results are unusable
// since all "field names" get lowercased:
var config Config
config.Server.AdditionalISupport = map[string]string{"extban": "a"}
env := []string{
`ERGO__SERVER__ADDITIONAL_ISUPPORT__EXTBAN=~,a`,
`ERGO__FAKELAG__COMMAND_BUDGETS__PRIVMSG=10`,
}
mungeEnvForTesting(&config, env, t)
} }
func TestEnvironmentOverrideErrors(t *testing.T) { func TestEnvironmentOverrideErrors(t *testing.T) {
@ -76,20 +139,20 @@ func TestEnvironmentOverrideErrors(t *testing.T) {
config.History.Enabled = true config.History.Enabled = true
invalidEnvs := []string{ invalidEnvs := []string{
`ORAGONO__=asdf`, `ERGO__=asdf`,
`ORAGONO__SERVER__=asdf`, `ERGO__SERVER__=asdf`,
`ORAGONO__SERVER____=asdf`, `ERGO__SERVER____=asdf`,
`ORAGONO__NONEXISTENT_KEY=1`, `ERGO__NONEXISTENT_KEY=1`,
`ORAGONO__SERVER__NONEXISTENT_KEY=1`, `ERGO__SERVER__NONEXISTENT_KEY=1`,
// invalid yaml: // invalid yaml:
`ORAGONO__SERVER__IP_CLOAKING__NETNAME="`, `ERGO__SERVER__IP_CLOAKING__NETNAME="`,
// invalid type: // invalid type:
`ORAGONO__SERVER__IP_CLOAKING__NUM_BITS=asdf`, `ERGO__SERVER__IP_CLOAKING__NUM_BITS=asdf`,
`ORAGONO__SERVER__STS=[]`, `ERGO__SERVER__STS=[]`,
// index into non-struct: // index into non-struct:
`ORAGONO__NETWORK__NAME__QUX=1`, `ERGO__NETWORK__NAME__QUX=1`,
// private field: // private field:
`ORAGONO__SERVER__PASSWORDBYTES="asdf"`, `ERGO__SERVER__PASSWORDBYTES="asdf"`,
} }
for _, env := range invalidEnvs { for _, env := range invalidEnvs {

View File

@ -18,6 +18,7 @@ import (
"github.com/ergochat/ergo/irc/datastore" "github.com/ergochat/ergo/irc/datastore"
"github.com/ergochat/ergo/irc/modes" "github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/utils" "github.com/ergochat/ergo/irc/utils"
"github.com/ergochat/ergo/irc/webpush"
"github.com/tidwall/buntdb" "github.com/tidwall/buntdb"
) )
@ -27,15 +28,17 @@ const (
// 'version' of the database schema // 'version' of the database schema
// latest schema of the db // latest schema of the db
latestDbSchema = 23 latestDbSchema = 24
) )
var ( var (
schemaVersionUUID = utils.UUID{0, 255, 85, 13, 212, 10, 191, 121, 245, 152, 142, 89, 97, 141, 219, 87} // AP9VDdQKv3n1mI5ZYY3bVw 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 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) keySchemaVersion = bunt.BuntKey(datastore.TableMetadata, schemaVersionUUID)
keyCloakSecret = bunt.BuntKey(datastore.TableMetadata, cloakSecretUUID) keyCloakSecret = bunt.BuntKey(datastore.TableMetadata, cloakSecretUUID)
keyVAPIDKeys = bunt.BuntKey(datastore.TableMetadata, vapidKeysUUID)
) )
type SchemaChanger func(*Config, *buntdb.Tx) error type SchemaChanger func(*Config, *buntdb.Tx) error
@ -80,6 +83,15 @@ func initializeDB(path string) error {
// set schema version // set schema version
tx.Set(keySchemaVersion, strconv.Itoa(latestDbSchema), nil) tx.Set(keySchemaVersion, strconv.Itoa(latestDbSchema), nil)
tx.Set(keyCloakSecret, utils.GenerateSecretKey(), 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 return nil
}) })
@ -233,6 +245,16 @@ func StoreCloakSecret(dstore datastore.Datastore, secret string) {
dstore.Set(datastore.TableMetadata, cloakSecretUUID, []byte(secret), time.Time{}) 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 { func schemaChangeV1toV2(config *Config, tx *buntdb.Tx) error {
// == version 1 -> 2 == // == version 1 -> 2 ==
// account key changes and account.verified key bugfix. // account key changes and account.verified key bugfix.
@ -1218,6 +1240,20 @@ func schemaChangeV22ToV23(config *Config, tx *buntdb.Tx) error {
return 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) { func getSchemaChange(initialVersion int) (result SchemaChange, ok bool) {
for _, change := range allChanges { for _, change := range allChanges {
if initialVersion == change.InitialVersion { if initialVersion == change.InitialVersion {
@ -1338,4 +1374,9 @@ var allChanges = []SchemaChange{
TargetVersion: 23, TargetVersion: 23,
Changer: schemaChangeV22ToV23, Changer: schemaChangeV22ToV23,
}, },
{
InitialVersion: 23,
TargetVersion: 24,
Changer: schemaChangeV23ToV24,
},
} }

View File

@ -4,9 +4,18 @@
package email package email
import ( import (
"bytes"
"crypto"
"crypto/ed25519"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors" "errors"
dkim "github.com/toorop/go-dkim" "fmt"
"os" "os"
dkim "github.com/emersion/go-msgauth/dkim"
) )
var ( var (
@ -17,38 +26,77 @@ type DKIMConfig struct {
Domain string Domain string
Selector string Selector string
KeyFile string `yaml:"key-file"` KeyFile string `yaml:"key-file"`
keyBytes []byte privKey crypto.Signer
}
func (dkim *DKIMConfig) Enabled() bool {
return dkim.Domain != ""
} }
func (dkim *DKIMConfig) Postprocess() (err error) { func (dkim *DKIMConfig) Postprocess() (err error) {
if dkim.Domain != "" { if !dkim.Enabled() {
if dkim.Selector == "" || dkim.KeyFile == "" { return nil
return ErrMissingFields
}
dkim.keyBytes, err = os.ReadFile(dkim.KeyFile)
if err != nil {
return err
}
} }
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 return nil
} }
var defaultOptions = dkim.SigOptions{ func parseDKIMPrivKey(input []byte) (crypto.Signer, error) {
Version: 1, if len(input) == 0 {
Canonicalization: "relaxed/relaxed", return nil, errors.New("DKIM private key is empty")
Algo: "rsa-sha256", }
Headers: []string{"from", "to", "subject", "message-id", "date"},
BodyLength: 0, // raw ed25519 private key format
QueryMethods: []string{"dns/txt"}, if len(input) == ed25519.PrivateKeySize {
AddSignatureTimestamp: true, return ed25519.PrivateKey(input), nil
SignatureExpireIn: 0, }
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) { func DKIMSign(message []byte, dkimConfig DKIMConfig) (result []byte, err error) {
options := defaultOptions options := dkim.SignOptions{
options.PrivateKey = dkimConfig.keyBytes Domain: dkimConfig.Domain,
options.Domain = dkimConfig.Domain Selector: dkimConfig.Selector,
options.Selector = dkimConfig.Selector Signer: dkimConfig.privKey,
err = dkim.Sign(&message, options) HeaderCanonicalization: dkim.CanonicalizationRelaxed,
return message, err 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

@ -233,7 +233,7 @@ func SendMail(config MailtoConfig, recipient string, msg []byte) (err error) {
} }
} }
if config.DKIM.Domain != "" { if config.DKIM.Enabled() {
msg, err = DKIMSign(msg, config.DKIM) msg, err = DKIMSign(msg, config.DKIM)
if err != nil { if err != nil {
return return

View File

@ -33,6 +33,7 @@ var (
errAccountVerificationInvalidCode = errors.New("Invalid account verification code") errAccountVerificationInvalidCode = errors.New("Invalid account verification code")
errAccountUpdateFailed = errors.New(`Error while updating your account information`) errAccountUpdateFailed = errors.New(`Error while updating your account information`)
errAccountMustHoldNick = errors.New(`You must hold that nickname in order to register it`) 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`) errAuthzidAuthcidMismatch = errors.New(`authcid and authzid must be the same`)
errCertfpAlreadyExists = errors.New(`An account already exists for your certificate fingerprint`) errCertfpAlreadyExists = errors.New(`An account already exists for your certificate fingerprint`)
errChannelNotOwnedByAccount = errors.New("Channel not owned by the specified account") errChannelNotOwnedByAccount = errors.New("Channel not owned by the specified account")
@ -81,9 +82,8 @@ var (
// String Errors // String Errors
var ( var (
errCouldNotStabilize = errors.New("Could not stabilize string while casefolding") errStringIsEmpty = errors.New("String is empty")
errStringIsEmpty = errors.New("String is empty") errInvalidCharacter = errors.New("Invalid character")
errInvalidCharacter = errors.New("Invalid character")
) )
type CertKeyError struct { type CertKeyError struct {

View File

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

View File

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

View File

@ -92,7 +92,7 @@ func (client *Client) ApplyProxiedIP(session *Session, proxiedIP net.IP, tls boo
client.server.connectionLimiter.RemoveClient(flatip.FromNetIP(session.realIP)) client.server.connectionLimiter.RemoveClient(flatip.FromNetIP(session.realIP))
// given IP is sane! override the client's current IP // 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() client.stateMutex.Lock()
defer client.stateMutex.Unlock() defer client.stateMutex.Unlock()

View File

@ -7,12 +7,15 @@ import (
"fmt" "fmt"
"maps" "maps"
"net" "net"
"slices"
"time" "time"
"github.com/ergochat/ergo/irc/caps" "github.com/ergochat/ergo/irc/caps"
"github.com/ergochat/ergo/irc/connection_limits"
"github.com/ergochat/ergo/irc/languages" "github.com/ergochat/ergo/irc/languages"
"github.com/ergochat/ergo/irc/modes" "github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/utils" "github.com/ergochat/ergo/irc/utils"
"github.com/ergochat/ergo/irc/webpush"
) )
func (server *Server) Config() (config *Config) { func (server *Server) Config() (config *Config) {
@ -54,6 +57,7 @@ type SessionData struct {
certfp string certfp string
deviceID string deviceID string
connInfo string connInfo string
connID string
sessionID int64 sessionID int64
caps []string caps []string
} }
@ -74,6 +78,7 @@ func (client *Client) AllSessionData(currentSession *Session, hasPrivs bool) (da
hostname: session.rawHostname, hostname: session.rawHostname,
certfp: session.certfp, certfp: session.certfp,
deviceID: session.deviceID, deviceID: session.deviceID,
connID: session.connID,
sessionID: session.sessionID, sessionID: session.sessionID,
} }
if session.proxiedIP != nil { if session.proxiedIP != nil {
@ -107,8 +112,8 @@ func (client *Client) AddSession(session *Session) (success bool, numSessions in
newSessions[len(newSessions)-1] = session newSessions[len(newSessions)-1] = session
if client.accountSettings.AutoreplayMissed || session.deviceID != "" { if client.accountSettings.AutoreplayMissed || session.deviceID != "" {
lastSeen = client.lastSeen[session.deviceID] lastSeen = client.lastSeen[session.deviceID]
client.setLastSeen(time.Now().UTC(), session.deviceID)
} }
client.setLastSeen(time.Now().UTC(), session.deviceID)
client.sessions = newSessions client.sessions = newSessions
wasAway = client.awayMessage wasAway = client.awayMessage
if client.autoAwayEnabledNoMutex(config) { if client.autoAwayEnabledNoMutex(config) {
@ -220,6 +225,13 @@ func (session *Session) SetAway(awayMessage string) (wasAway, nowAway string) {
return return
} }
func (session *Session) ConnID() string {
if session == nil {
return "*"
}
return session.connID
}
func (client *Client) autoAwayEnabledNoMutex(config *Config) bool { func (client *Client) autoAwayEnabledNoMutex(config *Config) bool {
return client.registered && client.alwaysOn && return client.registered && client.alwaysOn &&
persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway) persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway)
@ -486,6 +498,9 @@ func (client *Client) checkAlwaysOnExpirationNoMutex(config *Config, ignoreRegis
if !((client.registered || ignoreRegistration) && client.alwaysOn) { if !((client.registered || ignoreRegistration) && client.alwaysOn) {
return false 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) deadline := time.Duration(config.Accounts.Multiclient.AlwaysOnExpiration)
if deadline == 0 { if deadline == 0 {
return false return false
@ -504,11 +519,18 @@ func (client *Client) GetReadMarker(cfname string) (result string) {
t, ok := client.readMarkers[cfname] t, ok := client.readMarkers[cfname]
client.stateMutex.RUnlock() client.stateMutex.RUnlock()
if ok { if ok {
return fmt.Sprintf("timestamp=%s", t.Format(IRCv3TimestampFormat)) return fmt.Sprintf("timestamp=%s", t.Format(utils.IRCv3TimestampFormat))
} }
return "*" 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) { func (client *Client) copyReadMarkers() (result map[string]time.Time) {
client.stateMutex.RLock() client.stateMutex.RLock()
defer client.stateMutex.RUnlock() defer client.stateMutex.RUnlock()
@ -547,6 +569,28 @@ func updateLRUMap(lru map[string]time.Time, key string, val time.Time, maxItems
return val 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) { func (client *Client) shouldFlushTimestamps() (result bool) {
client.stateMutex.Lock() client.stateMutex.Lock()
defer client.stateMutex.Unlock() defer client.stateMutex.Unlock()
@ -562,6 +606,134 @@ func (client *Client) setKlined() {
client.stateMutex.Unlock() 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 { func (channel *Channel) Name() string {
channel.stateMutex.RLock() channel.stateMutex.RLock()
defer channel.stateMutex.RUnlock() defer channel.stateMutex.RUnlock()
@ -627,10 +799,12 @@ func (channel *Channel) Settings() (result ChannelSettings) {
} }
func (channel *Channel) SetSettings(settings ChannelSettings) { func (channel *Channel) SetSettings(settings ChannelSettings) {
defer channel.MarkDirty(IncludeSettings)
channel.stateMutex.Lock() channel.stateMutex.Lock()
defer channel.stateMutex.Unlock()
channel.settings = settings channel.settings = settings
channel.stateMutex.Unlock()
channel.MarkDirty(IncludeSettings)
} }
func (channel *Channel) setForward(forward string) { func (channel *Channel) setForward(forward string) {
@ -657,3 +831,262 @@ func (channel *Channel) UUID() utils.UUID {
defer channel.stateMutex.RUnlock() defer channel.stateMutex.RUnlock()
return channel.uuid 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
}

View File

@ -9,11 +9,13 @@ package irc
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"maps"
"net" "net"
"os" "os"
"runtime" "runtime"
"runtime/debug" "runtime/debug"
"runtime/pprof" "runtime/pprof"
"slices"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@ -33,6 +35,7 @@ import (
"github.com/ergochat/ergo/irc/oauth2" "github.com/ergochat/ergo/irc/oauth2"
"github.com/ergochat/ergo/irc/sno" "github.com/ergochat/ergo/irc/sno"
"github.com/ergochat/ergo/irc/utils" "github.com/ergochat/ergo/irc/utils"
"github.com/ergochat/ergo/irc/webpush"
) )
// helper function to parse ACC callbacks, e.g., mailto:person@example.com, tel:16505551234 // helper function to parse ACC callbacks, e.g., mailto:person@example.com, tel:16505551234
@ -136,7 +139,7 @@ func sendSuccessfulAccountAuth(service *ircService, client *Client, rb *Response
} }
} }
client.server.logger.Info("accounts", "client", details.nick, "logged into account", details.accountName) client.server.logger.Info("accounts", rb.session.ConnID(), details.nick, "logged into account", details.accountName)
} }
func (server *Server) sendLoginSnomask(nickMask, accountName string) { func (server *Server) sendLoginSnomask(nickMask, accountName string) {
@ -148,11 +151,8 @@ func (server *Server) sendLoginSnomask(nickMask, accountName string) {
// to indicate that it should be removed from the list // to indicate that it should be removed from the list
func acceptHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { func acceptHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
for _, tNick := range strings.Split(msg.Params[0], ",") { for _, tNick := range strings.Split(msg.Params[0], ",") {
add := true tNick, negPrefix := strings.CutPrefix(tNick, "-")
if strings.HasPrefix(tNick, "-") { add := !negPrefix
add = false
tNick = strings.TrimPrefix(tNick, "-")
}
target := server.clients.Get(tNick) target := server.clients.Get(tNick)
if target == nil { if target == nil {
@ -700,11 +700,13 @@ func chathistoryHandler(server *Server, client *Client, msg ircmsg.Message, rb *
var channel *Channel var channel *Channel
var sequence history.Sequence var sequence history.Sequence
var err error var err error
var listTargets bool var disabled, listTargets bool
var targets []history.TargetListing var targets []history.TargetListing
defer func() { defer func() {
// errors are sent either without a batch, or in a draft/labeled-response batch as usual // errors are sent either without a batch, or in a draft/labeled-response batch as usual
if err == utils.ErrInvalidParams { if disabled {
rb.Add(nil, server.name, "FAIL", "CHATHISTORY", "MESSAGE_ERROR", msg.Params[0], client.t("That feature is disabled"))
} else if err == utils.ErrInvalidParams {
rb.Add(nil, server.name, "FAIL", "CHATHISTORY", "INVALID_PARAMS", msg.Params[0], client.t("Invalid parameters")) rb.Add(nil, server.name, "FAIL", "CHATHISTORY", "INVALID_PARAMS", msg.Params[0], client.t("Invalid parameters"))
} else if !listTargets && sequence == nil { } else if !listTargets && sequence == nil {
rb.Add(nil, server.name, "FAIL", "CHATHISTORY", "INVALID_TARGET", msg.Params[0], utils.SafeErrorParam(target), client.t("Messages could not be retrieved")) rb.Add(nil, server.name, "FAIL", "CHATHISTORY", "INVALID_TARGET", msg.Params[0], utils.SafeErrorParam(target), client.t("Messages could not be retrieved"))
@ -718,7 +720,7 @@ func chathistoryHandler(server *Server, client *Client, msg ircmsg.Message, rb *
for _, target := range targets { for _, target := range targets {
name := server.UnfoldName(target.CfName) name := server.UnfoldName(target.CfName)
rb.Add(nil, server.name, "CHATHISTORY", "TARGETS", name, rb.Add(nil, server.name, "CHATHISTORY", "TARGETS", name,
target.Time.Format(IRCv3TimestampFormat)) target.Time.Format(utils.IRCv3TimestampFormat))
} }
} else if channel != nil { } else if channel != nil {
channel.replayHistoryItems(rb, items, true) channel.replayHistoryItems(rb, items, true)
@ -730,7 +732,8 @@ func chathistoryHandler(server *Server, client *Client, msg ircmsg.Message, rb *
config := server.Config() config := server.Config()
maxChathistoryLimit := config.History.ChathistoryMax maxChathistoryLimit := config.History.ChathistoryMax
if maxChathistoryLimit == 0 { if !config.History.Enabled || maxChathistoryLimit == 0 {
disabled = true
return return
} }
preposition := strings.ToLower(msg.Params[0]) preposition := strings.ToLower(msg.Params[0])
@ -753,7 +756,15 @@ func chathistoryHandler(server *Server, client *Client, msg ircmsg.Message, rb *
msgid, err = history.NormalizeMsgid(value), nil msgid, err = history.NormalizeMsgid(value), nil
return return
} else if identifier == "timestamp" { } else if identifier == "timestamp" {
timestamp, err = time.Parse(IRCv3TimestampFormat, value) timestamp, err = time.Parse(utils.IRCv3TimestampFormat, value)
if err == nil {
timestamp = timestamp.UTC()
if timestamp.Before(unixEpoch) {
timestamp = unixEpoch
} else if timestamp.After(year2262Problem) {
timestamp = year2262Problem
}
}
return return
} }
return return
@ -764,7 +775,7 @@ func chathistoryHandler(server *Server, client *Client, msg ircmsg.Message, rb *
return maxChathistoryLimit return maxChathistoryLimit
} }
limit, err := strconv.Atoi(msg.Params[paramIndex]) limit, err := strconv.Atoi(msg.Params[paramIndex])
if err != nil || limit == 0 || limit > maxChathistoryLimit { if err != nil || limit <= 0 || limit > maxChathistoryLimit {
limit = maxChathistoryLimit limit = maxChathistoryLimit
} }
return return
@ -833,7 +844,12 @@ func chathistoryHandler(server *Server, client *Client, msg ircmsg.Message, rb *
} }
if listTargets { if listTargets {
targets, err = client.listTargets(start, end, limit) // TARGETS must take time= selectors
if start.Time.IsZero() || end.Time.IsZero() {
err = utils.ErrInvalidParams
return
}
targets, err = client.listTargets(start.Time, end.Time, limit)
} else { } else {
channel, sequence, err = server.GetHistorySequence(nil, client, target) channel, sequence, err = server.GetHistorySequence(nil, client, target)
if err != nil || sequence == nil { if err != nil || sequence == nil {
@ -855,7 +871,6 @@ func debugHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon
switch param { switch param {
case "GCSTATS": case "GCSTATS":
stats := debug.GCStats{ stats := debug.GCStats{
Pause: make([]time.Duration, 10),
PauseQuantiles: make([]time.Duration, 5), PauseQuantiles: make([]time.Duration, 5),
} }
debug.ReadGCStats(&stats) debug.ReadGCStats(&stats)
@ -927,7 +942,9 @@ func defconHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo
level, err := strconv.Atoi(msg.Params[0]) level, err := strconv.Atoi(msg.Params[0])
if err == nil && 1 <= level && level <= 5 { if err == nil && 1 <= level && level <= 5 {
server.SetDefcon(uint32(level)) server.SetDefcon(uint32(level))
server.snomasks.Send(sno.LocalAnnouncements, fmt.Sprintf("%s [%s] set DEFCON level to %d", client.Nick(), client.Oper().Name, level)) message := fmt.Sprintf("%s [%s] set DEFCON level to %d", client.Nick(), client.Oper().Name, level)
server.logger.Info("server", message)
server.snomasks.Send(sno.LocalAnnouncements, message)
} else { } else {
rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.Nick(), msg.Command, client.t("Invalid DEFCON parameter")) rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.Nick(), msg.Command, client.t("Invalid DEFCON parameter"))
return false return false
@ -1321,6 +1338,15 @@ func isonHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons
return false return false
} }
// ISUPPORT
func isupportHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
server.RplISupport(client, rb)
if !client.registered {
rb.session.isupportSentPrereg = true
}
return false
}
// JOIN <channel>{,<channel>} [<key>{,<key>}] // JOIN <channel>{,<channel>} [<key>{,<key>}]
func joinHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { func joinHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
// #1417: allow `JOIN 0` with a confirmation code // #1417: allow `JOIN 0` with a confirmation code
@ -1628,7 +1654,7 @@ func klineHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon
// get comment(s) // get comment(s)
reason, operReason := getReasonsFromParams(msg.Params, currentArg) reason, operReason := getReasonsFromParams(msg.Params, currentArg)
err = server.klines.AddMask(mask, duration, reason, operReason, operName) err = server.klines.AddMask(mask, duration, false, reason, operReason, operName)
if err != nil { if err != nil {
rb.Notice(fmt.Sprintf(client.t("Could not successfully save new K-LINE: %s"), err.Error())) rb.Notice(fmt.Sprintf(client.t("Could not successfully save new K-LINE: %s"), err.Error()))
return false return false
@ -1843,14 +1869,14 @@ func cmodeHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon
if 1 < len(msg.Params) { if 1 < len(msg.Params) {
// parse out real mode changes // parse out real mode changes
params := msg.Params[1:] params := msg.Params[1:]
var unknown map[rune]bool var unknown []rune
changes, unknown = modes.ParseChannelModeChanges(params...) changes, unknown = modes.ParseChannelModeChanges(params...)
// alert for unknown mode changes // alert for unknown mode changes
for char := range unknown { for _, char := range unknown {
rb.Add(nil, server.name, ERR_UNKNOWNMODE, client.nick, string(char), client.t("is an unknown mode character to me")) rb.Add(nil, server.name, ERR_UNKNOWNMODE, client.nick, string(char), client.t("is an unknown mode character to me"))
} }
if len(unknown) == 1 && len(changes) == 0 { if len(unknown) != 0 && len(changes) == 0 {
return false return false
} }
} }
@ -1934,10 +1960,10 @@ func umodeHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon
changes, unknown := modes.ParseUserModeChanges(params...) changes, unknown := modes.ParseUserModeChanges(params...)
// alert for unknown mode changes // alert for unknown mode changes
for char := range unknown { for _, char := range unknown {
rb.Add(nil, server.name, ERR_UNKNOWNMODE, cDetails.nick, string(char), client.t("is an unknown mode character to me")) rb.Add(nil, server.name, ERR_UNKNOWNMODE, cDetails.nick, string(char), client.t("is an unknown mode character to me"))
} }
if len(unknown) == 1 && len(changes) == 0 { if len(unknown) != 0 && len(changes) == 0 {
return false return false
} }
@ -2457,6 +2483,20 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi
Tags: tags, Tags: tags,
} }
client.addHistoryItem(user, item, &details, &tDetails, config) client.addHistoryItem(user, item, &details, &tDetails, config)
if config.WebPush.Enabled && histType != history.Tagmsg && user.hasPushSubscriptions() && client != user {
pushMsgBytes, err := webpush.MakePushMessage(command, nickMaskString, accountName, tnick, message)
if err == nil {
user.dispatchPushMessage(pushMessage{
msg: pushMsgBytes,
urgency: webpush.UrgencyHigh,
cftarget: details.nickCasefolded,
time: message.Time,
})
} else {
server.logger.Error("internal", "can't serialize push message", err.Error())
}
}
} }
} }
@ -2515,8 +2555,19 @@ func operHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons
return false return false
} }
config := server.Config()
now := time.Now()
nextAllowableAttempt := rb.session.lastOperAttempt.Add(config.Server.OperThrottle)
if now.Before(nextAllowableAttempt) {
timeLeft := nextAllowableAttempt.Sub(now).Round(time.Millisecond)
rb.Add(nil, server.name, ERR_NOOPERHOST, client.Nick(), fmt.Sprintf(client.t("You must wait %v before issuing OPER again"), timeLeft))
return false
}
rb.session.lastOperAttempt = now
// must pass at least one check, and all enabled checks // must pass at least one check, and all enabled checks
var checkPassed, checkFailed, passwordFailed bool var checkPassed, checkFailed, certFailed, passwordFailed bool
oper := server.GetOperator(msg.Params[0]) oper := server.GetOperator(msg.Params[0])
if oper != nil { if oper != nil {
if oper.Certfp != "" { if oper.Certfp != "" {
@ -2524,11 +2575,13 @@ func operHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons
checkPassed = true checkPassed = true
} else { } else {
checkFailed = true checkFailed = true
certFailed = true
} }
} }
if !checkFailed && oper.Pass != nil { if !checkFailed && oper.Pass != nil {
if len(msg.Params) == 1 { if len(msg.Params) == 1 {
checkFailed = true checkFailed = true
passwordFailed = true
} else if bcrypt.CompareHashAndPassword(oper.Pass, []byte(msg.Params[1])) != nil { } else if bcrypt.CompareHashAndPassword(oper.Pass, []byte(msg.Params[1])) != nil {
checkFailed = true checkFailed = true
passwordFailed = true passwordFailed = true
@ -2539,14 +2592,21 @@ func operHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons
} }
if !checkPassed || checkFailed { if !checkPassed || checkFailed {
rb.Add(nil, server.name, ERR_PASSWDMISMATCH, client.Nick(), client.t("Password incorrect")) rb.Add(nil, server.name, ERR_NOOPERHOST, client.Nick(), client.t("OPER failed; check the server logs for details."))
// #951: only disconnect them if we actually tried to check a password for them
if passwordFailed { // hopefully not too spammy given the throttling:
client.Quit(client.t("Password incorrect"), rb.session) if oper == nil {
return true server.logger.Info("opers", "OPER failed with invalid oper name", msg.Params[0])
} else if certFailed {
server.logger.Info("opers", "OPER attempt for", msg.Params[0], "failed with invalid certfp")
} else if passwordFailed {
server.logger.Info("opers", "OPER attempt for", msg.Params[0], "failed with invalid password")
} else { } else {
return false // should not be possible given config validation
server.logger.Info("opers", "OPER attempt for", msg.Params[0], "failed with invalid config")
} }
return false
} }
if oper != nil { if oper != nil {
@ -2739,8 +2799,10 @@ func redactHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo
targetmsgid := msg.Params[1] targetmsgid := msg.Params[1]
//clientOnlyTags := msg.ClientOnlyTags() //clientOnlyTags := msg.ClientOnlyTags()
var reason string var reason string
var reasonPresent bool
if len(msg.Params) > 2 { if len(msg.Params) > 2 {
reason = msg.Params[2] reason = msg.Params[2]
reasonPresent = true
} }
var members []*Client // members of a channel, or both parties of a PM var members []*Client // members of a channel, or both parties of a PM
var canDelete CanDelete var canDelete CanDelete
@ -2753,7 +2815,7 @@ func redactHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo
if target[0] == '#' { if target[0] == '#' {
channel := server.channels.Get(target) channel := server.channels.Get(target)
if channel == nil { if channel == nil {
rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), utils.SafeErrorParam(target), client.t("No such channel")) rb.Add(nil, server.name, "FAIL", "REDACT", "INVALID_TARGET", utils.SafeErrorParam(target), client.t("No such channel"))
return false return false
} }
members = channel.Members() members = channel.Members()
@ -2782,10 +2844,16 @@ func redactHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo
} }
err := server.DeleteMessage(target, targetmsgid, accountName) err := server.DeleteMessage(target, targetmsgid, accountName)
if err == errNoop { switch err {
case history.ErrNotFound:
rb.Add(nil, server.name, "FAIL", "REDACT", "UNKNOWN_MSGID", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), client.t("This message does not exist or is too old")) rb.Add(nil, server.name, "FAIL", "REDACT", "UNKNOWN_MSGID", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), client.t("This message does not exist or is too old"))
return false return false
} else if err != nil { case history.ErrDisallowed:
rb.Add(nil, server.name, "FAIL", "REDACT", "REDACT_FORBIDDEN", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), client.t("You are not authorized to delete this message"))
return false
case nil:
// OK
default:
isOper := client.HasRoleCapabs("history") isOper := client.HasRoleCapabs("history")
if isOper { if isOper {
rb.Add(nil, server.name, "FAIL", "REDACT", "REDACT_FORBIDDEN", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), fmt.Sprintf(client.t("Error deleting message: %v"), err)) rb.Add(nil, server.name, "FAIL", "REDACT", "REDACT_FORBIDDEN", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), fmt.Sprintf(client.t("Error deleting message: %v"), err))
@ -2800,7 +2868,8 @@ func redactHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo
// now we have to remove it from the buffer of the client who sent the REDACT command // now we have to remove it from the buffer of the client who sent the REDACT command
err := server.DeleteMessage(client.Nick(), targetmsgid, accountName) err := server.DeleteMessage(client.Nick(), targetmsgid, accountName)
if err != nil { // ErrNotFound is expected if both clients are using persistent history
if err != nil && err != history.ErrNotFound {
client.server.logger.Error("internal", fmt.Sprintf("Private message %s is not deletable by %s from their own buffer's even though we just deleted it from %s's. This is a bug, please report it in details.", targetmsgid, client.Nick(), target), client.Nick()) client.server.logger.Error("internal", fmt.Sprintf("Private message %s is not deletable by %s from their own buffer's even though we just deleted it from %s's. This is a bug, please report it in details.", targetmsgid, client.Nick(), target), client.Nick())
isOper := client.HasRoleCapabs("history") isOper := client.HasRoleCapabs("history")
if isOper { if isOper {
@ -2814,7 +2883,11 @@ func redactHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo
for _, member := range members { for _, member := range members {
for _, session := range member.Sessions() { for _, session := range member.Sessions() {
if session.capabilities.Has(caps.MessageRedaction) { if session.capabilities.Has(caps.MessageRedaction) {
session.sendFromClientInternal(false, time, msgid, details.nickMask, details.accountName, isBot, nil, "REDACT", target, targetmsgid, reason) if reasonPresent {
session.sendFromClientInternal(false, time, msgid, details.nickMask, details.accountName, isBot, nil, "REDACT", target, targetmsgid, reason)
} else {
session.sendFromClientInternal(false, time, msgid, details.nickMask, details.accountName, isBot, nil, "REDACT", target, targetmsgid)
}
} else { } else {
// If we wanted to send a fallback to clients which do not support // If we wanted to send a fallback to clients which do not support
// draft/message-redaction, we would do it from here. // draft/message-redaction, we would do it from here.
@ -2879,11 +2952,23 @@ func quitHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons
// REGISTER < account | * > < email | * > <password> // REGISTER < account | * > < email | * > <password>
func registerHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) (exiting bool) { func registerHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) (exiting bool) {
accountName := client.Nick() var accountName string
if accountName == "*" { if client.registered {
accountName = client.Nick()
} else {
accountName = client.preregNick accountName = client.preregNick
} }
config := server.Config()
if client.registered && config.Accounts.NickReservation.ForceGuestFormat {
matches := config.Accounts.NickReservation.guestRegexp.FindStringSubmatch(accountName)
if matches == nil || len(matches) < 2 {
rb.Add(nil, server.name, "FAIL", "REGISTER", "INVALID_USERNAME", utils.SafeErrorParam(accountName), client.t("Username invalid or not given"))
return
}
accountName = matches[1]
}
switch msg.Params[0] { switch msg.Params[0] {
case "*", accountName: case "*", accountName:
// ok // ok
@ -2900,7 +2985,6 @@ func registerHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res
return return
} }
config := server.Config()
if !config.Accounts.Registration.Enabled { if !config.Accounts.Registration.Enabled {
rb.Add(nil, server.name, "FAIL", "REGISTER", "DISALLOWED", accountName, client.t("Account registration is disabled")) rb.Add(nil, server.name, "FAIL", "REGISTER", "DISALLOWED", accountName, client.t("Account registration is disabled"))
return return
@ -2946,7 +3030,7 @@ func registerHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res
announcePendingReg(client, rb, accountName) announcePendingReg(client, rb, accountName)
} }
case errAccountAlreadyRegistered, errAccountAlreadyUnregistered, errAccountMustHoldNick: case errAccountAlreadyRegistered, errAccountAlreadyUnregistered, errAccountMustHoldNick:
rb.Add(nil, server.name, "FAIL", "REGISTER", "USERNAME_EXISTS", accountName, client.t("Username is already registered or otherwise unavailable")) rb.Add(nil, server.name, "FAIL", "REGISTER", "ACCOUNT_EXISTS", accountName, client.t("Username is already registered or otherwise unavailable"))
case errAccountBadPassphrase: case errAccountBadPassphrase:
rb.Add(nil, server.name, "FAIL", "REGISTER", "INVALID_PASSWORD", accountName, client.t("Password was invalid")) rb.Add(nil, server.name, "FAIL", "REGISTER", "INVALID_PASSWORD", accountName, client.t("Password was invalid"))
default: default:
@ -3024,13 +3108,14 @@ func markReadHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res
// "MARKREAD client set command": MARKREAD <target> <timestamp> // "MARKREAD client set command": MARKREAD <target> <timestamp>
readTimestamp := strings.TrimPrefix(msg.Params[1], "timestamp=") readTimestamp := strings.TrimPrefix(msg.Params[1], "timestamp=")
readTime, err := time.Parse(IRCv3TimestampFormat, readTimestamp) readTime, err := time.Parse(utils.IRCv3TimestampFormat, readTimestamp)
if err != nil { if err != nil {
rb.Add(nil, server.name, "FAIL", "MARKREAD", "INVALID_PARAMS", utils.SafeErrorParam(readTimestamp), client.t("Invalid timestamp")) rb.Add(nil, server.name, "FAIL", "MARKREAD", "INVALID_PARAMS", utils.SafeErrorParam(readTimestamp), client.t("Invalid timestamp"))
return return
} }
readTime = readTime.UTC()
result := client.SetReadMarker(cftarget, readTime) result := client.SetReadMarker(cftarget, readTime)
readTimestamp = fmt.Sprintf("timestamp=%s", result.Format(IRCv3TimestampFormat)) readTimestamp = fmt.Sprintf("timestamp=%s", result.Format(utils.IRCv3TimestampFormat))
// inform the originating session whether it was a success or a no-op: // inform the originating session whether it was a success or a no-op:
rb.Add(nil, server.name, "MARKREAD", unfoldedTarget, readTimestamp) rb.Add(nil, server.name, "MARKREAD", unfoldedTarget, readTimestamp)
if result.Equal(readTime) { if result.Equal(readTime) {
@ -3041,10 +3126,311 @@ func markReadHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res
session.Send(nil, server.name, "MARKREAD", unfoldedTarget, readTimestamp) session.Send(nil, server.name, "MARKREAD", unfoldedTarget, readTimestamp)
} }
} }
if client.clearClearablePushMessage(cftarget, readTime) {
markreadPushMessage := ircmsg.MakeMessage(nil, server.name, "MARKREAD", unfoldedTarget, readTimestamp)
markreadPushMessage.SetTag("time", time.Now().UTC().Format(utils.IRCv3TimestampFormat))
line, err := webpush.MakePushLine(markreadPushMessage)
if err == nil {
client.dispatchPushMessage(pushMessage{
msg: line,
originatingEndpoint: rb.session.webPushEndpoint,
urgency: webpush.UrgencyNormal, // copied from soju
})
} else {
server.logger.Error("internal", "couldn't serialize MARKREAD push message", err.Error())
}
}
} }
return return
} }
// METADATA <target> <subcommand> [<and so on>...]
func metadataHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) (exiting bool) {
config := server.Config()
if !config.Metadata.Enabled {
rb.Add(nil, server.name, "FAIL", "METADATA", "FORBIDDEN", utils.SafeErrorParam(msg.Params[0]), client.t("Metadata is disabled on this server"))
return
}
subcommand := strings.ToLower(msg.Params[1])
needsKey := subcommand == "set" || subcommand == "get" || subcommand == "sub" || subcommand == "unsub"
if needsKey && len(msg.Params) < 3 {
rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.Nick(), msg.Command, client.t("Not enough parameters"))
return
}
switch subcommand {
case "sub", "unsub", "subs":
// these are session-local and function the same whether or not the client is registered
return metadataSubsHandler(client, subcommand, msg.Params, rb)
case "set", "clear":
if config.Metadata.OperatorOnlyModification && !client.HasRoleCapabs("metadata") {
rb.Add(nil, server.name, "FAIL", "METADATA", "FORBIDDEN", utils.SafeErrorParam(msg.Params[0]), client.t("Only server operators can modify metadata"))
return
}
fallthrough
case "get", "list", "sync":
if client.registered {
return metadataRegisteredHandler(client, config, subcommand, msg.Params, rb)
} else {
return metadataUnregisteredHandler(client, config, subcommand, msg.Params, rb)
}
default:
rb.Add(nil, server.name, "FAIL", "METADATA", "SUBCOMMAND_INVALID", utils.SafeErrorParam(msg.Params[1]), client.t("Invalid subcommand"))
return
}
}
// metadataRegisteredHandler handles metadata-modifying commands from registered clients
func metadataRegisteredHandler(client *Client, config *Config, subcommand string, params []string, rb *ResponseBuffer) (exiting bool) {
server := client.server
target := params[0]
noKeyPerms := func(key string) {
rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_NO_PERMISSION", target, key, client.t("You do not have permission to perform this action"))
}
if target == "*" {
target = client.Nick()
}
var targetObj MetadataHaver
var targetClient *Client
var targetChannel *Channel
if strings.HasPrefix(target, "#") {
targetChannel = server.channels.Get(target)
if targetChannel != nil {
targetObj = targetChannel
target = targetChannel.Name() // canonicalize case
}
} else {
targetClient = server.clients.Get(target)
if targetClient != nil {
targetObj = targetClient
target = targetClient.Nick() // canonicalize case
}
}
if targetObj == nil {
rb.Add(nil, server.name, "FAIL", "METADATA", "INVALID_TARGET", target, client.t("Invalid metadata target"))
return
}
switch subcommand {
case "set":
key := params[2]
if metadataKeyIsEvil(key) {
rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_INVALID", utils.SafeErrorParam(key), client.t("Invalid key name"))
return
}
if !metadataCanIEditThisKey(client, targetObj, key) {
noKeyPerms(key)
return
}
// only rate limit clients changing their own metadata:
// channel metadata updates are not any more costly than a PRIVMSG
if client == targetClient {
if throttled, remainingTime := client.checkMetadataThrottle(); throttled {
retryAfter := strconv.Itoa(int(remainingTime.Seconds()) + 1)
rb.Add(nil, server.name, "FAIL", "METADATA", "RATE_LIMITED",
target, utils.SafeErrorParam(key), retryAfter,
fmt.Sprintf(client.t("Please wait at least %v and try again"), remainingTime.Round(time.Millisecond)))
return
}
}
if len(params) > 3 {
value := params[3]
config := client.server.Config()
if failMsg := metadataValueIsEvil(config, key, value); failMsg != "" {
rb.Add(nil, server.name, "FAIL", "METADATA", "VALUE_INVALID", client.t(failMsg))
return
}
updated, err := targetObj.SetMetadata(key, value, config.Metadata.MaxKeys)
if err != nil {
// errLimitExceeded is the only possible error
rb.Add(nil, server.name, "FAIL", "METADATA", "LIMIT_REACHED", client.t("Too many metadata keys"))
return
}
// echo the value to the client whether or not there was a real update
rb.Add(nil, server.name, RPL_KEYVALUE, client.Nick(), target, key, "*", value)
if updated {
notifySubscribers(server, rb.session, targetObj, target, key, value, true)
}
} else {
if updated := targetObj.DeleteMetadata(key); updated {
notifySubscribers(server, rb.session, targetObj, target, key, "", false)
rb.Add(nil, server.name, RPL_KEYNOTSET, client.Nick(), target, key, client.t("Key deleted"))
} else {
rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_NOT_SET", utils.SafeErrorParam(key), client.t("Metadata key not set"))
}
}
case "get":
if !metadataCanISeeThisTarget(client, targetObj) {
noKeyPerms("*")
return
}
batchId := rb.StartNestedBatch("metadata", target)
defer rb.EndNestedBatch(batchId)
for _, key := range params[2:] {
if metadataKeyIsEvil(key) {
rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_INVALID", utils.SafeErrorParam(key), client.t("Invalid key name"))
continue
}
val, ok := targetObj.GetMetadata(key)
if !ok {
rb.Add(nil, server.name, RPL_KEYNOTSET, client.Nick(), target, key, client.t("Key is not set"))
continue
}
visibility := "*"
rb.Add(nil, server.name, RPL_KEYVALUE, client.Nick(), target, key, visibility, val)
}
case "list":
playMetadataList(rb, client.Nick(), target, targetObj.ListMetadata())
case "clear":
if !metadataCanIEditThisTarget(client, targetObj) {
noKeyPerms("*")
return
}
values := targetObj.ClearMetadata()
playMetadataList(rb, client.Nick(), target, values)
case "sync":
if targetChannel != nil {
syncChannelMetadata(server, rb, targetChannel)
}
if targetClient != nil {
syncClientMetadata(server, rb, targetClient)
}
}
return
}
// metadataUnregisteredHandler handles metadata-modifying commands for pre-connection-registration
// clients. these operations act on a session-local buffer; if/when the client completes registration,
// they are applied to the final Client object (possibly a different client if there was a reattach)
// on a best-effort basis.
func metadataUnregisteredHandler(client *Client, config *Config, subcommand string, params []string, rb *ResponseBuffer) (exiting bool) {
server := client.server
if params[0] != "*" {
rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_NO_PERMISSION", utils.SafeErrorParam(params[0]), "*", client.t("You can only modify your own metadata before completing connection registration"))
return
}
switch subcommand {
case "set":
if rb.session.metadataPreregVals == nil {
rb.session.metadataPreregVals = make(map[string]string)
}
key := params[2]
if metadataKeyIsEvil(key) {
rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_INVALID", utils.SafeErrorParam(key), client.t("Invalid key name"))
return
}
if len(params) >= 4 {
value := params[3]
// enforce a sane limit on prereg keys. we don't need to enforce the exact limit,
// that will be done when applying the buffer after registration
if len(rb.session.metadataPreregVals) > config.Metadata.MaxKeys {
rb.Add(nil, server.name, "FAIL", "METADATA", "LIMIT_REACHED", client.t("Too many metadata keys"))
return
}
if failMsg := metadataValueIsEvil(config, key, value); failMsg != "" {
rb.Add(nil, server.name, "FAIL", "METADATA", "VALUE_INVALID", client.t(failMsg))
return
}
rb.session.metadataPreregVals[key] = value
rb.Add(nil, server.name, RPL_KEYVALUE, "*", "*", key, "*", value)
} else {
// unset
_, present := rb.session.metadataPreregVals[key]
if present {
delete(rb.session.metadataPreregVals, key)
rb.Add(nil, server.name, RPL_KEYNOTSET, "*", "*", key, client.t("Key deleted"))
} else {
rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_NOT_SET", utils.SafeErrorParam(key), client.t("Metadata key not set"))
}
}
case "list":
playMetadataList(rb, "*", "*", rb.session.metadataPreregVals)
case "clear":
oldMetadata := rb.session.metadataPreregVals
rb.session.metadataPreregVals = nil
playMetadataList(rb, "*", "*", oldMetadata)
case "sync":
rb.Add(nil, server.name, RPL_METADATASYNCLATER, "*", utils.SafeErrorParam(params[1]), "60") // lol
}
return false
}
// metadataSubsHandler handles subscription-related commands;
// these are handled the same whether the client is registered or not
func metadataSubsHandler(client *Client, subcommand string, params []string, rb *ResponseBuffer) (exiting bool) {
server := client.server
switch subcommand {
case "sub":
keys := params[2:]
for _, key := range keys {
if metadataKeyIsEvil(key) {
rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_INVALID", utils.SafeErrorParam(key), client.t("Invalid key name"))
return
}
}
added, err := rb.session.SubscribeTo(keys...)
if err == errMetadataTooManySubs {
bad := keys[len(added)] // get the key that broke the camel's back
rb.Add(nil, server.name, "FAIL", "METADATA", "TOO_MANY_SUBS", utils.SafeErrorParam(bad), client.t("Too many subscriptions"))
}
lineLength := MaxLineLen - len(server.name) - len(RPL_METADATASUBOK) - len(client.Nick()) - 10
chunked := utils.ChunkifyParams(slices.Values(added), lineLength)
for _, line := range chunked {
params := append([]string{client.Nick()}, line...)
rb.Add(nil, server.name, RPL_METADATASUBOK, params...)
}
case "unsub":
keys := params[2:]
removed := rb.session.UnsubscribeFrom(keys...)
lineLength := MaxLineLen - len(server.name) - len(RPL_METADATAUNSUBOK) - len(client.Nick()) - 10
chunked := utils.ChunkifyParams(slices.Values(removed), lineLength)
for _, line := range chunked {
params := append([]string{client.Nick()}, line...)
rb.Add(nil, server.name, RPL_METADATAUNSUBOK, params...)
}
case "subs":
lineLength := MaxLineLen - len(server.name) - len(RPL_METADATASUBS) - len(client.Nick()) - 10
subs := rb.session.MetadataSubscriptions()
batchID := rb.StartNestedBatch("metadata-subs")
defer rb.EndNestedBatch(batchID)
chunked := utils.ChunkifyParams(maps.Keys(subs), lineLength)
for _, line := range chunked {
params := append([]string{client.Nick()}, line...)
rb.Add(nil, server.name, RPL_METADATASUBS, params...)
}
}
return false
}
// REHASH // REHASH
func rehashHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { func rehashHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
nick := client.Nick() nick := client.Nick()
@ -3582,6 +3968,90 @@ func webircHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo
return true return true
} }
// WEBPUSH <subcommand> <endpoint> [key]
func webpushHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
subcommand := strings.ToUpper(msg.Params[0])
endpoint := msg.Params[1]
config := server.Config()
if !config.WebPush.Enabled {
rb.Add(nil, server.name, "FAIL", "WEBPUSH", "FORBIDDEN", subcommand, utils.SafeErrorParam(endpoint), client.t("Web push is disabled"))
return false
}
if client.Account() == "" {
rb.Add(nil, server.name, "FAIL", "WEBPUSH", "FORBIDDEN", subcommand, utils.SafeErrorParam(endpoint), client.t("You must be logged in to receive push messages"))
return false
}
// XXX web push can be used to deanonymize a Tor hidden service, but we do not know
// whether an Ergo deployment with a Tor listener is intended to run as a hidden
// service, or as a single onion service where Tor is optional. Hidden service operators
// should disable web push. However, as a sanity check, disallow enabling it over a Tor
// connection:
if rb.session.isTor {
rb.Add(nil, server.name, "FAIL", "WEBPUSH", "FORBIDDEN", subcommand, utils.SafeErrorParam(endpoint), client.t("Web push cannot be enabled over Tor"))
return false
}
if err := webpush.SanityCheckWebPushEndpoint(endpoint); err != nil {
rb.Add(nil, server.name, "FAIL", "WEBPUSH", "INVALID_PARAMS", subcommand, utils.SafeErrorParam(endpoint), client.t("Invalid web push URL"))
return false
}
switch subcommand {
case "REGISTER":
// allow web push enable even if they are not always-on (they just won't get push messages)
if len(msg.Params) < 3 {
rb.Add(nil, server.name, "FAIL", "WEBPUSH", "INVALID_PARAMS", subcommand, utils.SafeErrorParam(endpoint), client.t("Insufficient parameters for WEBPUSH REGISTER"))
return false
}
keys, err := webpush.DecodeSubscriptionKeys(msg.Params[2])
if err != nil {
rb.Add(nil, server.name, "FAIL", "WEBPUSH", "INVALID_PARAMS", subcommand, utils.SafeErrorParam(endpoint), client.t("Invalid subscription keys for WEBPUSH REGISTER"))
return false
}
if client.refreshPushSubscription(endpoint, keys) {
// success, don't send a test message
rb.Add(nil, server.name, "WEBPUSH", "REGISTER", msg.Params[1], msg.Params[2])
rb.session.webPushEndpoint = endpoint
return false
}
// send a test message
if err := client.sendPush(
endpoint,
keys,
webpush.UrgencyHigh,
webpush.PingMessage,
); err == nil {
if err := client.addPushSubscription(endpoint, keys); err == nil {
rb.Add(nil, server.name, "WEBPUSH", "REGISTER", msg.Params[1], msg.Params[2])
rb.session.webPushEndpoint = endpoint
if !client.AlwaysOn() {
rb.Add(nil, server.name, "WARN", "WEBPUSH", "PERSISTENCE_REQUIRED", client.t("You have enabled push notifications, but you will not receive them unless you become always-on. Try: /msg nickserv set always-on true"))
}
} else if err == errLimitExceeded {
rb.Add(nil, server.name, "FAIL", "WEBPUSH", "MAX_REGISTRATIONS", "REGISTER", utils.SafeErrorParam(endpoint), client.t("You have too many push subscriptions already"))
} else {
server.logger.Error("webpush", "Failed to add webpush subscription", err.Error())
rb.Add(nil, server.name, "FAIL", "WEBPUSH", "INTERNAL_ERROR", "REGISTER", utils.SafeErrorParam(endpoint), client.t("An error occurred"))
}
} else {
server.logger.Debug("webpush", "WEBPUSH REGISTER failed validation", endpoint, err.Error())
rb.Add(nil, server.name, "FAIL", "WEBPUSH", "INVALID_PARAMS", "REGISTER", utils.SafeErrorParam(endpoint), client.t("Test push message failed to send"))
}
case "UNREGISTER":
client.deletePushSubscription(endpoint, true)
rb.session.webPushEndpoint = ""
// this always succeeds
rb.Add(nil, server.name, "WEBPUSH", "UNREGISTER", endpoint)
default:
rb.Add(nil, server.name, "FAIL", "WEBPUSH", "INVALID_PARAMS", subcommand, utils.SafeErrorParam(endpoint), client.t("Unknown subcommand"))
}
return false
}
type whoxFields uint32 // bitset to hold the WHOX field values, 'a' through 'z' type whoxFields uint32 // bitset to hold the WHOX field values, 'a' through 'z'
func (fields whoxFields) Add(field rune) (result whoxFields) { func (fields whoxFields) Add(field rune) (result whoxFields) {
@ -3992,9 +4462,9 @@ func zncHandler(server *Server, client *Client, msg ircmsg.Message, rb *Response
// fake handler for unknown commands // fake handler for unknown commands
func unknownCommandHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { func unknownCommandHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
var message string var message string
if strings.HasPrefix(msg.Command, "/") { if trimmedCmd, initialSlash := strings.CutPrefix(msg.Command, "/"); initialSlash {
message = fmt.Sprintf(client.t("Unknown command; if you are using /QUOTE, the correct syntax is /QUOTE %[1]s, not /QUOTE %[2]s"), message = fmt.Sprintf(client.t("Unknown command; if you are using /QUOTE, the correct syntax is /QUOTE %[1]s, not /QUOTE %[2]s"),
strings.TrimPrefix(msg.Command, "/"), msg.Command) trimmedCmd, msg.Command)
} else { } else {
message = client.t("Unknown command") message = client.t("Unknown command")
} }

View File

@ -238,11 +238,10 @@ Get an explanation of <argument>, or "index" for a list of help topics.`,
"history": { "history": {
text: `HISTORY <target> [limit] text: `HISTORY <target> [limit]
Replay message history. <target> can be a channel name, "me" to replay direct Replay message history. <target> can be a channel name or a nickname you have
message history, or a nickname to replay another client's direct message direct message history with. [limit] can be either an integer (the maximum
history (they must be logged into the same account as you). [limit] can be number of messages to replay), or a time duration like 10m or 1h (the time
either an integer (the maximum number of messages to replay), or a time window within which to replay messages).`,
duration like 10m or 1h (the time window within which to replay messages).`,
}, },
"info": { "info": {
text: `INFO text: `INFO
@ -259,6 +258,11 @@ appropriate channel privs.`,
text: `ISON <nickname>{ <nickname>} text: `ISON <nickname>{ <nickname>}
Returns whether the given nicks exist on the network.`, Returns whether the given nicks exist on the network.`,
},
"isupport": {
text: `ISUPPORT
Returns RPL_ISUPPORT lines describing the server's capabilities.`,
}, },
"join": { "join": {
text: `JOIN <channel>{,<channel>} [<key>{,<key>}] text: `JOIN <channel>{,<channel>} [<key>{,<key>}]
@ -334,6 +338,12 @@ command is processed by that server.`,
MARKREAD updates an IRCv3 read message marker. It is not intended for use by 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 end users. For more details, see the latest draft of the read-marker
specification.`, 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": { "mode": {
text: `MODE <target> [<modestring> [<mode arguments>...]] text: `MODE <target> [<modestring> [<mode arguments>...]]
@ -605,6 +615,11 @@ ircv3.net/specs/extensions/webirc.html
the connection from the client to the gateway, such as: the connection from the client to the gateway, such as:
- tls: this flag indicates that the client->gateway connection is secure`, - 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": { "who": {
text: `WHO <name> [o] text: `WHO <name> [o]

126
irc/history/database.go Normal file
View File

@ -0,0 +1,126 @@
// Copyright (c) 2025 Shivaram Lingamneni
// released under the MIT license
package history
import (
"errors"
"io"
"time"
)
var (
ErrDisallowed = errors.New("disallowed")
ErrNotFound = errors.New("not found")
)
// Database is an interface for persistent history storage backends.
type Database interface {
// Close closes the database connection and releases resources.
io.Closer
// AddChannelItem adds a history item for a channel.
// target is the casefolded channel name.
// account is the sender's casefolded account name ("" for no account).
AddChannelItem(target string, item Item, account string) error
// AddDirectMessage adds a history item for a direct message.
// All identifiers are casefolded; account identifiers are "" for no account.
AddDirectMessage(sender, senderAccount, recipient, recipientAccount string, item Item) error
// DeleteMsgid deletes a message by its msgid.
// accountName is the unfolded account name, or "*" to skip
// account validation
DeleteMsgid(msgid, accountName string) error
// MakeSequence creates a Sequence for querying history.
// target is the primary target (channel or account), casefolded.
// correspondent is the casefolded DM correspondent (empty for channels).
// cutoff is the earliest time to include in results.
MakeSequence(target, correspondent string, cutoff time.Time) Sequence
// ListChannels returns the timestamp of the latest message in each
// of the given channels (specified as casefolded names).
ListChannels(cfchannels []string) (results []TargetListing, err error)
// ListCorrespondents lists the DM correspondents associated with an account,
// in order to implement CHATHISTORY TARGETS.
ListCorrespondents(cftarget string, start, end time.Time, limit int) ([]TargetListing, error)
// these are for theoretical GDPR compliance, not actual chat functionality,
// and are not essential:
// Forget enqueues an account (casefolded) for message deletion.
// This is used for GDPR-style "right to be forgotten" requests.
// The actual deletion happens asynchronously.
Forget(account string)
// Export exports all messages for an account (casefolded) to the given writer.
Export(account string, writer io.Writer)
}
type noopDatabase struct{}
// NewNoopDatabase returns a Database implementation that does nothing.
func NewNoopDatabase() Database {
return noopDatabase{}
}
func (n noopDatabase) Close() error {
return nil
}
func (n noopDatabase) AddChannelItem(target string, item Item, account string) error {
return nil
}
func (n noopDatabase) AddDirectMessage(sender, senderAccount, recipient, recipientAccount string, item Item) error {
return nil
}
func (n noopDatabase) DeleteMsgid(msgid, accountName string) error {
return nil
}
func (n noopDatabase) Forget(account string) {
// no-op
}
func (n noopDatabase) Export(account string, writer io.Writer) {
// no-op
}
func (n noopDatabase) ListChannels(cfchannels []string) (results []TargetListing, err error) {
return nil, nil
}
func (n noopDatabase) ListCorrespondents(target string, start, end time.Time, limit int) (results []TargetListing, err error) {
return nil, nil
}
func (n noopDatabase) MakeSequence(target, correspondent string, cutoff time.Time) Sequence {
return noopSequence{}
}
// noopSequence is a no-op implementation of Sequence.
// XXX: this should never be accessed, because if persistent history is disabled,
// we should always be working with a bufferSequence instead. But we might as well
// be defensive in case there's an edge case where (noopDatabase).MakeSequence ends
// up getting called.
type noopSequence struct{}
func (n noopSequence) Between(start, end Selector, limit int) (results []Item, err error) {
return nil, nil
}
func (n noopSequence) Around(start Selector, limit int) (results []Item, err error) {
return nil, nil
}
func (n noopSequence) Cutoff() time.Time {
return time.Time{}
}
func (n noopSequence) Ephemeral() bool {
return false // we're pretending to be an empty database
}

View File

@ -230,10 +230,8 @@ func (list *Buffer) allCorrespondents() (results []TargetListing) {
} }
// list DM correspondents, as one input to CHATHISTORY TARGETS // list DM correspondents, as one input to CHATHISTORY TARGETS
func (list *Buffer) listCorrespondents(start, end Selector, cutoff time.Time, limit int) (results []TargetListing, err error) { func (list *Buffer) ListCorrespondents(start, end time.Time, limit int) (results []TargetListing, err error) {
after := start.Time after, before, ascending := MinMaxAsc(start, end, time.Time{})
before := end.Time
after, before, ascending := MinMaxAsc(after, before, cutoff)
correspondents := list.allCorrespondents() correspondents := list.allCorrespondents()
if len(correspondents) == 0 { if len(correspondents) == 0 {
@ -300,10 +298,6 @@ func (seq *bufferSequence) Around(start Selector, limit int) (results []Item, er
return GenericAround(seq, start, limit) return GenericAround(seq, start, limit)
} }
func (seq *bufferSequence) ListCorrespondents(start, end Selector, limit int) (results []TargetListing, err error) {
return seq.list.listCorrespondents(start, end, seq.cutoff, limit)
}
func (seq *bufferSequence) Cutoff() time.Time { func (seq *bufferSequence) Cutoff() time.Time {
return seq.cutoff return seq.cutoff
} }

View File

@ -21,8 +21,6 @@ type Sequence interface {
Between(start, end Selector, limit int) (results []Item, err error) Between(start, end Selector, limit int) (results []Item, err error)
Around(start Selector, limit int) (results []Item, err error) Around(start Selector, limit int) (results []Item, err error)
ListCorrespondents(start, end Selector, limit int) (results []TargetListing, err error)
// this are weird hacks that violate the encapsulation of Sequence to some extent; // this are weird hacks that violate the encapsulation of Sequence to some extent;
// Cutoff() returns the cutoff time for other code to use (it returns the zero time // Cutoff() returns the cutoff time for other code to use (it returns the zero time
// if none is set), and Ephemeral() returns whether the backing store is in-memory // if none is set), and Ephemeral() returns whether the backing store is in-memory

View File

@ -0,0 +1,19 @@
// Copyright (c) 2020 Shivaram Lingamneni
// released under the MIT license
package history
import (
"encoding/json"
)
// 123 / '{' is the magic number that means JSON;
// if we want to do a binary encoding later, we just have to add different magic version numbers
func MarshalItem(item *Item) (result []byte, err error) {
return json.Marshal(item)
}
func UnmarshalItem(data []byte, result *Item) (err error) {
return json.Unmarshal(data, result)
}

View File

@ -164,7 +164,7 @@ func histservExportHandler(service *ircService, server *Server, client *Client,
config := server.Config() config := server.Config()
// don't include the account name in the filename because of escaping concerns // 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) pathname := config.getOutputPath(filename)
outfile, err := os.Create(pathname) outfile, err := os.Create(pathname)
if err != nil { if err != nil {
@ -177,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) { func histservExportAndNotify(service *ircService, server *Server, cfAccount string, outfile *os.File, filename, alertNick string) {
defer server.HandlePanic() defer server.HandlePanic(nil)
defer outfile.Close() defer outfile.Close()
writer := bufio.NewWriter(outfile) writer := bufio.NewWriter(outfile)

79
irc/i18n/common.go Normal file
View File

@ -0,0 +1,79 @@
package i18n
import (
"errors"
"fmt"
"strings"
)
// Casemapping represents a set of algorithm for case normalization
// and confusables prevention for IRC identifiers (nicknames and channel names)
type Casemapping uint
const (
// "precis" is the default / zero value:
// casefolding/validation: PRECIS + ircd restrictions (like no *)
// confusables detection: standard skeleton algorithm
CasemappingPRECIS Casemapping = iota
// "ascii" is the traditional ircd behavior:
// casefolding/validation: must be pure ASCII and follow ircd restrictions, ASCII lowercasing
// confusables detection: none
CasemappingASCII
// "permissive" is an insecure mode:
// casefolding/validation: arbitrary unicodes that follow ircd restrictions, unicode casefolding
// 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
)
var (
errInvalidCharacter = errors.New("Invalid character")
)
func (cm *Casemapping) UnmarshalYAML(unmarshal func(interface{}) error) (err error) {
var orig string
if err = unmarshal(&orig); err != nil {
return err
}
var result Casemapping
switch strings.ToLower(orig) {
case "ascii":
result = CasemappingASCII
case "precis", "rfc7613", "rfc8265":
result = CasemappingPRECIS
case "permissive", "fun":
result = CasemappingPermissive
case "rfc1459":
result = CasemappingRFC1459
case "rfc1459-strict":
result = CasemappingRFC1459Strict
default:
return fmt.Errorf("invalid casemapping value: %s", orig)
}
*cm = result
return nil
}
func isPrintableASCII(str string) bool {
for i := 0; i < len(str); i++ {
// allow space here because it's technically printable;
// it will be disallowed later by CasefoldName/CasefoldChannel
chr := str[i]
if chr < ' ' || chr > '~' {
return false
}
}
return true
}
func foldASCII(str string) (result string, err error) {
if !isPrintableASCII(str) {
return "", errInvalidCharacter
}
return strings.ToLower(str), nil
}

132
irc/i18n/strings.go Normal file
View File

@ -0,0 +1,132 @@
//go:build i18n
package i18n
import (
"errors"
"regexp"
"strings"
"github.com/ergochat/confusables"
"golang.org/x/text/cases"
"golang.org/x/text/secure/precis"
"golang.org/x/text/unicode/norm"
"golang.org/x/text/width"
)
const (
Enabled = true
// 1.x configurations don't have a server.casemapping field, but
// expect PRECIS. however, technically it's not this value that
// causes them to get PRECIS, it's that PRECIS is the zero value of
// Casemapping (so that's how the YAML deserializes when the field
// is missing).
DefaultCasemapping = CasemappingPRECIS
)
var (
// reviving the old ergonomadic nickname regex:
// in permissive mode, allow arbitrary letters, numbers, punctuation, and symbols
permissiveCharsRegex = regexp.MustCompile(`^[\pL\pN\pP\pS]*$`)
)
// String Errors
var (
errCouldNotStabilize = errors.New("Could not stabilize string while casefolding")
)
// Each pass of PRECIS casefolding is a composition of idempotent operations,
// but not idempotent itself. Therefore, the spec says "do it four times and hope
// it converges" (lolwtf). Golang's PRECIS implementation has a "repeat" option,
// which provides this functionality, but unfortunately it's not exposed publicly.
func iterateFolding(profile *precis.Profile, oldStr string) (str string, err error) {
str = oldStr
// follow the stabilizing rules laid out here:
// https://tools.ietf.org/html/draft-ietf-precis-7564bis-10.html#section-7
for i := 0; i < 4; i++ {
str, err = profile.CompareKey(str)
if err != nil {
return "", err
}
if oldStr == str {
break
}
oldStr = str
}
if oldStr != str {
return "", errCouldNotStabilize
}
return str, nil
}
func foldPRECIS(str string) (result string, err error) {
return iterateFolding(precis.UsernameCaseMapped, str)
}
func foldPermissive(str string) (result string, err error) {
if !permissiveCharsRegex.MatchString(str) {
return "", errInvalidCharacter
}
// YOLO
str = norm.NFD.String(str)
str = cases.Fold().String(str)
str = norm.NFD.String(str)
return 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 CasefoldWithSetting(str string, setting Casemapping) (string, error) {
switch setting {
default:
return foldPRECIS(str)
case CasemappingASCII:
return foldASCII(str)
case CasemappingPermissive:
return foldPermissive(str)
case CasemappingRFC1459:
return foldRFC1459(str, false)
case CasemappingRFC1459Strict:
return foldRFC1459(str, true)
}
}
// Skeleton produces a canonicalized identifier that tries to catch
// homoglyphic / confusable identifiers. It's a tweaked version of the TR39
// skeleton algorithm. We apply the skeleton algorithm first and only then casefold,
// because casefolding first would lose some information about visual confusability.
// This has the weird consequence that the skeleton is not a function of the
// casefolded identifier --- therefore it must always be computed
// from the original (unfolded) identifier and stored/tracked separately from the
// casefolded identifier.
func Skeleton(name string) (string, error) {
// XXX the confusables table includes some, but not all, fullwidth->standard
// mappings for latin characters. do a pass of explicit width folding,
// same as PRECIS:
name = width.Fold.String(name)
name = confusables.SkeletonTweaked(name)
// internationalized lowercasing for skeletons; this is much more lenient than
// Casefold. In particular, skeletons are expected to mix scripts (which may
// violate the bidi rule). We also don't care if they contain runes
// that are disallowed by PRECIS, because every identifier must independently
// pass PRECIS --- we are just further canonicalizing the skeleton.
return cases.Fold().String(name), nil
}

156
irc/i18n/strings_test.go Normal file
View File

@ -0,0 +1,156 @@
//go:build i18n
package i18n
import "testing"
func validFoldTester(first, second string, equal bool, folder func(string) (string, error), t *testing.T) {
firstFolded, err := folder(first)
if err != nil {
panic(err)
}
secondFolded, err := folder(second)
if err != nil {
panic(err)
}
foundEqual := firstFolded == secondFolded
if foundEqual != equal {
t.Errorf("%s and %s: expected equality %t, but got %t", first, second, equal, foundEqual)
}
}
func TestFoldPermissive(t *testing.T) {
tester := func(first, second string, equal bool) {
validFoldTester(first, second, equal, foldPermissive, t)
}
tester("SHIVARAM", "shivaram", true)
tester("shIvaram", "shivaraM", true)
tester("shivaram", "DAN-", false)
tester("dolph🐬n", "DOLPH🐬n", true)
tester("dolph🐬n", "dolph💻n", false)
tester("9FRONT", "9front", true)
}
func TestFoldPermissiveInvalid(t *testing.T) {
_, err := foldPermissive("a\tb")
if err == nil {
t.Errorf("whitespace should be invalid in identifiers")
}
_, err = foldPermissive("a\x00b")
if err == nil {
t.Errorf("the null byte should be invalid in identifiers")
}
_, err = foldPermissive("a b")
if err == nil {
t.Errorf("space should be invalid in identifiers")
}
}
func TestFoldPermissiveNormalization(t *testing.T) {
tester := func(first, second string, equal bool) {
validFoldTester(first, second, equal, foldPermissive, t)
}
// case folding should work on non-ASCII letters
tester("Ω", "ω", true) // Greek capital/small omega
tester("Ñoño", "ñoño", true) // Spanish precomposed tilde-n, upper vs lower
tester("中文", "中文", true) // CJK (no case distinction)
tester("中文", "English", false) // different scripts, not equal
// NFC-encoded input: "É" (U+00C9) and "é" (U+00E9) should fold equal
// NFD normalization before case folding ensures composed chars are handled
tester("\u00c9l\u00e8ve", "\u00e9l\u00e8ve", true) // Élève vs élève
}
func TestFoldASCII(t *testing.T) {
tester := func(first, second string, equal bool) {
validFoldTester(first, second, equal, foldASCII, t)
}
tester("shivaram", "SHIVARAM", true)
tester("X|Y", "x|y", true)
tester("a != b", "A != B", true)
}
func TestFoldASCIIInvalid(t *testing.T) {
_, err := foldASCII("\x01")
if err == nil {
t.Errorf("control characters should be invalid in identifiers")
}
_, err = foldASCII("\x7F")
if err == nil {
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)
}
func TestSkeleton(t *testing.T) {
skeleton := func(str string) string {
skel, err := Skeleton(str)
if err != nil {
t.Error(err)
}
return skel
}
if skeleton("warning") == skeleton("waming") {
t.Errorf("Oragono shouldn't consider rn confusable with m")
}
if skeleton("Phi|ip") != "philip" {
t.Errorf("but we still consider pipe confusable with l")
}
if skeleton("") != skeleton("smt") {
t.Errorf("fullwidth characters should skeletonize to plain old ascii characters")
}
if skeleton("") != skeleton("smt") {
t.Errorf("after skeletonizing, we should casefold")
}
if skeleton("sm") != skeleton("smt") {
t.Errorf("our friend lover successfully tricked the skeleton algorithm!")
}
if skeleton("еvan") != "evan" {
t.Errorf("we must protect against cyrillic homoglyph attacks")
}
if skeleton("еmily") != skeleton("emily") {
t.Errorf("we must protect against cyrillic homoglyph attacks")
}
if skeleton("РОТАТО") != "potato" {
t.Errorf("we must protect against cyrillic homoglyph attacks")
}
// should not raise an error:
skeleton("けらんぐ")
}

18
irc/i18n/stub.go Normal file
View File

@ -0,0 +1,18 @@
//go:build !i18n
package i18n
const (
Enabled = false
DefaultCasemapping = CasemappingASCII
)
func CasefoldWithSetting(str string, setting Casemapping) (string, error) {
return foldASCII(str)
}
func Skeleton(str string) (string, error) {
// identity function is fine because we independently case-normalize in Casefold
return str, nil
}

View File

@ -17,6 +17,7 @@ import (
"github.com/ergochat/ergo/irc/datastore" "github.com/ergochat/ergo/irc/datastore"
"github.com/ergochat/ergo/irc/modes" "github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/utils" "github.com/ergochat/ergo/irc/utils"
"github.com/ergochat/ergo/irc/webpush"
) )
const ( const (
@ -24,7 +25,7 @@ const (
// XXX instead of referencing, e.g., keyAccountExists, we should write in the string literal // 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 // (to ensure that no matter what code changes happen elsewhere, we're still producing a
// db of the hardcoded version) // db of the hardcoded version)
importDBSchemaVersion = 23 importDBSchemaVersion = 24
) )
type userImport struct { type userImport struct {
@ -82,6 +83,15 @@ func doImportDBGeneric(config *Config, dbImport databaseImport, credsType Creden
tx.Set(keySchemaVersion, strconv.Itoa(importDBSchemaVersion), nil) tx.Set(keySchemaVersion, strconv.Itoa(importDBSchemaVersion), nil)
tx.Set(keyCloakSecret, utils.GenerateSecretKey(), 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.HashSet[string]) cfUsernames := make(utils.HashSet[string])
skeletonToUsername := make(map[string]string) skeletonToUsername := make(map[string]string)

View File

@ -5,12 +5,12 @@ package isupport
import ( import (
"fmt" "fmt"
"sort" "slices"
"strings" "strings"
) )
const ( const (
maxLastArgLength = 400 maxPayloadLength = 380
/* Modern: "As the maximum number of message parameters to any reply is 15, /* 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." the maximum number of RPL_ISUPPORT tokens that can be advertised is 13."
@ -47,6 +47,12 @@ func (il *List) AddNoValue(name string) {
il.Tokens[name] = "" 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. // getTokenString gets the appropriate string for a token+value.
func getTokenString(name string, value string) string { func getTokenString(name string, value string) string {
if len(value) == 0 { if len(value) == 0 {
@ -58,7 +64,7 @@ func getTokenString(name string, value string) string {
// GetDifference returns the difference between two token lists. // GetDifference returns the difference between two token lists.
func (il *List) GetDifference(newil *List) [][]string { func (il *List) GetDifference(newil *List) [][]string {
var outTokens sort.StringSlice var outTokens []string
// append removed tokens // append removed tokens
for name := range il.Tokens { for name := range il.Tokens {
@ -84,7 +90,7 @@ func (il *List) GetDifference(newil *List) [][]string {
outTokens = append(outTokens, token) outTokens = append(outTokens, token)
} }
sort.Sort(outTokens) slices.Sort(outTokens)
// create output list // create output list
replies := make([][]string, 0) replies := make([][]string, 0)
@ -92,7 +98,7 @@ func (il *List) GetDifference(newil *List) [][]string {
var cache []string // Token list cache var cache []string // Token list cache
for _, token := range outTokens { for _, token := range outTokens {
if len(token)+length <= maxLastArgLength { if len(token)+length <= maxPayloadLength {
// account for the space separating tokens // account for the space separating tokens
if len(cache) > 0 { if len(cache) > 0 {
length++ length++
@ -101,7 +107,7 @@ func (il *List) GetDifference(newil *List) [][]string {
length += len(token) length += len(token)
} }
if len(cache) == maxParameters || len(token)+length >= maxLastArgLength { if len(cache) == maxParameters || len(token)+length >= maxPayloadLength {
replies = append(replies, cache) replies = append(replies, cache)
cache = make([]string, 0) cache = make([]string, 0)
length = 0 length = 0
@ -115,40 +121,54 @@ func (il *List) GetDifference(newil *List) [][]string {
return replies 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 // RegenerateCachedReply regenerates the cached RPL_ISUPPORT reply
func (il *List) RegenerateCachedReply() (err error) { func (il *List) RegenerateCachedReply() (err error) {
il.CachedReply = make([][]string, 0) var tokens []string
var length int // Length of the current cache for name, value := range il.Tokens {
var cache []string // Token list cache token := getTokenString(name, value)
if tokenErr := validateToken(token); tokenErr == nil {
// make sure we get a sorted list of tokens, needed for tests and looks nice tokens = append(tokens, token)
var tokens sort.StringSlice } else {
for name := range il.Tokens { err = tokenErr
tokens = append(tokens, name) }
} }
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 { var cache []string // Tokens in current line
token := getTokenString(name, il.Tokens[name]) var length int // Length of the current line
if token[0] == ':' || strings.Contains(token, " ") {
err = fmt.Errorf("bad isupport token (cannot contain spaces or start with :): %s", token)
continue
}
if len(token)+length <= maxLastArgLength { for _, token := range tokens {
// account for the space separating tokens // account for the space separating tokens
if len(cache) > 0 { if len(cache) == maxParameters || (len(token)+1)+length > maxPayloadLength {
length++
}
cache = append(cache, token)
length += len(token)
}
if len(cache) == maxParameters || len(token)+length >= maxLastArgLength {
il.CachedReply = append(il.CachedReply, cache) il.CachedReply = append(il.CachedReply, cache)
cache = make([]string, 0) cache = nil
length = 0 length = 0
} }
if len(cache) > 0 {
length++
}
length += len(token)
cache = append(cache, token)
} }
if len(cache) > 0 { if len(cache) > 0 {

View File

@ -37,7 +37,7 @@ func TestISUPPORT(t *testing.T) {
} }
if !reflect.DeepEqual(tListLong.CachedReply, longReplies) { 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 // create first list

View File

@ -66,11 +66,12 @@ func (km *KLineManager) AllBans() map[string]IPBanInfo {
} }
// AddMask adds to the blocked list. // 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() km.persistenceMutex.Lock()
defer km.persistenceMutex.Unlock() defer km.persistenceMutex.Unlock()
info := IPBanInfo{ info := IPBanInfo{
RequireSASL: requireSASL,
Reason: reason, Reason: reason,
OperReason: operReason, OperReason: operReason,
OperName: operName, OperName: operName,
@ -208,13 +209,14 @@ func (km *KLineManager) CheckMasks(masks ...string) (isBanned bool, info IPBanIn
for _, entryInfo := range km.entries { for _, entryInfo := range km.entries {
for _, mask := range masks { for _, mask := range masks {
if entryInfo.Matcher.MatchString(mask) { 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 return
} }

View File

@ -99,8 +99,13 @@ func (nl *NetListener) serve() {
// hand off the connection // hand off the connection
wConn, ok := conn.(*utils.WrappedConn) wConn, ok := conn.(*utils.WrappedConn)
if ok { if ok {
confirmProxyData(wConn, "", "", "", nl.server.Config()) if wConn.ProxyError == nil {
go nl.server.RunClient(NewIRCStreamConn(wConn)) confirmProxyData(wConn, "", "", "", nl.server.Config())
go nl.server.RunClient(NewIRCStreamConn(wConn))
} else {
nl.server.logger.Error("internal", "PROXY protocol error", nl.addr, wConn.ProxyError.Error())
conn.Close()
}
} else { } else {
nl.server.logger.Error("internal", "invalid connection type", nl.addr) nl.server.logger.Error("internal", "invalid connection type", nl.addr)
} }
@ -185,6 +190,13 @@ func (wl *WSListener) handle(w http.ResponseWriter, r *http.Request) {
conn.Close() conn.Close()
return return
} }
if wConn.ProxyError != nil {
// actually the connection is likely corrupted, so probably Upgrade()
// would have already failed
wl.server.logger.Error("internal", "PROXY protocol error on websocket", wl.addr, wConn.ProxyError.Error())
conn.Close()
return
}
confirmProxyData(wConn, remoteAddr, xff, xfp, config) confirmProxyData(wConn, remoteAddr, xff, xfp, config)

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) { func addAllTags(msg *ircmsg.Message, tags map[string]string, serverTime time.Time, msgid, accountName string, isBot bool) {
msg.UpdateTags(tags) msg.UpdateTags(tags)
msg.SetTag("time", serverTime.Format(IRCv3TimestampFormat)) msg.SetTag("time", serverTime.Format(utils.IRCv3TimestampFormat))
if accountName != "*" { if accountName != "*" {
msg.SetTag("account", accountName) msg.SetTag("account", accountName)
} }

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

@ -116,7 +116,7 @@ func ApplyUserModeChanges(client *Client, changes modes.ModeChanges, force bool,
} }
// parseDefaultModes uses the provided mode change parser to parse the rawModes. // 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) modeChangeStrings := strings.Fields(rawModes)
modeChanges, _ := parser(modeChangeStrings...) modeChanges, _ := parser(modeChangeStrings...)
defaultModes := make(modes.Modes, 0) defaultModes := make(modes.Modes, 0)
@ -251,9 +251,11 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c
switch change.Op { switch change.Op {
case modes.Add: case modes.Add:
val, err := strconv.Atoi(change.Arg) val, err := strconv.Atoi(change.Arg)
if err == nil { if err == nil && val > 0 {
channel.setUserLimit(val) channel.setUserLimit(val)
applied = append(applied, change) applied = append(applied, change)
} else {
rb.Add(nil, client.server.name, ERR_INVALIDMODEPARAM, details.nick, chname, string(change.Mode), utils.SafeErrorParam(change.Arg), client.t("+l user limit value must be an integer between 1 and 2147483647, expressed in base 10"))
} }
case modes.Remove: case modes.Remove:
@ -266,9 +268,9 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c
case modes.Add: case modes.Add:
ch := client.server.channels.Get(change.Arg) ch := client.server.channels.Get(change.Arg)
if ch == nil { 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 { } 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 { } else {
if isSamode || ch.ClientIsAtLeast(client, modes.ChannelOperator) { if isSamode || ch.ClientIsAtLeast(client, modes.ChannelOperator) {
change.Arg = ch.Name() change.Arg = ch.Name()

View File

@ -7,7 +7,7 @@ package modes
import ( import (
"fmt" "fmt"
"sort" "slices"
"strings" "strings"
"github.com/ergochat/ergo/irc/utils" "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. // ParseUserModeChanges returns the valid changes, and the list of unknown chars.
func ParseUserModeChanges(params ...string) (ModeChanges, map[rune]bool) { func ParseUserModeChanges(params ...string) (changes ModeChanges, unknown []rune) {
changes := make(ModeChanges, 0)
unknown := make(map[rune]bool)
op := List op := List
if 0 < len(params) { if 0 < len(params) {
@ -219,19 +216,11 @@ func ParseUserModeChanges(params ...string) (ModeChanges, map[rune]bool) {
} }
} }
var isKnown bool if slices.Contains(SupportedUserModes, Mode(mode)) {
for _, supportedMode := range SupportedUserModes { changes = append(changes, change)
if rune(supportedMode) == mode { } else {
isKnown = true unknown = append(unknown, mode)
break
}
} }
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. // ParseChannelModeChanges returns the valid changes, and the list of unknown chars.
func ParseChannelModeChanges(params ...string) (ModeChanges, map[rune]bool) { func ParseChannelModeChanges(params ...string) (changes ModeChanges, unknown []rune) {
changes := make(ModeChanges, 0)
unknown := make(map[rune]bool)
op := List op := List
if 0 < len(params) { if 0 < len(params) {
@ -304,25 +290,11 @@ func ParseChannelModeChanges(params ...string) (ModeChanges, map[rune]bool) {
} }
} }
var isKnown bool if slices.Contains(SupportedChannelModes, Mode(mode)) || slices.Contains(ChannelUserModes, Mode(mode)) {
for _, supportedMode := range SupportedChannelModes { changes = append(changes, change)
if rune(supportedMode) == mode { } else {
isKnown = true unknown = append(unknown, mode)
break
}
} }
for _, supportedMode := range ChannelUserModes {
if rune(supportedMode) == mode {
isKnown = true
break
}
}
if !isKnown {
unknown[mode] = true
continue
}
changes = append(changes, change)
} }
} }
@ -428,33 +400,37 @@ func (set *ModeSet) HighestChannelUserMode() (result Mode) {
return return
} }
type ByCodepoint Modes var (
rplMyInfo1, rplMyInfo2, rplMyInfo3, chanmodesToken string
)
func (a ByCodepoint) Len() int { return len(a) } func init() {
func (a ByCodepoint) Swap(i, j int) { a[i], a[j] = a[j], a[i] } initRplMyInfo()
func (a ByCodepoint) Less(i, j int) bool { return a[i] < a[j] } initChanmodesToken()
}
func RplMyInfo() (param1, param2, param3 string) { func initRplMyInfo() {
// initialize constant strings published in initial numerics
userModes := make(Modes, len(SupportedUserModes), len(SupportedUserModes)+1) userModes := make(Modes, len(SupportedUserModes), len(SupportedUserModes)+1)
copy(userModes, SupportedUserModes) copy(userModes, SupportedUserModes)
// TLS is not in SupportedUserModes because it can't be modified // TLS is not in SupportedUserModes because it can't be modified
userModes = append(userModes, TLS) userModes = append(userModes, TLS)
sort.Sort(ByCodepoint(userModes)) slices.Sort(userModes)
channelModes := make(Modes, len(SupportedChannelModes)+len(ChannelUserModes)) channelModes := make(Modes, len(SupportedChannelModes)+len(ChannelUserModes))
copy(channelModes, SupportedChannelModes) copy(channelModes, SupportedChannelModes)
copy(channelModes[len(SupportedChannelModes):], ChannelUserModes) 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 // XXX enumerate these by hand, i can't see any way to DRY this
channelParametrizedModes := Modes{BanMask, ExceptMask, InviteMask, Key, UserLimit, Forward} channelParametrizedModes := Modes{BanMask, ExceptMask, InviteMask, Key, UserLimit, Forward}
channelParametrizedModes = append(channelParametrizedModes, ChannelUserModes...) 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 // https://modern.ircdocs.horse#chanmodes-parameter
// type A: listable modes with parameters // type A: listable modes with parameters
A := Modes{BanMask, ExceptMask, InviteMask} A := Modes{BanMask, ExceptMask, InviteMask}
@ -465,10 +441,18 @@ func ChanmodesToken() (result string) {
// type D: modes without parameters // type D: modes without parameters
D := Modes{InviteOnly, Moderated, NoOutside, OpOnlyTopic, ChanRoleplaying, Secret, NoCTCP, RegisteredOnly, RegisteredOnlySpeak, Auditorium, OpModerated} D := Modes{InviteOnly, Moderated, NoOutside, OpOnlyTopic, ChanRoleplaying, Secret, NoCTCP, RegisteredOnly, RegisteredOnlySpeak, Auditorium, OpModerated}
sort.Sort(ByCodepoint(A)) slices.Sort(A)
sort.Sort(ByCodepoint(B)) slices.Sort(B)
sort.Sort(ByCodepoint(C)) slices.Sort(C)
sort.Sort(ByCodepoint(D)) 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 ( import (
"reflect" "reflect"
"slices"
"strings" "strings"
"testing" "testing"
) )
@ -16,7 +17,7 @@ func assertEqual(supplied, expected interface{}, t *testing.T) {
} }
func TestParseUserModeChanges(t *testing.T) { func TestParseUserModeChanges(t *testing.T) {
emptyUnknown := make(map[rune]bool) var emptyUnknown []rune
changes, unknown := ParseUserModeChanges("+i") changes, unknown := ParseUserModeChanges("+i")
assertEqual(unknown, emptyUnknown, t) assertEqual(unknown, emptyUnknown, t)
assertEqual(changes, ModeChanges{ModeChange{Op: Add, Mode: Invisible}}, t) assertEqual(changes, ModeChanges{ModeChange{Op: Add, Mode: Invisible}}, t)
@ -48,10 +49,11 @@ func TestParseUserModeChanges(t *testing.T) {
} }
func TestIssue874(t *testing.T) { func TestIssue874(t *testing.T) {
emptyUnknown := make(map[rune]bool) var emptyModeChanges ModeChanges
var emptyUnknown []rune
modes, unknown := ParseChannelModeChanges("+k") modes, unknown := ParseChannelModeChanges("+k")
assertEqual(unknown, emptyUnknown, t) assertEqual(unknown, emptyUnknown, t)
assertEqual(modes, ModeChanges{}, t) assertEqual(modes, emptyModeChanges, t)
modes, unknown = ParseChannelModeChanges("+k", "beer") modes, unknown = ParseChannelModeChanges("+k", "beer")
assertEqual(unknown, emptyUnknown, t) assertEqual(unknown, emptyUnknown, t)
@ -151,7 +153,7 @@ func TestParseChannelModeChanges(t *testing.T) {
} }
modes, unknown = ParseChannelModeChanges("+tx") 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) t.Errorf("expected that x is an unknown mode, instead: %v", unknown)
} }
expected = ModeChange{ expected = ModeChange{

View File

@ -28,17 +28,21 @@ func (mm *MonitorManager) Initialize() {
// AddMonitors adds clients using extended-monitor monitoring `client`'s nick to the passed user set. // AddMonitors adds clients using extended-monitor monitoring `client`'s nick to the passed user set.
func (manager *MonitorManager) AddMonitors(users utils.HashSet[*Session], cfnick string, capabs ...caps.Capability) { func (manager *MonitorManager) AddMonitors(users utils.HashSet[*Session], cfnick string, capabs ...caps.Capability) {
// technically, we should check extended-monitor here, but it's not really necessary
// since clients will ignore AWAY, ACCOUNT, CHGHOST, and SETNAME for users
// they're not tracking
manager.RLock() manager.RLock()
defer manager.RUnlock() defer manager.RUnlock()
for session := range manager.watchedby[cfnick] { for session := range manager.watchedby[cfnick] {
if session.capabilities.Has(caps.ExtendedMonitor) && session.capabilities.HasAll(capabs...) { if session.capabilities.HasAll(capabs...) {
users.Add(session) users.Add(session)
} }
} }
} }
// AlertAbout alerts everyone monitoring `client`'s nick that `client` is now {on,off}line. // 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 var watchers []*Session
// safely copy the list of clients watching our nick // safely copy the list of clients watching our nick
manager.RLock() manager.RLock()
@ -52,8 +56,21 @@ func (manager *MonitorManager) AlertAbout(nick, cfnick string, online bool) {
command = RPL_MONONLINE command = RPL_MONONLINE
} }
var metadata map[string]string
if online && client != nil {
metadata = client.ListMetadata()
}
for _, session := range watchers { for _, session := range watchers {
session.Send(nil, session.client.server.name, command, session.client.Nick(), nick) 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)
}
}
}
} }
} }

View File

@ -7,6 +7,12 @@ import (
"time" "time"
) )
const (
// maximum length in bytes of any message target (nickname or channel name) in its
// canonicalized (i.e., casefolded) state:
MaxTargetLength = 64
)
type Config struct { type Config struct {
// these are intended to be written directly into the config file: // these are intended to be written directly into the config file:
Enabled bool Enabled bool

View File

@ -1,3 +1,5 @@
//go:build mysql
// Copyright (c) 2020 Shivaram Lingamneni // Copyright (c) 2020 Shivaram Lingamneni
// released under the MIT license // released under the MIT license
@ -7,7 +9,6 @@ import (
"context" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"runtime/debug" "runtime/debug"
@ -23,14 +24,9 @@ import (
_ "github.com/go-sql-driver/mysql" _ "github.com/go-sql-driver/mysql"
) )
var (
ErrDisallowed = errors.New("disallowed")
)
const ( const (
// maximum length in bytes of any message target (nickname or channel name) in its // Enabled is true when MySQL support is compiled in
// canonicalized (i.e., casefolded) state: Enabled = true
MaxTargetLength = 64
// latest schema of the db // latest schema of the db
latestDbSchema = "2" latestDbSchema = "2"
@ -64,10 +60,16 @@ type MySQL struct {
trackAccountMessages atomic.Uint32 trackAccountMessages atomic.Uint32
} }
func (mysql *MySQL) Initialize(logger *logger.Manager, config Config) { var _ history.Database = (*MySQL)(nil)
func NewMySQLDatabase(logger *logger.Manager, config Config) (*MySQL, error) {
var mysql MySQL
mysql.logger = logger mysql.logger = logger
mysql.wakeForgetter = make(chan e, 1) mysql.wakeForgetter = make(chan e, 1)
mysql.SetConfig(config) mysql.SetConfig(config)
return &mysql, mysql.open()
} }
func (mysql *MySQL) SetConfig(config Config) { func (mysql *MySQL) SetConfig(config Config) {
@ -89,7 +91,7 @@ func (mysql *MySQL) getExpireTime() (expireTime time.Duration) {
return return
} }
func (m *MySQL) Open() (err error) { func (m *MySQL) open() (err error) {
var address string var address string
if m.config.SocketPath != "" { if m.config.SocketPath != "" {
address = fmt.Sprintf("unix(%s)", m.config.SocketPath) address = fmt.Sprintf("unix(%s)", m.config.SocketPath)
@ -128,7 +130,7 @@ func (m *MySQL) Open() (err error) {
func (mysql *MySQL) fixSchemas() (err error) { func (mysql *MySQL) fixSchemas() (err error) {
_, err = mysql.db.Exec(`CREATE TABLE IF NOT EXISTS metadata ( _, err = mysql.db.Exec(`CREATE TABLE IF NOT EXISTS metadata (
key_name VARCHAR(32) primary key, key_name VARCHAR(32) PRIMARY KEY,
value VARCHAR(32) NOT NULL value VARCHAR(32) NOT NULL
) CHARSET=ascii COLLATE=ascii_bin;`) ) CHARSET=ascii COLLATE=ascii_bin;`)
if err != nil { if err != nil {
@ -136,17 +138,17 @@ func (mysql *MySQL) fixSchemas() (err error) {
} }
var schema string var schema string
err = mysql.db.QueryRow(`select value from metadata where key_name = ?;`, keySchemaVersion).Scan(&schema) err = mysql.db.QueryRow(`SELECT value FROM metadata WHERE key_name = ?;`, keySchemaVersion).Scan(&schema)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
err = mysql.createTables() err = mysql.createTables()
if err != nil { if err != nil {
return return
} }
_, err = mysql.db.Exec(`insert into metadata (key_name, value) values (?, ?);`, keySchemaVersion, latestDbSchema) _, err = mysql.db.Exec(`INSERT INTO metadata (key_name, value) VALUES (?, ?);`, keySchemaVersion, latestDbSchema)
if err != nil { if err != nil {
return return
} }
_, err = mysql.db.Exec(`insert into metadata (key_name, value) values (?, ?);`, keySchemaMinorVersion, latestDbMinorVersion) _, err = mysql.db.Exec(`INSERT INTO metadata (key_name, value) VALUES (?, ?);`, keySchemaMinorVersion, latestDbMinorVersion)
if err != nil { if err != nil {
return return
} }
@ -159,7 +161,7 @@ func (mysql *MySQL) fixSchemas() (err error) {
} }
var minorVersion string var minorVersion string
err = mysql.db.QueryRow(`select value from metadata where key_name = ?;`, keySchemaMinorVersion).Scan(&minorVersion) err = mysql.db.QueryRow(`SELECT value FROM metadata WHERE key_name = ?;`, keySchemaMinorVersion).Scan(&minorVersion)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
// XXX for now, the only minor version upgrade is the account tracking tables // XXX for now, the only minor version upgrade is the account tracking tables
err = mysql.createComplianceTables() err = mysql.createComplianceTables()
@ -170,7 +172,7 @@ func (mysql *MySQL) fixSchemas() (err error) {
if err != nil { if err != nil {
return return
} }
_, err = mysql.db.Exec(`insert into metadata (key_name, value) values (?, ?);`, keySchemaMinorVersion, latestDbMinorVersion) _, err = mysql.db.Exec(`INSERT INTO metadata (key_name, value) VALUES (?, ?);`, keySchemaMinorVersion, latestDbMinorVersion)
if err != nil { if err != nil {
return return
} }
@ -180,13 +182,15 @@ func (mysql *MySQL) fixSchemas() (err error) {
if err != nil { if err != nil {
return return
} }
_, err = mysql.db.Exec(`update metadata set value = ? where key_name = ?;`, latestDbMinorVersion, keySchemaMinorVersion) _, err = mysql.db.Exec(`UPDATE metadata SET value = ? WHERE key_name = ?;`, latestDbMinorVersion, keySchemaMinorVersion)
if err != nil { if err != nil {
return return
} }
} else if err == nil && minorVersion != latestDbMinorVersion { } else if err == nil && minorVersion != latestDbMinorVersion {
// TODO: if minorVersion < latestDbMinorVersion, upgrade, // TODO: if minorVersion < latestDbMinorVersion, upgrade,
// if latestDbMinorVersion < minorVersion, ignore because backwards compatible // if latestDbMinorVersion < minorVersion, ignore because backwards compatible
} else if err != nil {
return
} }
return return
} }
@ -416,7 +420,7 @@ func (mysql *MySQL) deleteCorrespondents(ctx context.Context, threshold int64) {
} else { } else {
count, err := result.RowsAffected() count, err := result.RowsAffected()
if !mysql.logError("error deleting correspondents", err) { if !mysql.logError("error deleting correspondents", err) {
mysql.logger.Debug(fmt.Sprintf("deleted %d correspondents entries", count)) mysql.logger.Debug("mysql", fmt.Sprintf("deleted %d correspondents entries", count))
} }
} }
} }
@ -623,40 +627,46 @@ func (mysql *MySQL) AddChannelItem(target string, item history.Item, account str
func (mysql *MySQL) insertSequenceEntry(ctx context.Context, target string, messageTime int64, id int64) (err error) { func (mysql *MySQL) insertSequenceEntry(ctx context.Context, target string, messageTime int64, id int64) (err error) {
_, err = mysql.insertSequence.ExecContext(ctx, target, messageTime, id) _, err = mysql.insertSequence.ExecContext(ctx, target, messageTime, id)
mysql.logError("could not insert sequence entry", err) if err != nil {
return fmt.Errorf("could not insert sequence entry: %w", err)
}
return return
} }
func (mysql *MySQL) insertConversationEntry(ctx context.Context, target, correspondent string, messageTime int64, id int64) (err error) { func (mysql *MySQL) insertConversationEntry(ctx context.Context, target, correspondent string, messageTime int64, id int64) (err error) {
_, err = mysql.insertConversation.ExecContext(ctx, target, correspondent, messageTime, id) _, err = mysql.insertConversation.ExecContext(ctx, target, correspondent, messageTime, id)
mysql.logError("could not insert conversations entry", err) if err != nil {
return fmt.Errorf("could not insert conversations entry: %w", err)
}
return return
} }
func (mysql *MySQL) insertCorrespondentsEntry(ctx context.Context, target, correspondent string, messageTime int64, historyId int64) (err error) { func (mysql *MySQL) insertCorrespondentsEntry(ctx context.Context, target, correspondent string, messageTime int64, historyId int64) (err error) {
_, err = mysql.insertCorrespondent.ExecContext(ctx, target, correspondent, messageTime, messageTime) _, err = mysql.insertCorrespondent.ExecContext(ctx, target, correspondent, messageTime, messageTime)
mysql.logError("could not insert conversations entry", err) if err != nil {
return fmt.Errorf("could not insert correspondents entry: %w", err)
}
return return
} }
func (mysql *MySQL) insertBase(ctx context.Context, item history.Item) (id int64, err error) { func (mysql *MySQL) insertBase(ctx context.Context, item history.Item) (id int64, err error) {
value, err := marshalItem(&item) value, err := history.MarshalItem(&item)
if mysql.logError("could not marshal item", err) { if err != nil {
return return 0, fmt.Errorf("could not marshal item: %w", err)
} }
msgidBytes, err := decodeMsgid(item.Message.Msgid) msgidBytes, err := utils.DecodeSecretToken(item.Message.Msgid)
if mysql.logError("could not decode msgid", err) { if err != nil {
return return 0, fmt.Errorf("could not decode msgid: %w", err)
} }
result, err := mysql.insertHistory.ExecContext(ctx, value, msgidBytes) result, err := mysql.insertHistory.ExecContext(ctx, value, msgidBytes)
if mysql.logError("could not insert item", err) { if err != nil {
return return 0, fmt.Errorf("could not insert item: %w", err)
} }
id, err = result.LastInsertId() id, err = result.LastInsertId()
if mysql.logError("could not insert item", err) { if err != nil {
return return 0, fmt.Errorf("could not insert item: %w", err)
} }
return return
@ -667,7 +677,9 @@ func (mysql *MySQL) insertAccountMessageEntry(ctx context.Context, id int64, acc
return return
} }
_, err = mysql.insertAccountMessage.ExecContext(ctx, id, account) _, err = mysql.insertAccountMessage.ExecContext(ctx, id, account)
mysql.logError("could not insert account-message entry", err) if err != nil {
return fmt.Errorf("could not insert account-message entry: %w", err)
}
return return
} }
@ -735,20 +747,25 @@ func (mysql *MySQL) DeleteMsgid(msgid, accountName string) (err error) {
_, id, data, err := mysql.lookupMsgid(ctx, msgid, true) _, id, data, err := mysql.lookupMsgid(ctx, msgid, true)
if err != nil { if err != nil {
if err == sql.ErrNoRows {
return history.ErrNotFound
}
return return
} }
if accountName != "*" { if accountName != "*" {
var item history.Item var item history.Item
err = unmarshalItem(data, &item) err = history.UnmarshalItem(data, &item)
// delete if the entry is corrupt // delete if the entry is corrupt
if err == nil && item.AccountName != accountName { if err == nil && item.AccountName != accountName {
return ErrDisallowed return history.ErrDisallowed
} }
} }
err = mysql.deleteHistoryIDs(ctx, []uint64{id}) err = mysql.deleteHistoryIDs(ctx, []uint64{id})
mysql.logError("couldn't delete msgid", err) if err != nil {
return fmt.Errorf("couldn't delete msgid: %w", err)
}
return return
} }
@ -784,7 +801,7 @@ func (mysql *MySQL) Export(account string, writer io.Writer) {
if err != nil { if err != nil {
return return
} }
err = unmarshalItem(blob, &item) err = history.UnmarshalItem(blob, &item)
if err != nil { if err != nil {
return return
} }
@ -812,8 +829,11 @@ func (mysql *MySQL) Export(account string, writer io.Writer) {
} }
func (mysql *MySQL) lookupMsgid(ctx context.Context, msgid string, includeData bool) (result time.Time, id uint64, data []byte, err error) { func (mysql *MySQL) lookupMsgid(ctx context.Context, msgid string, includeData bool) (result time.Time, id uint64, data []byte, err error) {
decoded, err := decodeMsgid(msgid) decoded, err := utils.DecodeSecretToken(msgid)
if err != nil { if err != nil {
// use sql.ErrNoRows internally for consistency, translate to history.ErrNotFound
// at the package boundary if necessary
err = sql.ErrNoRows
return return
} }
cols := `sequence.nanotime, conversations.nanotime` cols := `sequence.nanotime, conversations.nanotime`
@ -831,10 +851,10 @@ func (mysql *MySQL) lookupMsgid(ctx context.Context, msgid string, includeData b
} else { } else {
err = row.Scan(&nanoSeq, &nanoConv, &id, &data) err = row.Scan(&nanoSeq, &nanoConv, &id, &data)
} }
if err != sql.ErrNoRows {
mysql.logError("could not resolve msgid to time", err)
}
if err != nil { if err != nil {
if err != sql.ErrNoRows {
err = fmt.Errorf("could not resolve msgid to time: %w", err)
}
return return
} }
nanotime := extractNanotime(nanoSeq, nanoConv) nanotime := extractNanotime(nanoSeq, nanoConv)
@ -857,8 +877,8 @@ func extractNanotime(seq, conv sql.NullInt64) (result int64) {
func (mysql *MySQL) selectItems(ctx context.Context, query string, args ...interface{}) (results []history.Item, err error) { func (mysql *MySQL) selectItems(ctx context.Context, query string, args ...interface{}) (results []history.Item, err error) {
rows, err := mysql.db.QueryContext(ctx, query, args...) rows, err := mysql.db.QueryContext(ctx, query, args...)
if mysql.logError("could not select history items", err) { if err != nil {
return return nil, fmt.Errorf("could not select history items: %w", err)
} }
defer rows.Close() defer rows.Close()
@ -867,12 +887,12 @@ func (mysql *MySQL) selectItems(ctx context.Context, query string, args ...inter
var blob []byte var blob []byte
var item history.Item var item history.Item
err = rows.Scan(&blob) err = rows.Scan(&blob)
if mysql.logError("could not scan history item", err) { if err != nil {
return return nil, fmt.Errorf("could not scan history item: %w", err)
} }
err = unmarshalItem(blob, &item) err = history.UnmarshalItem(blob, &item)
if mysql.logError("could not unmarshal history item", err) { if err != nil {
return return nil, fmt.Errorf("could not unmarshal history item: %w", err)
} }
results = append(results, item) results = append(results, item)
} }
@ -949,7 +969,7 @@ func (mysql *MySQL) listCorrespondentsInternal(ctx context.Context, target strin
rows, err := mysql.db.QueryContext(ctx, query, args...) rows, err := mysql.db.QueryContext(ctx, query, args...)
if err != nil { if err != nil {
return return nil, fmt.Errorf("could not query correspondents: %w", err)
} }
defer rows.Close() defer rows.Close()
var correspondent string var correspondent string
@ -957,11 +977,11 @@ func (mysql *MySQL) listCorrespondentsInternal(ctx context.Context, target strin
for rows.Next() { for rows.Next() {
err = rows.Scan(&correspondent, &nanotime) err = rows.Scan(&correspondent, &nanotime)
if err != nil { if err != nil {
return return nil, fmt.Errorf("could not scan correspondents: %w", err)
} }
results = append(results, history.TargetListing{ results = append(results, history.TargetListing{
CfName: correspondent, CfName: correspondent,
Time: time.Unix(0, nanotime), Time: time.Unix(0, nanotime).UTC(),
}) })
} }
@ -972,6 +992,19 @@ func (mysql *MySQL) listCorrespondentsInternal(ctx context.Context, target strin
return return
} }
func (mysql *MySQL) ListCorrespondents(cftarget string, start, end time.Time, limit int) (results []history.TargetListing, err error) {
ctx, cancel := context.WithTimeout(context.Background(), mysql.getTimeout())
defer cancel()
// TODO accept msgids here?
results, err = mysql.listCorrespondentsInternal(ctx, cftarget, start, end, time.Time{}, limit)
if err != nil {
return nil, fmt.Errorf("could not read correspondents: %w", err)
}
return
}
func (mysql *MySQL) ListChannels(cfchannels []string) (results []history.TargetListing, err error) { func (mysql *MySQL) ListChannels(cfchannels []string) (results []history.TargetListing, err error) {
if mysql.db == nil { if mysql.db == nil {
return return
@ -985,7 +1018,7 @@ func (mysql *MySQL) ListChannels(cfchannels []string) (results []history.TargetL
defer cancel() defer cancel()
var queryBuf strings.Builder var queryBuf strings.Builder
args := make([]interface{}, 0, len(results)) args := make([]interface{}, 0, len(cfchannels))
// https://dev.mysql.com/doc/refman/8.0/en/group-by-optimization.html // https://dev.mysql.com/doc/refman/8.0/en/group-by-optimization.html
// this should be a "loose index scan" // this should be a "loose index scan"
queryBuf.WriteString(`SELECT sequence.target, MAX(sequence.nanotime) FROM sequence queryBuf.WriteString(`SELECT sequence.target, MAX(sequence.nanotime) FROM sequence
@ -1000,8 +1033,8 @@ func (mysql *MySQL) ListChannels(cfchannels []string) (results []history.TargetL
queryBuf.WriteString(") GROUP BY sequence.target;") queryBuf.WriteString(") GROUP BY sequence.target;")
rows, err := mysql.db.QueryContext(ctx, queryBuf.String(), args...) rows, err := mysql.db.QueryContext(ctx, queryBuf.String(), args...)
if mysql.logError("could not query channel listings", err) { if err != nil {
return return nil, fmt.Errorf("could not query channel listings: %w", err)
} }
defer rows.Close() defer rows.Close()
@ -1009,23 +1042,24 @@ func (mysql *MySQL) ListChannels(cfchannels []string) (results []history.TargetL
var nanotime int64 var nanotime int64
for rows.Next() { for rows.Next() {
err = rows.Scan(&target, &nanotime) err = rows.Scan(&target, &nanotime)
if mysql.logError("could not scan channel listings", err) { if err != nil {
return return nil, fmt.Errorf("could not scan channel listings: %w", err)
} }
results = append(results, history.TargetListing{ results = append(results, history.TargetListing{
CfName: target, CfName: target,
Time: time.Unix(0, nanotime), Time: time.Unix(0, nanotime).UTC(),
}) })
} }
return return
} }
func (mysql *MySQL) Close() { func (mysql *MySQL) Close() (err error) {
// closing the database will close our prepared statements as well // closing the database will close our prepared statements as well
if mysql.db != nil { if mysql.db != nil {
mysql.db.Close() err = mysql.db.Close()
} }
mysql.db = nil mysql.db = nil
return
} }
// implements history.Sequence, emulating a single history buffer (for a channel, // implements history.Sequence, emulating a single history buffer (for a channel,
@ -1072,19 +1106,6 @@ func (s *mySQLHistorySequence) Around(start history.Selector, limit int) (result
return history.GenericAround(s, start, limit) return history.GenericAround(s, start, limit)
} }
func (seq *mySQLHistorySequence) ListCorrespondents(start, end history.Selector, limit int) (results []history.TargetListing, err error) {
ctx, cancel := context.WithTimeout(context.Background(), seq.mysql.getTimeout())
defer cancel()
// TODO accept msgids here?
startTime := start.Time
endTime := end.Time
results, err = seq.mysql.listCorrespondentsInternal(ctx, seq.target, startTime, endTime, seq.cutoff, limit)
seq.mysql.logError("could not read correspondents", err)
return
}
func (seq *mySQLHistorySequence) Cutoff() time.Time { func (seq *mySQLHistorySequence) Cutoff() time.Time {
return seq.cutoff return seq.cutoff
} }

View File

@ -1,23 +0,0 @@
package mysql
import (
"encoding/json"
"github.com/ergochat/ergo/irc/history"
"github.com/ergochat/ergo/irc/utils"
)
// 123 / '{' is the magic number that means JSON;
// if we want to do a binary encoding later, we just have to add different magic version numbers
func marshalItem(item *history.Item) (result []byte, err error) {
return json.Marshal(item)
}
func unmarshalItem(data []byte, result *history.Item) (err error) {
return json.Unmarshal(data, result)
}
func decodeMsgid(msgid string) ([]byte, error) {
return utils.B32Encoder.DecodeString(msgid)
}

31
irc/mysql/stub.go Normal file
View File

@ -0,0 +1,31 @@
//go:build !mysql
package mysql
// Copyright (c) 2020 Shivaram Lingamneni
// released under the MIT license
import (
"errors"
"github.com/ergochat/ergo/irc/history"
"github.com/ergochat/ergo/irc/logger"
)
// Enabled is false when MySQL support is not compiled in
const Enabled = false
// MySQL is a stub implementation when the mysql build tag is not present
type MySQL struct {
history.Database
}
// NewMySQLDatabase returns an error when MySQL support is not compiled in
func NewMySQLDatabase(logger *logger.Manager, config Config) (*MySQL, error) {
return nil, errors.New("MySQL support not enabled in this build. Rebuild with `make build_full` to enable")
}
// SetConfig is a no-op for the stub implementation
func (m *MySQL) SetConfig(config Config) {
// no-op
}

View File

@ -128,9 +128,11 @@ func performNickChange(server *Server, client *Client, target *Client, session *
} }
newCfnick := target.NickCasefolded() newCfnick := target.NickCasefolded()
if newCfnick != details.nickCasefolded { // send MONITOR updates only for nick changes, not for new connection registration;
client.server.monitorManager.AlertAbout(details.nick, details.nickCasefolded, false) // defer MONITOR for new connection registration until pre-registration metadata is applied
client.server.monitorManager.AlertAbout(assignedNickname, newCfnick, true) 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 return nil
} }

View File

@ -241,6 +241,18 @@ indicate an empty password, use * instead.`,
"password": { "password": {
aliasOf: "passwd", 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": { "get": {
handler: nsGetHandler, handler: nsGetHandler,
help: `Syntax: $bGET <setting>$b help: `Syntax: $bGET <setting>$b
@ -1043,10 +1055,10 @@ func nsSaregisterHandler(service *ircService, server *Server, client *Client, co
var failCode string var failCode string
if err == errAccountAlreadyRegistered || err == errAccountAlreadyVerified { if err == errAccountAlreadyRegistered || err == errAccountAlreadyVerified {
errMsg = client.t("Account already exists") errMsg = client.t("Account already exists")
failCode = "USERNAME_EXISTS" failCode = "ACCOUNT_EXISTS"
} else if err == errNameReserved { } else if err == errNameReserved {
errMsg = client.t(err.Error()) errMsg = client.t(err.Error())
failCode = "USERNAME_EXISTS" failCode = "ACCOUNT_EXISTS"
} else if err == errAccountBadPassphrase { } else if err == errAccountBadPassphrase {
errMsg = client.t("Passphrase contains forbidden characters or is otherwise invalid") errMsg = client.t("Passphrase contains forbidden characters or is otherwise invalid")
failCode = "INVALID_PASSWORD" failCode = "INVALID_PASSWORD"
@ -1310,21 +1322,24 @@ func nsClientsListHandler(service *ircService, server *Server, client *Client, p
service.Notice(rb, fmt.Sprintf(client.t("Client %d:"), session.sessionID)) service.Notice(rb, fmt.Sprintf(client.t("Client %d:"), session.sessionID))
} }
if session.deviceID != "" { 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 { 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("IP address: %s"), session.ip.String()))
service.Notice(rb, fmt.Sprintf(client.t("Last active: %s"), session.atime.Format(time.RFC1123))) 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 != "" { 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 { for _, capStr := range session.caps {
if capStr != "" { if capStr != "" {
service.Notice(rb, fmt.Sprintf(client.t("IRCv3 CAPs: %s"), capStr)) service.Notice(rb, fmt.Sprintf(client.t("IRCv3 CAPs: %s"), capStr))
} }
} }
} }
@ -1656,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

@ -79,8 +79,8 @@ const (
RPL_WHOISACTUALLY = "338" RPL_WHOISACTUALLY = "338"
RPL_INVITING = "341" RPL_INVITING = "341"
RPL_SUMMONING = "342" RPL_SUMMONING = "342"
RPL_INVITELIST = "346" RPL_INVEXLIST = "346"
RPL_ENDOFINVITELIST = "347" RPL_ENDOFINVEXLIST = "347"
RPL_EXCEPTLIST = "348" RPL_EXCEPTLIST = "348"
RPL_ENDOFEXCEPTLIST = "349" RPL_ENDOFEXCEPTLIST = "349"
RPL_VERSION = "351" RPL_VERSION = "351"
@ -183,6 +183,13 @@ const (
RPL_MONLIST = "732" RPL_MONLIST = "732"
RPL_ENDOFMONLIST = "733" RPL_ENDOFMONLIST = "733"
ERR_MONLISTFULL = "734" 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_LOGGEDIN = "900"
RPL_LOGGEDOUT = "901" RPL_LOGGEDOUT = "901"
ERR_NICKLOCKED = "902" ERR_NICKLOCKED = "902"

View File

@ -6,14 +6,19 @@ package irc
import ( import (
"fmt" "fmt"
"runtime/debug" "runtime/debug"
"time"
) )
// HandlePanic is a general-purpose panic handler for ad-hoc goroutines. // HandlePanic is a general-purpose panic handler for ad-hoc goroutines.
// Because of the semantics of `recover`, it must be called directly // Because of the semantics of `recover`, it must be called directly
// from the routine on whose call stack the panic would occur, with `defer`, // from the routine on whose call stack the panic would occur, with `defer`,
// e.g. `defer server.HandlePanic()` // e.g. `defer server.HandlePanic()`
func (server *Server) HandlePanic() { func (server *Server) HandlePanic(restartable func()) {
if r := recover(); r != nil { if r := recover(); r != nil {
server.logger.Error("internal", fmt.Sprintf("Panic encountered: %v\n%s", r, debug.Stack())) 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,11 +3,15 @@
package passwd package passwd
import "golang.org/x/crypto/bcrypt" import (
import "golang.org/x/crypto/sha3" "crypto/sha3"
"golang.org/x/crypto/bcrypt"
)
const ( const (
MinCost = bcrypt.MinCost MinCost = bcrypt.MinCost
MaxCost = bcrypt.MaxCost
DefaultCost = 12 // ballpark: 250 msec on a modern Intel CPU DefaultCost = 12 // ballpark: 250 msec on a modern Intel CPU
) )

41
irc/postgresql/config.go Normal file
View File

@ -0,0 +1,41 @@
// Copyright (c) 2020 Shivaram Lingamneni
// released under the MIT license
package postgresql
import (
"time"
)
const (
// maximum length in bytes of any message target (nickname or channel name) in its
// canonicalized (i.e., casefolded) state:
MaxTargetLength = 64
)
type Config struct {
// these are intended to be written directly into the config file:
Enabled bool
Host string
Port int
SocketPath string `yaml:"socket-path"`
User string
Password string
HistoryDatabase string `yaml:"history-database"`
Timeout time.Duration
MaxConns int `yaml:"max-conns"`
ConnMaxLifetime time.Duration `yaml:"conn-max-lifetime"`
// PostgreSQL-specific configuration:
ApplicationName string `yaml:"application-name"` // shown in pg_stat_activity
ConnectTimeout time.Duration `yaml:"connect-timeout"` // timeout for establishing connections
// PostgreSQL SSL/TLS configuration:
SSLMode string `yaml:"ssl-mode"` // disable, require, verify-ca, verify-full
SSLCert string `yaml:"ssl-cert"` // client certificate path
SSLKey string `yaml:"ssl-key"` // client key path
SSLRootCert string `yaml:"ssl-root-cert"` // CA certificate path
URI string `yaml:"uri"` // libpq postgresql:// URI overriding the above
// XXX these are copied from elsewhere in the config:
ExpireTime time.Duration
TrackAccountMessages bool
}

View File

@ -0,0 +1,86 @@
//go:build postgres
// Copyright (c) 2020 Shivaram Lingamneni
// released under the MIT license
package postgresql
import (
"testing"
"time"
)
func testBuildURI(t *testing.T, config Config, expected string) {
t.Helper()
uri, err := config.buildURI()
if err != nil {
t.Fatal(err)
}
if uri != expected {
t.Errorf("got %q, want %q", uri, expected)
}
}
func TestBuildURITCP(t *testing.T) {
testBuildURI(t, Config{
Host: "db.example.com",
Port: 5432,
User: "ergo",
Password: "secret",
HistoryDatabase: "ergo_history",
}, "postgresql://ergo:secret@db.example.com:5432/ergo_history?sslmode=disable")
}
func TestBuildURIDefaultPort(t *testing.T) {
testBuildURI(t, Config{
Host: "localhost",
HistoryDatabase: "ergo_history",
}, "postgresql://localhost:5432/ergo_history?sslmode=disable")
}
func TestBuildURIDefaultHost(t *testing.T) {
testBuildURI(t, Config{
HistoryDatabase: "ergo_history",
}, "postgresql://localhost:5432/ergo_history?sslmode=disable")
}
func TestBuildURISSLMode(t *testing.T) {
testBuildURI(t, Config{
Host: "db.example.com",
Port: 5432,
HistoryDatabase: "ergo_history",
SSLMode: "verify-full",
SSLCert: "/etc/ssl/client.crt",
SSLKey: "/etc/ssl/client.key",
SSLRootCert: "/etc/ssl/ca.crt",
}, "postgresql://db.example.com:5432/ergo_history?sslcert=%2Fetc%2Fssl%2Fclient.crt&sslkey=%2Fetc%2Fssl%2Fclient.key&sslmode=verify-full&sslrootcert=%2Fetc%2Fssl%2Fca.crt")
}
func TestBuildURIUnixSocket(t *testing.T) {
testBuildURI(t, Config{
SocketPath: "/var/run/postgresql",
User: "ergo",
Password: "secret",
HistoryDatabase: "ergo_history",
}, "postgresql://ergo:secret@/ergo_history?host=%2Fvar%2Frun%2Fpostgresql")
}
func TestBuildURISpecialCharsInPassword(t *testing.T) {
testBuildURI(t, Config{
Host: "db.example.com",
Port: 5432,
User: "ergo",
Password: "p@ss:w/ord?#&=",
HistoryDatabase: "ergo_history",
}, "postgresql://ergo:p%40ss%3Aw%2Ford%3F%23&=@db.example.com:5432/ergo_history?sslmode=disable")
}
func TestBuildURIOptionalParams(t *testing.T) {
testBuildURI(t, Config{
Host: "db.example.com",
Port: 5433,
HistoryDatabase: "ergo_history",
ApplicationName: "ergo",
ConnectTimeout: 30 * time.Second,
}, "postgresql://db.example.com:5433/ergo_history?application_name=ergo&connect_timeout=30&sslmode=disable")
}

1200
irc/postgresql/history.go Normal file

File diff suppressed because it is too large Load Diff

31
irc/postgresql/stub.go Normal file
View File

@ -0,0 +1,31 @@
//go:build !postgresql
// Copyright (c) 2020 Shivaram Lingamneni
// released under the MIT license
package postgresql
import (
"errors"
"github.com/ergochat/ergo/irc/history"
"github.com/ergochat/ergo/irc/logger"
)
// Enabled is false when PostgreSQL support is not compiled in
const Enabled = false
// PostgreSQL is a stub implementation when the postgres build tag is not present
type PostgreSQL struct {
history.Database
}
// NewPostgreSQLDatabase returns an error when PostgreSQL support is not compiled in
func NewPostgreSQLDatabase(logger *logger.Manager, config Config) (*PostgreSQL, error) {
return nil, errors.New("PostgreSQL support not enabled in this build. Rebuild with `make build_full` to enable")
}
// SetConfig is a no-op for the stub implementation
func (pg *PostgreSQL) SetConfig(config Config) {
// no-op
}

View File

@ -13,8 +13,8 @@ import (
) )
const ( const (
npcNickMask = "*%s*!%s@npc.fakeuser.invalid" defaultNPCNickMask = "*%s*!%s@npc.fakeuser.invalid"
sceneNickMask = "=Scene=!%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) { 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 var sourceMask string
if isScene { if isScene {
sourceMask = fmt.Sprintf(sceneNickMask, client.Nick()) sourceMask = fmt.Sprintf(server.Config().Roleplay.SceneNickMask, client.Nick())
} else { } else {
cfSource, cfSourceErr := CasefoldName(source) cfSource, cfSourceErr := CasefoldName(source)
skelSource, skelErr := Skeleton(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")) rb.Add(nil, client.server.name, ERR_CANNOTSENDRP, targetString, client.t("Invalid roleplay name"))
return 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 // block attempts to send CTCP messages to Tor clients

View File

@ -34,28 +34,20 @@ import (
"github.com/ergochat/ergo/irc/logger" "github.com/ergochat/ergo/irc/logger"
"github.com/ergochat/ergo/irc/modes" "github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/mysql" "github.com/ergochat/ergo/irc/mysql"
"github.com/ergochat/ergo/irc/postgresql"
"github.com/ergochat/ergo/irc/sno" "github.com/ergochat/ergo/irc/sno"
"github.com/ergochat/ergo/irc/sqlite"
"github.com/ergochat/ergo/irc/utils" "github.com/ergochat/ergo/irc/utils"
"github.com/ergochat/ergo/irc/webpush"
) )
const ( const (
alwaysOnMaintenanceInterval = 30 * time.Minute alwaysOnMaintenanceInterval = 30 * time.Minute
) pushMaintenanceInterval = 24 * time.Hour
var (
// common error line to sub values into // common error line to sub values into
errorMsg = "ERROR :%s\r\n" 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 // we only have standard channels for now. TODO: any updates to this
// will also need to be reflected in CasefoldChannel // will also need to be reflected in CasefoldChannel
chanTypes = "#" chanTypes = "#"
@ -63,6 +55,17 @@ var (
throttleMessage = "You have attempted to connect too many times within a short duration. Wait a while, and you will be able to connect." 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. // Server is the main Oragono server.
type Server struct { type Server struct {
accepts AcceptManager accepts AcceptManager
@ -89,13 +92,22 @@ type Server struct {
snomasks SnoManager snomasks SnoManager
store *buntdb.DB store *buntdb.DB
dstore datastore.Datastore dstore datastore.Datastore
historyDB mysql.MySQL mysqlHistoryDB *mysql.MySQL
postgresHistoryDB *postgresql.PostgreSQL
sqliteHistoryDB *sqlite.SQLite
historyDB history.Database
torLimiter connection_limits.TorLimiter torLimiter connection_limits.TorLimiter
whoWas WhoWasList whoWas WhoWasList
stats Stats stats Stats
semaphores ServerSemaphores semaphores ServerSemaphores
flock flock.Flocker flock flock.Flocker
connIDCounter atomic.Uint64
defcon atomic.Uint32 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. // NewServer returns a new Oragono server.
@ -122,6 +134,8 @@ func NewServer(config *Config, logger *logger.Manager) (*Server, error) {
server.monitorManager.Initialize() server.monitorManager.Initialize()
server.snomasks.Initialize() server.snomasks.Initialize()
server.apiHandler = newAPIHandler(server)
if err := server.applyConfig(config); err != nil { if err := server.applyConfig(config); err != nil {
return nil, err return nil, err
} }
@ -134,6 +148,7 @@ func NewServer(config *Config, logger *logger.Manager) (*Server, error) {
} }
time.AfterFunc(alwaysOnMaintenanceInterval, server.periodicAlwaysOnMaintenance) time.AfterFunc(alwaysOnMaintenanceInterval, server.periodicAlwaysOnMaintenance)
time.AfterFunc(pushMaintenanceInterval, server.periodicPushMaintenance)
return server, nil return server, nil
} }
@ -143,7 +158,6 @@ func (server *Server) Shutdown() {
sdnotify.Stopping() sdnotify.Stopping()
server.logger.Info("server", "Stopping server") server.logger.Info("server", "Stopping server")
//TODO(dan): Make sure we disallow new nicks
for _, client := range server.clients.AllClients() { for _, client := range server.clients.AllClients() {
client.Notice("Server is shutting down") client.Notice("Server is shutting down")
} }
@ -152,10 +166,12 @@ func (server *Server) Shutdown() {
server.performAlwaysOnMaintenance(false, true) server.performAlwaysOnMaintenance(false, true)
if err := server.store.Close(); err != nil { if err := server.store.Close(); err != nil {
server.logger.Error("shutdown", fmt.Sprintln("Could not close datastore:", err)) server.logger.Error("shutdown", "Could not close datastore", err.Error())
} }
server.historyDB.Close() if err := server.historyDB.Close(); err != nil {
server.logger.Error("shutdown", "Could not close history database", err.Error())
}
server.logger.Info("server", fmt.Sprintf("%s exiting", Ver)) server.logger.Info("server", fmt.Sprintf("%s exiting", Ver))
} }
@ -266,7 +282,7 @@ func (server *Server) periodicAlwaysOnMaintenance() {
time.AfterFunc(alwaysOnMaintenanceInterval, server.periodicAlwaysOnMaintenance) time.AfterFunc(alwaysOnMaintenanceInterval, server.periodicAlwaysOnMaintenance)
}() }()
defer server.HandlePanic() defer server.HandlePanic(nil)
server.logger.Info("accounts", "Performing periodic always-on client checks") server.logger.Info("accounts", "Performing periodic always-on client checks")
server.performAlwaysOnMaintenance(true, true) server.performAlwaysOnMaintenance(true, true)
@ -290,6 +306,47 @@ func (server *Server) performAlwaysOnMaintenance(checkExpiration, flushTimestamp
} }
} }
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)
}
}
// handles server.ip-check-script.exempt-sasl: // handles server.ip-check-script.exempt-sasl:
// run the ip check script at the end of the handshake, only for anonymous connections // run the ip check script at the end of the handshake, only for anonymous connections
func (server *Server) checkBanScriptExemptSASL(config *Config, session *Session) (outcome AuthOutcome) { func (server *Server) checkBanScriptExemptSASL(config *Config, session *Session) (outcome AuthOutcome) {
@ -302,7 +359,7 @@ func (server *Server) checkBanScriptExemptSASL(config *Config, session *Session)
return authSuccess return authSuccess
} }
if output.Result == IPBanned || output.Result == IPRequireSASL { 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 != "" { if output.BanMessage != "" {
session.client.requireSASLMessage = output.BanMessage session.client.requireSASLMessage = output.BanMessage
} }
@ -377,16 +434,27 @@ func (server *Server) tryRegister(c *Client, session *Session) (exiting bool) {
c.SetMode(defaultMode, true) 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) // count new user in statistics (before checking KLINEs, see #1303)
server.stats.Register(c.HasMode(modes.Invisible)) server.stats.Register(c.HasMode(modes.Invisible))
// check KLINEs (#671: ignore KLINEs for loopback connections) // check KLINEs (#671: ignore KLINEs for loopback connections)
if !session.IP().IsLoopback() || session.isTor { if !session.IP().IsLoopback() || session.isTor {
isBanned, info := server.klines.CheckMasks(c.AllNickmasks()...) isBanned, info := server.klines.CheckMasks(c.AllNickmasks()...)
if isBanned { if isBanned && !(info.RequireSASL && session.client.Account() != "") {
c.setKlined() c.setKlined()
c.Quit(info.BanMessage(c.t("You are banned from this server (%s)")), nil) c.Quit(info.BanMessage(c.t("You are banned from this server (%s)")), nil)
server.logger.Info("connect", "Client rejected by k-line", c.NickMaskString()) server.logger.Info("connect", session.connID, "Client rejected by k-line", c.NickMaskString())
return true return true
} }
} }
@ -418,7 +486,7 @@ func (server *Server) playRegistrationBurst(session *Session) {
c := session.client c := session.client
// continue registration // continue registration
d := c.Details() 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)) 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 != "" { if d.account != "" {
server.sendLoginSnomask(d.nickMask, d.accountName) server.sendLoginSnomask(d.nickMask, d.accountName)
@ -431,10 +499,16 @@ 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_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_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))) 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) session.Send(nil, server.name, RPL_MYINFO, d.nick, server.name, Ver, rplMyInfo1, rplMyInfo2, rplMyInfo3)
rb := NewResponseBuffer(session) rb := NewResponseBuffer(session)
server.RplISupport(c, rb) if !(rb.session.capabilities.Has(caps.ExtendedISupport) && rb.session.isupportSentPrereg) {
server.RplISupport(c, rb)
}
if session.capabilities.Has(caps.Metadata) {
playMetadataVerbBatch(rb, d.nick, c.ListMetadata())
}
if d.account != "" && session.capabilities.Has(caps.Persistence) { if d.account != "" && session.capabilities.Has(caps.Persistence) {
reportPersistenceStatus(c, rb, false) reportPersistenceStatus(c, rb, false)
} }
@ -456,15 +530,22 @@ func (server *Server) playRegistrationBurst(session *Session) {
// RplISupport outputs our ISUPPORT lines to the client. This is used on connection and in VERSION responses. // RplISupport outputs our ISUPPORT lines to the client. This is used on connection and in VERSION responses.
func (server *Server) RplISupport(client *Client, rb *ResponseBuffer) { func (server *Server) RplISupport(client *Client, rb *ResponseBuffer) {
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() nick := client.Nick()
config := server.Config() for _, cachedTokenLine := range lines {
for _, cachedTokenLine := range config.Server.isupport.CachedReply {
length := len(cachedTokenLine) + 2 length := len(cachedTokenLine) + 2
tokenline := make([]string, length) tokenline := make([]string, length)
tokenline[0] = nick tokenline[0] = nick
copy(tokenline[1:], cachedTokenLine) copy(tokenline[1:], cachedTokenLine)
tokenline[length-1] = translatedISupport tokenline[length-1] = finalText
rb.Add(nil, server.name, RPL_ISUPPORT, tokenline...) rb.Add(nil, server.name, RPL_ISUPPORT, tokenline...)
} }
} }
@ -562,7 +643,6 @@ func (client *Client) getWhoisOf(target *Client, hasPrivs bool, rb *ResponseBuff
if target.HasMode(modes.Bot) { 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)) 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") { if client == target || oper.HasRoleCapab("ban") {
for _, session := range target.Sessions() { for _, session := range target.Sessions() {
if session.certfp != "" { if session.certfp != "" {
@ -574,12 +654,17 @@ func (client *Client) getWhoisOf(target *Client, hasPrivs bool, rb *ResponseBuff
if away, awayMessage := target.Away(); away { if away, awayMessage := target.Away(); away {
rb.Add(nil, client.server.name, RPL_AWAY, cnick, tnick, awayMessage) 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. // rehash reloads the config and applies the changes from the config file.
func (server *Server) rehash() error { func (server *Server) rehash() error {
// #1570; this needs its own panic handling because it can be invoked via SIGHUP // #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") server.logger.Info("server", "Attempting rehash")
@ -617,6 +702,9 @@ func (server *Server) applyConfig(config *Config) (err error) {
globalCasemappingSetting = config.Server.Casemapping globalCasemappingSetting = config.Server.Casemapping
globalUtf8EnforcementSetting = config.Server.EnforceUtf8 globalUtf8EnforcementSetting = config.Server.EnforceUtf8
MaxLineLen = config.Server.MaxLineLen MaxLineLen = config.Server.MaxLineLen
RegisterTimeout = config.Server.IdleTimeouts.Registration
PingTimeout = config.Server.IdleTimeouts.Ping
DisconnectTimeout = config.Server.IdleTimeouts.Disconnect
} else { } else {
// enforce configs that can't be changed after launch: // enforce configs that can't be changed after launch:
if server.name != config.Server.Name { if server.name != config.Server.Name {
@ -642,6 +730,8 @@ func (server *Server) applyConfig(config *Config) (err error) {
return fmt.Errorf("Cannot enable MySQL after launching the server, rehash aborted") return fmt.Errorf("Cannot enable MySQL after launching the server, rehash aborted")
} else if oldConfig.Server.MaxLineLen != config.Server.MaxLineLen { } else if oldConfig.Server.MaxLineLen != config.Server.MaxLineLen {
return fmt.Errorf("Cannot change max-line-len after launching the server, rehash aborted") 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")
} }
} }
@ -720,8 +810,20 @@ func (server *Server) applyConfig(config *Config) (err error) {
return err return err
} }
} else { } else {
if config.Datastore.MySQL.Enabled && config.Datastore.MySQL != oldConfig.Datastore.MySQL { if config.Datastore.MySQL.Enabled && server.mysqlHistoryDB != nil {
server.historyDB.SetConfig(config.Datastore.MySQL) if config.Datastore.MySQL != oldConfig.Datastore.MySQL {
server.mysqlHistoryDB.SetConfig(config.Datastore.MySQL)
}
}
if config.Datastore.PostgreSQL.Enabled && server.postgresHistoryDB != nil {
if config.Datastore.PostgreSQL != oldConfig.Datastore.PostgreSQL {
server.postgresHistoryDB.SetConfig(config.Datastore.PostgreSQL)
}
}
if config.Datastore.SQLite.Enabled && server.sqliteHistoryDB != nil {
if config.Datastore.SQLite != oldConfig.Datastore.SQLite {
server.sqliteHistoryDB.SetConfig(config.Datastore.SQLite)
}
} }
} }
@ -733,6 +835,16 @@ func (server *Server) applyConfig(config *Config) (err error) {
return fmt.Errorf("Could not load cloak secret: %w", err) return fmt.Errorf("Could not load cloak secret: %w", err)
} }
config.Server.Cloaks.SetSecret(cloakSecret) 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 // activate the new config
server.config.Store(config) server.config.Store(config)
@ -775,6 +887,8 @@ func (server *Server) applyConfig(config *Config) (err error) {
server.setupPprofListener(config) server.setupPprofListener(config)
server.setupAPIListener(config)
// set RPL_ISUPPORT // set RPL_ISUPPORT
var newISupportReplies [][]string var newISupportReplies [][]string
if oldConfig != nil { if oldConfig != nil {
@ -794,13 +908,19 @@ func (server *Server) applyConfig(config *Config) (err error) {
} }
if !initial { if !initial {
// push new info to all of our clients // send 005 updates (somewhat rare)
for _, sClient := range server.clients.AllClients() { if len(newISupportReplies) != 0 {
for _, tokenline := range newISupportReplies { for _, sClient := range server.clients.AllClients() {
sClient.Send(nil, server.name, RPL_ISUPPORT, append([]string{sClient.nick}, tokenline...)...) for _, session := range sClient.Sessions() {
rb := NewResponseBuffer(session)
server.sendRplISupportLines(sClient, rb, newISupportReplies)
rb.Send(false)
}
} }
}
if sendRawOutputNotice { if sendRawOutputNotice {
for _, sClient := range server.clients.AllClients() {
sClient.Notice(sClient.t("This server is in debug mode and is logging all user I/O. If you do not wish for everything you send to be readable by the server owner(s), please disconnect.")) sClient.Notice(sClient.t("This server is in debug mode and is logging all user I/O. If you do not wish for everything you send to be readable by the server owner(s), please disconnect."))
} }
} }
@ -810,6 +930,9 @@ func (server *Server) applyConfig(config *Config) (err error) {
if config.Accounts.RequireSasl.Enabled && config.Accounts.Registration.Enabled { 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.") 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 return err
} }
@ -837,6 +960,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 { func (server *Server) loadDatastore(config *Config) error {
// open the datastore and load server state for which it (rather than config) // open the datastore and load server state for which it (rather than config)
// is the source of truth // is the source of truth
@ -870,12 +1033,28 @@ func (server *Server) loadFromDatastore(config *Config) (err error) {
server.accounts.Initialize(server) server.accounts.Initialize(server)
if config.Datastore.MySQL.Enabled { if config.Datastore.MySQL.Enabled {
server.historyDB.Initialize(server.logger, config.Datastore.MySQL) server.mysqlHistoryDB, err = mysql.NewMySQLDatabase(server.logger, config.Datastore.MySQL)
err = server.historyDB.Open()
if err != nil { if err != nil {
server.logger.Error("internal", "could not connect to mysql", err.Error()) server.logger.Error("internal", "could not connect to mysql", err.Error())
return err return err
} }
server.historyDB = server.mysqlHistoryDB
} else if config.Datastore.PostgreSQL.Enabled {
server.postgresHistoryDB, err = postgresql.NewPostgreSQLDatabase(server.logger, config.Datastore.PostgreSQL)
if err != nil {
server.logger.Error("internal", "could not connect to postgresql", err.Error())
return err
}
server.historyDB = server.postgresHistoryDB
} else if config.Datastore.SQLite.Enabled {
server.sqliteHistoryDB, err = sqlite.NewSQLiteDatabase(server.logger, config.Datastore.SQLite)
if err != nil {
server.logger.Error("internal", "could not open sqlite database", err.Error())
return err
}
server.historyDB = server.sqliteHistoryDB
} else {
server.historyDB = history.NewNoopDatabase()
} }
return nil return nil
@ -940,10 +1119,6 @@ func (server *Server) setupListeners(config *Config) (err error) {
// we may already know the channel we're querying, or we may have // we may already know the channel we're querying, or we may have
// to look it up via a string query. This function is responsible for // to look it up via a string query. This function is responsible for
// privilege checking. // privilege checking.
// XXX: call this with providedChannel==nil and query=="" to get a sequence
// suitable for ListCorrespondents (i.e., this function is still used to
// decide whether the ringbuf or mysql is authoritative about the client's
// message history).
func (server *Server) GetHistorySequence(providedChannel *Channel, client *Client, query string) (channel *Channel, sequence history.Sequence, err error) { func (server *Server) GetHistorySequence(providedChannel *Channel, client *Client, query string) (channel *Channel, sequence history.Sequence, err error) {
config := server.Config() config := server.Config()
// 4 cases: {persistent, ephemeral} x {normal, conversation} // 4 cases: {persistent, ephemeral} x {normal, conversation}
@ -1095,7 +1270,7 @@ func (server *Server) DeleteMessage(target, msgid, accountName string) (err erro
return item.Message.Msgid == msgid && (accountName == "*" || item.AccountName == accountName) return item.Message.Msgid == msgid && (accountName == "*" || item.AccountName == accountName)
}) })
if count == 0 { if count == 0 {
err = errNoop err = history.ErrNotFound
} }
} }
@ -1109,6 +1284,16 @@ func (server *Server) UnfoldName(cfname string) (name string) {
return server.clients.UnfoldNick(cfname) 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 // elistMatcher takes and matches ELIST conditions
type elistMatcher struct { type elistMatcher struct {
MinClientsActive bool MinClientsActive bool

View File

@ -7,7 +7,7 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"log" "log"
"sort" "slices"
"strings" "strings"
"time" "time"
@ -223,7 +223,6 @@ func serviceRunCommand(service *ircService, server *Server, client *Client, cmd
return return
} }
server.logger.Debug("services", fmt.Sprintf("Client %s ran %s command %s", client.Nick(), service.Name, commandName))
if commandName == "help" { if commandName == "help" {
serviceHelpHandler(service, server, client, params, rb) serviceHelpHandler(service, server, client, params, rb)
} else { } else {
@ -251,7 +250,7 @@ func serviceHelpHandler(service *ircService, server *Server, client *Client, par
client.t("Here are the commands you can use:"), client.t("Here are the commands you can use:"),
}...) }...)
// show general help // show general help
var shownHelpLines sort.StringSlice var shownHelpLines []string
var disabledCommands bool var disabledCommands bool
for _, commandInfo := range service.Commands { for _, commandInfo := range service.Commands {
// skip commands user can't access // skip commands user can't access
@ -269,13 +268,13 @@ func serviceHelpHandler(service *ircService, server *Server, client *Client, par
shownHelpLines = append(shownHelpLines, " "+ircfmt.Unescape(client.t(commandInfo.helpShort))) shownHelpLines = append(shownHelpLines, " "+ircfmt.Unescape(client.t(commandInfo.helpShort)))
} }
// sort help lines
slices.Sort(shownHelpLines)
if disabledCommands { if disabledCommands {
shownHelpLines = append(shownHelpLines, " "+client.t("... and other commands which have been disabled")) shownHelpLines = append(shownHelpLines, " "+client.t("... and other commands which have been disabled"))
} }
// sort help lines
sort.Sort(shownHelpLines)
// push out help text // push out help text
for _, line := range helpBannerLines { for _, line := range helpBannerLines {
sendNotice(line) sendNotice(line)

View File

@ -233,7 +233,7 @@ func (c *Client) Auth(a Auth) error {
} }
resp64 := make([]byte, encoding.EncodedLen(len(resp))) resp64 := make([]byte, encoding.EncodedLen(len(resp)))
encoding.Encode(resp64, 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 { for err == nil {
var msg []byte var msg []byte
switch code { switch code {
@ -259,7 +259,7 @@ func (c *Client) Auth(a Auth) error {
} }
resp64 = make([]byte, encoding.EncodedLen(len(resp))) resp64 = make([]byte, encoding.EncodedLen(len(resp)))
encoding.Encode(resp64, resp) encoding.Encode(resp64, resp)
code, msg64, err = c.cmd(0, string(resp64)) code, msg64, err = c.cmd(0, "%s", resp64)
} }
return err return err
} }

26
irc/sqlite/config.go Normal file
View File

@ -0,0 +1,26 @@
// Copyright (c) 2020 Shivaram Lingamneni
// released under the MIT license
package sqlite
import (
"time"
)
const (
// maximum length in bytes of any message target (nickname or channel name) in its
// canonicalized (i.e., casefolded) state:
MaxTargetLength = 64
)
type Config struct {
// these are intended to be written directly into the config file:
Enabled bool
DatabasePath string `yaml:"database-path"`
BusyTimeout time.Duration `yaml:"busy-timeout"`
MaxConns int `yaml:"max-conns"`
// XXX these are copied from elsewhere in the config:
ExpireTime time.Duration
TrackAccountMessages bool
}

1083
irc/sqlite/history.go Normal file

File diff suppressed because it is too large Load Diff

34
irc/sqlite/stub.go Normal file
View File

@ -0,0 +1,34 @@
//go:build !sqlite || !(linux || darwin || freebsd || windows)
// Copyright (c) 2020 Shivaram Lingamneni
// released under the MIT license
// Package sqlite provides a stub implementation when SQLite support is not enabled.
// To enable SQLite support, build with: make build_full
// This stub prevents the binary from including the large modernc.org/sqlite driver dependencies.
package sqlite
import (
"errors"
"github.com/ergochat/ergo/irc/history"
"github.com/ergochat/ergo/irc/logger"
)
// Enabled is false when SQLite support is not compiled in
const Enabled = false
// SQLite is a stub implementation when the sqlite build tag is not present
type SQLite struct {
history.Database
}
// NewSQLiteDatabase returns an error when SQLite support is not compiled in
func NewSQLiteDatabase(logger *logger.Manager, config Config) (*SQLite, error) {
return nil, errors.New("SQLite support not enabled in this build. Rebuild with `make build_full` to enable")
}
// SetConfig is a no-op for the stub implementation
func (s *SQLite) SetConfig(config Config) {
// no-op
}

View File

@ -7,15 +7,9 @@ package irc
import ( import (
"fmt" "fmt"
"regexp"
"strings" "strings"
"github.com/ergochat/confusables" "github.com/ergochat/ergo/irc/i18n"
"golang.org/x/text/cases"
"golang.org/x/text/secure/precis"
"golang.org/x/text/unicode/norm"
"golang.org/x/text/width"
"github.com/ergochat/ergo/irc/utils" "github.com/ergochat/ergo/irc/utils"
) )
@ -38,87 +32,20 @@ const (
disfavoredNameCharacters = `<>'";#` disfavoredNameCharacters = `<>'";#`
) )
var (
// reviving the old ergonomadic nickname regex:
// in permissive mode, allow arbitrary letters, numbers, punctuation, and symbols
permissiveCharsRegex = regexp.MustCompile(`^[\pL\pN\pP\pS]*$`)
)
type Casemapping uint
const (
// "precis" is the default / zero value:
// casefolding/validation: PRECIS + ircd restrictions (like no *)
// confusables detection: standard skeleton algorithm
CasemappingPRECIS Casemapping = iota
// "ascii" is the traditional ircd behavior:
// casefolding/validation: must be pure ASCII and follow ircd restrictions, ASCII lowercasing
// confusables detection: none
CasemappingASCII
// "permissive" is an insecure mode:
// casefolding/validation: arbitrary unicodes that follow ircd restrictions, unicode casefolding
// 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. // XXX this is a global variable without explicit synchronization.
// it gets set during the initial Server.applyConfig and cannot be changed by rehash: // it gets set during the initial Server.applyConfig and cannot be changed by rehash:
// this happens-before all IRC connections and all casefolding operations. // this happens-before all IRC connections and all casefolding operations.
var globalCasemappingSetting Casemapping = CasemappingPRECIS var globalCasemappingSetting i18n.Casemapping = i18n.DefaultCasemapping
// XXX analogous unsynchronized global variable controlling utf8 validation // XXX analogous unsynchronized global variable controlling utf8 validation
// if this is off, you get the traditional IRC behavior (relaying any valid RFC1459 // 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. // if this is on, invalid utf8 inputs get a FAIL reply.
var globalUtf8EnforcementSetting bool var globalUtf8EnforcementSetting bool
// Each pass of PRECIS casefolding is a composition of idempotent operations,
// but not idempotent itself. Therefore, the spec says "do it four times and hope
// it converges" (lolwtf). Golang's PRECIS implementation has a "repeat" option,
// which provides this functionality, but unfortunately it's not exposed publicly.
func iterateFolding(profile *precis.Profile, oldStr string) (str string, err error) {
str = oldStr
// follow the stabilizing rules laid out here:
// https://tools.ietf.org/html/draft-ietf-precis-7564bis-10.html#section-7
for i := 0; i < 4; i++ {
str, err = profile.CompareKey(str)
if err != nil {
return "", err
}
if oldStr == str {
break
}
oldStr = str
}
if oldStr != str {
return "", errCouldNotStabilize
}
return str, nil
}
// Casefold returns a casefolded string, without doing any name or channel character checks. // Casefold returns a casefolded string, without doing any name or channel character checks.
func Casefold(str string) (string, error) { func Casefold(str string) (string, error) {
return casefoldWithSetting(str, globalCasemappingSetting) return i18n.CasefoldWithSetting(str, globalCasemappingSetting)
}
func casefoldWithSetting(str string, setting Casemapping) (string, error) {
switch setting {
default:
return iterateFolding(precis.UsernameCaseMapped, str)
case CasemappingASCII:
return foldASCII(str)
case CasemappingPermissive:
return foldPermissive(str)
case CasemappingRFC1459:
return foldRFC1459(str, false)
case CasemappingRFC1459Strict:
return foldRFC1459(str, true)
}
} }
// CasefoldChannel returns a casefolded version of a channel name. // CasefoldChannel returns a casefolded version of a channel name.
@ -211,39 +138,17 @@ func isIdent(name string) bool {
} }
// Skeleton produces a canonicalized identifier that tries to catch // Skeleton produces a canonicalized identifier that tries to catch
// homoglyphic / confusable identifiers. It's a tweaked version of the TR39 // homoglyphic / confusable identifiers.
// skeleton algorithm. We apply the skeleton algorithm first and only then casefold,
// because casefolding first would lose some information about visual confusability.
// This has the weird consequence that the skeleton is not a function of the
// casefolded identifier --- therefore it must always be computed
// from the original (unfolded) identifier and stored/tracked separately from the
// casefolded identifier.
func Skeleton(name string) (string, error) { func Skeleton(name string) (string, error) {
switch globalCasemappingSetting { switch globalCasemappingSetting {
default: default:
return realSkeleton(name) return i18n.Skeleton(name)
case CasemappingASCII, CasemappingRFC1459, CasemappingRFC1459Strict: case i18n.CasemappingASCII, i18n.CasemappingRFC1459, i18n.CasemappingRFC1459Strict:
// identity function is fine because we independently case-normalize in Casefold // identity function is fine because we independently case-normalize in Casefold
return name, nil return name, nil
} }
} }
func realSkeleton(name string) (string, error) {
// XXX the confusables table includes some, but not all, fullwidth->standard
// mappings for latin characters. do a pass of explicit width folding,
// same as PRECIS:
name = width.Fold.String(name)
name = confusables.SkeletonTweaked(name)
// internationalized lowercasing for skeletons; this is much more lenient than
// Casefold. In particular, skeletons are expected to mix scripts (which may
// violate the bidi rule). We also don't care if they contain runes
// that are disallowed by PRECIS, because every identifier must independently
// pass PRECIS --- we are just further canonicalizing the skeleton.
return cases.Fold().String(name), nil
}
// maps a nickmask fragment to an expanded, casefolded wildcard: // maps a nickmask fragment to an expanded, casefolded wildcard:
// Shivaram@good-fortune -> *!shivaram@good-fortune // Shivaram@good-fortune -> *!shivaram@good-fortune
// EDMUND -> edmund!*@* // EDMUND -> edmund!*@*
@ -303,30 +208,6 @@ func CanonicalizeMaskWildcard(userhost string) (expanded string, err error) {
return return
} }
func foldASCII(str string) (result string, err error) {
if !IsPrintableASCII(str) {
return "", errInvalidCharacter
}
return strings.ToLower(str), nil
}
var (
rfc1459Replacer = strings.NewReplacer("[", "{", "]", "}", "\\", "|", "~", "^")
rfc1459StrictReplacer = strings.NewReplacer("[", "{", "]", "}", "\\", "|")
)
func foldRFC1459(str string, strict bool) (result string, err error) {
asciiFold, err := foldASCII(str)
if err != nil {
return "", err
}
replacer := rfc1459Replacer
if strict {
replacer = rfc1459StrictReplacer
}
return replacer.Replace(asciiFold), nil
}
func IsPrintableASCII(str string) bool { func IsPrintableASCII(str string) bool {
for i := 0; i < len(str); i++ { for i := 0; i < len(str); i++ {
// allow space here because it's technically printable; // allow space here because it's technically printable;
@ -339,17 +220,6 @@ func IsPrintableASCII(str string) bool {
return true return true
} }
func foldPermissive(str string) (result string, err error) {
if !permissiveCharsRegex.MatchString(str) {
return "", errInvalidCharacter
}
// YOLO
str = norm.NFD.String(str)
str = cases.Fold().String(str)
str = norm.NFD.String(str)
return str, nil
}
// Reduce, e.g., `alice!~u@host` to `alice` // Reduce, e.g., `alice!~u@host` to `alice`
func NUHToNick(nuh string) (nick string) { func NUHToNick(nuh string) (nick string) {
if idx := strings.IndexByte(nuh, '!'); idx != -1 { if idx := strings.IndexByte(nuh, '!'); idx != -1 {

View File

@ -7,13 +7,27 @@ package irc
import ( import (
"fmt" "fmt"
"testing" "testing"
"github.com/ergochat/ergo/irc/i18n"
) )
func TestCasefoldChannel(t *testing.T) { var (
asciiCasemappingOnly = []i18n.Casemapping{i18n.CasemappingASCII}
)
func TestCasefoldChannelAllCasemappings(t *testing.T) {
oldGlobalCasemapping := globalCasemappingSetting
t.Cleanup(func() {
globalCasemappingSetting = oldGlobalCasemapping
})
globalCasemappingSetting = i18n.CasemappingPRECIS
type channelTest struct { type channelTest struct {
channel string channel string
folded string folded string
err bool nonASCII bool
err bool
} }
testCases := []channelTest{ testCases := []channelTest{
{ {
@ -49,18 +63,20 @@ func TestCasefoldChannel(t *testing.T) {
folded: "##ubuntu", folded: "##ubuntu",
}, },
{ {
channel: "#中文频道", channel: "#中文频道",
folded: "#中文频道", folded: "#中文频道",
nonASCII: true,
}, },
{ {
// Hebrew; it's up to the client to display this right-to-left, including the # // Hebrew; it's up to the client to display this right-to-left, including the #
channel: "#שלום", channel: "#שלום",
folded: "#שלום", folded: "#שלום",
nonASCII: true,
}, },
} }
for _, errCase := range []string{ for _, errCase := range []string{
"", "#*starpower", "# NASA", "#interro?", "OOF#", "foo", "", "#*starpower", "# NASA", "#interro?", "OOF#", "foo", "a b", "#a b",
// bidi violation mixing latin and hebrew characters: // bidi violation mixing latin and hebrew characters:
"#shalomעליכם", "#shalomעליכם",
"#tab\tcharacter", "#\t", "#carriage\rreturn", "#tab\tcharacter", "#\t", "#carriage\rreturn",
@ -68,25 +84,41 @@ func TestCasefoldChannel(t *testing.T) {
testCases = append(testCases, channelTest{channel: errCase, err: true}) testCases = append(testCases, channelTest{channel: errCase, err: true})
} }
for i, tt := range testCases { // don't test permissive because it doesn't fail on bidi violations
t.Run(fmt.Sprintf("case %d: %s", i, tt.channel), func(t *testing.T) { casemappings := []i18n.Casemapping{i18n.CasemappingASCII, i18n.CasemappingPRECIS}
res, err := CasefoldChannel(tt.channel) if !i18n.Enabled {
if tt.err && err == nil { casemappings = asciiCasemappingOnly // XXX allow testing this package with i18n compiled out
t.Errorf("expected error when casefolding [%s], but did not receive one", tt.channel) }
return
} for _, casemapping := range casemappings {
if !tt.err && err != nil { globalCasemappingSetting = casemapping
t.Errorf("unexpected error while casefolding [%s]: %s", tt.channel, err.Error())
return for i, tt := range testCases {
} t.Run(fmt.Sprintf("case %d: %s", i, tt.channel), func(t *testing.T) {
if tt.folded != res { res, err := CasefoldChannel(tt.channel)
t.Errorf("expected [%v] to be [%v]", res, tt.folded) errExpected := tt.err || (tt.nonASCII && (casemapping == i18n.CasemappingASCII || casemapping == i18n.CasemappingRFC1459Strict))
} if errExpected && err == nil {
}) t.Errorf("expected error when casefolding [%s] under casemapping %d, but did not receive one", tt.channel, casemapping)
return
}
if !errExpected && err != nil {
t.Errorf("unexpected error while casefolding [%s] under casemapping %d: %s", tt.channel, casemapping, err.Error())
return
}
if !errExpected && tt.folded != res {
t.Errorf("expected [%v] to be [%v] under casemapping %d", res, tt.folded, casemapping)
}
})
}
} }
} }
func TestCasefoldName(t *testing.T) { func TestCasefoldNameAllCasemappings(t *testing.T) {
oldGlobalCasemapping := globalCasemappingSetting
t.Cleanup(func() {
globalCasemappingSetting = oldGlobalCasemapping
})
type nameTest struct { type nameTest struct {
name string name string
folded string folded string
@ -104,28 +136,37 @@ func TestCasefoldName(t *testing.T) {
} }
for _, errCase := range []string{ for _, errCase := range []string{
"", "#", "foo,bar", "star*man*junior", "lo7t?", "", "#", "foo,bar", "star*man*junior", "lo7t?", "a b", "#a b",
"f.l", "excited!nick", "foo@bar", ":trail", "f.l", "excited!nick", "foo@bar", ":trail",
"~o", "&o", "@o", "%h", "+v", "-m", "\t", "a\tb", "~o", "&o", "@o", "%h", "+v", "-m", "\t", "a\tb",
} { } {
testCases = append(testCases, nameTest{name: errCase, err: true}) testCases = append(testCases, nameTest{name: errCase, err: true})
} }
for i, tt := range testCases { casemappings := []i18n.Casemapping{i18n.CasemappingASCII, i18n.CasemappingPRECIS, i18n.CasemappingPermissive, i18n.CasemappingRFC1459Strict}
t.Run(fmt.Sprintf("case %d: %s", i, tt.name), func(t *testing.T) { if !i18n.Enabled {
res, err := CasefoldName(tt.name) casemappings = asciiCasemappingOnly // XXX allow testing this package with i18n compiled out
if tt.err && err == nil { }
t.Errorf("expected error when casefolding [%s], but did not receive one", tt.name)
return for _, casemapping := range casemappings {
} globalCasemappingSetting = casemapping
if !tt.err && err != nil {
t.Errorf("unexpected error while casefolding [%s]: %s", tt.name, err.Error()) for i, tt := range testCases {
return t.Run(fmt.Sprintf("case %d: %s", i, tt.name), func(t *testing.T) {
} res, err := CasefoldName(tt.name)
if tt.folded != res { if tt.err && err == nil {
t.Errorf("expected [%v] to be [%v]", res, tt.folded) t.Errorf("expected error when casefolding [%s], but did not receive one", tt.name)
} return
}) }
if !tt.err && err != nil {
t.Errorf("unexpected error while casefolding [%s]: %s", tt.name, err.Error())
return
}
if tt.folded != res {
t.Errorf("expected [%v] to be [%v]", res, tt.folded)
}
})
}
} }
} }
@ -145,51 +186,6 @@ func TestIsIdent(t *testing.T) {
assertIdent("-dan56", false) assertIdent("-dan56", false)
} }
func TestSkeleton(t *testing.T) {
skeleton := func(str string) string {
skel, err := Skeleton(str)
if err != nil {
t.Error(err)
}
return skel
}
if skeleton("warning") == skeleton("waming") {
t.Errorf("Oragono shouldn't consider rn confusable with m")
}
if skeleton("Phi|ip") != "philip" {
t.Errorf("but we still consider pipe confusable with l")
}
if skeleton("") != skeleton("smt") {
t.Errorf("fullwidth characters should skeletonize to plain old ascii characters")
}
if skeleton("") != skeleton("smt") {
t.Errorf("after skeletonizing, we should casefold")
}
if skeleton("sm") != skeleton("smt") {
t.Errorf("our friend lover successfully tricked the skeleton algorithm!")
}
if skeleton("еvan") != "evan" {
t.Errorf("we must protect against cyrillic homoglyph attacks")
}
if skeleton("еmily") != skeleton("emily") {
t.Errorf("we must protect against cyrillic homoglyph attacks")
}
if skeleton("РОТАТО") != "potato" {
t.Errorf("we must protect against cyrillic homoglyph attacks")
}
// should not raise an error:
skeleton("けらんぐ")
}
func TestCanonicalizeMaskWildcard(t *testing.T) { func TestCanonicalizeMaskWildcard(t *testing.T) {
tester := func(input, expected string, expectedErr error) { tester := func(input, expected string, expectedErr error) {
out, err := CanonicalizeMaskWildcard(input) out, err := CanonicalizeMaskWildcard(input)
@ -203,10 +199,8 @@ func TestCanonicalizeMaskWildcard(t *testing.T) {
tester("shivaram", "shivaram!*@*", nil) tester("shivaram", "shivaram!*@*", nil)
tester("slingamn!shivaram", "slingamn!shivaram@*", nil) tester("slingamn!shivaram", "slingamn!shivaram@*", nil)
tester("ברוך", "ברוך!*@*", nil)
tester("hacker@monad.io", "*!hacker@monad.io", nil) tester("hacker@monad.io", "*!hacker@monad.io", nil)
tester("Evan!hacker@monad.io", "evan!hacker@monad.io", nil) tester("Evan!hacker@monad.io", "evan!hacker@monad.io", nil)
tester("РОТАТО!Potato", "ротато!potato@*", nil)
tester("tkadich*", "tkadich*!*@*", nil) tester("tkadich*", "tkadich*!*@*", nil)
tester("SLINGAMN!*@*", "slingamn!*@*", nil) tester("SLINGAMN!*@*", "slingamn!*@*", nil)
tester("slingamn!shivaram*", "slingamn!shivaram*@*", nil) tester("slingamn!shivaram*", "slingamn!shivaram*@*", nil)
@ -220,90 +214,9 @@ func TestCanonicalizeMaskWildcard(t *testing.T) {
tester(":shivaram", "", errInvalidCharacter) tester(":shivaram", "", errInvalidCharacter)
tester("shivaram!us er@host", "", errInvalidCharacter) tester("shivaram!us er@host", "", errInvalidCharacter)
tester("shivaram!user@ho st", "", errInvalidCharacter) tester("shivaram!user@ho st", "", errInvalidCharacter)
}
func validFoldTester(first, second string, equal bool, folder func(string) (string, error), t *testing.T) { if i18n.Enabled {
firstFolded, err := folder(first) tester("ברוך", "ברוך!*@*", nil)
if err != nil { tester("РОТАТО!Potato", "ротато!potato@*", nil)
panic(err)
}
secondFolded, err := folder(second)
if err != nil {
panic(err)
}
foundEqual := firstFolded == secondFolded
if foundEqual != equal {
t.Errorf("%s and %s: expected equality %t, but got %t", first, second, equal, foundEqual)
} }
} }
func TestFoldPermissive(t *testing.T) {
tester := func(first, second string, equal bool) {
validFoldTester(first, second, equal, foldPermissive, t)
}
tester("SHIVARAM", "shivaram", true)
tester("shIvaram", "shivaraM", true)
tester("shivaram", "DAN-", false)
tester("dolph🐬n", "DOLPH🐬n", true)
tester("dolph🐬n", "dolph💻n", false)
tester("9FRONT", "9front", true)
}
func TestFoldPermissiveInvalid(t *testing.T) {
_, err := foldPermissive("a\tb")
if err == nil {
t.Errorf("whitespace should be invalid in identifiers")
}
_, err = foldPermissive("a\x00b")
if err == nil {
t.Errorf("the null byte should be invalid in identifiers")
}
}
func TestFoldASCII(t *testing.T) {
tester := func(first, second string, equal bool) {
validFoldTester(first, second, equal, foldASCII, t)
}
tester("shivaram", "SHIVARAM", true)
tester("X|Y", "x|y", true)
tester("a != b", "A != B", true)
}
func TestFoldASCIIInvalid(t *testing.T) {
_, err := foldASCII("\x01")
if err == nil {
t.Errorf("control characters should be invalid in identifiers")
}
_, err = foldASCII("\x7F")
if err == nil {
t.Errorf("control characters should be invalid in identifiers")
}
}
func TestFoldRFC1459(t *testing.T) {
folder := func(str string) (string, error) {
return foldRFC1459(str, false)
}
tester := func(first, second string, equal bool) {
validFoldTester(first, second, equal, folder, t)
}
tester("shivaram", "SHIVARAM", true)
tester("shivaram[a]", "shivaram{a}", true)
tester("shivaram\\a]", "shivaram{a}", false)
tester("shivaram\\a]", "shivaram|a}", true)
tester("shivaram~a]", "shivaram^a}", true)
}
func TestFoldRFC1459Strict(t *testing.T) {
folder := func(str string) (string, error) {
return foldRFC1459(str, true)
}
tester := func(first, second string, equal bool) {
validFoldTester(first, second, equal, folder, t)
}
tester("shivaram", "SHIVARAM", true)
tester("shivaram[a]", "shivaram{a}", true)
tester("shivaram\\a]", "shivaram{a}", false)
tester("shivaram\\a]", "shivaram|a}", true)
tester("shivaram~a]", "shivaram^a}", false)
}

View File

@ -163,7 +163,7 @@ func ubanAddHandler(client *Client, target ubanTarget, params []string, rb *Resp
case ubanCIDR: case ubanCIDR:
err = ubanAddCIDR(client, target, duration, requireSASL, operReason, rb) err = ubanAddCIDR(client, target, duration, requireSASL, operReason, rb)
case ubanNickmask: case ubanNickmask:
err = ubanAddNickmask(client, target, duration, operReason, rb) err = ubanAddNickmask(client, target, duration, requireSASL, operReason, rb)
case ubanNick: case ubanNick:
err = ubanAddAccount(client, target, duration, operReason, rb) err = ubanAddAccount(client, target, duration, operReason, rb)
} }
@ -242,8 +242,8 @@ func ubanAddCIDR(client *Client, target ubanTarget, duration time.Duration, requ
return return
} }
func ubanAddNickmask(client *Client, target ubanTarget, duration time.Duration, operReason string, rb *ResponseBuffer) (err error) { func ubanAddNickmask(client *Client, target ubanTarget, duration time.Duration, requireSASL bool, operReason string, rb *ResponseBuffer) (err error) {
err = client.server.klines.AddMask(target.nickOrMask, duration, "", operReason, client.Oper().Name) err = client.server.klines.AddMask(target.nickOrMask, duration, requireSASL, "", operReason, client.Oper().Name)
if err == nil { if err == nil {
rb.Notice(fmt.Sprintf(client.t("Successfully added UBAN for %s"), target.nickOrMask)) rb.Notice(fmt.Sprintf(client.t("Successfully added UBAN for %s"), target.nickOrMask))
} else { } else {
@ -455,7 +455,7 @@ func ubanInfoNick(client *Client, target ubanTarget, rb *ResponseBuffer) {
rb.Notice(client.t("Warning: banning this IP or a network that contains it may affect other users. Use /UBAN INFO on the candidate IP or network for more information.")) rb.Notice(client.t("Warning: banning this IP or a network that contains it may affect other users. Use /UBAN INFO on the candidate IP or network for more information."))
} }
} else { } else {
rb.Notice(fmt.Sprintf(client.t("No client is currently using that nickname"))) rb.Notice(client.t("No client is currently using that nickname"))
} }
account, err := client.server.accounts.LoadAccount(target.nickOrMask) account, err := client.server.accounts.LoadAccount(target.nickOrMask)

28
irc/utils/chunks.go Normal file
View File

@ -0,0 +1,28 @@
package utils
import "iter"
func ChunkifyParams(params iter.Seq[string], maxChars int) [][]string {
var chunked [][]string
var acc []string
var length = 0
for p := range params {
length = length + len(p) + 1 // (accounting for the space)
if length > maxChars {
chunked = append(chunked, acc)
acc = []string{}
length = 0
}
acc = append(acc, p)
}
if len(acc) != 0 {
chunked = append(chunked, acc)
}
return chunked
}

View File

@ -42,6 +42,11 @@ func GenerateSecretToken() string {
return B32Encoder.EncodeToString(buf[:]) return B32Encoder.EncodeToString(buf[:])
} }
// return a compact representation of a token generated by GenerateSecretToken()
func DecodeSecretToken(t string) ([]byte, error) {
return B32Encoder.DecodeString(t)
}
// securely check if a supplied token matches a stored token // securely check if a supplied token matches a stored token
func SecretTokensMatch(storedToken string, suppliedToken string) bool { func SecretTokensMatch(storedToken string, suppliedToken string) bool {
// XXX fix a potential gotcha: if the stored token is uninitialized, // XXX fix a potential gotcha: if the stored token is uninitialized,

View File

@ -6,6 +6,7 @@ package utils
import ( import (
"crypto/tls" "crypto/tls"
"encoding/binary" "encoding/binary"
"errors"
"io" "io"
"net" "net"
"strings" "strings"
@ -20,24 +21,8 @@ const (
maxProxyLineLenV1 = 107 maxProxyLineLenV1 = 107
) )
// XXX implement net.Error with a Temporary() method that returns true;
// otherwise, ErrBadProxyLine will cause (*http.Server).Serve() to exit
type proxyLineError struct{}
func (p *proxyLineError) Error() string {
return "invalid PROXY line"
}
func (p *proxyLineError) Timeout() bool {
return false
}
func (p *proxyLineError) Temporary() bool {
return true
}
var ( var (
ErrBadProxyLine error = &proxyLineError{} ErrBadProxyLine = errors.New("invalid PROXY protocol line")
) )
// ListenerConfig is all the information about how to process // ListenerConfig is all the information about how to process
@ -208,12 +193,13 @@ func parseProxyLineV2(line []byte) (ip net.IP, err error) {
// configuration. // configuration.
type WrappedConn struct { type WrappedConn struct {
net.Conn net.Conn
ProxiedIP net.IP ProxiedIP net.IP
TLS bool ProxyError error
Tor bool TLS bool
STSOnly bool Tor bool
WebSocket bool STSOnly bool
HideSTS bool WebSocket bool
HideSTS bool
// Secure indicates whether we believe the connection between us and the client // Secure indicates whether we believe the connection between us and the client
// was secure against interception and modification (including all proxies): // was secure against interception and modification (including all proxies):
Secure bool Secure bool
@ -256,6 +242,7 @@ func (rl *ReloadableListener) Accept() (conn net.Conn, err error) {
} }
var proxiedIP net.IP var proxiedIP net.IP
var proxyError error
if config.RequireProxy { if config.RequireProxy {
// this will occur synchronously on the goroutine calling Accept(), // this will occur synchronously on the goroutine calling Accept(),
// but that's OK because this listener *requires* a PROXY line, // but that's OK because this listener *requires* a PROXY line,
@ -265,10 +252,7 @@ func (rl *ReloadableListener) Accept() (conn net.Conn, err error) {
if err == nil { if err == nil {
proxiedIP, err = ParseProxyLine(proxyLine) proxiedIP, err = ParseProxyLine(proxyLine)
} }
if err != nil { proxyError = err
conn.Close()
return nil, err
}
} }
if config.TLSConfig != nil { if config.TLSConfig != nil {
@ -276,13 +260,14 @@ func (rl *ReloadableListener) Accept() (conn net.Conn, err error) {
} }
return &WrappedConn{ return &WrappedConn{
Conn: conn, Conn: conn,
ProxiedIP: proxiedIP, ProxiedIP: proxiedIP,
TLS: config.TLSConfig != nil, ProxyError: proxyError,
Tor: config.Tor, TLS: config.TLSConfig != nil,
STSOnly: config.STSOnly, Tor: config.Tor,
WebSocket: config.WebSocket, STSOnly: config.STSOnly,
HideSTS: config.HideSTS, WebSocket: config.WebSocket,
HideSTS: config.HideSTS,
// Secure will be set later by client code // Secure will be set later by client code
}, nil }, nil
} }

View File

@ -1,35 +0,0 @@
// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package utils
import (
"sync"
"sync/atomic"
)
// Once is a fork of sync.Once to expose a Done() method.
type Once struct {
done uint32
m sync.Mutex
}
func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}
func (o *Once) Done() bool {
return atomic.LoadUint32(&o.done) == 1
}

View File

@ -95,6 +95,20 @@ func (sm *SplitMessage) Is512() bool {
return sm.Split == nil return sm.Split == nil
} }
func (sm *SplitMessage) CombinedValue() string {
if sm.Split == nil {
return sm.Message
}
var buf strings.Builder
for i := range sm.Split {
if i != 0 && !sm.Split[i].Concat {
buf.WriteRune('\n')
}
buf.WriteString(sm.Split[i].Message)
}
return buf.String()
}
// TokenLineBuilder is a helper for building IRC lines composed of delimited tokens, // TokenLineBuilder is a helper for building IRC lines composed of delimited tokens,
// with a maximum line length. // with a maximum line length.
type TokenLineBuilder struct { type TokenLineBuilder struct {

View File

@ -66,3 +66,15 @@ func BenchmarkTokenLines(b *testing.B) {
tl.Lines() tl.Lines()
} }
} }
func TestCombinedValue(t *testing.T) {
var split = SplitMessage{
Split: []MessagePair{
{"hi", false},
{"hi", false},
{" again", true},
{"you", false},
},
}
assertEqual(split.CombinedValue(), "hi\nhi again\nyou", t)
}

15
irc/utils/time.go Normal file
View File

@ -0,0 +1,15 @@
package utils
import (
"time"
)
// ReadMarkerLessThanOrEqual compares times from the standpoint of
// draft/read-marker (the presentation format of which truncates the time
// to the millisecond). In future we might want to consider proactively rounding,
// instead of truncating, the time, but this has complex implications.
func ReadMarkerLessThanOrEqual(t1, t2 time.Time) bool {
t1 = t1.Truncate(time.Millisecond)
t2 = t2.Truncate(time.Millisecond)
return t1.Before(t2) || t1.Equal(t2)
}

View File

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

60
irc/webpush/highlight.go Normal file
View File

@ -0,0 +1,60 @@
// Copyright (c) 2021-2024 Simon Ser <contact@emersion.fr>
// Originally released under the AGPLv3, relicensed to the Ergo project under the MIT license
package webpush
import (
"strings"
"unicode"
"unicode/utf8"
)
func isWordBoundary(r rune) bool {
switch r {
case '-', '_', '|': // inspired from weechat.look.highlight_regex
return false
default:
return !unicode.IsLetter(r) && !unicode.IsNumber(r)
}
}
func isURIPrefix(text string) bool {
if i := strings.LastIndexFunc(text, unicode.IsSpace); i >= 0 {
text = text[i:]
}
i := strings.Index(text, "://")
if i < 0 {
return false
}
// See RFC 3986 section 3
r, _ := utf8.DecodeLastRuneInString(text[:i])
switch r {
case '+', '-', '.':
return true
default:
return ('0' <= r && r <= '9') || ('a' <= r && r <= 'z') || ('A' <= r && r <= 'Z')
}
}
func IsHighlight(text, nick string) bool {
if len(nick) == 0 {
return false
}
for {
i := strings.Index(text, nick)
if i < 0 {
return false
}
left, _ := utf8.DecodeLastRuneInString(text[:i])
right, _ := utf8.DecodeRuneInString(text[i+len(nick):])
if isWordBoundary(left) && isWordBoundary(right) && !isURIPrefix(text[:i]) {
return true
}
text = text[i+len(nick):]
}
}

66
irc/webpush/security.go Normal file
View File

@ -0,0 +1,66 @@
// Copyright (c) 2024 Shivaram Lingamneni <slingamn@cs.stanford.edu>
// Released under the MIT license
// Some portions of this code are:
// Copyright (c) 2024 Simon Ser <contact@emersion.fr>
// Originally released under the AGPLv3, relicensed to the Ergo project under the MIT license
package webpush
import (
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"syscall"
)
var (
errInternalIP = errors.New("dialing an internal IP is forbidden")
)
func SanityCheckWebPushEndpoint(endpoint string) error {
u, err := url.Parse(endpoint)
if err != nil {
return err
}
if u.Scheme != "https" {
return fmt.Errorf("scheme must be HTTPS")
}
return nil
}
// makeExternalOnlyClient builds an http.Client that can only connect
// to external IP addresses.
func makeExternalOnlyClient() *http.Client {
dialer := &net.Dialer{
Control: func(network, address string, c syscall.RawConn) error {
ip, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
parsedIP, err := netip.ParseAddr(ip)
if err != nil {
return err
}
if isInternalIP(parsedIP) {
return errInternalIP
}
return nil
},
}
return &http.Client{
Transport: &http.Transport{
DialContext: dialer.DialContext,
},
}
}
func isInternalIP(ip netip.Addr) bool {
return ip.IsLoopback() || ip.IsMulticast() || ip.IsPrivate()
}

View File

@ -0,0 +1,21 @@
package webpush
import (
"errors"
"testing"
)
func TestExternalOnlyHTTPClient(t *testing.T) {
client := makeExternalOnlyClient()
for _, url := range []string{
"https://127.0.0.2/test",
"https://127.0.0.2:8201",
"https://127.0.0.2:8201/asdf",
} {
_, err := client.Get(url)
if err == nil || !errors.Is(err, errInternalIP) {
t.Errorf("%s was not forbidden as expected (got %v)", url, err)
}
}
}

150
irc/webpush/webpush.go Normal file
View File

@ -0,0 +1,150 @@
// Copyright (c) 2024 Shivaram Lingamneni <slingamn@cs.stanford.edu>
// Released under the MIT license
// Some portions of this code are:
// Copyright (c) 2021-2024 Simon Ser <contact@emersion.fr>
// Originally released under the AGPLv3, relicensed to the Ergo project under the MIT license
package webpush
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/ergochat/irc-go/ircmsg"
webpush "github.com/ergochat/webpush-go/v2"
"github.com/ergochat/ergo/irc/utils"
)
// alias some public types and names from webpush-go
type VAPIDKeys = webpush.VAPIDKeys
type Keys = webpush.Keys
var (
GenerateVAPIDKeys = webpush.GenerateVAPIDKeys
)
// Urgency is a uint8 representation of urgency to save a few
// bytes on channel sizes.
type Urgency uint8
const (
// UrgencyVeryLow requires device state: on power and Wi-Fi
UrgencyVeryLow Urgency = iota // "very-low"
// UrgencyLow requires device state: on either power or Wi-Fi
UrgencyLow // "low"
// UrgencyNormal excludes device state: low battery
UrgencyNormal // "normal"
// UrgencyHigh admits device state: low battery
UrgencyHigh // "high"
)
var (
// PingMessage is a valid IRC message that we can send to test that the subscription
// is valid (i.e. responds to POSTs with a 20x). We do not expect that the client will
// actually connect to IRC and send PONG (although it might be nice to have a way to
// hint to a client that they should reconnect to renew their subscription?)
PingMessage = []byte("PING webpush")
)
func convertUrgency(u Urgency) webpush.Urgency {
switch u {
case UrgencyVeryLow:
return webpush.UrgencyVeryLow
case UrgencyLow:
return webpush.UrgencyLow
case UrgencyNormal:
return webpush.UrgencyNormal
case UrgencyHigh:
return webpush.UrgencyHigh
default:
return webpush.UrgencyNormal // shouldn't happen
}
}
var httpClient webpush.HTTPClient = makeExternalOnlyClient()
var (
Err404 = errors.New("endpoint returned a 404, indicating that the push subscription is no longer valid")
errInvalidKey = errors.New("invalid key format")
)
func DecodeSubscriptionKeys(keysParam string) (keys webpush.Keys, err error) {
// The keys parameter is tag-encoded, with each tag value being URL-safe base64 encoded:
// * One public key with the name p256dh set to the client's P-256 ECDH public key.
// * One shared key with the name auth set to a 16-byte client-generated authentication secret.
// since we don't have a separate tag parser implementation, wrap it in a fake IRC line for parsing:
fakeIRCLine := fmt.Sprintf("@%s PING", keysParam)
ircMsg, err := ircmsg.ParseLine(fakeIRCLine)
if err != nil {
return
}
_, auth := ircMsg.GetTag("auth")
_, p256 := ircMsg.GetTag("p256dh")
return webpush.DecodeSubscriptionKeys(auth, p256)
}
// MakePushMessage serializes a utils.SplitMessage as a web push message (the args are in
// logical order)
func MakePushMessage(command, nuh, accountName, target string, msg utils.SplitMessage) ([]byte, error) {
var messageForPush string
if msg.Is512() {
messageForPush = msg.Message
} else {
messageForPush = msg.Split[0].Message
}
pushMessage := ircmsg.MakeMessage(nil, nuh, command, target, messageForPush)
pushMessage.SetTag("time", msg.Time.Format(utils.IRCv3TimestampFormat))
pushMessage.SetTag("msgid", msg.Msgid)
// "*" is canonical for the unset form of the unfolded account name, but check both:
if accountName != "*" && accountName != "" {
pushMessage.SetTag("account", accountName)
}
return MakePushLine(pushMessage)
}
// MakePushLine serializes an arbitrary IRC message as a web push message;
// we assume tags were already filtered.
func MakePushLine(pushMessage ircmsg.Message) ([]byte, error) {
if line, err := pushMessage.LineBytesStrict(false, 512); err == nil {
// strip final \r\n
return line[:len(line)-2], nil
} else {
return nil, err
}
}
func SendWebPush(ctx context.Context, endpoint string, keys Keys, vapidKeys *VAPIDKeys, urgency Urgency, subscriber string, msg []byte) error {
wpsub := webpush.Subscription{
Endpoint: endpoint,
Keys: keys,
}
options := webpush.Options{
HTTPClient: httpClient,
VAPIDKeys: vapidKeys,
Subscriber: subscriber,
TTL: 7 * 24 * 60 * 60, // seconds
Urgency: convertUrgency(urgency),
RecordSize: 2048,
}
resp, err := webpush.SendNotification(ctx, msg, &wpsub, &options)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return Err404
} else if 200 <= resp.StatusCode && resp.StatusCode < 300 {
return nil
} else {
return fmt.Errorf("HTTP error: %v", resp.Status)
}
}

View File

@ -0,0 +1,58 @@
package webpush
import (
"strings"
"testing"
"time"
"github.com/ergochat/irc-go/ircmsg"
"github.com/ergochat/ergo/irc/utils"
)
func TestBuildPushLine(t *testing.T) {
now := "2025-01-12T00:55:44.403Z"
readTimestamp := "timestamp=2025-01-12T00:07:57.972Z"
markreadPushMessage := ircmsg.MakeMessage(nil, "ergo.test", "MARKREAD", "#ergo", readTimestamp)
markreadPushMessage.SetTag("time", now)
line, err := MakePushLine(markreadPushMessage)
if err != nil {
t.Fatal(err)
}
if string(line) != "@time=2025-01-12T00:55:44.403Z :ergo.test MARKREAD #ergo timestamp=2025-01-12T00:07:57.972Z" {
t.Errorf("got wrong line output: %s", line)
}
}
func TestBuildPushMessage(t *testing.T) {
now, err := time.Parse(utils.IRCv3TimestampFormat, "2025-01-12T01:05:04.422Z")
if err != nil {
panic(err)
}
lineBytes, err := MakePushMessage("PRIVMSG", "shivaram!~u@kca7nfgniet7q.irc", "shivaram", "#redacted", utils.SplitMessage{
Message: "[redacted message contents]",
Msgid: "t8st5bb4b9qhed3zs3pwspinca",
Time: now,
})
if err != nil {
t.Fatal(err)
}
line := string(lineBytes)
parsed, err := ircmsg.ParseLineStrict(line, false, 512)
if err != nil {
t.Fatal(err)
}
if ok, account := parsed.GetTag("account"); !ok || account != "shivaram" {
t.Fatalf("bad account tag %s", account)
}
if ok, timestamp := parsed.GetTag("time"); !ok || timestamp != "2025-01-12T01:05:04.422Z" {
t.Fatal("bad time")
}
idx := strings.IndexByte(line, ' ')
if line[idx+1:] != ":shivaram!~u@kca7nfgniet7q.irc PRIVMSG #redacted :[redacted message contents]" {
t.Fatal("bad line")
}
}

View File

@ -218,7 +218,7 @@ func zncPlayPrivmsgsFromAll(client *Client, rb *ResponseBuffer, start, end time.
// PRIVMSG *playback :list // PRIVMSG *playback :list
func zncPlaybackListHandler(client *Client, command string, params []string, rb *ResponseBuffer) { func zncPlaybackListHandler(client *Client, command string, params []string, rb *ResponseBuffer) {
limit := client.server.Config().History.ChathistoryMax limit := client.server.Config().History.ChathistoryMax
correspondents, err := client.listTargets(history.Selector{}, history.Selector{}, limit) correspondents, err := client.listTargets(time.Time{}, time.Time{}, limit)
if err != nil { if err != nil {
client.server.logger.Error("internal", "couldn't get history for ZNC list", err.Error()) client.server.logger.Error("internal", "couldn't get history for ZNC list", err.Error())
return return

@ -1 +1 @@
Subproject commit 9856317a64e0b72f925bd1a77cbce7e0f460eb59 Subproject commit 17fac53c5cdfe78caecb601399512574f242cc85

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