mirror of
https://github.com/ergochat/ergo.git
synced 2025-12-20 10:10:08 -08:00
Compare commits
189 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5fb189a55 | ||
|
|
53664694c4 | ||
|
|
d26aa37f2c | ||
|
|
9ca936a777 | ||
|
|
fdd261a1e6 | ||
|
|
aef5d77b3b | ||
|
|
0ce9016098 | ||
|
|
f91d1d94f6 | ||
|
|
0119bbc36f | ||
|
|
96aa018352 | ||
|
|
68faf82787 | ||
|
|
5cda5bdac9 | ||
|
|
ed841ee62a | ||
|
|
6fdac13ad4 | ||
|
|
efc1627d23 | ||
|
|
6b8265fb17 | ||
|
|
92f069846c | ||
|
|
8913bd7fa9 | ||
|
|
064291e902 | ||
|
|
65295cbafa | ||
|
|
f0b1f34da7 | ||
|
|
f918e28513 | ||
|
|
8798676ae9 | ||
|
|
cca400de73 | ||
|
|
73e51333ad | ||
|
|
a5e435a26b | ||
|
|
17ed01c1ed | ||
|
|
8f18454e8f | ||
|
|
23844d4103 | ||
|
|
3b7db7fff7 | ||
|
|
4dcbc48159 | ||
|
|
0f5603eca2 | ||
|
|
7d4f5e4adf | ||
|
|
16568c5ab7 | ||
|
|
9a186f8e54 | ||
|
|
7828218bc7 | ||
|
|
7138e76151 | ||
|
|
e4aac56bda | ||
|
|
4da6511674 | ||
|
|
253972a9d2 | ||
|
|
a1c46a4be7 | ||
|
|
7718081440 | ||
|
|
e7501ef847 | ||
|
|
e404942d83 | ||
|
|
0a947115d6 | ||
|
|
9b9c39ddd4 | ||
|
|
e200e9fd8f | ||
|
|
66a7a488b7 | ||
|
|
28ed16261c | ||
|
|
686ce4d5b2 | ||
|
|
808799b100 | ||
|
|
e382036ddb | ||
|
|
43fe72f83e | ||
|
|
4ab1a10eec | ||
|
|
54b17b0700 | ||
|
|
2cf569c5d9 | ||
|
|
a4194c38d8 | ||
|
|
5bab190d33 | ||
|
|
68cee9e2cd | ||
|
|
9c3173f573 | ||
|
|
98e04c10a8 | ||
|
|
a6df370bd9 | ||
|
|
9791606f62 | ||
|
|
7256d83ff0 | ||
|
|
f5bb5afdd6 | ||
|
|
d3eb787a1e | ||
|
|
19dbe10c99 | ||
|
|
467df24914 | ||
|
|
9dc2fd52ed | ||
|
|
a46732f6ab | ||
|
|
ea81ec86e1 | ||
|
|
4bcd008416 | ||
|
|
aed216a62e | ||
|
|
f3e24c7bdb | ||
|
|
23b65e225b | ||
|
|
4ced4ef328 | ||
|
|
ec3417be79 | ||
|
|
7e18362d35 | ||
|
|
eb84ede5f7 | ||
|
|
d50f1471eb | ||
|
|
d9f663c400 | ||
|
|
e1b5a05c27 | ||
|
|
a850602bcc | ||
|
|
d1126b53eb | ||
|
|
4851825d4f | ||
|
|
8fa6e19c2e | ||
|
|
07669f9eb4 | ||
|
|
4dfb7cc7ae | ||
|
|
b6a8cc20c2 | ||
|
|
cf7db4bc2a | ||
|
|
b6f6959acc | ||
|
|
af124cd964 | ||
|
|
e60afda556 | ||
|
|
c92f23b0cb | ||
|
|
656eea43e7 | ||
|
|
881f403164 | ||
|
|
b38ca31ced | ||
|
|
7b71839615 | ||
|
|
9dd7a2bbcb | ||
|
|
148d743eb1 | ||
|
|
2a79f64f2d | ||
|
|
799e1b14f4 | ||
|
|
2163d96348 | ||
|
|
e520ba7e0e | ||
|
|
92e2aa987e | ||
|
|
ab2d842b27 | ||
|
|
21ee867ebb | ||
|
|
36e5451aa5 | ||
|
|
efd3764337 | ||
|
|
375079e636 | ||
|
|
38862b0529 | ||
|
|
2bb9980e56 | ||
|
|
1bdc45ebb4 | ||
|
|
eddd4cc723 | ||
|
|
726d997d07 | ||
|
|
9577e87d9a | ||
|
|
7586520032 | ||
|
|
f68d32b4ee | ||
|
|
796bc198ed | ||
|
|
df6aa4c34b | ||
|
|
30f47a9b22 | ||
|
|
92a23229f8 | ||
|
|
825b4298b8 | ||
|
|
eba6d532ea | ||
|
|
7d3971835e | ||
|
|
99393d49bf | ||
|
|
82c50cc497 | ||
|
|
ce41f501c9 | ||
|
|
d25fc2a758 | ||
|
|
f598da300d | ||
|
|
bb6c7ee158 | ||
|
|
958eb43393 | ||
|
|
9b8562c211 | ||
|
|
2bb0a9c8e3 | ||
|
|
0b333c7e72 | ||
|
|
2aec5e167c | ||
|
|
3127353b84 | ||
|
|
654381071b | ||
|
|
71671405f3 | ||
|
|
aa6be594b9 | ||
|
|
6326982767 | ||
|
|
0517b5571d | ||
|
|
1117680fdd | ||
|
|
f44d902ce3 | ||
|
|
7318e48629 | ||
|
|
60f7d1122d | ||
|
|
289b78d2fd | ||
|
|
ad0149be5e | ||
|
|
d81494ac09 | ||
|
|
54ca659e57 | ||
|
|
794b4a2483 | ||
|
|
af521c844f | ||
|
|
7772b55cab | ||
|
|
ed683bff79 | ||
|
|
5ee32cda1c | ||
|
|
218f6f2454 | ||
|
|
ca4b9c15c5 | ||
|
|
6abb291290 | ||
|
|
ccc362be84 | ||
|
|
19b9867409 | ||
|
|
f6626ddb6e | ||
|
|
40ceb4956c | ||
|
|
74fa04c5ea | ||
|
|
15d686c593 | ||
|
|
f96f918ff1 | ||
|
|
7726160ec7 | ||
|
|
b426dd8f93 | ||
|
|
1f4b5248a0 | ||
|
|
0c804f8ea3 | ||
|
|
3d2f014d4c | ||
|
|
d56e4ea301 | ||
|
|
8d082865da | ||
|
|
837f6ac1a2 | ||
|
|
681e8b1292 | ||
|
|
432d4ea860 | ||
|
|
78f342655d | ||
|
|
cab192e2af | ||
|
|
c67835ce5c | ||
|
|
7afd6dbc74 | ||
|
|
ee7f818674 | ||
|
|
8475b62da4 | ||
|
|
52d15a483c | ||
|
|
f691b8c058 | ||
|
|
6b7bfe0c09 | ||
|
|
2098cc9f2b | ||
|
|
4b9aa725cb | ||
|
|
24ac3b68b4 | ||
|
|
0918564edc | ||
|
|
921651f664 |
351 changed files with 22162 additions and 11442 deletions
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
|
|
@ -12,14 +12,14 @@ 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@v3"
|
||||||
- name: "setup go"
|
- name: "setup go"
|
||||||
uses: "actions/setup-go@v3"
|
uses: "actions/setup-go@v3"
|
||||||
with:
|
with:
|
||||||
go-version: "1.21"
|
go-version: "1.25"
|
||||||
- name: "install python3-pytest"
|
- name: "install python3-pytest"
|
||||||
run: "sudo apt install -y python3-pytest"
|
run: "sudo apt install -y python3-pytest"
|
||||||
- name: "make install"
|
- name: "make install"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
# .goreleaser.yml
|
# .goreleaser.yml
|
||||||
# Build customization
|
# Build customization
|
||||||
|
version: 2
|
||||||
project_name: ergo
|
project_name: ergo
|
||||||
builds:
|
builds:
|
||||||
- main: ergo.go
|
- main: ergo.go
|
||||||
|
|
@ -17,6 +18,7 @@ builds:
|
||||||
- amd64
|
- amd64
|
||||||
- arm
|
- arm
|
||||||
- arm64
|
- arm64
|
||||||
|
- riscv64
|
||||||
goarm:
|
goarm:
|
||||||
- 6
|
- 6
|
||||||
ignore:
|
ignore:
|
||||||
|
|
@ -24,30 +26,41 @@ builds:
|
||||||
goarch: arm
|
goarch: arm
|
||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: arm64
|
goarch: arm64
|
||||||
|
- goos: windows
|
||||||
|
goarch: riscv64
|
||||||
- goos: darwin
|
- goos: darwin
|
||||||
goarch: arm
|
goarch: arm
|
||||||
|
- goos: darwin
|
||||||
|
goarch: riscv64
|
||||||
- goos: freebsd
|
- goos: freebsd
|
||||||
goarch: arm
|
goarch: arm
|
||||||
- goos: freebsd
|
- goos: freebsd
|
||||||
goarch: arm64
|
goarch: arm64
|
||||||
|
- goos: freebsd
|
||||||
|
goarch: riscv64
|
||||||
- goos: openbsd
|
- goos: openbsd
|
||||||
goarch: arm
|
goarch: arm
|
||||||
- goos: openbsd
|
- goos: openbsd
|
||||||
goarch: arm64
|
goarch: arm64
|
||||||
|
- goos: openbsd
|
||||||
|
goarch: riscv64
|
||||||
- goos: plan9
|
- goos: plan9
|
||||||
goarch: arm
|
goarch: arm
|
||||||
- goos: plan9
|
- goos: plan9
|
||||||
goarch: arm64
|
goarch: arm64
|
||||||
|
- goos: plan9
|
||||||
|
goarch: riscv64
|
||||||
flags:
|
flags:
|
||||||
- -trimpath
|
- -trimpath
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
-
|
-
|
||||||
name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
|
name_template: >-
|
||||||
|
{{ .ProjectName }}-{{ .Version }}-
|
||||||
|
{{- if eq .Os "darwin" }}macos{{- else }}{{ .Os }}{{ end -}}-
|
||||||
|
{{- if eq .Arch "amd64" }}x86_64{{- else }}{{ .Arch }}{{ end -}}
|
||||||
|
{{ if .Arm }}v{{ .Arm }}{{ end -}}
|
||||||
format: tar.gz
|
format: tar.gz
|
||||||
replacements:
|
|
||||||
amd64: x86_64
|
|
||||||
darwin: macos
|
|
||||||
format_overrides:
|
format_overrides:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
format: zip
|
format: zip
|
||||||
|
|
@ -58,6 +71,7 @@ archives:
|
||||||
- ergo.motd
|
- ergo.motd
|
||||||
- default.yaml
|
- default.yaml
|
||||||
- traditional.yaml
|
- traditional.yaml
|
||||||
|
- docs/API.md
|
||||||
- docs/MANUAL.md
|
- docs/MANUAL.md
|
||||||
- docs/USERGUIDE.md
|
- docs/USERGUIDE.md
|
||||||
- languages/*.yaml
|
- languages/*.yaml
|
||||||
|
|
|
||||||
155
CHANGELOG.md
155
CHANGELOG.md
|
|
@ -1,6 +1,161 @@
|
||||||
# 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.17.0-rc1] - 2025-12-14
|
||||||
|
|
||||||
|
We're pleased to be publishing the release candidate for v2.17.0 (the official release should follow within a week or so). This release adds support for the [IRCv3 metadata specification](https://ircv3.net/specs/extensions/metadata), thanks to [@thatcher-gaming](https://github.com/thatcher-gaming), as well as bug fixes and minor updates.
|
||||||
|
|
||||||
|
This release includes changes to the config file format, all of which are fully backwards-compatible and do not require updating the file before upgrading. It includes no changes to the database file format.
|
||||||
|
|
||||||
|
Many thanks to [@branchgrove](https://github.com/branchgrove), [@Brutus5000](https://github.com/Brutus5000), [@progval](https://github.com/progval), [@SarahRoseLives](https://github.com/SarahRoseLives), [@thatcher-gaming](https://github.com/thatcher-gaming), [@ValwareIRC](https://github.com/ValwareIRC), and Xogium for contributing patches, reporting issues, and helping test.
|
||||||
|
|
||||||
|
### Config changes
|
||||||
|
* Added `accounts.metadata` block to configure the new metadata feature. If this block is absent, metadata is disabled. See `default.yaml` for an example. (#2273)
|
||||||
|
* Added `server.idle-timeouts` for configurable idle timeouts; when unset, the previous hardcoded defaults are used (#2292, thanks [@Brutus5000](https://github.com/Brutus5000)!)
|
||||||
|
* Added `server.oper-throttle` to configure throttling for failed `OPER` attempts; when unset, this defaults to 1 attempt every 10 seconds (#2296)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* Implemented support for the [draft/metadata-2](https://ircv3.net/specs/extensions/metadata) specification, allowing clients to set and retrieve metadata on accounts and channels (#2273, #2277, #2281, #2282, #2301, thanks [@thatcher-gaming](https://github.com/thatcher-gaming)!)
|
||||||
|
* Added `/v1/status` and `/v1/account_list` HTTP API endpoints (#2261, thanks [@SarahRoseLives](https://github.com/SarahRoseLives)!)
|
||||||
|
* Enhanced `/v1/account_details` API response with additional fields (#2261, thanks [@SarahRoseLives](https://github.com/SarahRoseLives)!)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* Fixed `REGISTER` command to strip guest format when applicable, matching `NS REGISTER` behavior (#2270, #2271, thanks [@ValwareIRC](https://github.com/ValwareIRC) and [@thatcher-gaming](https://github.com/thatcher-gaming)!)
|
||||||
|
* Fixed invalid `FAIL` codes in `REGISTER` command (#2269, thanks [@ValwareIRC](https://github.com/ValwareIRC)!)
|
||||||
|
* Fixed validation of web push URLs to reject non-HTTPS URLs (#2295)
|
||||||
|
* Fixed inconsistent behavior when `history.enabled` is set but `history.chathistory-maxmessages` is not (#2303, #2304, thanks [@branchgrove](https://github.com/branchgrove)!)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* The `OPER` command now imposes a throttle on all attempts, never disconnects the client on failure, and logs non-sensitive information about failed attempts (#2296, #2298, thanks Xogium!)
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
* Official release builds use Go 1.25 (#2290)
|
||||||
|
* Upgraded the Docker base image from Alpine 3.19 to 3.22 (#2306)
|
||||||
|
|
||||||
|
## [2.16.0] - 2025-05-18
|
||||||
|
We're pleased to be publishing v2.16.0, a new stable release. This release contains bug fixes and some minor updates.
|
||||||
|
|
||||||
|
This release includes changes to the config file format, all of which are fully backwards-compatible and do not require updating the file before upgrading. It includes no changes to the database file format.
|
||||||
|
|
||||||
|
Many thanks to [@csmith](https://github.com/csmith), [@delthas](https://github.com/delthas), donio, [@emersion](https://github.com/emersion), [@KlaasT](https://github.com/KlaasT), [@knolley](https://github.com/knolley), [@Mailaender](https://github.com/Mailaender), and [@prdes](https://github.com/prdes) for reporting issues and helping test.
|
||||||
|
|
||||||
|
### Config changes
|
||||||
|
* Added `api` block for configuring the new HTTP API. If this block is absent, the API is disabled (#2231)
|
||||||
|
* Added `server.additional-isupport` for publishing arbitrary ISUPPORT tokens (#2220, #2240)
|
||||||
|
* Added `server.command-aliases` to configure aliases for server commands (#2229, #2236)
|
||||||
|
* Added options to `roleplay` to customize the NUH's sent for `NPC` and `SCENE`. Roleplay remains deprecated and disabled by default. (#2237)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
* Mitigated HTTP DoS attacks by rejecting IRC sessions that begin with an HTTP verb, such as `POST`. If you were relying on this to create IRC sessions via an HTTP client, please open an issue. (#2239)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* Added an HTTP API, providing programmatic access to Ergo functionality (#2231, thanks [@KlaasT](https://github.com/KlaasT)!)
|
||||||
|
* Added SAFERATE to 005 ISUPPORT tokens (#2223, thanks [@delthas](https://github.com/delthas)!)
|
||||||
|
* Added support for ed25519-sha256 for DKIM. However, enabling this algorithm is not recommended since mainstream email providers still do not support it. (#1041, #2242)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* Fixed `CHATHISTORY TARGETS` from MySQL backend reporting incorrect timestamps when the server timezone is not UTC (#2224)
|
||||||
|
* Fixed batch name parameter in `draft/isupport` responses (#2253)
|
||||||
|
* Fixed `NS UNREGISTER` not deleting the stored push subscriptions (#2254)
|
||||||
|
* Fixed cases where `NS SAREGISTER` could create clients without applying the default user modes (#2252, #2254, thanks donio!)
|
||||||
|
* Improved validation of `CHATHISTORY` parameters (#2248, #2249, thanks [@prdes](https://github.com/prdes)!)
|
||||||
|
* Added validation to ensure the MOTD is UTF-8 when `enforce-utf8` is enabled (the recommended default) (#2228, #2233, thanks [@KlaasT](https://github.com/KlaasT)!)
|
||||||
|
* The client's own `QUIT` line now respects the `server-time` capability (#2218, #2219)
|
||||||
|
* Fixed sending unnecessary replies to certain invalid `MODE` changes (#2213)
|
||||||
|
* Improved safety of ISUPPORT length limits (#2241)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* The `draft/message-redaction` capability is no longer advertised when `allow-individual-delete` is disabled (#2215, #2216, thanks [@delthas](https://github.com/delthas)!)
|
||||||
|
* Receiving the UTF-8 BOM (byte-order mark) at the start of an IRC connection now produces an explicit error (#2244, #2247, thanks [@csmith](https://github.com/csmith), [@Mailaender](https://github.com/Mailaender)!)
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
* Release builds use Go 1.24.3 (#2217)
|
||||||
|
|
||||||
|
## [2.15.0] - 2025-01-26
|
||||||
|
|
||||||
|
We're pleased to be publishing v2.15.0, a new stable release. This release adds support for mobile push notifications, via the [draft/webpush](https://github.com/ircv3/ircv3-specifications/pull/471) specification. More information on this is available in the [manual](https://github.com/ergochat/ergo/blob/ab2d842b270d9df217c779df9c7a5c594d85fdd5/docs/MANUAL.md#push-notifications) and [user guide](https://github.com/ergochat/ergo/blob/ab2d842b270d9df217c779df9c7a5c594d85fdd5/docs/USERGUIDE.md#push-notifications). This feature is still considered to be in an experimental state; `default.yaml` ships with it disabled, and its configuration may have backwards-incompatible changes in the future.
|
||||||
|
|
||||||
|
This release includes changes to the config file format, all of which are fully backwards-compatible and do not require updating the file before upgrading.
|
||||||
|
|
||||||
|
This release includes a database change. If you have `datastore.autoupgrade` set to `true` in your configuration, it will be automatically applied when you restart Ergo. Otherwise, you can update the database manually by running `ergo upgradedb` (see the manual for complete instructions).
|
||||||
|
|
||||||
|
Many thanks to [@delthas](https://github.com/delthas), [@donatj](https://github.com/donatj), donio, [@emersion](https://github.com/emersion), and [@eskimo](https://github.com/eskimo) for contributing patches and helping test.
|
||||||
|
|
||||||
|
### Config changes
|
||||||
|
* Added `webpush` block to the config file to configure push notifications. See `default.yaml` for an example. Note that at this time, `default.yaml` ships with support for push notifications disabled; operators can enable them by setting `webpush.enabled: true`. In the absence of such a block, push notifications are disabled.
|
||||||
|
* We recommend the addition of `"WEBPUSH": 1` to `fakelag.command-budgets`, to speed up mobile reattach when web push is enabled. See `default.yaml` for an example.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* Added support for the [draft/webpush](https://github.com/ircv3/ircv3-specifications/pull/471) specification (#2205, thanks [@emersion](https://github.com/emersion), [@eskimo](https://github.com/eskimo)!)
|
||||||
|
* Added support for the [draft/extended-isupport](https://github.com/ircv3/ircv3-specifications/pull/543) specification (#2184, thanks [@emersion](https://github.com/emersion)!)
|
||||||
|
* `UBAN ADD` now accepts `REQUIRE-SASL` with NUH masks, i.e. k-lines (#2198, #2199)
|
||||||
|
* Ergo now publishes the `SAFELIST` ISUPPORT parameter (#2196, thanks [@delthas](https://github.com/delthas)!)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* Fixed incorrect parameters when pushing `005` (ISUPPORT) updates to clients on rehash (#2177, #2184)
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
* Official release builds use Go 1.23.5
|
||||||
|
* Added a unique identifier to identify connections in debug logs. This has no privacy implications in a standard, non-debug configuration of Ergo. (#2206, thanks donio!)
|
||||||
|
* Added support for Solaris on amd64 CPUs (#2183)
|
||||||
|
|
||||||
|
## [2.14.0] - 2024-06-30
|
||||||
|
|
||||||
|
We're pleased to be publishing v2.14.0, a new stable release. This release contains primarily bug fixes, with the addition of some new authentication mechanisms for integrating with web clients.
|
||||||
|
|
||||||
|
This release includes changes to the config file format, all of which are fully backwards-compatible and do not require updating the file before upgrading. It includes no changes to the database file format.
|
||||||
|
|
||||||
|
Many thanks to [@al3xandros](https://github.com/al3xandros), donio, [@eeeeeta](https://github.com/eeeeeta), [@emersion](https://github.com/emersion), [@Eriner](https://github.com/Eriner), [@eskimo](https://github.com/eskimo), [@Herringway](https://github.com/Herringway), [@jwheare](https://github.com/jwheare), [@knolley](https://github.com/knolley), [@mengzhuo](https://github.com/mengzhuo), pathof, [@poVoq](https://github.com/poVoq), [@progval](https://github.com/progval), [@RNDpacman](https://github.com/RNDpacman), and [@xnaas](https://github.com/xnaas) for contributing patches, reporting issues, and helping test.
|
||||||
|
|
||||||
|
### Config changes
|
||||||
|
* Added `accounts.oauth2` and `accounts.jwt-auth` blocks for configuring OAuth2 and JWT authentication (#2004)
|
||||||
|
* Added `protocol` and `local-address` options to `accounts.registration.email-verification`, to force emails to be sent over IPv4 (or IPv6) or to force the use of a particular source address (#2142)
|
||||||
|
* Added `limits.realnamelen`, a configurable limit on the length of realnames. If unset, no limit is enforced beyond the IRC protocol line length limits (the previous behavior). (#2123, thanks [@eskimo](https://github.com/eskimo)!)
|
||||||
|
* Added the `accept-hostname` option to the webirc config block, allowing Ergo to accept hostnames passed from reverse proxies on the `WEBIRC` line. Note that this will have no effect under the default/recommended configuration, in which cloaks are used instead (#1686, #2146, thanks [@RNDpacman](https://github.com/RNDpacman)!)
|
||||||
|
* The default/recommended value of `limits.chan-list-modes` (the size limit for ban/except/invite lists) was raised to 100 (#2081, #2165, #2167)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* Added support for the `OAUTHBEARER` SASL mechanism, allowing Ergo to interoperate with Gamja and an OAuth2 provider (#2004, #2122, thanks [@emersion](https://github.com/emersion)!)
|
||||||
|
* Added support for the [`IRCV3BEARER` SASL mechanism](https://github.com/ircv3/ircv3-specifications/pull/545), allowing Ergo to accept OAuth2 or JWT bearer tokens (#2158)
|
||||||
|
* Added support for the legacy `rfc1459` and `rfc1459-strict` casemappings (#2099, #2159, thanks [@xnaas](https://github.com/xnaas)!)
|
||||||
|
* The new `ergo defaultconfig` subcommand prints a copy of the default config file to standard output (#2157, #2160, thanks [@al3xandros](https://github.com/al3xandros)!)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* Even with `allow-truncation: false` (the recommended default), some oversized messages were being accepted and relayed with truncation. These messages will now be rejected with `417 ERR_INPUTTOOLONG` as expected (#2170)
|
||||||
|
* NICK and QUIT from invisible members of auditorium channels are no longer recorded in history (#2133, #2137, thanks [@knolley](https://github.com/knolley) and [@poVoq](https://github.com/poVoq)!)
|
||||||
|
* If channel registration was disabled, registered channels could become inaccessible after rehash; this has been fixed (#2130, thanks [@eeeeeta](https://github.com/eeeeeta)!)
|
||||||
|
* Attempts to use unrecognized SASL mechanisms no longer count against the login throttle, improving compatibility with Pidgin (#2156, thanks donio and pathof!)
|
||||||
|
* Fixed database autoupgrade on Windows, which was previously broken due to the use of a colon in the backup filename (#2139, #2140, thanks [@Herringway](https://github.com/Herringway)!)
|
||||||
|
* Fixed handling of `NS CERT ADD <user> <fp>` when an unprivileged user invokes it on themself (#2128, #2098, thanks [@Eriner](https://github.com/Eriner)!)
|
||||||
|
* Fixed missing human-readable trailing parameters for two multiline `FAIL` messages (#2043, #2162, thanks [@jwheare](https://github.com/jwheare) and [@progval](https://github.com/progval)!)
|
||||||
|
* Fixed symbol sent by `353 RPL_NAMREPLY` for secret channels (#2144, #2145, thanks savoyard!)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* Trying to claim a registered nickname that is also actually in use by another client now produces `433 ERR_NICKNAMEINUSE` as expected (#2135, #2136, thanks savoyard!)
|
||||||
|
* `SAMODE` now overrides the enforcement of `limits.chan-list-modes` (the size limit for ban/except/invite lists) (#2081, #2165)
|
||||||
|
* Certain unsuccessful `MODE` changes no longer send `324 RPL_CHANNELMODEIS` and `329 RPL_CREATIONTIME` (#2163)
|
||||||
|
* Debug logging for environment variable configuration overrides no longer prints the value, only the key (#2129, #2132, thanks [@eeeeeta](https://github.com/eeeeeta)!)
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
|
||||||
|
* Official release builds use Go 1.22.4
|
||||||
|
* Added a linux/riscv64 release (#2172, #2173, thanks [@mengzhuo](https://github.com/mengzhuo)!)
|
||||||
|
|
||||||
|
## [2.13.1] - 2024-05-06
|
||||||
|
|
||||||
|
Ergo 2.13.1 is a bugfix release, fixing an exploitable deadlock that could lead to a denial of service. We regret the oversight.
|
||||||
|
|
||||||
|
This release includes no changes to the config file format or database format.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
* Fixed an exploitable deadlock that could lead to a denial of service (#2149)
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
|
||||||
|
* Official release builds use Go 1.22.2
|
||||||
|
|
||||||
|
|
||||||
## [2.13.0] - 2024-01-14
|
## [2.13.0] - 2024-01-14
|
||||||
|
|
||||||
We're pleased to be publishing v2.13.0, a new stable release. This is a bugfix release that fixes some issues, including a crash.
|
We're pleased to be publishing v2.13.0, a new stable release. This is a bugfix release that fixes some issues, including a crash.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
## build ergo binary
|
## build ergo binary
|
||||||
FROM docker.io/golang:1.21-alpine AS build-env
|
FROM docker.io/golang:1.25-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>" \
|
||||||
|
|
|
||||||
13
Makefile
13
Makefile
|
|
@ -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)
|
||||||
|
|
||||||
|
|
@ -9,33 +7,42 @@ export CGO_ENABLED ?= 0
|
||||||
|
|
||||||
capdef_file = ./irc/caps/defs.go
|
capdef_file = ./irc/caps/defs.go
|
||||||
|
|
||||||
|
.PHONY: all
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
|
.PHONY: install
|
||||||
install:
|
install:
|
||||||
go install -v -ldflags "-X main.commit=$(GIT_COMMIT) -X main.version=$(GIT_TAG)"
|
go install -v -ldflags "-X main.commit=$(GIT_COMMIT) -X main.version=$(GIT_TAG)"
|
||||||
|
|
||||||
|
.PHONY: build
|
||||||
build:
|
build:
|
||||||
go build -v -ldflags "-X main.commit=$(GIT_COMMIT) -X main.version=$(GIT_TAG)"
|
go build -v -ldflags "-X main.commit=$(GIT_COMMIT) -X main.version=$(GIT_TAG)"
|
||||||
|
|
||||||
|
.PHONY: release
|
||||||
release:
|
release:
|
||||||
goreleaser --skip-publish --rm-dist
|
goreleaser --skip=publish --clean
|
||||||
|
|
||||||
|
.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 ./...
|
||||||
go vet ./...
|
go vet ./...
|
||||||
./.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
|
||||||
|
|
|
||||||
146
default.yaml
146
default.yaml
|
|
@ -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
|
||||||
#
|
#
|
||||||
|
|
@ -134,9 +135,10 @@ server:
|
||||||
# the recommended default is 'ascii' (traditional ASCII-only identifiers).
|
# the recommended default is 'ascii' (traditional ASCII-only identifiers).
|
||||||
# the other options are 'precis', which allows UTF8 identifiers that are "sane"
|
# the other options are 'precis', which allows UTF8 identifiers that are "sane"
|
||||||
# (according to UFC 8265), with additional mitigations for homoglyph attacks,
|
# (according to UFC 8265), with additional mitigations for homoglyph attacks,
|
||||||
# and 'permissive', which allows identifiers containing unusual characters like
|
# 'permissive', which allows identifiers containing unusual characters like
|
||||||
# emoji, at the cost of increased vulnerability to homoglyph attacks and potential
|
# emoji, at the cost of increased vulnerability to homoglyph attacks and potential
|
||||||
# client compatibility problems. we recommend leaving this value at its default;
|
# client compatibility problems, and the legacy mappings 'rfc1459' and
|
||||||
|
# 'rfc1459-strict'. we recommend leaving this value at its default;
|
||||||
# however, note that changing it once the network is already up and running is
|
# however, note that changing it once the network is already up and running is
|
||||||
# problematic.
|
# problematic.
|
||||||
casemapping: "ascii"
|
casemapping: "ascii"
|
||||||
|
|
@ -178,6 +180,17 @@ 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
|
||||||
|
|
||||||
|
# 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?
|
||||||
|
|
@ -218,6 +231,10 @@ server:
|
||||||
# - "192.168.1.1"
|
# - "192.168.1.1"
|
||||||
# - "192.168.10.1/24"
|
# - "192.168.10.1/24"
|
||||||
|
|
||||||
|
# whether to accept the hostname parameter on the WEBIRC line as the IRC hostname
|
||||||
|
# (the default/recommended Ergo configuration will use cloaks instead)
|
||||||
|
accept-hostname: false
|
||||||
|
|
||||||
# maximum length of clients' sendQ in bytes
|
# maximum length of clients' sendQ in bytes
|
||||||
# this should be big enough to hold bursts of channel/direct messages
|
# this should be big enough to hold bursts of channel/direct messages
|
||||||
max-sendq: 96k
|
max-sendq: 96k
|
||||||
|
|
@ -352,6 +369,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:
|
||||||
|
|
@ -370,6 +391,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?
|
||||||
|
|
@ -405,6 +437,10 @@ accounts:
|
||||||
sender: "admin@my.network"
|
sender: "admin@my.network"
|
||||||
require-tls: true
|
require-tls: true
|
||||||
helo-domain: "my.network" # defaults to server name if unset
|
helo-domain: "my.network" # defaults to server name if unset
|
||||||
|
# set to `tcp4` to force sending over IPv4, `tcp6` to force IPv6:
|
||||||
|
# protocol: "tcp4"
|
||||||
|
# set to force a specific source/local IPv4 or IPv6 address:
|
||||||
|
# local-address: "1.2.3.4"
|
||||||
# options to enable DKIM signing of outgoing emails (recommended, but
|
# options to enable DKIM signing of outgoing emails (recommended, but
|
||||||
# requires creating a DNS entry for the public key):
|
# requires creating a DNS entry for the public key):
|
||||||
# dkim:
|
# dkim:
|
||||||
|
|
@ -501,7 +537,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-*"
|
||||||
|
|
@ -586,6 +622,40 @@ accounts:
|
||||||
# how many scripts are allowed to run at once? 0 for no limit:
|
# how many scripts are allowed to run at once? 0 for no limit:
|
||||||
max-concurrency: 64
|
max-concurrency: 64
|
||||||
|
|
||||||
|
# support for login via OAuth2 bearer tokens
|
||||||
|
oauth2:
|
||||||
|
enabled: false
|
||||||
|
# should we automatically create users on presentation of a valid token?
|
||||||
|
autocreate: true
|
||||||
|
# enable this to use auth-script for validation:
|
||||||
|
auth-script: false
|
||||||
|
introspection-url: "https://example.com/api/oidc/introspection"
|
||||||
|
introspection-timeout: 10s
|
||||||
|
# omit for auth method `none`; required for auth method `client_secret_basic`:
|
||||||
|
client-id: "ergo"
|
||||||
|
client-secret: "4TA0I7mJ3fUUcW05KJiODg"
|
||||||
|
|
||||||
|
# support for login via JWT bearer tokens
|
||||||
|
jwt-auth:
|
||||||
|
enabled: false
|
||||||
|
# should we automatically create users on presentation of a valid token?
|
||||||
|
autocreate: true
|
||||||
|
# any of these token definitions can be accepted, allowing for key rotation
|
||||||
|
tokens:
|
||||||
|
-
|
||||||
|
algorithm: "hmac" # either 'hmac', 'rsa', or 'eddsa' (ed25519)
|
||||||
|
# hmac takes a symmetric key, rsa and eddsa take PEM-encoded public keys;
|
||||||
|
# either way, the key can be specified either as a YAML string:
|
||||||
|
key: "nANiZ1De4v6WnltCHN2H7Q"
|
||||||
|
# or as a path to the file containing the key:
|
||||||
|
#key-file: "jwt_pubkey.pem"
|
||||||
|
# list of JWT claim names to search for the user's account name (make sure the format
|
||||||
|
# is what you expect, especially if using "sub"):
|
||||||
|
account-claims: ["preferred_username"]
|
||||||
|
# if a claim is formatted as an email address, require it to have the following domain,
|
||||||
|
# and then strip off the domain and use the local-part as the account name:
|
||||||
|
#strip-domain: "example.com"
|
||||||
|
|
||||||
# channel options
|
# channel options
|
||||||
channels:
|
channels:
|
||||||
# modes that are set when new channels are created
|
# modes that are set when new channels are created
|
||||||
|
|
@ -669,6 +739,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:
|
||||||
|
|
@ -733,7 +804,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
|
||||||
|
|
@ -777,7 +848,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
|
||||||
|
|
@ -820,6 +891,9 @@ limits:
|
||||||
# identlen is the max ident length allowed
|
# identlen is the max ident length allowed
|
||||||
identlen: 20
|
identlen: 20
|
||||||
|
|
||||||
|
# realnamelen is the maximum realname length allowed
|
||||||
|
realnamelen: 150
|
||||||
|
|
||||||
# channellen is the max channel length allowed
|
# channellen is the max channel length allowed
|
||||||
channellen: 64
|
channellen: 64
|
||||||
|
|
||||||
|
|
@ -839,7 +913,7 @@ limits:
|
||||||
whowas-entries: 100
|
whowas-entries: 100
|
||||||
|
|
||||||
# maximum length of channel lists (beI modes)
|
# maximum length of channel lists (beI modes)
|
||||||
chan-list-modes: 60
|
chan-list-modes: 100
|
||||||
|
|
||||||
# maximum number of messages to accept during registration (prevents
|
# maximum number of messages to accept during registration (prevents
|
||||||
# DoS / resource exhaustion attacks):
|
# DoS / resource exhaustion attacks):
|
||||||
|
|
@ -876,6 +950,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
|
||||||
|
|
@ -894,6 +969,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.
|
||||||
|
|
@ -1021,3 +1102,56 @@ 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
|
||||||
|
# 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"
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
124
docs/API.md
Normal file
124
docs/API.md
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
__ __ ______ ___ ______ ___
|
||||||
|
__/ // /_/ ____/ __ \/ ____/ __ \
|
||||||
|
/_ // __/ __/ / /_/ / / __/ / / /
|
||||||
|
/_ // __/ /___/ _, _/ /_/ / /_/ /
|
||||||
|
/_//_/ /_____/_/ |_|\____/\____/
|
||||||
|
|
||||||
|
Ergo IRCd API Documentation
|
||||||
|
https://ergo.chat/
|
||||||
|
|
||||||
|
_Copyright © Daniel Oaks <daniel@danieloaks.net>, Shivaram Lingamneni <slingamn@cs.stanford.edu>_
|
||||||
|
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Ergo has an experimental HTTP API. Some general information about the API:
|
||||||
|
|
||||||
|
1. All requests to the API are via POST.
|
||||||
|
1. All requests to the API are authenticated via bearer authentication. This is a header named `Authorization` with the value `Bearer <token>`. A list of valid tokens is hardcoded in the Ergo config. Future versions of Ergo may allow additional validation schemes for tokens.
|
||||||
|
1. The request parameters are sent as JSON in the POST body.
|
||||||
|
1. Any status code other than 200 is an error response; the response body is undefined in this case (likely human-readable text for debugging).
|
||||||
|
1. A 200 status code indicates successful execution of the request. The response body will be JSON and may indicate application-level success or failure (typically via the `success` field, which takes a boolean value).
|
||||||
|
|
||||||
|
API endpoints are versioned (currently all endpoints have a `/v1/` path prefix). Backwards-incompatible updates will most likely take the form of endpoints with new names, or an increased version prefix. Any exceptions to this will be specifically documented in the changelog.
|
||||||
|
|
||||||
|
All API endpoints should be considered highly privileged. Bearer tokens should be kept secret. Access to the API should be either over a trusted link (like loopback) or secured via verified TLS. See the `api` section of `default.yaml` for examples of how to configure this.
|
||||||
|
|
||||||
|
Here's an example of how to test an API configured to run over loopback TCP in plaintext:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -d '{"accountName": "invalidaccountname", "passphrase": "invalidpassphrase"}' -H 'Authorization: Bearer EYBbXVilnumTtfn4A9HE8_TiKLGWEGylre7FG6gEww0' -v http://127.0.0.1:8089/v1/check_auth
|
||||||
|
```
|
||||||
|
|
||||||
|
This returns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"success":false}
|
||||||
|
```
|
||||||
|
|
||||||
|
Endpoints
|
||||||
|
=========
|
||||||
|
|
||||||
|
`/v1/account_details`
|
||||||
|
----------------
|
||||||
|
|
||||||
|
This endpoint fetches account details and returns them as JSON. The request is a JSON object with fields:
|
||||||
|
|
||||||
|
* `accountName`: string, name of the account
|
||||||
|
|
||||||
|
The response is a JSON object with fields:
|
||||||
|
|
||||||
|
* `success`: whether the account exists or not
|
||||||
|
* `accountName`: canonical, case-unfolded version of the account name
|
||||||
|
* `email`: email address of the account provided
|
||||||
|
* `registeredAt`: string, registration date/time of the account (in ISO8601 format)
|
||||||
|
* `channels`: array of strings, list of channels the account is registered on or associated with
|
||||||
|
|
||||||
|
`/v1/check_auth`
|
||||||
|
----------------
|
||||||
|
|
||||||
|
This endpoint verifies the credentials of a NickServ account; this allows Ergo to be used as the source of truth for authentication by another system. The request is a JSON object with fields:
|
||||||
|
|
||||||
|
* `accountName`: string, name of the account
|
||||||
|
* `passphrase`: string, alleged passphrase of the account
|
||||||
|
|
||||||
|
The response is a JSON object with fields:
|
||||||
|
|
||||||
|
* `success`: whether the credentials provided were valid
|
||||||
|
* `accountName`: canonical, case-unfolded version of the account name
|
||||||
|
|
||||||
|
`/v1/rehash`
|
||||||
|
------------
|
||||||
|
|
||||||
|
This endpoint rehashes the server (i.e. reloads the configuration file, TLS certificates, and other associated data). The body is ignored. The response is a JSON object with fields:
|
||||||
|
|
||||||
|
* `success`: boolean, indicates whether the rehash was successful
|
||||||
|
* `error`: string, optional, human-readable description of the failure
|
||||||
|
|
||||||
|
`/v1/saregister`
|
||||||
|
----------------
|
||||||
|
|
||||||
|
This endpoint registers an account in NickServ, with the same semantics as `NS SAREGISTER`. The request is a JSON object with fields:
|
||||||
|
|
||||||
|
* `accountName`: string, name of the account
|
||||||
|
* `passphrase`: string, passphrase of the account
|
||||||
|
|
||||||
|
The response is a JSON object with fields:
|
||||||
|
|
||||||
|
* `success`: whether the account creation succeeded
|
||||||
|
* `errorCode`: string, optional, machine-readable description of the error. Possible values include: `ACCOUNT_EXISTS`, `INVALID_PASSPHRASE`, `UNKNOWN_ERROR`.
|
||||||
|
* `error`: string, optional, human-readable description of the failure.
|
||||||
|
|
||||||
|
`/v1/account_list`
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
This endpoint fetches a list of all accounts. The request body is ignored and can be empty.
|
||||||
|
|
||||||
|
The response is a JSON object with fields:
|
||||||
|
|
||||||
|
* `success`: whether the request succeeded
|
||||||
|
* `accounts`: array of objects, each with fields:
|
||||||
|
* `success`: boolean, whether this individual account query succeeded
|
||||||
|
* `accountName`: string, canonical, case-unfolded version of the account name
|
||||||
|
* `totalCount`: integer, total number of accounts returned
|
||||||
|
|
||||||
|
|
||||||
|
`/v1/status`
|
||||||
|
-------------
|
||||||
|
|
||||||
|
This endpoint returns status information about the running Ergo server. The request body is ignored and can be empty.
|
||||||
|
|
||||||
|
The response is a JSON object with fields:
|
||||||
|
|
||||||
|
* `success`: whether the request succeeded
|
||||||
|
* `version`: string, Ergo server version string
|
||||||
|
* `go_version`: string, version of Go runtime used
|
||||||
|
* `start_time`: string, server start time in ISO8601 format
|
||||||
|
* `users`: object with fields:
|
||||||
|
* `total`: total number of users connected
|
||||||
|
* `invisible`: number of invisible users
|
||||||
|
* `operators`: number of operators connected
|
||||||
|
* `unknown`: number of users with unknown status
|
||||||
|
* `max`: maximum number of users seen connected at once
|
||||||
|
* `channels`: integer, number of channels currently active
|
||||||
|
* `servers`: integer, number of servers connected in the network
|
||||||
|
|
@ -44,6 +44,7 @@ _Copyright © Daniel Oaks <daniel@danieloaks.net>, Shivaram Lingamneni <slingamn
|
||||||
- [Persistent history with MySQL](#persistent-history-with-mysql)
|
- [Persistent history with MySQL](#persistent-history-with-mysql)
|
||||||
- [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)
|
||||||
|
|
@ -60,7 +61,9 @@ _Copyright © Daniel Oaks <daniel@danieloaks.net>, Shivaram Lingamneni <slingamn
|
||||||
- [Migrating from Anope or Atheme](#migrating-from-anope-or-atheme)
|
- [Migrating from Anope or Atheme](#migrating-from-anope-or-atheme)
|
||||||
- [HOPM](#hopm)
|
- [HOPM](#hopm)
|
||||||
- [Tor](#tor)
|
- [Tor](#tor)
|
||||||
|
- [I2P](#i2p)
|
||||||
- [ZNC](#znc)
|
- [ZNC](#znc)
|
||||||
|
- [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)
|
||||||
|
|
@ -168,6 +171,7 @@ Rehashing also reloads TLS certificates and the MOTD. Some configuration setting
|
||||||
|
|
||||||
Ergo can also be configured using environment variables, using the following technique:
|
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`.
|
||||||
|
|
@ -482,6 +486,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.
|
||||||
|
|
||||||
|
|
||||||
-------------------------------------------------------------------------------------------
|
-------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -510,7 +527,7 @@ 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?
|
||||||
|
|
@ -1120,6 +1137,7 @@ Tor provides end-to-end encryption for onion services, so there's no need to ena
|
||||||
The second way is to run Ergo as a true hidden service, where the server's actual IP address is a secret. This requires hardening measures on the Ergo side:
|
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.
|
||||||
|
|
@ -1142,6 +1160,16 @@ Instructions on how client software should connect to an .onion address are outs
|
||||||
1. [Hexchat](https://hexchat.github.io/) is known to support .onion addresses, once it has been configured to use a local Tor daemon as a SOCKS proxy (Settings -> Preferences -> Network Setup -> Proxy Server).
|
1. [Hexchat](https://hexchat.github.io/) is known to support .onion addresses, once it has been configured to use a local Tor daemon as a SOCKS proxy (Settings -> Preferences -> Network Setup -> Proxy Server).
|
||||||
1. Pidgin should work with [torsocks](https://trac.torproject.org/projects/tor/wiki/doc/torsocks).
|
1. Pidgin should work with [torsocks](https://trac.torproject.org/projects/tor/wiki/doc/torsocks).
|
||||||
|
|
||||||
|
## I2P
|
||||||
|
|
||||||
|
I2P is an anonymizing overlay network similar to Tor. The recommended configuration for I2P is to treat it similarly to Tor: have the i2pd reverse proxy its connections to an Ergo listener configured with `tor: true`. See the [i2pd configuration guide](https://i2pd.readthedocs.io/en/latest/tutorials/irc/#running-anonymous-irc-server) for more details; note that the instructions to separate I2P traffic from other localhost traffic are unnecessary for a `tor: true` listener.
|
||||||
|
|
||||||
|
I2P can additionally expose an opaque client identifier (the user's "b32 address"). Exposing this identifier via Ergo is not recommended, but if you wish to do so, you can use the following procedure:
|
||||||
|
|
||||||
|
1. Enable WEBIRC support in the i2pd configuration by adding the `webircpassword` key to the [i2pd server block](https://i2pd.readthedocs.io/en/latest/tutorials/irc/#running-anonymous-irc-server)
|
||||||
|
1. Remove `tor: true` from the relevant Ergo listener config
|
||||||
|
1. Enable WEBIRC support in Ergo (starting from the default/recommended configuration, find the existing webirc block, delete the `certfp` configuration, change `password` to use the output of `ergo genpasswd` on the password you configured i2pd to send, and set `accept-hostname: true`)
|
||||||
|
1. To prevent Ergo from overwriting the hostname as passed from i2pd, set the following options: `server.ip-cloaking.enabled: false` and `server.lookup-hostnames: false`. (There is currently no support for applying cloaks to regular IP traffic but displaying the b32 address for I2P traffic).
|
||||||
|
|
||||||
## ZNC
|
## ZNC
|
||||||
|
|
||||||
|
|
@ -1149,6 +1177,10 @@ ZNC 1.6.x (still pretty common in distros that package old versions of IRC softw
|
||||||
|
|
||||||
Ergo can emulate certain capabilities of the ZNC bouncer for the benefit of clients, in particular the third-party [playback](https://wiki.znc.in/Playback) module. This enables clients with specific support for ZNC to receive selective history playback automatically. To configure this in [Textual](https://www.codeux.com/textual/), go to "Server properties", select "Vendor specific", uncheck "Do not automatically join channels on connect", and check "Only play back messages you missed". Other clients with support are listed on ZNC's wiki page.
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
15
ergo.go
15
ergo.go
|
|
@ -7,6 +7,7 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
_ "embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -20,12 +21,16 @@ 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:
|
||||||
var commit = "" // git hash
|
var commit = "" // git hash
|
||||||
var version = "" // tagged version
|
var version = "" // tagged version
|
||||||
|
|
||||||
|
//go:embed default.yaml
|
||||||
|
var defaultConfig string
|
||||||
|
|
||||||
// get a password from stdin from the user
|
// get a password from stdin from the user
|
||||||
func getPasswordFromTerminal() string {
|
func getPasswordFromTerminal() string {
|
||||||
bytePassword, err := term.ReadPassword(int(syscall.Stdin))
|
bytePassword, err := term.ReadPassword(int(syscall.Stdin))
|
||||||
|
|
@ -94,6 +99,8 @@ Usage:
|
||||||
ergo importdb <database.json> [--conf <filename>] [--quiet]
|
ergo importdb <database.json> [--conf <filename>] [--quiet]
|
||||||
ergo genpasswd [--conf <filename>] [--quiet]
|
ergo genpasswd [--conf <filename>] [--quiet]
|
||||||
ergo mkcerts [--conf <filename>] [--quiet]
|
ergo mkcerts [--conf <filename>] [--quiet]
|
||||||
|
ergo defaultconfig
|
||||||
|
ergo gentoken
|
||||||
ergo run [--conf <filename>] [--quiet] [--smoke]
|
ergo run [--conf <filename>] [--quiet] [--smoke]
|
||||||
ergo -h | --help
|
ergo -h | --help
|
||||||
ergo --version
|
ergo --version
|
||||||
|
|
@ -133,6 +140,12 @@ Options:
|
||||||
}
|
}
|
||||||
fmt.Println(string(hash))
|
fmt.Println(string(hash))
|
||||||
return
|
return
|
||||||
|
} else if arguments["defaultconfig"].(bool) {
|
||||||
|
fmt.Print(defaultConfig)
|
||||||
|
return
|
||||||
|
} else if arguments["gentoken"].(bool) {
|
||||||
|
fmt.Println(utils.GenerateSecretKey())
|
||||||
|
return
|
||||||
} else if arguments["mkcerts"].(bool) {
|
} else if arguments["mkcerts"].(bool) {
|
||||||
doMkcerts(arguments["--conf"].(string), arguments["--quiet"].(bool))
|
doMkcerts(arguments["--conf"].(string), arguments["--quiet"].(bool))
|
||||||
return
|
return
|
||||||
|
|
@ -180,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)
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
23
go.mod
23
go.mod
|
|
@ -1,6 +1,6 @@
|
||||||
module github.com/ergochat/ergo
|
module github.com/ergochat/ergo
|
||||||
|
|
||||||
go 1.21
|
go 1.25
|
||||||
|
|
||||||
require (
|
require (
|
||||||
code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48
|
code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48
|
||||||
|
|
@ -8,25 +8,28 @@ 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.4.0
|
github.com/ergochat/irc-go v0.5.0-rc2
|
||||||
github.com/go-sql-driver/mysql v1.7.0
|
github.com/go-sql-driver/mysql v1.7.0
|
||||||
github.com/go-test/deep v1.0.6 // indirect
|
|
||||||
github.com/gofrs/flock v0.8.1
|
github.com/gofrs/flock v0.8.1
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible
|
|
||||||
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/stretchr/testify v1.4.0 // indirect
|
||||||
github.com/tidwall/buntdb v1.2.10
|
github.com/tidwall/buntdb v1.3.2
|
||||||
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.38.0
|
||||||
golang.org/x/term v0.15.0
|
golang.org/x/term v0.32.0
|
||||||
golang.org/x/text v0.14.0
|
golang.org/x/text v0.25.0
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.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.2.2
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
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
|
||||||
|
|
@ -36,7 +39,7 @@ require (
|
||||||
github.com/tidwall/rtred v0.1.2 // indirect
|
github.com/tidwall/rtred v0.1.2 // indirect
|
||||||
github.com/tidwall/tinyqueue v0.1.1 // indirect
|
github.com/tidwall/tinyqueue v0.1.1 // indirect
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
golang.org/x/sys v0.15.0 // indirect
|
golang.org/x/sys v0.33.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
|
||||||
|
|
|
||||||
50
go.sum
50
go.sum
|
|
@ -6,25 +6,29 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ=
|
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 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/emersion/go-msgauth v0.6.8 h1:kW/0E9E8Zx5CdKsERC/WnAvnXvX7q9wTHia1OA4944A=
|
||||||
|
github.com/emersion/go-msgauth v0.6.8/go.mod h1:YDwuyTCUHu9xxmAeVj0eW4INnwB6NNZoPdLerpSxRrc=
|
||||||
|
github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc=
|
||||||
|
github.com/emersion/go-msgauth v0.7.0/go.mod h1:mmS9I6HkSovrNgq0HNXTeu8l3sRAAuQ9RMvbM4KU7Ck=
|
||||||
github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1 h1:WLHTOodthVyv5NvYLIvWl112kSFv5IInKKrRN2qpons=
|
github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1 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-rc2 h1:VuSQJF5K4hWvYSzGa4b8vgL6kzw8HF6LSOejE+RWpAo=
|
||||||
github.com/ergochat/irc-go v0.4.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
|
github.com/ergochat/irc-go v0.5.0-rc2/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
|
||||||
github.com/ergochat/scram v1.0.2-ergo1 h1:2bYXiRFQH636pT0msOG39fmEYl4Eq+OuutcyDsCix/g=
|
github.com/ergochat/scram v1.0.2-ergo1 h1:2bYXiRFQH636pT0msOG39fmEYl4Eq+OuutcyDsCix/g=
|
||||||
github.com/ergochat/scram v1.0.2-ergo1/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
|
github.com/ergochat/scram v1.0.2-ergo1/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
|
||||||
|
github.com/ergochat/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.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||||
github.com/go-test/deep v1.0.6 h1:UHSEyLZUwX9Qoi99vVwvewiMC8mM2bf7XEM2nqvzEn8=
|
|
||||||
github.com/go-test/deep v1.0.6/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
|
|
||||||
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
|
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
|
||||||
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
|
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
|
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
|
||||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
|
|
@ -45,8 +49,8 @@ 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=
|
||||||
|
|
@ -62,28 +66,34 @@ github.com/tidwall/rtred v0.1.2 h1:exmoQtOLvDoO8ud++6LwVsAMTu0KPzLTUrMln8u1yu8=
|
||||||
github.com/tidwall/rtred v0.1.2/go.mod h1:hd69WNXQ5RP9vHd7dqekAz+RIdtfBogmglkZSRxCHFQ=
|
github.com/tidwall/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.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||||
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||||
|
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||||
|
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
|
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||||
|
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||||
|
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||||
|
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.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.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||||
|
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
|
|
||||||
156
irc/accounts.go
156
irc/accounts.go
|
|
@ -4,6 +4,7 @@
|
||||||
package irc
|
package irc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
@ -23,6 +24,7 @@ import (
|
||||||
"github.com/ergochat/ergo/irc/email"
|
"github.com/ergochat/ergo/irc/email"
|
||||||
"github.com/ergochat/ergo/irc/migrations"
|
"github.com/ergochat/ergo/irc/migrations"
|
||||||
"github.com/ergochat/ergo/irc/modes"
|
"github.com/ergochat/ergo/irc/modes"
|
||||||
|
"github.com/ergochat/ergo/irc/oauth2"
|
||||||
"github.com/ergochat/ergo/irc/passwd"
|
"github.com/ergochat/ergo/irc/passwd"
|
||||||
"github.com/ergochat/ergo/irc/utils"
|
"github.com/ergochat/ergo/irc/utils"
|
||||||
)
|
)
|
||||||
|
|
@ -49,6 +51,8 @@ const (
|
||||||
// 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
|
||||||
)
|
)
|
||||||
|
|
@ -133,6 +137,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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -713,6 +719,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 {
|
||||||
|
|
@ -948,7 +1022,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:
|
||||||
|
|
@ -1119,7 +1193,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):"))
|
||||||
|
|
@ -1427,6 +1501,74 @@ func (am *AccountManager) AuthenticateByPassphrase(client *Client, accountName s
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (am *AccountManager) AuthenticateByBearerToken(client *Client, tokenType, token string) (err error) {
|
||||||
|
switch tokenType {
|
||||||
|
case "oauth2":
|
||||||
|
return am.AuthenticateByOAuthBearer(client, oauth2.OAuthBearerOptions{Token: token})
|
||||||
|
case "jwt":
|
||||||
|
return am.AuthenticateByJWT(client, token)
|
||||||
|
default:
|
||||||
|
return errInvalidBearerTokenType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (am *AccountManager) AuthenticateByOAuthBearer(client *Client, opts oauth2.OAuthBearerOptions) (err error) {
|
||||||
|
config := am.server.Config()
|
||||||
|
|
||||||
|
if !config.Accounts.OAuth2.Enabled {
|
||||||
|
return errFeatureDisabled
|
||||||
|
}
|
||||||
|
|
||||||
|
if throttled, remainingTime := client.checkLoginThrottle(); throttled {
|
||||||
|
return &ThrottleError{remainingTime}
|
||||||
|
}
|
||||||
|
|
||||||
|
var username string
|
||||||
|
if config.Accounts.AuthScript.Enabled && config.Accounts.OAuth2.AuthScript {
|
||||||
|
username, err = am.authenticateByOAuthBearerScript(client, config, opts)
|
||||||
|
} else {
|
||||||
|
username, err = config.Accounts.OAuth2.Introspect(context.Background(), opts.Token)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
account, err := am.loadWithAutocreation(username, config.Accounts.OAuth2.Autocreate)
|
||||||
|
if err == nil {
|
||||||
|
am.Login(client, account)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (am *AccountManager) AuthenticateByJWT(client *Client, token string) (err error) {
|
||||||
|
config := am.server.Config()
|
||||||
|
// enabled check is encapsulated here:
|
||||||
|
accountName, err := config.Accounts.JWTAuth.Validate(token)
|
||||||
|
if err != nil {
|
||||||
|
am.server.logger.Debug("accounts", "invalid JWT token", err.Error())
|
||||||
|
return errAccountInvalidCredentials
|
||||||
|
}
|
||||||
|
account, err := am.loadWithAutocreation(accountName, config.Accounts.JWTAuth.Autocreate)
|
||||||
|
if err == nil {
|
||||||
|
am.Login(client, account)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (am *AccountManager) authenticateByOAuthBearerScript(client *Client, config *Config, opts oauth2.OAuthBearerOptions) (username string, err error) {
|
||||||
|
output, err := CheckAuthScript(am.server.semaphores.AuthScript, config.Accounts.AuthScript.ScriptConfig,
|
||||||
|
AuthScriptInput{OAuthBearer: &opts, IP: client.IP().String()})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
am.server.logger.Error("internal", "failed shell auth invocation", err.Error())
|
||||||
|
return "", oauth2.ErrInvalidToken
|
||||||
|
} else if output.Success {
|
||||||
|
return output.AccountName, nil
|
||||||
|
} else {
|
||||||
|
return "", oauth2.ErrInvalidToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AllNicks returns the uncasefolded nicknames for all accounts, including additional (grouped) nicks.
|
// AllNicks returns the uncasefolded nicknames for all accounts, including additional (grouped) nicks.
|
||||||
func (am *AccountManager) AllNicks() (result []string) {
|
func (am *AccountManager) AllNicks() (result []string) {
|
||||||
accountNamePrefix := fmt.Sprintf(keyAccountName, "")
|
accountNamePrefix := fmt.Sprintf(keyAccountName, "")
|
||||||
|
|
@ -1773,6 +1915,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() {
|
||||||
|
|
@ -1831,6 +1975,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
|
||||||
})
|
})
|
||||||
|
|
@ -1939,9 +2085,11 @@ func (am *AccountManager) AuthenticateByCertificate(client *Client, certfp strin
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if authzid != "" && authzid != account {
|
if authzid != "" {
|
||||||
|
if cfAuthzid, err := CasefoldName(authzid); err != nil || cfAuthzid != account {
|
||||||
return errAuthzidAuthcidMismatch
|
return errAuthzidAuthcidMismatch
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
|
|
@ -2145,6 +2293,8 @@ var (
|
||||||
"PLAIN": authPlainHandler,
|
"PLAIN": authPlainHandler,
|
||||||
"EXTERNAL": authExternalHandler,
|
"EXTERNAL": authExternalHandler,
|
||||||
"SCRAM-SHA-256": authScramHandler,
|
"SCRAM-SHA-256": authScramHandler,
|
||||||
|
"OAUTHBEARER": authOauthBearerHandler,
|
||||||
|
"IRCV3BEARER": authIRCv3BearerHandler,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
311
irc/api.go
Normal file
311
irc/api.go
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
package irc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ergochat/ergo/irc/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newAPIHandler(server *Server) http.Handler {
|
||||||
|
api := &ergoAPI{
|
||||||
|
server: server,
|
||||||
|
mux: http.NewServeMux(),
|
||||||
|
}
|
||||||
|
|
||||||
|
api.mux.HandleFunc("POST /v1/rehash", api.handleRehash)
|
||||||
|
api.mux.HandleFunc("POST /v1/check_auth", api.handleCheckAuth)
|
||||||
|
api.mux.HandleFunc("POST /v1/saregister", api.handleSaregister)
|
||||||
|
api.mux.HandleFunc("POST /v1/account_details", api.handleAccountDetails)
|
||||||
|
api.mux.HandleFunc("POST /v1/account_list", api.handleAccountList)
|
||||||
|
api.mux.HandleFunc("POST /v1/status", api.handleStatus)
|
||||||
|
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
|
||||||
|
type ergoAPI struct {
|
||||||
|
server *Server
|
||||||
|
mux *http.ServeMux
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ergoAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer a.server.HandlePanic(nil)
|
||||||
|
defer a.server.logger.Debug("api", r.URL.Path)
|
||||||
|
|
||||||
|
if a.checkBearerAuth(r.Header.Get("Authorization")) {
|
||||||
|
a.mux.ServeHTTP(w, r)
|
||||||
|
} else {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ergoAPI) checkBearerAuth(authHeader string) (authorized bool) {
|
||||||
|
if authHeader == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c := a.server.Config()
|
||||||
|
if !c.API.Enabled {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
spaceIdx := strings.IndexByte(authHeader, ' ')
|
||||||
|
if spaceIdx < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !strings.EqualFold("Bearer", authHeader[:spaceIdx]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
providedTokenBytes := []byte(authHeader[spaceIdx+1:])
|
||||||
|
for _, tokenBytes := range c.API.bearerTokenBytes {
|
||||||
|
if subtle.ConstantTimeCompare(tokenBytes, providedTokenBytes) == 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ergoAPI) decodeJSONRequest(request any, w http.ResponseWriter, r *http.Request) (err error) {
|
||||||
|
err = json.NewDecoder(r.Body).Decode(request)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("failed to deserialize json request: %v", err), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ergoAPI) writeJSONResponse(response any, w http.ResponseWriter, r *http.Request) {
|
||||||
|
j, err := json.Marshal(response)
|
||||||
|
if err == nil {
|
||||||
|
j = append(j, '\n') // less annoying in curl output
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(j)
|
||||||
|
} else {
|
||||||
|
a.server.logger.Error("internal", "failed to serialize API response", r.URL.Path, err.Error())
|
||||||
|
http.Error(w, fmt.Sprintf("failed to serialize json response: %v", err), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiGenericResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
ErrorCode string `json:"errorCode,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ergoAPI) handleRehash(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var response apiGenericResponse
|
||||||
|
err := a.server.rehash()
|
||||||
|
if err == nil {
|
||||||
|
response.Success = true
|
||||||
|
} else {
|
||||||
|
response.Success = false
|
||||||
|
response.Error = err.Error()
|
||||||
|
}
|
||||||
|
a.writeJSONResponse(response, w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiCheckAuthResponse struct {
|
||||||
|
apiGenericResponse
|
||||||
|
AccountName string `json:"accountName,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ergoAPI) handleCheckAuth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request AuthScriptInput
|
||||||
|
if err := a.decodeJSONRequest(&request, w, r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var response apiCheckAuthResponse
|
||||||
|
|
||||||
|
// try passphrase if present
|
||||||
|
if request.AccountName != "" && request.Passphrase != "" {
|
||||||
|
account, err := a.server.accounts.checkPassphrase(request.AccountName, request.Passphrase)
|
||||||
|
switch err {
|
||||||
|
case nil:
|
||||||
|
// success, no error
|
||||||
|
response.Success = true
|
||||||
|
response.AccountName = account.Name
|
||||||
|
case errAccountDoesNotExist, errAccountInvalidCredentials, errAccountUnverified, errAccountSuspended:
|
||||||
|
// fail, no error
|
||||||
|
response.Success = false
|
||||||
|
default:
|
||||||
|
response.Success = false
|
||||||
|
response.Error = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// try certfp if present
|
||||||
|
if !response.Success && request.Certfp != "" {
|
||||||
|
// TODO support cerftp
|
||||||
|
}
|
||||||
|
|
||||||
|
a.writeJSONResponse(response, w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiSaregisterRequest struct {
|
||||||
|
AccountName string `json:"accountName"`
|
||||||
|
Passphrase string `json:"passphrase"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ergoAPI) handleSaregister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request apiSaregisterRequest
|
||||||
|
if err := a.decodeJSONRequest(&request, w, r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var response apiGenericResponse
|
||||||
|
err := a.server.accounts.SARegister(request.AccountName, request.Passphrase)
|
||||||
|
if err == nil {
|
||||||
|
response.Success = true
|
||||||
|
} else {
|
||||||
|
response.Success = false
|
||||||
|
response.Error = err.Error()
|
||||||
|
switch err {
|
||||||
|
case errAccountAlreadyRegistered, errAccountAlreadyVerified, errNameReserved:
|
||||||
|
response.ErrorCode = "ACCOUNT_EXISTS"
|
||||||
|
case errAccountBadPassphrase:
|
||||||
|
response.ErrorCode = "INVALID_PASSPHRASE"
|
||||||
|
default:
|
||||||
|
response.ErrorCode = "UNKNOWN_ERROR"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.writeJSONResponse(response, w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiAccountDetailsResponse struct {
|
||||||
|
apiGenericResponse
|
||||||
|
AccountName string `json:"accountName,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
RegisteredAt string `json:"registeredAt,omitempty"`
|
||||||
|
Channels []string `json:"channels,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiAccountDetailsRequest struct {
|
||||||
|
AccountName string `json:"accountName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ergoAPI) handleAccountDetails(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request apiAccountDetailsRequest
|
||||||
|
if err := a.decodeJSONRequest(&request, w, r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var response apiAccountDetailsResponse
|
||||||
|
|
||||||
|
if request.AccountName != "" {
|
||||||
|
accountData, err := a.server.accounts.LoadAccount(request.AccountName)
|
||||||
|
if err == nil {
|
||||||
|
if !accountData.Verified {
|
||||||
|
err = errAccountUnverified
|
||||||
|
} else if accountData.Suspended != nil {
|
||||||
|
err = errAccountSuspended
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch err {
|
||||||
|
case nil:
|
||||||
|
response.AccountName = accountData.Name
|
||||||
|
response.Email = accountData.Settings.Email
|
||||||
|
if !accountData.RegisteredAt.IsZero() {
|
||||||
|
response.RegisteredAt = accountData.RegisteredAt.Format(utils.IRCv3TimestampFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get channels the account is in
|
||||||
|
response.Channels = a.server.channels.ChannelsForAccount(accountData.NameCasefolded)
|
||||||
|
response.Success = true
|
||||||
|
case errAccountDoesNotExist, errAccountUnverified, errAccountSuspended:
|
||||||
|
response.Success = false
|
||||||
|
default:
|
||||||
|
response.Success = false
|
||||||
|
response.ErrorCode = "UNKNOWN_ERROR"
|
||||||
|
response.Error = err.Error()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
response.Success = false
|
||||||
|
response.ErrorCode = "INVALID_REQUEST"
|
||||||
|
}
|
||||||
|
|
||||||
|
a.writeJSONResponse(response, w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiAccountListResponse struct {
|
||||||
|
apiGenericResponse
|
||||||
|
Accounts []apiAccountDetailsResponse `json:"accounts"`
|
||||||
|
TotalCount int `json:"totalCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ergoAPI) handleAccountList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var response apiAccountListResponse
|
||||||
|
|
||||||
|
// Get all account names
|
||||||
|
accounts := a.server.accounts.AllNicks()
|
||||||
|
response.TotalCount = len(accounts)
|
||||||
|
|
||||||
|
// Load account details
|
||||||
|
response.Accounts = make([]apiAccountDetailsResponse, len(accounts))
|
||||||
|
for i, account := range accounts {
|
||||||
|
accountData, err := a.server.accounts.LoadAccount(account)
|
||||||
|
if err != nil {
|
||||||
|
response.Accounts[i] = apiAccountDetailsResponse{
|
||||||
|
apiGenericResponse: apiGenericResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: err.Error(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Accounts[i] = apiAccountDetailsResponse{
|
||||||
|
apiGenericResponse: apiGenericResponse{
|
||||||
|
Success: true,
|
||||||
|
},
|
||||||
|
AccountName: accountData.Name,
|
||||||
|
Email: accountData.Settings.Email,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success = true
|
||||||
|
a.writeJSONResponse(response, w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiStatusResponse struct {
|
||||||
|
apiGenericResponse
|
||||||
|
Version string `json:"version"`
|
||||||
|
GoVersion string `json:"go_version"`
|
||||||
|
Commit string `json:"commit,omitempty"`
|
||||||
|
StartTime string `json:"start_time"`
|
||||||
|
Users struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Invisible int `json:"invisible"`
|
||||||
|
Operators int `json:"operators"`
|
||||||
|
Unknown int `json:"unknown"`
|
||||||
|
Max int `json:"max"`
|
||||||
|
} `json:"users"`
|
||||||
|
Channels int `json:"channels"`
|
||||||
|
Servers int `json:"servers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ergoAPI) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
server := a.server
|
||||||
|
stats := server.stats.GetValues()
|
||||||
|
|
||||||
|
response := apiStatusResponse{
|
||||||
|
apiGenericResponse: apiGenericResponse{Success: true},
|
||||||
|
Version: SemVer,
|
||||||
|
GoVersion: runtime.Version(),
|
||||||
|
Commit: Commit,
|
||||||
|
StartTime: server.ctime.Format(utils.IRCv3TimestampFormat),
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Users.Total = stats.Total
|
||||||
|
response.Users.Invisible = stats.Invisible
|
||||||
|
response.Users.Operators = stats.Operators
|
||||||
|
response.Users.Unknown = stats.Unknown
|
||||||
|
response.Users.Max = stats.Max
|
||||||
|
response.Channels = server.channels.Len()
|
||||||
|
response.Servers = 1
|
||||||
|
|
||||||
|
a.writeJSONResponse(response, w, r)
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
|
||||||
|
"github.com/ergochat/ergo/irc/oauth2"
|
||||||
"github.com/ergochat/ergo/irc/utils"
|
"github.com/ergochat/ergo/irc/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -21,6 +22,7 @@ type AuthScriptInput struct {
|
||||||
PeerCerts []string `json:"peerCerts,omitempty"`
|
PeerCerts []string `json:"peerCerts,omitempty"`
|
||||||
peerCerts []*x509.Certificate
|
peerCerts []*x509.Certificate
|
||||||
IP string `json:"ip,omitempty"`
|
IP string `json:"ip,omitempty"`
|
||||||
|
OAuthBearer *oauth2.OAuthBearerOptions `json:"oauth2,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthScriptOutput struct {
|
type AuthScriptOutput struct {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
105
irc/channel.go
105
irc/channel.go
|
|
@ -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
|
||||||
|
|
@ -449,6 +454,10 @@ func (channel *Channel) Names(client *Client, rb *ResponseBuffer) {
|
||||||
chname := channel.name
|
chname := channel.name
|
||||||
membersCache, memberDataCache := channel.membersCache, channel.memberDataCache
|
membersCache, memberDataCache := channel.membersCache, channel.memberDataCache
|
||||||
channel.stateMutex.RUnlock()
|
channel.stateMutex.RUnlock()
|
||||||
|
symbol := "=" // https://modern.ircdocs.horse/#rplnamreply-353
|
||||||
|
if channel.flags.HasMode(modes.Secret) {
|
||||||
|
symbol = "@"
|
||||||
|
}
|
||||||
isOper := client.HasRoleCapabs("sajoin")
|
isOper := client.HasRoleCapabs("sajoin")
|
||||||
respectAuditorium := channel.flags.HasMode(modes.Auditorium) && !isOper &&
|
respectAuditorium := channel.flags.HasMode(modes.Auditorium) && !isOper &&
|
||||||
(!isJoined || clientData.modes.HighestChannelUserMode() == modes.Mode(0))
|
(!isJoined || clientData.modes.HighestChannelUserMode() == modes.Mode(0))
|
||||||
|
|
@ -478,7 +487,7 @@ func (channel *Channel) Names(client *Client, rb *ResponseBuffer) {
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, line := range tl.Lines() {
|
for _, line := range tl.Lines() {
|
||||||
rb.Add(nil, client.server.name, RPL_NAMREPLY, client.nick, "=", chname, line)
|
rb.Add(nil, client.server.name, RPL_NAMREPLY, client.nick, symbol, chname, line)
|
||||||
}
|
}
|
||||||
rb.Add(nil, client.server.name, RPL_ENDOFNAMES, client.nick, chname, client.t("End of NAMES list"))
|
rb.Add(nil, client.server.name, RPL_ENDOFNAMES, client.nick, chname, client.t("End of NAMES list"))
|
||||||
}
|
}
|
||||||
|
|
@ -887,6 +896,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)
|
||||||
|
|
@ -1316,18 +1329,21 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
|
||||||
isBot := client.HasMode(modes.Bot)
|
isBot := client.HasMode(modes.Bot)
|
||||||
chname := channel.Name()
|
chname := channel.Name()
|
||||||
|
|
||||||
if !client.server.Config().Server.Compatibility.allowTruncation {
|
// STATUSMSG targets are prefixed with the supplied min-prefix, e.g., @#channel
|
||||||
|
if minPrefixMode != modes.Mode(0) {
|
||||||
|
chname = fmt.Sprintf("%s%s", modes.ChannelModePrefixes[minPrefixMode], chname)
|
||||||
|
}
|
||||||
|
|
||||||
|
config := client.server.Config()
|
||||||
|
dispatchWebPush := false
|
||||||
|
|
||||||
|
if !config.Server.Compatibility.allowTruncation {
|
||||||
if !validateSplitMessageLen(histType, details.nickMask, chname, message) {
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// STATUSMSG targets are prefixed with the supplied min-prefix, e.g., @#channel
|
|
||||||
if minPrefixMode != modes.Mode(0) {
|
|
||||||
chname = fmt.Sprintf("%s%s", modes.ChannelModePrefixes[minPrefixMode], chname)
|
|
||||||
}
|
|
||||||
|
|
||||||
if channel.flags.HasMode(modes.OpModerated) {
|
if channel.flags.HasMode(modes.OpModerated) {
|
||||||
channel.stateMutex.RLock()
|
channel.stateMutex.RLock()
|
||||||
cuData, ok := channel.members[client]
|
cuData, ok := channel.members[client]
|
||||||
|
|
@ -1351,6 +1367,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
|
||||||
|
|
@ -1374,6 +1393,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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1622,6 +1677,40 @@ 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
|
||||||
|
// (i.e., respecting auditorium mode). note that this assumes that the client
|
||||||
|
// is a member; if the client is not, it may return true anyway
|
||||||
|
func (channel *Channel) memberIsVisible(client *Client) bool {
|
||||||
|
// fast path, we assume they're a member so if this isn't an auditorium,
|
||||||
|
// they're visible:
|
||||||
|
if !channel.flags.HasMode(modes.Auditorium) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.stateMutex.RLock()
|
||||||
|
defer channel.stateMutex.RUnlock()
|
||||||
|
|
||||||
|
clientData, found := channel.members[client]
|
||||||
|
if !found {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return clientData.modes.HighestChannelUserMode() != modes.Mode(0)
|
||||||
|
}
|
||||||
|
|
||||||
// data for RPL_LIST
|
// data for RPL_LIST
|
||||||
func (channel *Channel) listData() (memberCount int, name, topic string) {
|
func (channel *Channel) listData() (memberCount int, name, topic string) {
|
||||||
channel.stateMutex.RLock()
|
channel.stateMutex.RLock()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
353
irc/client.go
353
irc/client.go
|
|
@ -6,6 +6,7 @@
|
||||||
package irc
|
package irc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
|
|
@ -21,6 +22,7 @@ import (
|
||||||
"github.com/ergochat/irc-go/ircfmt"
|
"github.com/ergochat/irc-go/ircfmt"
|
||||||
"github.com/ergochat/irc-go/ircmsg"
|
"github.com/ergochat/irc-go/ircmsg"
|
||||||
"github.com/ergochat/irc-go/ircreader"
|
"github.com/ergochat/irc-go/ircreader"
|
||||||
|
"github.com/ergochat/irc-go/ircutils"
|
||||||
"github.com/xdg-go/scram"
|
"github.com/xdg-go/scram"
|
||||||
|
|
||||||
"github.com/ergochat/ergo/irc/caps"
|
"github.com/ergochat/ergo/irc/caps"
|
||||||
|
|
@ -28,8 +30,10 @@ import (
|
||||||
"github.com/ergochat/ergo/irc/flatip"
|
"github.com/ergochat/ergo/irc/flatip"
|
||||||
"github.com/ergochat/ergo/irc/history"
|
"github.com/ergochat/ergo/irc/history"
|
||||||
"github.com/ergochat/ergo/irc/modes"
|
"github.com/ergochat/ergo/irc/modes"
|
||||||
|
"github.com/ergochat/ergo/irc/oauth2"
|
||||||
"github.com/ergochat/ergo/irc/sno"
|
"github.com/ergochat/ergo/irc/sno"
|
||||||
"github.com/ergochat/ergo/irc/utils"
|
"github.com/ergochat/ergo/irc/utils"
|
||||||
|
"github.com/ergochat/ergo/irc/webpush"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -38,31 +42,36 @@ const (
|
||||||
|
|
||||||
// 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
|
||||||
)
|
)
|
||||||
|
|
@ -115,16 +124,31 @@ type Client struct {
|
||||||
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 {
|
||||||
mechanism string
|
mechanism string
|
||||||
value string
|
value ircutils.SASLBuffer
|
||||||
scramConv *scram.ServerConversation
|
scramConv *scram.ServerConversation
|
||||||
|
oauthConv *oauth2.OAuthBearerServer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *saslStatus) Initialize() {
|
||||||
|
s.value.Initialize(saslMaxResponseLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *saslStatus) Clear() {
|
func (s *saslStatus) Clear() {
|
||||||
*s = saslStatus{}
|
s.mechanism = ""
|
||||||
|
s.value.Clear()
|
||||||
|
s.scramConv = nil
|
||||||
|
s.oauthConv = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// what stage the client is at w.r.t. the PASS command:
|
// what stage the client is at w.r.t. the PASS command:
|
||||||
|
|
@ -142,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
|
||||||
|
|
@ -155,12 +181,15 @@ type Session struct {
|
||||||
realIP net.IP
|
realIP net.IP
|
||||||
proxiedIP net.IP
|
proxiedIP net.IP
|
||||||
rawHostname string
|
rawHostname string
|
||||||
|
hostnameFinalized bool
|
||||||
isTor bool
|
isTor bool
|
||||||
hideSTS bool
|
hideSTS bool
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -168,6 +197,8 @@ type Session struct {
|
||||||
|
|
||||||
batchCounter atomic.Uint32
|
batchCounter atomic.Uint32
|
||||||
|
|
||||||
|
isupportSentPrereg bool
|
||||||
|
|
||||||
quitMessage string
|
quitMessage string
|
||||||
|
|
||||||
awayMessage string
|
awayMessage string
|
||||||
|
|
@ -183,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.
|
||||||
|
|
@ -321,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:
|
||||||
|
|
@ -361,7 +398,9 @@ 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()
|
||||||
client.sessions = []*Session{session}
|
client.sessions = []*Session{session}
|
||||||
|
|
||||||
session.resetFakelag()
|
session.resetFakelag()
|
||||||
|
|
@ -389,7 +428,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 {
|
||||||
|
|
@ -466,6 +505,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) {
|
||||||
|
|
@ -477,12 +528,21 @@ func (client *Client) resizeHistory(config *Config) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolve an IP to an IRC-ready hostname, using reverse DNS, forward-confirming if necessary,
|
// once we have the final IP address (from the connection itself or from proxy data),
|
||||||
// and sending appropriate notices to the client
|
// compute the various possibilities for the hostname:
|
||||||
func (client *Client) lookupHostname(session *Session, overwrite bool) {
|
// * In the default/recommended configuration, via the cloak algorithm
|
||||||
|
// * If hostname lookup is enabled, via (forward-confirmed) reverse DNS
|
||||||
|
// * If WEBIRC was used, possibly via the hostname passed on the WEBIRC line
|
||||||
|
func (client *Client) finalizeHostname(session *Session) {
|
||||||
|
// only allow this once, since registration can fail (e.g. if the nickname is in use)
|
||||||
|
if session.hostnameFinalized {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.hostnameFinalized = true
|
||||||
|
|
||||||
if session.isTor {
|
if session.isTor {
|
||||||
return
|
return
|
||||||
} // else: even if cloaking is enabled, look up the real hostname to show to operators
|
}
|
||||||
|
|
||||||
config := client.server.Config()
|
config := client.server.Config()
|
||||||
ip := session.realIP
|
ip := session.realIP
|
||||||
|
|
@ -490,6 +550,8 @@ func (client *Client) lookupHostname(session *Session, overwrite bool) {
|
||||||
ip = session.proxiedIP
|
ip = session.proxiedIP
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// even if cloaking is enabled, we may want to look up the real hostname to show to operators:
|
||||||
|
if session.rawHostname == "" {
|
||||||
var hostname string
|
var hostname string
|
||||||
lookupSuccessful := false
|
lookupSuccessful := false
|
||||||
if config.Server.lookupHostnames {
|
if config.Server.lookupHostnames {
|
||||||
|
|
@ -503,17 +565,12 @@ func (client *Client) lookupHostname(session *Session, overwrite bool) {
|
||||||
} else {
|
} else {
|
||||||
hostname = utils.IPStringToHostname(ip.String())
|
hostname = utils.IPStringToHostname(ip.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
session.rawHostname = hostname
|
session.rawHostname = hostname
|
||||||
cloakedHostname := config.Server.Cloaks.ComputeCloak(ip)
|
|
||||||
client.stateMutex.Lock()
|
|
||||||
defer client.stateMutex.Unlock()
|
|
||||||
// update the hostname if this is a new connection, but not if it's a reattach
|
|
||||||
if overwrite || client.rawHostname == "" {
|
|
||||||
client.rawHostname = hostname
|
|
||||||
client.cloakedHostname = cloakedHostname
|
|
||||||
client.updateNickMaskNoMutex()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// these will be discarded if this is actually a reattach:
|
||||||
|
client.rawHostname = session.rawHostname
|
||||||
|
client.cloakedHostname = config.Server.Cloaks.ComputeCloak(ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) doIdentLookup(conn net.Conn) {
|
func (client *Client) doIdentLookup(conn net.Conn) {
|
||||||
|
|
@ -626,7 +683,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
|
||||||
|
|
@ -637,7 +694,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:
|
||||||
|
|
@ -650,7 +707,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:
|
||||||
|
|
@ -682,8 +739,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
|
||||||
|
|
@ -701,17 +762,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
|
||||||
|
|
@ -723,7 +783,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() {
|
||||||
|
|
@ -747,6 +809,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
|
||||||
//
|
//
|
||||||
|
|
@ -778,19 +868,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()
|
||||||
|
|
@ -1078,6 +1168,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1142,12 +1233,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1267,7 +1364,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
|
||||||
|
|
@ -1286,10 +1383,10 @@ func (client *Client) destroy(session *Session) {
|
||||||
}
|
}
|
||||||
|
|
||||||
var quitItem history.Item
|
var quitItem history.Item
|
||||||
var channels []*Channel
|
var quitHistoryChannels []*Channel
|
||||||
// use a defer here to avoid writing to mysql while holding the destroy semaphore:
|
// use a defer here to avoid writing to mysql while holding the destroy semaphore:
|
||||||
defer func() {
|
defer func() {
|
||||||
for _, channel := range channels {
|
for _, channel := range quitHistoryChannels {
|
||||||
channel.AddHistoryItem(quitItem, details.account)
|
channel.AddHistoryItem(quitItem, details.account)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
@ -1305,14 +1402,17 @@ 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
|
||||||
// (note that if this is a reattach, client has no channels and therefore no friends)
|
// (note that if this is a reattach, client has no channels and therefore no friends)
|
||||||
friends := make(ClientSet)
|
friends := make(ClientSet)
|
||||||
channels = client.Channels()
|
channels := client.Channels()
|
||||||
for _, channel := range channels {
|
for _, channel := range channels {
|
||||||
|
if channel.memberIsVisible(client) {
|
||||||
|
quitHistoryChannels = append(quitHistoryChannels, channel)
|
||||||
|
}
|
||||||
for _, member := range channel.auditoriumFriends(client) {
|
for _, member := range channel.auditoriumFriends(client) {
|
||||||
friends.Add(member)
|
friends.Add(member)
|
||||||
}
|
}
|
||||||
|
|
@ -1402,7 +1502,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)
|
||||||
|
|
@ -1474,7 +1574,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 {
|
||||||
|
|
@ -1483,7 +1583,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
|
||||||
}
|
}
|
||||||
|
|
@ -1510,7 +1610,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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1753,6 +1853,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) {
|
||||||
|
|
@ -1773,7 +1875,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)
|
||||||
|
|
@ -1831,6 +1933,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
|
||||||
|
|
@ -1850,3 +1958,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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -116,6 +115,8 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick
|
||||||
useAccountName = alwaysOn || config.Accounts.NickReservation.ForceNickEqualsAccount
|
useAccountName = alwaysOn || config.Accounts.NickReservation.ForceNickEqualsAccount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nickIsReserved := false
|
||||||
|
|
||||||
if useAccountName {
|
if useAccountName {
|
||||||
if registered && newNick != accountName {
|
if registered && newNick != accountName {
|
||||||
return "", errNickAccountMismatch, false
|
return "", errNickAccountMismatch, false
|
||||||
|
|
@ -167,7 +168,9 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick
|
||||||
|
|
||||||
reservedAccount, method := client.server.accounts.EnforcementStatus(newCfNick, newSkeleton)
|
reservedAccount, method := client.server.accounts.EnforcementStatus(newCfNick, newSkeleton)
|
||||||
if method == NickEnforcementStrict && reservedAccount != "" && reservedAccount != account {
|
if method == NickEnforcementStrict && reservedAccount != "" && reservedAccount != account {
|
||||||
return "", errNicknameReserved, false
|
// see #2135: we want to enter the critical section, see if the nick is actually in use,
|
||||||
|
// and return errNicknameInUse in that case
|
||||||
|
nickIsReserved = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -205,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 {
|
||||||
|
|
@ -219,6 +218,9 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick
|
||||||
if skeletonHolder != nil && skeletonHolder != client {
|
if skeletonHolder != nil && skeletonHolder != client {
|
||||||
return "", errNicknameInUse, false
|
return "", errNicknameInUse, false
|
||||||
}
|
}
|
||||||
|
if nickIsReserved {
|
||||||
|
return "", errNicknameReserved, false
|
||||||
|
}
|
||||||
|
|
||||||
if dryRun {
|
if dryRun {
|
||||||
return "", nil, false
|
return "", nil, false
|
||||||
|
|
@ -246,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -263,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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
272
irc/config.go
272
irc/config.go
|
|
@ -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"
|
||||||
|
|
@ -38,8 +39,14 @@ import (
|
||||||
"github.com/ergochat/ergo/irc/logger"
|
"github.com/ergochat/ergo/irc/logger"
|
||||||
"github.com/ergochat/ergo/irc/modes"
|
"github.com/ergochat/ergo/irc/modes"
|
||||||
"github.com/ergochat/ergo/irc/mysql"
|
"github.com/ergochat/ergo/irc/mysql"
|
||||||
|
"github.com/ergochat/ergo/irc/oauth2"
|
||||||
"github.com/ergochat/ergo/irc/passwd"
|
"github.com/ergochat/ergo/irc/passwd"
|
||||||
"github.com/ergochat/ergo/irc/utils"
|
"github.com/ergochat/ergo/irc/utils"
|
||||||
|
"github.com/ergochat/ergo/irc/webpush"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultProxyDeadline = time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
// here's how this works: exported (capitalized) members of the config structs
|
// here's how this works: exported (capitalized) members of the config structs
|
||||||
|
|
@ -332,6 +339,8 @@ type AccountConfig struct {
|
||||||
Bouncer *MulticlientConfig // # handle old name for 'multiclient'
|
Bouncer *MulticlientConfig // # handle old name for 'multiclient'
|
||||||
VHosts VHostConfig
|
VHosts VHostConfig
|
||||||
AuthScript AuthScriptConfig `yaml:"auth-script"`
|
AuthScript AuthScriptConfig `yaml:"auth-script"`
|
||||||
|
OAuth2 oauth2.OAuth2BearerConfig `yaml:"oauth2"`
|
||||||
|
JWTAuth jwt.JWTAuthConfig `yaml:"jwt-auth"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScriptConfig struct {
|
type ScriptConfig struct {
|
||||||
|
|
@ -450,6 +459,10 @@ func (cm *Casemapping) UnmarshalYAML(unmarshal func(interface{}) error) (err err
|
||||||
result = CasemappingPRECIS
|
result = CasemappingPRECIS
|
||||||
case "permissive", "fun":
|
case "permissive", "fun":
|
||||||
result = CasemappingPermissive
|
result = CasemappingPermissive
|
||||||
|
case "rfc1459":
|
||||||
|
result = CasemappingRFC1459
|
||||||
|
case "rfc1459-strict":
|
||||||
|
result = CasemappingRFC1459Strict
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("invalid casemapping value: %s", orig)
|
return fmt.Errorf("invalid casemapping value: %s", orig)
|
||||||
}
|
}
|
||||||
|
|
@ -484,6 +497,7 @@ type Limits struct {
|
||||||
ChanListModes int `yaml:"chan-list-modes"`
|
ChanListModes int `yaml:"chan-list-modes"`
|
||||||
ChannelLen int `yaml:"channellen"`
|
ChannelLen int `yaml:"channellen"`
|
||||||
IdentLen int `yaml:"identlen"`
|
IdentLen int `yaml:"identlen"`
|
||||||
|
RealnameLen int `yaml:"realnamelen"`
|
||||||
KickLen int `yaml:"kicklen"`
|
KickLen int `yaml:"kicklen"`
|
||||||
MonitorEntries int `yaml:"monitor-entries"`
|
MonitorEntries int `yaml:"monitor-entries"`
|
||||||
NickLen int `yaml:"nicklen"`
|
NickLen int `yaml:"nicklen"`
|
||||||
|
|
@ -567,6 +581,11 @@ type Config struct {
|
||||||
MOTD string
|
MOTD string
|
||||||
motdLines []string
|
motdLines []string
|
||||||
MOTDFormatting bool `yaml:"motd-formatting"`
|
MOTDFormatting bool `yaml:"motd-formatting"`
|
||||||
|
IdleTimeouts struct {
|
||||||
|
Registration time.Duration
|
||||||
|
Ping time.Duration
|
||||||
|
Disconnect time.Duration
|
||||||
|
} `yaml:"idle-timeouts"`
|
||||||
Relaymsg struct {
|
Relaymsg struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
Separators string
|
Separators string
|
||||||
|
|
@ -589,6 +608,7 @@ 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
|
||||||
|
|
@ -599,14 +619,27 @@ type Config struct {
|
||||||
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 {
|
||||||
|
|
@ -700,6 +733,24 @@ type Config struct {
|
||||||
} `yaml:"tagmsg-storage"`
|
} `yaml:"tagmsg-storage"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Metadata struct {
|
||||||
|
Enabled bool
|
||||||
|
MaxSubs int `yaml:"max-subs"`
|
||||||
|
MaxKeys int `yaml:"max-keys"`
|
||||||
|
MaxValueBytes int `yaml:"max-value-length"`
|
||||||
|
ClientThrottle ThrottleConfig `yaml:"client-throttle"`
|
||||||
|
}
|
||||||
|
|
||||||
|
WebPush struct {
|
||||||
|
Enabled bool
|
||||||
|
Timeout time.Duration
|
||||||
|
Delay time.Duration
|
||||||
|
Subscriber string
|
||||||
|
MaxSubscriptions int `yaml:"max-subscriptions"`
|
||||||
|
Expiration custime.Duration
|
||||||
|
vapidKeys *webpush.VAPIDKeys
|
||||||
|
} `yaml:"webpush"`
|
||||||
|
|
||||||
Filename string
|
Filename string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -946,7 +997,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 {
|
||||||
|
|
@ -990,6 +1041,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)
|
||||||
|
|
@ -1039,7 +1124,7 @@ func (ce *configPathError) Error() string {
|
||||||
return fmt.Sprintf("Couldn't apply config override `%s`: %s", ce.name, ce.desc)
|
return fmt.Sprintf("Couldn't apply config override `%s`: %s", ce.name, ce.desc)
|
||||||
}
|
}
|
||||||
|
|
||||||
func mungeFromEnvironment(config *Config, envPair string) (applied bool, err *configPathError) {
|
func mungeFromEnvironment(config *Config, envPair string) (applied bool, name string, err *configPathError) {
|
||||||
equalIdx := strings.IndexByte(envPair, '=')
|
equalIdx := strings.IndexByte(envPair, '=')
|
||||||
name, value := envPair[:equalIdx], envPair[equalIdx+1:]
|
name, value := envPair[:equalIdx], envPair[equalIdx+1:]
|
||||||
if strings.HasPrefix(name, "ERGO__") {
|
if strings.HasPrefix(name, "ERGO__") {
|
||||||
|
|
@ -1047,7 +1132,7 @@ func mungeFromEnvironment(config *Config, envPair string) (applied bool, err *co
|
||||||
} else if strings.HasPrefix(name, "ORAGONO__") {
|
} else if strings.HasPrefix(name, "ORAGONO__") {
|
||||||
name = strings.TrimPrefix(name, "ORAGONO__")
|
name = strings.TrimPrefix(name, "ORAGONO__")
|
||||||
} else {
|
} else {
|
||||||
return false, nil
|
return false, "", nil
|
||||||
}
|
}
|
||||||
pathComponents := strings.Split(name, "__")
|
pathComponents := strings.Split(name, "__")
|
||||||
for i, pathComponent := range pathComponents {
|
for i, pathComponent := range pathComponents {
|
||||||
|
|
@ -1058,10 +1143,10 @@ func mungeFromEnvironment(config *Config, envPair string) (applied bool, err *co
|
||||||
t := v.Type()
|
t := v.Type()
|
||||||
for _, component := range pathComponents {
|
for _, component := range pathComponents {
|
||||||
if component == "" {
|
if component == "" {
|
||||||
return false, &configPathError{name, "invalid", nil}
|
return false, "", &configPathError{name, "invalid", nil}
|
||||||
}
|
}
|
||||||
if v.Kind() != reflect.Struct {
|
if v.Kind() != reflect.Struct {
|
||||||
return false, &configPathError{name, "index into non-struct", nil}
|
return false, "", &configPathError{name, "index into non-struct", nil}
|
||||||
}
|
}
|
||||||
var nextField reflect.StructField
|
var nextField reflect.StructField
|
||||||
success := false
|
success := false
|
||||||
|
|
@ -1087,7 +1172,7 @@ func mungeFromEnvironment(config *Config, envPair string) (applied bool, err *co
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !success {
|
if !success {
|
||||||
return false, &configPathError{name, fmt.Sprintf("couldn't resolve path component: `%s`", component), nil}
|
return false, "", &configPathError{name, fmt.Sprintf("couldn't resolve path component: `%s`", component), nil}
|
||||||
}
|
}
|
||||||
v = v.FieldByName(nextField.Name)
|
v = v.FieldByName(nextField.Name)
|
||||||
// dereference pointer field if necessary, initialize new value if necessary
|
// dereference pointer field if necessary, initialize new value if necessary
|
||||||
|
|
@ -1101,9 +1186,9 @@ func mungeFromEnvironment(config *Config, envPair string) (applied bool, err *co
|
||||||
}
|
}
|
||||||
yamlErr := yaml.Unmarshal([]byte(value), v.Addr().Interface())
|
yamlErr := yaml.Unmarshal([]byte(value), v.Addr().Interface())
|
||||||
if yamlErr != nil {
|
if yamlErr != nil {
|
||||||
return false, &configPathError{name, "couldn't deserialize YAML", yamlErr}
|
return false, "", &configPathError{name, "couldn't deserialize YAML", yamlErr}
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, name, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadConfig loads the given YAML configuration file.
|
// LoadConfig loads the given YAML configuration file.
|
||||||
|
|
@ -1115,7 +1200,7 @@ func LoadConfig(filename string) (config *Config, err error) {
|
||||||
|
|
||||||
if config.AllowEnvironmentOverrides {
|
if config.AllowEnvironmentOverrides {
|
||||||
for _, envPair := range os.Environ() {
|
for _, envPair := range os.Environ() {
|
||||||
applied, envErr := mungeFromEnvironment(config, envPair)
|
applied, name, envErr := mungeFromEnvironment(config, envPair)
|
||||||
if envErr != nil {
|
if envErr != nil {
|
||||||
if envErr.fatalErr != nil {
|
if envErr.fatalErr != nil {
|
||||||
return nil, envErr
|
return nil, envErr
|
||||||
|
|
@ -1123,7 +1208,7 @@ func LoadConfig(filename string) (config *Config, err error) {
|
||||||
log.Println(envErr.Error())
|
log.Println(envErr.Error())
|
||||||
}
|
}
|
||||||
} else if applied {
|
} else if applied {
|
||||||
log.Printf("applied environment override: %s\n", envPair)
|
log.Printf("applied environment override: %s\n", name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1162,6 +1247,23 @@ func LoadConfig(filename string) (config *Config, err error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.Server.IdleTimeouts.Registration <= 0 {
|
||||||
|
config.Server.IdleTimeouts.Registration = time.Minute
|
||||||
|
}
|
||||||
|
if config.Server.IdleTimeouts.Ping <= 0 {
|
||||||
|
config.Server.IdleTimeouts.Ping = time.Minute + 30*time.Second
|
||||||
|
}
|
||||||
|
if config.Server.IdleTimeouts.Disconnect <= 0 {
|
||||||
|
config.Server.IdleTimeouts.Disconnect = 2*time.Minute + 30*time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(config.Server.IdleTimeouts.Ping < config.Server.IdleTimeouts.Disconnect) {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"ping timeout %v must be strictly less than disconnect timeout %v, to give the client time to respond",
|
||||||
|
config.Server.IdleTimeouts.Ping, config.Server.IdleTimeouts.Disconnect,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if config.Server.CoerceIdent != "" {
|
if config.Server.CoerceIdent != "" {
|
||||||
if config.Server.CheckIdent {
|
if config.Server.CheckIdent {
|
||||||
return nil, errors.New("Can't configure both check-ident and coerce-ident")
|
return nil, errors.New("Can't configure both check-ident and coerce-ident")
|
||||||
|
|
@ -1390,15 +1492,38 @@ func LoadConfig(filename string) (config *Config, err error) {
|
||||||
config.Accounts.VHosts.validRegexp = defaultValidVhostRegex
|
config.Accounts.VHosts.validRegexp = defaultValidVhostRegex
|
||||||
}
|
}
|
||||||
|
|
||||||
saslCapValue := "PLAIN,EXTERNAL,SCRAM-SHA-256"
|
if config.Accounts.AuthenticationEnabled {
|
||||||
if !config.Accounts.AdvertiseSCRAM {
|
saslCapValues := []string{"PLAIN", "EXTERNAL"}
|
||||||
saslCapValue = "PLAIN,EXTERNAL"
|
if config.Accounts.AdvertiseSCRAM {
|
||||||
|
saslCapValues = append(saslCapValues, "SCRAM-SHA-256")
|
||||||
}
|
}
|
||||||
config.Server.capValues[caps.SASL] = saslCapValue
|
if config.Accounts.OAuth2.Enabled {
|
||||||
if !config.Accounts.AuthenticationEnabled {
|
saslCapValues = append(saslCapValues, "OAUTHBEARER")
|
||||||
|
}
|
||||||
|
if config.Accounts.OAuth2.Enabled || config.Accounts.JWTAuth.Enabled {
|
||||||
|
saslCapValues = append(saslCapValues, "IRCV3BEARER")
|
||||||
|
}
|
||||||
|
config.Server.capValues[caps.SASL] = strings.Join(saslCapValues, ",")
|
||||||
|
} else {
|
||||||
config.Server.supportedCaps.Disable(caps.SASL)
|
config.Server.supportedCaps.Disable(caps.SASL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.Server.OperThrottle <= 0 {
|
||||||
|
config.Server.OperThrottle = 10 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.Accounts.OAuth2.Postprocess(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.Accounts.JWTAuth.Postprocess(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Accounts.OAuth2.Enabled && config.Accounts.OAuth2.AuthScript && !config.Accounts.AuthScript.Enabled {
|
||||||
|
return nil, fmt.Errorf("oauth2 is enabled with auth-script, but no auth-script is enabled")
|
||||||
|
}
|
||||||
|
|
||||||
if !config.Accounts.Registration.Enabled {
|
if !config.Accounts.Registration.Enabled {
|
||||||
config.Server.supportedCaps.Disable(caps.AccountRegistration)
|
config.Server.supportedCaps.Disable(caps.AccountRegistration)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1486,12 +1611,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 {
|
||||||
|
|
@ -1522,7 +1648,17 @@ func LoadConfig(filename string) (config *Config, err error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !config.History.Retention.AllowIndividualDelete {
|
||||||
|
config.Server.supportedCaps.Disable(caps.MessageRedaction) // #2215
|
||||||
|
}
|
||||||
|
|
||||||
config.Roleplay.addSuffix = utils.BoolDefaultTrue(config.Roleplay.AddSuffix)
|
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
|
||||||
|
|
@ -1540,11 +1676,65 @@ func LoadConfig(filename string) (config *Config, err error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !config.Metadata.Enabled {
|
||||||
|
config.Server.supportedCaps.Disable(caps.Metadata)
|
||||||
|
} else {
|
||||||
|
metadataValues := make([]string, 0, 4)
|
||||||
|
metadataValues = append(metadataValues, "before-connect")
|
||||||
|
// these are required for normal operation, so set sane defaults:
|
||||||
|
if config.Metadata.MaxSubs == 0 {
|
||||||
|
config.Metadata.MaxSubs = 10
|
||||||
|
}
|
||||||
|
metadataValues = append(metadataValues, fmt.Sprintf("max-subs=%d", config.Metadata.MaxSubs))
|
||||||
|
if config.Metadata.MaxKeys == 0 {
|
||||||
|
config.Metadata.MaxKeys = 10
|
||||||
|
}
|
||||||
|
metadataValues = append(metadataValues, fmt.Sprintf("max-keys=%d", config.Metadata.MaxKeys))
|
||||||
|
// this is not required since we enforce a hardcoded upper bound on key+value
|
||||||
|
if config.Metadata.MaxValueBytes > 0 {
|
||||||
|
metadataValues = append(metadataValues, fmt.Sprintf("max-value-bytes=%d", config.Metadata.MaxValueBytes))
|
||||||
|
}
|
||||||
|
config.Server.capValues[caps.Metadata] = strings.Join(metadataValues, ",")
|
||||||
|
}
|
||||||
|
|
||||||
err = config.processExtjwt()
|
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 {
|
||||||
|
|
@ -1589,9 +1779,18 @@ func (config *Config) generateISupport() (err error) {
|
||||||
isupport.Initialize()
|
isupport.Initialize()
|
||||||
isupport.Add("AWAYLEN", strconv.Itoa(config.Limits.AwayLen))
|
isupport.Add("AWAYLEN", strconv.Itoa(config.Limits.AwayLen))
|
||||||
isupport.Add("BOT", "B")
|
isupport.Add("BOT", "B")
|
||||||
isupport.Add("CASEMAPPING", "ascii")
|
var casemappingToken string
|
||||||
|
switch config.Server.Casemapping {
|
||||||
|
default:
|
||||||
|
casemappingToken = "ascii" // this is published for ascii, precis, or permissive
|
||||||
|
case CasemappingRFC1459:
|
||||||
|
casemappingToken = "rfc1459"
|
||||||
|
case CasemappingRFC1459Strict:
|
||||||
|
casemappingToken = "rfc1459-strict"
|
||||||
|
}
|
||||||
|
isupport.Add("CASEMAPPING", casemappingToken)
|
||||||
isupport.Add("CHANLIMIT", fmt.Sprintf("%s:%d", chanTypes, config.Channels.MaxChannelsPerClient))
|
isupport.Add("CHANLIMIT", fmt.Sprintf("%s:%d", chanTypes, config.Channels.MaxChannelsPerClient))
|
||||||
isupport.Add("CHANMODES", chanmodesToken)
|
isupport.Add("CHANMODES", 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:
|
||||||
|
|
@ -1620,6 +1819,8 @@ 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))
|
||||||
|
|
@ -1629,8 +1830,21 @@ func (config *Config) generateISupport() (err error) {
|
||||||
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", "")
|
||||||
|
|
||||||
|
for key, value := range config.Server.AdditionalISupport {
|
||||||
|
if !isupport.Contains(key) {
|
||||||
|
isupport.Add(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
err = isupport.RegenerateCachedReply()
|
err = isupport.RegenerateCachedReply()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -1732,6 +1946,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)
|
||||||
|
|
@ -1739,3 +1956,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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ func TestEnvironmentOverrides(t *testing.T) {
|
||||||
`ORAGONO__SERVER__IP_CLOAKING={"enabled": true, "enabled-for-always-on": true, "netname": "irc", "cidr-len-ipv4": 32, "cidr-len-ipv6": 64, "num-bits": 64}`,
|
`ORAGONO__SERVER__IP_CLOAKING={"enabled": true, "enabled-for-always-on": true, "netname": "irc", "cidr-len-ipv4": 32, "cidr-len-ipv6": 64, "num-bits": 64}`,
|
||||||
}
|
}
|
||||||
for _, envPair := range env {
|
for _, envPair := range env {
|
||||||
_, err := mungeFromEnvironment(&config, envPair)
|
_, _, err := mungeFromEnvironment(&config, envPair)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("couldn't apply override `%s`: %v", envPair, err)
|
t.Errorf("couldn't apply override `%s`: %v", envPair, err)
|
||||||
}
|
}
|
||||||
|
|
@ -93,7 +93,7 @@ func TestEnvironmentOverrideErrors(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, env := range invalidEnvs {
|
for _, env := range invalidEnvs {
|
||||||
success, err := mungeFromEnvironment(&config, env)
|
success, _, err := mungeFromEnvironment(&config, env)
|
||||||
if err == nil || success {
|
if err == nil || success {
|
||||||
t.Errorf("accepted invalid env override `%s`", env)
|
t.Errorf("accepted invalid env override `%s`", env)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -150,7 +162,7 @@ func retrieveSchemaVersion(tx *buntdb.Tx) (version int, err error) {
|
||||||
func performAutoUpgrade(currentVersion int, config *Config) (err error) {
|
func performAutoUpgrade(currentVersion int, config *Config) (err error) {
|
||||||
path := config.Datastore.Path
|
path := config.Datastore.Path
|
||||||
log.Printf("attempting to auto-upgrade schema from version %d to %d\n", currentVersion, latestDbSchema)
|
log.Printf("attempting to auto-upgrade schema from version %d to %d\n", currentVersion, latestDbSchema)
|
||||||
timestamp := time.Now().UTC().Format("2006-01-02-15:04:05.000Z")
|
timestamp := time.Now().UTC().Format("2006-01-02-15.04.05.000Z")
|
||||||
backupPath := fmt.Sprintf("%s.v%d.%s.bak", path, currentVersion, timestamp)
|
backupPath := fmt.Sprintf("%s.v%d.%s.bak", path, currentVersion, timestamp)
|
||||||
log.Printf("making a backup of current database at %s\n", backupPath)
|
log.Printf("making a backup of current database at %s\n", backupPath)
|
||||||
err = utils.CopyFile(path, backupPath)
|
err = utils.CopyFile(path, backupPath)
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ErrMissingFields
|
|
||||||
}
|
|
||||||
dkim.keyBytes, err = os.ReadFile(dkim.KeyFile)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaultOptions = dkim.SigOptions{
|
if dkim.Selector == "" || dkim.KeyFile == "" {
|
||||||
Version: 1,
|
return ErrMissingFields
|
||||||
Canonicalization: "relaxed/relaxed",
|
}
|
||||||
Algo: "rsa-sha256",
|
|
||||||
Headers: []string{"from", "to", "subject", "message-id", "date"},
|
keyBytes, err := os.ReadFile(dkim.KeyFile)
|
||||||
BodyLength: 0,
|
if err != nil {
|
||||||
QueryMethods: []string{"dns/txt"},
|
return fmt.Errorf("Could not read DKIM key file: %w", err)
|
||||||
AddSignatureTimestamp: true,
|
}
|
||||||
SignatureExpireIn: 0,
|
dkim.privKey, err = parseDKIMPrivKey(keyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Could not parse DKIM key file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDKIMPrivKey(input []byte) (crypto.Signer, error) {
|
||||||
|
if len(input) == 0 {
|
||||||
|
return nil, errors.New("DKIM private key is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// raw ed25519 private key format
|
||||||
|
if len(input) == ed25519.PrivateKeySize {
|
||||||
|
return ed25519.PrivateKey(input), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
d, _ := pem.Decode(input)
|
||||||
|
if d == nil {
|
||||||
|
return nil, errors.New("Invalid PEM data for DKIM private key")
|
||||||
|
}
|
||||||
|
|
||||||
|
if rsaKey, err := x509.ParsePKCS1PrivateKey(d.Bytes); err == nil {
|
||||||
|
return rsaKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if k, err := x509.ParsePKCS8PrivateKey(d.Bytes); err == nil {
|
||||||
|
switch key := k.(type) {
|
||||||
|
case *rsa.PrivateKey:
|
||||||
|
return key, nil
|
||||||
|
case ed25519.PrivateKey:
|
||||||
|
return key, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("Unacceptable type for DKIM private key: %T", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("No acceptable format for DKIM private key")
|
||||||
}
|
}
|
||||||
|
|
||||||
func DKIMSign(message []byte, dkimConfig DKIMConfig) (result []byte, err error) {
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,9 @@ type MailtoConfig struct {
|
||||||
Sender string
|
Sender string
|
||||||
HeloDomain string `yaml:"helo-domain"`
|
HeloDomain string `yaml:"helo-domain"`
|
||||||
RequireTLS bool `yaml:"require-tls"`
|
RequireTLS bool `yaml:"require-tls"`
|
||||||
|
Protocol string `yaml:"protocol"`
|
||||||
|
LocalAddress string `yaml:"local-address"`
|
||||||
|
localAddress net.Addr
|
||||||
VerifyMessageSubject string `yaml:"verify-message-subject"`
|
VerifyMessageSubject string `yaml:"verify-message-subject"`
|
||||||
DKIM DKIMConfig
|
DKIM DKIMConfig
|
||||||
MTAReal MTAConfig `yaml:"mta"`
|
MTAReal MTAConfig `yaml:"mta"`
|
||||||
|
|
@ -159,6 +162,25 @@ func (config *MailtoConfig) Postprocess(heloDomain string) (err error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
config.Protocol = strings.ToLower(config.Protocol)
|
||||||
|
if config.Protocol == "" {
|
||||||
|
config.Protocol = "tcp"
|
||||||
|
}
|
||||||
|
if !(config.Protocol == "tcp" || config.Protocol == "tcp4" || config.Protocol == "tcp6") {
|
||||||
|
return fmt.Errorf("Invalid protocol for email sending: `%s`", config.Protocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.LocalAddress != "" {
|
||||||
|
ipAddr := net.ParseIP(config.LocalAddress)
|
||||||
|
if ipAddr == nil {
|
||||||
|
return fmt.Errorf("Could not parse local-address for email sending: `%s`", config.LocalAddress)
|
||||||
|
}
|
||||||
|
config.localAddress = &net.TCPAddr{
|
||||||
|
IP: ipAddr,
|
||||||
|
Port: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if config.MTAConfig.Server != "" {
|
if config.MTAConfig.Server != "" {
|
||||||
// smarthost, nothing more to validate
|
// smarthost, nothing more to validate
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -211,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
|
||||||
|
|
@ -241,6 +263,6 @@ func SendMail(config MailtoConfig, recipient string, msg []byte) (err error) {
|
||||||
|
|
||||||
return smtp.SendMail(
|
return smtp.SendMail(
|
||||||
addr, auth, config.HeloDomain, config.Sender, []string{recipient}, msg,
|
addr, auth, config.HeloDomain, config.Sender, []string{recipient}, msg,
|
||||||
config.RequireTLS, implicitTLS, config.Timeout,
|
config.RequireTLS, implicitTLS, config.Protocol, config.localAddress, config.Timeout,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -76,6 +77,7 @@ var (
|
||||||
errValidEmailRequired = errors.New("A valid email address is required for account registration")
|
errValidEmailRequired = errors.New("A valid email address is required for account registration")
|
||||||
errInvalidAccountRename = errors.New("Account renames can only change the casefolding of the account name")
|
errInvalidAccountRename = errors.New("Account renames can only change the casefolding of the account name")
|
||||||
errNameReserved = errors.New(`Name reserved due to a prior registration`)
|
errNameReserved = errors.New(`Name reserved due to a prior registration`)
|
||||||
|
errInvalidBearerTokenType = errors.New("invalid bearer token type")
|
||||||
)
|
)
|
||||||
|
|
||||||
// String Errors
|
// String Errors
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
//go:build !plan9
|
//go:build !(plan9 || solaris)
|
||||||
|
|
||||||
package flock
|
package flock
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
//go:build plan9
|
//go:build plan9 || solaris
|
||||||
|
|
||||||
package flock
|
package flock
|
||||||
|
|
||||||
|
|
@ -32,6 +32,7 @@ type webircConfig struct {
|
||||||
Fingerprint *string // legacy name for certfp, #1050
|
Fingerprint *string // legacy name for certfp, #1050
|
||||||
Certfp string
|
Certfp string
|
||||||
Hosts []string
|
Hosts []string
|
||||||
|
AcceptHostname bool `yaml:"accept-hostname"`
|
||||||
allowedNets []net.IPNet
|
allowedNets []net.IPNet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,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()
|
||||||
|
|
|
||||||
453
irc/getters.go
453
irc/getters.go
|
|
@ -7,22 +7,21 @@ 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) {
|
||||||
return server.config.Load()
|
return server.config.Load()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (server *Server) ChannelRegistrationEnabled() bool {
|
|
||||||
return server.Config().Channels.Registration.Enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
func (server *Server) GetOperator(name string) (oper *Oper) {
|
func (server *Server) GetOperator(name string) (oper *Oper) {
|
||||||
name, err := CasefoldName(name)
|
name, err := CasefoldName(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -58,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
|
||||||
}
|
}
|
||||||
|
|
@ -78,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 {
|
||||||
|
|
@ -111,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) {
|
||||||
|
|
@ -224,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)
|
||||||
|
|
@ -490,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
|
||||||
|
|
@ -508,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()
|
||||||
|
|
@ -551,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()
|
||||||
|
|
@ -566,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()
|
||||||
|
|
@ -616,9 +784,11 @@ func (channel *Channel) Founder() string {
|
||||||
|
|
||||||
func (channel *Channel) HighestUserMode(client *Client) (result modes.Mode) {
|
func (channel *Channel) HighestUserMode(client *Client) (result modes.Mode) {
|
||||||
channel.stateMutex.RLock()
|
channel.stateMutex.RLock()
|
||||||
clientModes := channel.members[client].modes
|
defer channel.stateMutex.RUnlock()
|
||||||
channel.stateMutex.RUnlock()
|
if clientData, ok := channel.members[client]; ok {
|
||||||
return clientModes.HighestChannelUserMode()
|
return clientData.modes.HighestChannelUserMode()
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (channel *Channel) Settings() (result ChannelSettings) {
|
func (channel *Channel) Settings() (result ChannelSettings) {
|
||||||
|
|
@ -629,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) {
|
||||||
|
|
@ -659,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
|
||||||
|
}
|
||||||
|
|
|
||||||
750
irc/handlers.go
750
irc/handlers.go
File diff suppressed because it is too large
Load diff
25
irc/help.go
25
irc/help.go
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,18 @@ 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,
|
||||||
|
the maximum number of RPL_ISUPPORT tokens that can be advertised is 13."
|
||||||
|
<nickname> [up to 13 parameters] <human-readable trailing>
|
||||||
|
*/
|
||||||
|
maxParameters = 13
|
||||||
)
|
)
|
||||||
|
|
||||||
// List holds a list of ISUPPORT tokens
|
// List holds a list of ISUPPORT tokens
|
||||||
|
|
@ -41,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 {
|
||||||
|
|
@ -52,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 {
|
||||||
|
|
@ -78,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)
|
||||||
|
|
@ -86,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++
|
||||||
|
|
@ -95,7 +107,7 @@ func (il *List) GetDifference(newil *List) [][]string {
|
||||||
length += len(token)
|
length += len(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cache) == 13 || len(token)+length >= maxLastArgLength {
|
if len(cache) == maxParameters || len(token)+length >= maxPayloadLength {
|
||||||
replies = append(replies, cache)
|
replies = append(replies, cache)
|
||||||
cache = make([]string, 0)
|
cache = make([]string, 0)
|
||||||
length = 0
|
length = 0
|
||||||
|
|
@ -109,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 {
|
||||||
|
tokens = append(tokens, token)
|
||||||
|
} else {
|
||||||
|
err = tokenErr
|
||||||
|
}
|
||||||
|
}
|
||||||
// make sure we get a sorted list of tokens, needed for tests and looks nice
|
// make sure we get a sorted list of tokens, needed for tests and looks nice
|
||||||
var tokens sort.StringSlice
|
slices.Sort(tokens)
|
||||||
for name := range il.Tokens {
|
|
||||||
tokens = append(tokens, name)
|
|
||||||
}
|
|
||||||
sort.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) == maxParameters || (len(token)+1)+length > maxPayloadLength {
|
||||||
|
il.CachedReply = append(il.CachedReply, cache)
|
||||||
|
cache = nil
|
||||||
|
length = 0
|
||||||
|
}
|
||||||
|
|
||||||
if len(cache) > 0 {
|
if len(cache) > 0 {
|
||||||
length++
|
length++
|
||||||
}
|
}
|
||||||
cache = append(cache, token)
|
|
||||||
length += len(token)
|
length += len(token)
|
||||||
}
|
cache = append(cache, token)
|
||||||
|
|
||||||
if len(cache) == 13 || len(token)+length >= maxLastArgLength {
|
|
||||||
il.CachedReply = append(il.CachedReply, cache)
|
|
||||||
cache = make([]string, 0)
|
|
||||||
length = 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cache) > 0 {
|
if len(cache) > 0 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
158
irc/jwt/bearer.go
Normal file
158
irc/jwt/bearer.go
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
// Copyright (c) 2024 Shivaram Lingamneni <slingamn@cs.stanford.edu>
|
||||||
|
// released under the MIT license
|
||||||
|
|
||||||
|
package jwt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrAuthDisabled = fmt.Errorf("JWT authentication is disabled")
|
||||||
|
ErrNoValidAccountClaim = fmt.Errorf("JWT token did not contain an acceptable account name claim")
|
||||||
|
)
|
||||||
|
|
||||||
|
// JWTAuthConfig is the config for Ergo to accept JWTs via draft/bearer
|
||||||
|
type JWTAuthConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Autocreate bool `yaml:"autocreate"`
|
||||||
|
Tokens []JWTAuthTokenConfig `yaml:"tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JWTAuthTokenConfig struct {
|
||||||
|
Algorithm string `yaml:"algorithm"`
|
||||||
|
KeyString string `yaml:"key"`
|
||||||
|
KeyFile string `yaml:"key-file"`
|
||||||
|
key any
|
||||||
|
parser *jwt.Parser
|
||||||
|
AccountClaims []string `yaml:"account-claims"`
|
||||||
|
StripDomain string `yaml:"strip-domain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JWTAuthConfig) Postprocess() error {
|
||||||
|
if !j.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(j.Tokens) == 0 {
|
||||||
|
return fmt.Errorf("JWT authentication enabled, but no valid tokens defined")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range j.Tokens {
|
||||||
|
if err := j.Tokens[i].Postprocess(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JWTAuthTokenConfig) Postprocess() error {
|
||||||
|
keyBytes, err := j.keyBytes()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
j.Algorithm = strings.ToLower(j.Algorithm)
|
||||||
|
|
||||||
|
var methods []string
|
||||||
|
switch j.Algorithm {
|
||||||
|
case "hmac":
|
||||||
|
j.key = keyBytes
|
||||||
|
methods = []string{"HS256", "HS384", "HS512"}
|
||||||
|
case "rsa":
|
||||||
|
rsaKey, err := jwt.ParseRSAPublicKeyFromPEM(keyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
j.key = rsaKey
|
||||||
|
methods = []string{"RS256", "RS384", "RS512"}
|
||||||
|
case "eddsa":
|
||||||
|
eddsaKey, err := jwt.ParseEdPublicKeyFromPEM(keyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
j.key = eddsaKey
|
||||||
|
methods = []string{"EdDSA"}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid jwt algorithm: %s", j.Algorithm)
|
||||||
|
}
|
||||||
|
j.parser = jwt.NewParser(jwt.WithValidMethods(methods))
|
||||||
|
|
||||||
|
if len(j.AccountClaims) == 0 {
|
||||||
|
return fmt.Errorf("JWT auth enabled, but no account-claims specified")
|
||||||
|
}
|
||||||
|
|
||||||
|
j.StripDomain = strings.ToLower(j.StripDomain)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JWTAuthConfig) Validate(t string) (accountName string, err error) {
|
||||||
|
if !j.Enabled || len(j.Tokens) == 0 {
|
||||||
|
return "", ErrAuthDisabled
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range j.Tokens {
|
||||||
|
accountName, err = j.Tokens[i].Validate(t)
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JWTAuthTokenConfig) keyBytes() (result []byte, err error) {
|
||||||
|
if j.KeyFile != "" {
|
||||||
|
o, err := os.Open(j.KeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer o.Close()
|
||||||
|
return io.ReadAll(o)
|
||||||
|
}
|
||||||
|
if j.KeyString != "" {
|
||||||
|
return []byte(j.KeyString), nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("JWT auth enabled, but no JWT key specified")
|
||||||
|
}
|
||||||
|
|
||||||
|
// implements jwt.Keyfunc
|
||||||
|
func (j *JWTAuthTokenConfig) keyFunc(_ *jwt.Token) (interface{}, error) {
|
||||||
|
return j.key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JWTAuthTokenConfig) Validate(t string) (accountName string, err error) {
|
||||||
|
token, err := j.parser.Parse(t, j.keyFunc)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
// impossible with Parse (as opposed to ParseWithClaims)
|
||||||
|
return "", fmt.Errorf("unexpected type from parsed token claims: %T", claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range j.AccountClaims {
|
||||||
|
if v, ok := claims[c]; ok {
|
||||||
|
if vstr, ok := v.(string); ok {
|
||||||
|
// validate and strip email addresses:
|
||||||
|
if idx := strings.IndexByte(vstr, '@'); idx != -1 {
|
||||||
|
suffix := vstr[idx+1:]
|
||||||
|
vstr = vstr[:idx]
|
||||||
|
if strings.ToLower(suffix) != j.StripDomain {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vstr, nil // success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", ErrNoValidAccountClaim
|
||||||
|
}
|
||||||
143
irc/jwt/bearer_test.go
Normal file
143
irc/jwt/bearer_test.go
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
package jwt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
rsaTestPubKey = `-----BEGIN PUBLIC KEY-----
|
||||||
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwhcCcXrfR/GmoPKxBi0H
|
||||||
|
cUl2pUl4acq2m3abFtMMoYTydJdEhgYWfsXuragyEIVkJU1ZnrgedW0QJUcANRGO
|
||||||
|
hP/B+MjBevDNsRXQECfhyjfzhz6KWZb4i7C2oImJuAjq/F4qGLdEGQDBpAzof8qv
|
||||||
|
9Zt5iN3GXY/EQtQVMFyR/7BPcbPLbHlOtzZ6tVEioXuUxQoai7x3Kc0jIcPWuyGa
|
||||||
|
Q04IvsgdaWO6oH4fhPfyVsmX37rYUn79zcqPHS4ieWM1KN9qc7W+/UJIeiwAStpJ
|
||||||
|
8gv+OSMrijRZGgQGCeOO5U59GGJC4mqUczB+JFvrlAIv0rggNpl+qalngosNxukB
|
||||||
|
uQIDAQAB
|
||||||
|
-----END PUBLIC KEY-----`
|
||||||
|
|
||||||
|
rsaTestPrivKey = `-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDCFwJxet9H8aag
|
||||||
|
8rEGLQdxSXalSXhpyrabdpsW0wyhhPJ0l0SGBhZ+xe6tqDIQhWQlTVmeuB51bRAl
|
||||||
|
RwA1EY6E/8H4yMF68M2xFdAQJ+HKN/OHPopZlviLsLagiYm4COr8XioYt0QZAMGk
|
||||||
|
DOh/yq/1m3mI3cZdj8RC1BUwXJH/sE9xs8tseU63Nnq1USKhe5TFChqLvHcpzSMh
|
||||||
|
w9a7IZpDTgi+yB1pY7qgfh+E9/JWyZffuthSfv3Nyo8dLiJ5YzUo32pztb79Qkh6
|
||||||
|
LABK2knyC/45IyuKNFkaBAYJ447lTn0YYkLiapRzMH4kW+uUAi/SuCA2mX6pqWeC
|
||||||
|
iw3G6QG5AgMBAAECggEARaAnejoP2ykvE1G8e3Cv2M33x/eBQMI9m6uCmz9+qnqc
|
||||||
|
14JkTIfmjffHVXie7RpNAKys16lJE+rZ/eVoh6EStVdiaDLsZYP45evjRcho0Tgd
|
||||||
|
Hokq7FSiOMpd2V09kE1yrrHA/DjSLv38eTNAPIejc8IgaR7VyD6Is0iNiVnL7iLa
|
||||||
|
mj1zB6+dSeQ5ICYkrihb1gA+SvECsjLZ/5XESXEdHJvxhC0vLAdHmdQf3BPPlrGg
|
||||||
|
VHondxL5gt6MFykpOxTFA6f5JkSefhUR/2OcCDpMs6a5GUytjl3rA3aGT6v3CbnR
|
||||||
|
ykD6PzyC20EUADQYF2pmJfzbxyRqfNdbSJwQv5QQYQKBgQD4rFdvgZC97L7WhZ5T
|
||||||
|
axW8hRW2dH24GIqFT4ZnCg0suyMNshyGvDMuBfGvokN/yACmvsdE0/f57esar+ye
|
||||||
|
l9RC+CzGUch08Ke5WdqwACOCNDpx0kJcXKTuLIgkvthdla/oAQQ9T7OgEwDrvaR0
|
||||||
|
m8s/Z7Hb3hLD3xdOt6Xjrv/6xQKBgQDHzvbcIkhmWdvaPDT9NEu7psR/fxF5UjqU
|
||||||
|
Cca/bfHhySRQs3A1CF57pfwpUqAcSivNf7O+3NI62AKoyMDYv0ek2h6hGk6g5GJ1
|
||||||
|
SuXYfjcbkL6SWNV0InsgmzCjvxhyms83xZq7uMClEBvkiKVMdt6zFkwW9eRKtUuZ
|
||||||
|
pzVK5RfqZQKBgF5SME/xGw+O7su7ntQROAtrh1LPWKgtVs093sLSgzDGQoN9XWiV
|
||||||
|
lewNASEXMPcUy3pzvm2S4OoBnj1fISb+e9py+7i1aI1CgrvBIzvCsbU/TjPCBr21
|
||||||
|
vjFA3trhMHw+vJwJVqxSwNUkoCLKqcg5F5yTHllBIGj/A34uFlQIGrvpAoGAextm
|
||||||
|
d+1bhExbLBQqZdOh0cWHjjKBVqm2U93OKcYY4Q9oI5zbRqGYbUCwo9k3sxZz9JJ4
|
||||||
|
8eDmWsEaqlm+kA0SnFyTwJkP1wvAKhpykTf6xi4hbNP0+DACgu17Q3iLHJmLkQZc
|
||||||
|
Nss3TrwlI2KZzgnzXo4fZYotFWasZMhkCngqiw0CgYEAmz2D70RYEauUNE1+zLhS
|
||||||
|
6Ox5+PF/8Z0rZOlTghMTfqYcDJa+qQe9pJp7RPgilsgemqo0XtgLKz3ATE5FmMa4
|
||||||
|
HRRGXPkMNu6Hzz4Yk4eM/yJqckoEc8azV25myqQ+7QXTwZEvxVbtUWZtxfImGwq+
|
||||||
|
s/uzBKNwWf9UPTeIt+4JScg=
|
||||||
|
-----END PRIVATE KEY-----`
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJWTBearerAuth(t *testing.T) {
|
||||||
|
j := JWTAuthConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Tokens: []JWTAuthTokenConfig{
|
||||||
|
{
|
||||||
|
Algorithm: "rsa",
|
||||||
|
KeyString: rsaTestPubKey,
|
||||||
|
AccountClaims: []string{"preferred_username", "email"},
|
||||||
|
StripDomain: "example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := j.Postprocess(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fixed test vector signed with the RSA privkey:
|
||||||
|
token := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJwcmVmZXJyZWRfdXNlcm5hbWUiOiJzbGluZ2FtbiJ9.caPZw2Dl4KZN-SErD5-WZB_lPPveHXaMCoUHxNebb94G9w3VaWDIRdngVU99JKx5nE_yRtpewkHHvXsQnNA_M63GBXGK7afXB8e-kV33QF3v9pXALMP5SzRwMgokyxas0RgHu4e4L0d7dn9o_nkdXp34GX3Pn1MVkUGBH6GdlbOdDHrs04pPQ0Qj-O2U0AIpnZq-X_GQs9ECJo4TlPKWR7Jlq5l9bS0dBnohea4FuqJr232je-dlRVkbCa7nrnFmsIsezsgA3Jb_j9Zu_iv460t_d2eaytbVp9P-DOVfzUfkBsKs-81URQEnTjW6ut445AJz2pxjX92X0GdmORpAkQ"
|
||||||
|
accountName, err := j.Validate(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("could not validate valid token: %v", err)
|
||||||
|
}
|
||||||
|
if accountName != "slingamn" {
|
||||||
|
t.Errorf("incorrect account name for token: `%s`", accountName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// programmatically sign a new token, validate it
|
||||||
|
privKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(rsaTestPrivKey))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
jTok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(map[string]any{"preferred_username": "slingamn"}))
|
||||||
|
token, err = jTok.SignedString(privKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
accountName, err = j.Validate(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("could not validate valid token: %v", err)
|
||||||
|
}
|
||||||
|
if accountName != "slingamn" {
|
||||||
|
t.Errorf("incorrect account name for token: `%s`", accountName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// test expiration
|
||||||
|
jTok = jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(map[string]any{"preferred_username": "slingamn", "exp": 1675740865}))
|
||||||
|
token, err = jTok.SignedString(privKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
accountName, err = j.Validate(token)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("validated expired token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// test for the infamous algorithm confusion bug
|
||||||
|
jTok = jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(map[string]any{"preferred_username": "slingamn"}))
|
||||||
|
token, err = jTok.SignedString([]byte(rsaTestPubKey))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
accountName, err = j.Validate(token)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("validated HS256 token despite RSA being required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// test no valid claims
|
||||||
|
jTok = jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(map[string]any{"sub": "slingamn"}))
|
||||||
|
token, err = jTok.SignedString(privKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
accountName, err = j.Validate(token)
|
||||||
|
if err != ErrNoValidAccountClaim {
|
||||||
|
t.Errorf("expected ErrNoValidAccountClaim, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// test email addresses
|
||||||
|
jTok = jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(map[string]any{"email": "Slingamn@example.com"}))
|
||||||
|
token, err = jTok.SignedString(privKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
accountName, err = j.Validate(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("could not validate valid token: %v", err)
|
||||||
|
}
|
||||||
|
if accountName != "Slingamn" {
|
||||||
|
t.Errorf("incorrect account name for token: `%s`", accountName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,18 +6,15 @@ package jwt
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
"crypto/x509"
|
|
||||||
"encoding/pem"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt"
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrNoKeys = errors.New("No signing keys are enabled")
|
ErrNoKeys = errors.New("No EXTJWT signing keys are enabled")
|
||||||
)
|
)
|
||||||
|
|
||||||
type MapClaims jwt.MapClaims
|
type MapClaims jwt.MapClaims
|
||||||
|
|
@ -38,22 +35,10 @@ func (t *JwtServiceConfig) Postprocess() (err error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
d, _ := pem.Decode(keyBytes)
|
t.rsaPrivateKey, err = jwt.ParseRSAPrivateKeyFromPEM(keyBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
t.rsaPrivateKey, err = x509.ParsePKCS1PrivateKey(d.Bytes)
|
|
||||||
if err != nil {
|
|
||||||
privateKey, err := x509.ParsePKCS8PrivateKey(d.Bytes)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if rsaPrivateKey, ok := privateKey.(*rsa.PrivateKey); ok {
|
|
||||||
t.rsaPrivateKey = rsaPrivateKey
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("Non-RSA key type for extjwt: %T", privateKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
irc/kline.go
10
irc/kline.go
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ package irc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"io/fs"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -29,7 +30,7 @@ type IRCListener interface {
|
||||||
|
|
||||||
// NewListener creates a new listener according to the specifications in the config file
|
// NewListener creates a new listener according to the specifications in the config file
|
||||||
func NewListener(server *Server, addr string, config utils.ListenerConfig, bindMode os.FileMode) (result IRCListener, err error) {
|
func NewListener(server *Server, addr string, config utils.ListenerConfig, bindMode os.FileMode) (result IRCListener, err error) {
|
||||||
baseListener, err := createBaseListener(addr, bindMode)
|
baseListener, err := createBaseListener(server, addr, bindMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -43,11 +44,14 @@ func NewListener(server *Server, addr string, config utils.ListenerConfig, bindM
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func createBaseListener(addr string, bindMode os.FileMode) (listener net.Listener, err error) {
|
func createBaseListener(server *Server, addr string, bindMode os.FileMode) (listener net.Listener, err error) {
|
||||||
addr = strings.TrimPrefix(addr, "unix:")
|
addr = strings.TrimPrefix(addr, "unix:")
|
||||||
if strings.HasPrefix(addr, "/") {
|
if strings.HasPrefix(addr, "/") {
|
||||||
// https://stackoverflow.com/a/34881585
|
// https://stackoverflow.com/a/34881585
|
||||||
os.Remove(addr)
|
removeErr := os.Remove(addr)
|
||||||
|
if removeErr != nil && !errors.Is(removeErr, fs.ErrNotExist) {
|
||||||
|
server.logger.Warning("listeners", "could not delete unix domain listener", addr, removeErr.Error())
|
||||||
|
}
|
||||||
listener, err = net.Listen("unix", addr)
|
listener, err = net.Listen("unix", addr)
|
||||||
if err == nil && bindMode != 0 {
|
if err == nil && bindMode != 0 {
|
||||||
os.Chmod(addr, bindMode)
|
os.Chmod(addr, bindMode)
|
||||||
|
|
|
||||||
|
|
@ -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
174
irc/metadata.go
Normal 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
25
irc/metadata_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
irc/modes.go
21
irc/modes.go
|
|
@ -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)
|
||||||
|
|
@ -158,7 +158,6 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c
|
||||||
|
|
||||||
var alreadySentPrivError bool
|
var alreadySentPrivError bool
|
||||||
|
|
||||||
maskOpCount := 0
|
|
||||||
chname := channel.Name()
|
chname := channel.Name()
|
||||||
details := client.Details()
|
details := client.Details()
|
||||||
|
|
||||||
|
|
@ -192,6 +191,11 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// should we send 324 RPL_CHANNELMODEIS? standard behavior is to send it for
|
||||||
|
// `MODE #channel`, i.e., an empty list of intended changes, but Ergo will
|
||||||
|
// also send it for no-op changes to zero-argument modes like +i
|
||||||
|
shouldSendModeIsLine := len(changes) == 0
|
||||||
|
|
||||||
for _, change := range changes {
|
for _, change := range changes {
|
||||||
if !hasPrivs(change) {
|
if !hasPrivs(change) {
|
||||||
if !alreadySentPrivError {
|
if !alreadySentPrivError {
|
||||||
|
|
@ -203,7 +207,6 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c
|
||||||
|
|
||||||
switch change.Mode {
|
switch change.Mode {
|
||||||
case modes.BanMask, modes.ExceptMask, modes.InviteMask:
|
case modes.BanMask, modes.ExceptMask, modes.InviteMask:
|
||||||
maskOpCount += 1
|
|
||||||
if change.Op == modes.List {
|
if change.Op == modes.List {
|
||||||
channel.ShowMaskList(client, change.Mode, rb)
|
channel.ShowMaskList(client, change.Mode, rb)
|
||||||
continue
|
continue
|
||||||
|
|
@ -212,7 +215,7 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c
|
||||||
mask := change.Arg
|
mask := change.Arg
|
||||||
switch change.Op {
|
switch change.Op {
|
||||||
case modes.Add:
|
case modes.Add:
|
||||||
if channel.lists[change.Mode].Length() >= client.server.Config().Limits.ChanListModes {
|
if !isSamode && channel.lists[change.Mode].Length() >= client.server.Config().Limits.ChanListModes {
|
||||||
if !listFullWarned[change.Mode] {
|
if !listFullWarned[change.Mode] {
|
||||||
rb.Add(nil, client.server.name, ERR_BANLISTFULL, details.nick, chname, change.Mode.String(), client.t("Channel list is full"))
|
rb.Add(nil, client.server.name, ERR_BANLISTFULL, details.nick, chname, change.Mode.String(), client.t("Channel list is full"))
|
||||||
listFullWarned[change.Mode] = true
|
listFullWarned[change.Mode] = true
|
||||||
|
|
@ -263,9 +266,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()
|
||||||
|
|
@ -313,11 +316,14 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c
|
||||||
default:
|
default:
|
||||||
// all channel modes with no args, e.g., InviteOnly, Secret
|
// all channel modes with no args, e.g., InviteOnly, Secret
|
||||||
if change.Op == modes.List {
|
if change.Op == modes.List {
|
||||||
|
shouldSendModeIsLine = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if channel.flags.SetMode(change.Mode, change.Op == modes.Add) {
|
if channel.flags.SetMode(change.Mode, change.Op == modes.Add) {
|
||||||
applied = append(applied, change)
|
applied = append(applied, change)
|
||||||
|
} else {
|
||||||
|
shouldSendModeIsLine = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -337,8 +343,7 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c
|
||||||
channel.MarkDirty(includeFlags)
|
channel.MarkDirty(includeFlags)
|
||||||
}
|
}
|
||||||
|
|
||||||
// #649: don't send 324 RPL_CHANNELMODEIS if we were only working with mask lists
|
if len(applied) == 0 && !alreadySentPrivError && shouldSendModeIsLine {
|
||||||
if len(applied) == 0 && !alreadySentPrivError && (maskOpCount == 0 || maskOpCount < len(changes)) {
|
|
||||||
args := append([]string{details.nick, chname}, channel.modeStrings(client)...)
|
args := append([]string{details.nick, chname}, channel.modeStrings(client)...)
|
||||||
rb.Add(nil, client.server.name, RPL_CHANNELMODEIS, args...)
|
rb.Add(nil, client.server.name, RPL_CHANNELMODEIS, args...)
|
||||||
rb.Add(nil, client.server.name, RPL_CREATIONTIME, details.nick, chname, strconv.FormatInt(channel.createdTime.Unix(), 10))
|
rb.Add(nil, client.server.name, RPL_CREATIONTIME, details.nick, chname, strconv.FormatInt(channel.createdTime.Unix(), 10))
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
|
||||||
if rune(supportedMode) == mode {
|
|
||||||
isKnown = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !isKnown {
|
|
||||||
unknown[mode] = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
changes = append(changes, change)
|
changes = append(changes, change)
|
||||||
|
} else {
|
||||||
|
unknown = append(unknown, mode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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 {
|
|
||||||
if rune(supportedMode) == mode {
|
|
||||||
isKnown = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, supportedMode := range ChannelUserModes {
|
|
||||||
if rune(supportedMode) == mode {
|
|
||||||
isKnown = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !isKnown {
|
|
||||||
unknown[mode] = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
changes = append(changes, change)
|
changes = append(changes, change)
|
||||||
|
} else {
|
||||||
|
unknown = append(unknown, mode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ func (manager *MonitorManager) AddMonitors(users utils.HashSet[*Session], cfnick
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 +52,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -961,7 +961,7 @@ func (mysql *MySQL) listCorrespondentsInternal(ctx context.Context, target strin
|
||||||
}
|
}
|
||||||
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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1014,7 +1014,7 @@ func (mysql *MySQL) ListChannels(cfchannels []string) (results []history.TargetL
|
||||||
}
|
}
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ func performNickChange(server *Server, client *Client, target *Client, session *
|
||||||
}
|
}
|
||||||
} else if err == errNicknameReserved {
|
} else if err == errNicknameReserved {
|
||||||
if !isSanick {
|
if !isSanick {
|
||||||
|
// see #1594 for context: ERR_NICKNAMEINUSE can confuse clients if the nickname is not
|
||||||
|
// literally in use:
|
||||||
if !client.registered {
|
if !client.registered {
|
||||||
rb.Add(nil, server.name, ERR_NICKNAMEINUSE, details.nick, utils.SafeErrorParam(nickname), client.t("Nickname is reserved by a different account"))
|
rb.Add(nil, server.name, ERR_NICKNAMEINUSE, details.nick, utils.SafeErrorParam(nickname), client.t("Nickname is reserved by a different account"))
|
||||||
}
|
}
|
||||||
|
|
@ -120,13 +122,17 @@ func performNickChange(server *Server, client *Client, target *Client, session *
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, channel := range target.Channels() {
|
for _, channel := range target.Channels() {
|
||||||
|
if channel.memberIsVisible(client) {
|
||||||
channel.AddHistoryItem(histItem, details.account)
|
channel.AddHistoryItem(histItem, details.account)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -1312,6 +1324,9 @@ func nsClientsListHandler(service *ircService, server *Server, client *Client, p
|
||||||
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))
|
||||||
}
|
}
|
||||||
|
if hasPrivs {
|
||||||
|
service.Notice(rb, fmt.Sprintf(client.t("Debug log ID: %s"), session.connID))
|
||||||
|
}
|
||||||
service.Notice(rb, fmt.Sprintf(client.t("IP address: %s"), session.ip.String()))
|
service.Notice(rb, fmt.Sprintf(client.t("IP address: %s"), session.ip.String()))
|
||||||
service.Notice(rb, fmt.Sprintf(client.t("Hostname: %s"), session.hostname))
|
service.Notice(rb, fmt.Sprintf(client.t("Hostname: %s"), session.hostname))
|
||||||
if hasPrivs {
|
if hasPrivs {
|
||||||
|
|
@ -1398,6 +1413,11 @@ func nsCertHandler(service *ircService, server *Server, client *Client, command
|
||||||
case "add", "del":
|
case "add", "del":
|
||||||
if 2 <= len(params) {
|
if 2 <= len(params) {
|
||||||
target, certfp = params[0], params[1]
|
target, certfp = params[0], params[1]
|
||||||
|
if cftarget, err := CasefoldName(target); err == nil && client.Account() == cftarget {
|
||||||
|
// If the target is equal to the account, then the user accidentally invoked operator
|
||||||
|
// syntax (cert add mynick <fp>) instead of self syntax (cert add <fp>).
|
||||||
|
target = ""
|
||||||
|
}
|
||||||
} else if len(params) == 1 {
|
} else if len(params) == 1 {
|
||||||
certfp = params[0]
|
certfp = params[0]
|
||||||
} else if len(params) == 0 && verb == "add" && rb.session.certfp != "" {
|
} else if len(params) == 0 && verb == "add" && rb.session.certfp != "" {
|
||||||
|
|
@ -1651,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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
108
irc/oauth2/oauth2.go
Normal file
108
irc/oauth2/oauth2.go
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
// Copyright 2022-2023 Simon Ser <contact@emersion.fr>
|
||||||
|
// Derived from https://git.sr.ht/~emersion/soju/tree/36d6cb19a4f90d217d55afb0b15318321baaad09/item/auth/oauth2.go
|
||||||
|
// Originally released under the AGPLv3, relicensed to the Ergo project under the MIT license
|
||||||
|
// Modifications copyright 2024 Shivaram Lingamneni <slingamn@cs.stanford.edu>
|
||||||
|
// Released under the MIT license
|
||||||
|
|
||||||
|
package oauth2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrAuthDisabled = fmt.Errorf("OAuth 2.0 authentication is disabled")
|
||||||
|
|
||||||
|
// all cases where the infrastructure is working correctly, but we determined
|
||||||
|
// that the user supplied an invalid token
|
||||||
|
ErrInvalidToken = fmt.Errorf("OAuth 2.0 bearer token invalid")
|
||||||
|
)
|
||||||
|
|
||||||
|
type OAuth2BearerConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Autocreate bool `yaml:"autocreate"`
|
||||||
|
AuthScript bool `yaml:"auth-script"`
|
||||||
|
IntrospectionURL string `yaml:"introspection-url"`
|
||||||
|
IntrospectionTimeout time.Duration `yaml:"introspection-timeout"`
|
||||||
|
// omit for `none`, required for `client_secret_basic`
|
||||||
|
ClientID string `yaml:"client-id"`
|
||||||
|
ClientSecret string `yaml:"client-secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OAuth2BearerConfig) Postprocess() error {
|
||||||
|
if !o.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if o.IntrospectionTimeout == 0 {
|
||||||
|
return fmt.Errorf("a nonzero oauthbearer introspection timeout is required (try 10s)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := url.Parse(o.IntrospectionURL); err != nil {
|
||||||
|
return fmt.Errorf("invalid introspection-url: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OAuth2BearerConfig) Introspect(ctx context.Context, token string) (username string, err error) {
|
||||||
|
if !o.Enabled {
|
||||||
|
return "", ErrAuthDisabled
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, o.IntrospectionTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
reqValues := make(url.Values)
|
||||||
|
reqValues.Set("token", token)
|
||||||
|
|
||||||
|
reqBody := strings.NewReader(reqValues.Encode())
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.IntrospectionURL, reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create OAuth 2.0 introspection request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
if o.ClientID != "" {
|
||||||
|
req.SetBasicAuth(url.QueryEscape(o.ClientID), url.QueryEscape(o.ClientSecret))
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to send OAuth 2.0 introspection request: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("OAuth 2.0 introspection error: %v", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data oauth2Introspection
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode OAuth 2.0 introspection response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !data.Active {
|
||||||
|
return "", ErrInvalidToken
|
||||||
|
}
|
||||||
|
if data.Username == "" {
|
||||||
|
// We really need the username here, otherwise an OAuth 2.0 user can
|
||||||
|
// impersonate any other user.
|
||||||
|
return "", fmt.Errorf("missing username in OAuth 2.0 introspection response")
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.Username, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type oauth2Introspection struct {
|
||||||
|
Active bool `json:"active"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
172
irc/oauth2/sasl.go
Normal file
172
irc/oauth2/sasl.go
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
package oauth2
|
||||||
|
|
||||||
|
/*
|
||||||
|
https://github.com/emersion/go-sasl/blob/e73c9f7bad438a9bf3f5b28e661b74d752ecafdd/oauthbearer.go
|
||||||
|
|
||||||
|
Copyright 2019-2022 Simon Ser, Frode Aannevik, Max Mazurov
|
||||||
|
Released under the MIT license
|
||||||
|
*/
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrUnexpectedClientResponse = errors.New("unexpected client response")
|
||||||
|
)
|
||||||
|
|
||||||
|
// The OAUTHBEARER mechanism name.
|
||||||
|
const OAuthBearer = "OAUTHBEARER"
|
||||||
|
|
||||||
|
type OAuthBearerError struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Schemes string `json:"schemes"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OAuthBearerOptions struct {
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
Host string `json:"host,omitempty"`
|
||||||
|
Port int `json:"port,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err *OAuthBearerError) Error() string {
|
||||||
|
return fmt.Sprintf("OAUTHBEARER authentication error (%v)", err.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
type OAuthBearerAuthenticator func(opts OAuthBearerOptions) *OAuthBearerError
|
||||||
|
|
||||||
|
type OAuthBearerServer struct {
|
||||||
|
done bool
|
||||||
|
failErr error
|
||||||
|
authenticate OAuthBearerAuthenticator
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *OAuthBearerServer) fail(descr string) ([]byte, bool, error) {
|
||||||
|
blob, err := json.Marshal(OAuthBearerError{
|
||||||
|
Status: "invalid_request",
|
||||||
|
Schemes: "bearer",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err) // wtf
|
||||||
|
}
|
||||||
|
a.failErr = errors.New(descr)
|
||||||
|
return blob, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *OAuthBearerServer) Next(response []byte) (challenge []byte, done bool, err error) {
|
||||||
|
// Per RFC, we cannot just send an error, we need to return JSON-structured
|
||||||
|
// value as a challenge and then after getting dummy response from the
|
||||||
|
// client stop the exchange.
|
||||||
|
if a.failErr != nil {
|
||||||
|
// Server libraries (go-smtp, go-imap) will not call Next on
|
||||||
|
// protocol-specific SASL cancel response ('*'). However, GS2 (and
|
||||||
|
// indirectly OAUTHBEARER) defines a protocol-independent way to do so
|
||||||
|
// using 0x01.
|
||||||
|
if len(response) != 1 && response[0] != 0x01 {
|
||||||
|
return nil, true, errors.New("unexpected response")
|
||||||
|
}
|
||||||
|
return nil, true, a.failErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.done {
|
||||||
|
err = ErrUnexpectedClientResponse
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate empty challenge.
|
||||||
|
if response == nil {
|
||||||
|
return []byte{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
a.done = true
|
||||||
|
|
||||||
|
// Cut n,a=username,\x01host=...\x01auth=...
|
||||||
|
// into
|
||||||
|
// n
|
||||||
|
// a=username
|
||||||
|
// \x01host=...\x01auth=...\x01\x01
|
||||||
|
parts := bytes.SplitN(response, []byte{','}, 3)
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return a.fail("Invalid response")
|
||||||
|
}
|
||||||
|
flag := parts[0]
|
||||||
|
authzid := parts[1]
|
||||||
|
if !bytes.Equal(flag, []byte{'n'}) {
|
||||||
|
return a.fail("Invalid response, missing 'n' in gs2-cb-flag")
|
||||||
|
}
|
||||||
|
opts := OAuthBearerOptions{}
|
||||||
|
if len(authzid) > 0 {
|
||||||
|
if !bytes.HasPrefix(authzid, []byte("a=")) {
|
||||||
|
return a.fail("Invalid response, missing 'a=' in gs2-authzid")
|
||||||
|
}
|
||||||
|
opts.Username = string(bytes.TrimPrefix(authzid, []byte("a=")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cut \x01host=...\x01auth=...\x01\x01
|
||||||
|
// into
|
||||||
|
// *empty*
|
||||||
|
// host=...
|
||||||
|
// auth=...
|
||||||
|
// *empty*
|
||||||
|
//
|
||||||
|
// Note that this code does not do a lot of checks to make sure the input
|
||||||
|
// follows the exact format specified by RFC.
|
||||||
|
params := bytes.Split(parts[2], []byte{0x01})
|
||||||
|
for _, p := range params {
|
||||||
|
// Skip empty fields (one at start and end).
|
||||||
|
if len(p) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
pParts := bytes.SplitN(p, []byte{'='}, 2)
|
||||||
|
if len(pParts) != 2 {
|
||||||
|
return a.fail("Invalid response, missing '='")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch string(pParts[0]) {
|
||||||
|
case "host":
|
||||||
|
opts.Host = string(pParts[1])
|
||||||
|
case "port":
|
||||||
|
port, err := strconv.ParseUint(string(pParts[1]), 10, 16)
|
||||||
|
if err != nil {
|
||||||
|
return a.fail("Invalid response, malformed 'port' value")
|
||||||
|
}
|
||||||
|
opts.Port = int(port)
|
||||||
|
case "auth":
|
||||||
|
const prefix = "bearer "
|
||||||
|
strValue := string(pParts[1])
|
||||||
|
// Token type is case-insensitive.
|
||||||
|
if !strings.HasPrefix(strings.ToLower(strValue), prefix) {
|
||||||
|
return a.fail("Unsupported token type")
|
||||||
|
}
|
||||||
|
opts.Token = strValue[len(prefix):]
|
||||||
|
default:
|
||||||
|
return a.fail("Invalid response, unknown parameter: " + string(pParts[0]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
authzErr := a.authenticate(opts)
|
||||||
|
if authzErr != nil {
|
||||||
|
blob, err := json.Marshal(authzErr)
|
||||||
|
if err != nil {
|
||||||
|
panic(err) // wtf
|
||||||
|
}
|
||||||
|
a.failErr = authzErr
|
||||||
|
return blob, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOAuthBearerServer(auth OAuthBearerAuthenticator) *OAuthBearerServer {
|
||||||
|
return &OAuthBearerServer{
|
||||||
|
authenticate: auth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,11 @@
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
214
irc/server.go
214
irc/server.go
|
|
@ -36,26 +36,16 @@ import (
|
||||||
"github.com/ergochat/ergo/irc/mysql"
|
"github.com/ergochat/ergo/irc/mysql"
|
||||||
"github.com/ergochat/ergo/irc/sno"
|
"github.com/ergochat/ergo/irc/sno"
|
||||||
"github.com/ergochat/ergo/irc/utils"
|
"github.com/ergochat/ergo/irc/utils"
|
||||||
|
"github.com/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 +53,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
|
||||||
|
|
@ -95,7 +96,13 @@ type Server struct {
|
||||||
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 +129,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 +143,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
|
||||||
}
|
}
|
||||||
|
|
@ -266,7 +276,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 +300,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 +353,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
|
||||||
}
|
}
|
||||||
|
|
@ -314,9 +365,7 @@ func (server *Server) checkBanScriptExemptSASL(config *Config, session *Session)
|
||||||
func (server *Server) tryRegister(c *Client, session *Session) (exiting bool) {
|
func (server *Server) tryRegister(c *Client, session *Session) (exiting bool) {
|
||||||
// XXX PROXY or WEBIRC MUST be sent as the first line of the session;
|
// XXX PROXY or WEBIRC MUST be sent as the first line of the session;
|
||||||
// if we are here at all that means we have the final value of the IP
|
// if we are here at all that means we have the final value of the IP
|
||||||
if session.rawHostname == "" {
|
c.finalizeHostname(session)
|
||||||
session.client.lookupHostname(session, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// try to complete registration normally
|
// try to complete registration normally
|
||||||
// XXX(#1057) username can be filled in by an ident query without the client
|
// XXX(#1057) username can be filled in by an ident query without the client
|
||||||
|
|
@ -379,16 +428,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -420,7 +480,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)
|
||||||
|
|
@ -433,10 +493,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)
|
||||||
|
if !(rb.session.capabilities.Has(caps.ExtendedISupport) && rb.session.isupportSentPrereg) {
|
||||||
server.RplISupport(c, rb)
|
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)
|
||||||
}
|
}
|
||||||
|
|
@ -458,15 +524,22 @@ func (server *Server) playRegistrationBurst(session *Session) {
|
||||||
|
|
||||||
// RplISupport outputs our ISUPPORT lines to the client. This is used on connection and in VERSION responses.
|
// 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...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -564,7 +637,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 != "" {
|
||||||
|
|
@ -576,12 +648,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")
|
||||||
|
|
||||||
|
|
@ -619,6 +696,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 {
|
||||||
|
|
@ -644,6 +724,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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -702,9 +784,6 @@ func (server *Server) applyConfig(config *Config) (err error) {
|
||||||
if !oldConfig.Accounts.NickReservation.Enabled {
|
if !oldConfig.Accounts.NickReservation.Enabled {
|
||||||
server.accounts.buildNickToAccountIndex(config)
|
server.accounts.buildNickToAccountIndex(config)
|
||||||
}
|
}
|
||||||
if !oldConfig.Channels.Registration.Enabled {
|
|
||||||
server.channels.loadRegisteredChannels(config)
|
|
||||||
}
|
|
||||||
// resize history buffers as needed
|
// resize history buffers as needed
|
||||||
if config.historyChangedFrom(oldConfig) {
|
if config.historyChangedFrom(oldConfig) {
|
||||||
for _, channel := range server.channels.Channels() {
|
for _, channel := range server.channels.Channels() {
|
||||||
|
|
@ -738,6 +817,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)
|
||||||
|
|
@ -780,6 +869,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 {
|
||||||
|
|
@ -799,13 +890,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)
|
||||||
|
if len(newISupportReplies) != 0 {
|
||||||
for _, sClient := range server.clients.AllClients() {
|
for _, sClient := range server.clients.AllClients() {
|
||||||
for _, tokenline := range newISupportReplies {
|
for _, session := range sClient.Sessions() {
|
||||||
sClient.Send(nil, server.name, RPL_ISUPPORT, append([]string{sClient.nick}, tokenline...)...)
|
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."))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -815,6 +912,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
|
||||||
}
|
}
|
||||||
|
|
@ -842,6 +942,46 @@ func (server *Server) setupPprofListener(config *Config) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (server *Server) setupAPIListener(config *Config) {
|
||||||
|
if server.apiServer != nil {
|
||||||
|
if !config.API.Enabled || (config.API.Listener != server.apiServer.Addr) {
|
||||||
|
server.logger.Info("server", "Stopping API listener", server.apiServer.Addr)
|
||||||
|
server.apiServer.Close()
|
||||||
|
server.apiListener = nil
|
||||||
|
server.apiServer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !config.API.Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
listenerConfig := utils.ListenerConfig{
|
||||||
|
TLSConfig: config.API.tlsConfig,
|
||||||
|
}
|
||||||
|
if server.apiListener != nil {
|
||||||
|
server.apiListener.Reload(listenerConfig)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
listener, err := net.Listen("tcp", config.API.Listener)
|
||||||
|
if err != nil {
|
||||||
|
server.logger.Error("server", "Couldn't create API listener", config.API.Listener, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
server.apiListener = utils.NewReloadableListener(listener, listenerConfig)
|
||||||
|
server.apiServer = &http.Server{
|
||||||
|
Addr: config.API.Listener, // just informational since we created the listener ourselves
|
||||||
|
Handler: server.apiHandler,
|
||||||
|
ReadTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 10 * time.Second,
|
||||||
|
MaxHeaderBytes: 16384,
|
||||||
|
}
|
||||||
|
go func(hs *http.Server, listener net.Listener) {
|
||||||
|
if err := hs.Serve(listener); err != nil {
|
||||||
|
server.logger.Error("server", "API listener failed", err.Error())
|
||||||
|
}
|
||||||
|
}(server.apiServer, server.apiListener)
|
||||||
|
server.logger.Info("server", "Started API listener", server.apiServer.Addr)
|
||||||
|
}
|
||||||
|
|
||||||
func (server *Server) loadDatastore(config *Config) error {
|
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
|
||||||
|
|
@ -1114,6 +1254,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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -55,17 +55,18 @@ type Client struct {
|
||||||
|
|
||||||
// Dial returns a new Client connected to an SMTP server at addr.
|
// Dial returns a new Client connected to an SMTP server at addr.
|
||||||
// The addr must include a port, as in "mail.example.com:smtp".
|
// The addr must include a port, as in "mail.example.com:smtp".
|
||||||
func Dial(addr string, timeout time.Duration, implicitTLS bool) (*Client, error) {
|
func Dial(protocol, addr string, localAddress net.Addr, timeout time.Duration, implicitTLS bool) (*Client, error) {
|
||||||
var conn net.Conn
|
var conn net.Conn
|
||||||
var err error
|
var err error
|
||||||
dialer := net.Dialer{
|
dialer := net.Dialer{
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
|
LocalAddr: localAddress,
|
||||||
}
|
}
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
if !implicitTLS {
|
if !implicitTLS {
|
||||||
conn, err = dialer.Dial("tcp", addr)
|
conn, err = dialer.Dial(protocol, addr)
|
||||||
} else {
|
} else {
|
||||||
conn, err = tls.DialWithDialer(&dialer, "tcp", addr, nil)
|
conn, err = tls.DialWithDialer(&dialer, protocol, addr, nil)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -232,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 {
|
||||||
|
|
@ -258,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
|
||||||
}
|
}
|
||||||
|
|
@ -341,7 +342,7 @@ var testHookStartTLS func(*tls.Config) // nil, except for tests
|
||||||
// functionality. Higher-level packages exist outside of the standard
|
// functionality. Higher-level packages exist outside of the standard
|
||||||
// library.
|
// library.
|
||||||
// XXX: modified in Ergo to add `requireTLS`, `heloDomain`, and `timeout` arguments
|
// XXX: modified in Ergo to add `requireTLS`, `heloDomain`, and `timeout` arguments
|
||||||
func SendMail(addr string, a Auth, heloDomain string, from string, to []string, msg []byte, requireTLS, implicitTLS bool, timeout time.Duration) error {
|
func SendMail(addr string, a Auth, heloDomain string, from string, to []string, msg []byte, requireTLS, implicitTLS bool, protocol string, localAddress net.Addr, timeout time.Duration) error {
|
||||||
if err := validateLine(from); err != nil {
|
if err := validateLine(from); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -350,7 +351,7 @@ func SendMail(addr string, a Auth, heloDomain string, from string, to []string,
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c, err := Dial(addr, timeout, implicitTLS)
|
c, err := Dial(protocol, addr, localAddress, timeout, implicitTLS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,10 @@ const (
|
||||||
// confusables detection: standard skeleton algorithm (which may be ineffective
|
// confusables detection: standard skeleton algorithm (which may be ineffective
|
||||||
// over the larger set of permitted identifiers)
|
// over the larger set of permitted identifiers)
|
||||||
CasemappingPermissive
|
CasemappingPermissive
|
||||||
|
// rfc1459 is a legacy mapping as defined here: https://modern.ircdocs.horse/#casemapping-parameter
|
||||||
|
CasemappingRFC1459
|
||||||
|
// rfc1459-strict is a legacy mapping as defined here: https://modern.ircdocs.horse/#casemapping-parameter
|
||||||
|
CasemappingRFC1459Strict
|
||||||
)
|
)
|
||||||
|
|
||||||
// XXX this is a global variable without explicit synchronization.
|
// XXX this is a global variable without explicit synchronization.
|
||||||
|
|
@ -69,7 +73,7 @@ var globalCasemappingSetting Casemapping = CasemappingPRECIS
|
||||||
|
|
||||||
// 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
|
||||||
|
|
||||||
|
|
@ -110,6 +114,10 @@ func casefoldWithSetting(str string, setting Casemapping) (string, error) {
|
||||||
return foldASCII(str)
|
return foldASCII(str)
|
||||||
case CasemappingPermissive:
|
case CasemappingPermissive:
|
||||||
return foldPermissive(str)
|
return foldPermissive(str)
|
||||||
|
case CasemappingRFC1459:
|
||||||
|
return foldRFC1459(str, false)
|
||||||
|
case CasemappingRFC1459Strict:
|
||||||
|
return foldRFC1459(str, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,7 +222,7 @@ func Skeleton(name string) (string, error) {
|
||||||
switch globalCasemappingSetting {
|
switch globalCasemappingSetting {
|
||||||
default:
|
default:
|
||||||
return realSkeleton(name)
|
return realSkeleton(name)
|
||||||
case CasemappingASCII:
|
case CasemappingASCII, CasemappingRFC1459, CasemappingRFC1459Strict:
|
||||||
// identity function is fine because we independently case-normalize in Casefold
|
// identity function is fine because we independently case-normalize in Casefold
|
||||||
return name, nil
|
return name, nil
|
||||||
}
|
}
|
||||||
|
|
@ -302,6 +310,23 @@ func foldASCII(str string) (result string, err error) {
|
||||||
return strings.ToLower(str), nil
|
return strings.ToLower(str), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
rfc1459Replacer = strings.NewReplacer("[", "{", "]", "}", "\\", "|", "~", "^")
|
||||||
|
rfc1459StrictReplacer = strings.NewReplacer("[", "{", "]", "}", "\\", "|")
|
||||||
|
)
|
||||||
|
|
||||||
|
func foldRFC1459(str string, strict bool) (result string, err error) {
|
||||||
|
asciiFold, err := foldASCII(str)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
replacer := rfc1459Replacer
|
||||||
|
if strict {
|
||||||
|
replacer = rfc1459StrictReplacer
|
||||||
|
}
|
||||||
|
return replacer.Replace(asciiFold), nil
|
||||||
|
}
|
||||||
|
|
||||||
func IsPrintableASCII(str string) bool {
|
func IsPrintableASCII(str string) bool {
|
||||||
for i := 0; i < len(str); i++ {
|
for i := 0; i < len(str); i++ {
|
||||||
// allow space here because it's technically printable;
|
// allow space here because it's technically printable;
|
||||||
|
|
|
||||||
|
|
@ -279,3 +279,31 @@ func TestFoldASCIIInvalid(t *testing.T) {
|
||||||
t.Errorf("control characters should be invalid in identifiers")
|
t.Errorf("control characters should be invalid in identifiers")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFoldRFC1459(t *testing.T) {
|
||||||
|
folder := func(str string) (string, error) {
|
||||||
|
return foldRFC1459(str, false)
|
||||||
|
}
|
||||||
|
tester := func(first, second string, equal bool) {
|
||||||
|
validFoldTester(first, second, equal, folder, t)
|
||||||
|
}
|
||||||
|
tester("shivaram", "SHIVARAM", true)
|
||||||
|
tester("shivaram[a]", "shivaram{a}", true)
|
||||||
|
tester("shivaram\\a]", "shivaram{a}", false)
|
||||||
|
tester("shivaram\\a]", "shivaram|a}", true)
|
||||||
|
tester("shivaram~a]", "shivaram^a}", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFoldRFC1459Strict(t *testing.T) {
|
||||||
|
folder := func(str string) (string, error) {
|
||||||
|
return foldRFC1459(str, true)
|
||||||
|
}
|
||||||
|
tester := func(first, second string, equal bool) {
|
||||||
|
validFoldTester(first, second, equal, folder, t)
|
||||||
|
}
|
||||||
|
tester("shivaram", "SHIVARAM", true)
|
||||||
|
tester("shivaram[a]", "shivaram{a}", true)
|
||||||
|
tester("shivaram\\a]", "shivaram{a}", false)
|
||||||
|
tester("shivaram\\a]", "shivaram|a}", true)
|
||||||
|
tester("shivaram~a]", "shivaram^a}", false)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
28
irc/utils/chunks.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
15
irc/utils/time.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@ import "fmt"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// SemVer is the semantic version of Ergo.
|
// SemVer is the semantic version of Ergo.
|
||||||
SemVer = "2.13.0"
|
SemVer = "2.17.0-rc1"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
|
||||||
60
irc/webpush/highlight.go
Normal file
60
irc/webpush/highlight.go
Normal 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
66
irc/webpush/security.go
Normal 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()
|
||||||
|
}
|
||||||
21
irc/webpush/security_test.go
Normal file
21
irc/webpush/security_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
148
irc/webpush/webpush.go
Normal file
148
irc/webpush/webpush.go
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
// 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"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
return MakePushLine(msg.Time, accountName, nuh, command, target, messageForPush)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MakePushLine serializes an arbitrary IRC line as a web push message (the args are in
|
||||||
|
// IRC syntax order)
|
||||||
|
func MakePushLine(time time.Time, accountName, source, command string, params ...string) ([]byte, error) {
|
||||||
|
pushMessage := ircmsg.MakeMessage(nil, source, command, params...)
|
||||||
|
pushMessage.SetTag("time", time.Format(utils.IRCv3TimestampFormat))
|
||||||
|
// "*" is canonical for the unset form of the unfolded account name, but check both:
|
||||||
|
if accountName != "*" && accountName != "" {
|
||||||
|
pushMessage.SetTag("account", accountName)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
57
irc/webpush/webpush_test.go
Normal file
57
irc/webpush/webpush_test.go
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
package webpush
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ergochat/irc-go/ircmsg"
|
||||||
|
|
||||||
|
"github.com/ergochat/ergo/irc/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildPushLine(t *testing.T) {
|
||||||
|
now, err := time.Parse(utils.IRCv3TimestampFormat, "2025-01-12T00:55:44.403Z")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
line, err := MakePushLine(now, "*", "ergo.test", "MARKREAD", "#ergo", "timestamp=2025-01-12T00:07:57.972Z")
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
2
irctest
2
irctest
|
|
@ -1 +1 @@
|
||||||
Subproject commit 6425e707acb092386b859ef738ee401a0021f65b
|
Subproject commit 13a76e4501749dbc1a604e16978e128ff40edace
|
||||||
145
traditional.yaml
145
traditional.yaml
|
|
@ -74,6 +74,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
|
||||||
#
|
#
|
||||||
|
|
@ -108,9 +109,10 @@ server:
|
||||||
# the recommended default is 'ascii' (traditional ASCII-only identifiers).
|
# the recommended default is 'ascii' (traditional ASCII-only identifiers).
|
||||||
# the other options are 'precis', which allows UTF8 identifiers that are "sane"
|
# the other options are 'precis', which allows UTF8 identifiers that are "sane"
|
||||||
# (according to UFC 8265), with additional mitigations for homoglyph attacks,
|
# (according to UFC 8265), with additional mitigations for homoglyph attacks,
|
||||||
# and 'permissive', which allows identifiers containing unusual characters like
|
# 'permissive', which allows identifiers containing unusual characters like
|
||||||
# emoji, at the cost of increased vulnerability to homoglyph attacks and potential
|
# emoji, at the cost of increased vulnerability to homoglyph attacks and potential
|
||||||
# client compatibility problems. we recommend leaving this value at its default;
|
# client compatibility problems, and the legacy mappings 'rfc1459' and
|
||||||
|
# 'rfc1459-strict'. we recommend leaving this value at its default;
|
||||||
# however, note that changing it once the network is already up and running is
|
# however, note that changing it once the network is already up and running is
|
||||||
# problematic.
|
# problematic.
|
||||||
casemapping: "ascii"
|
casemapping: "ascii"
|
||||||
|
|
@ -152,6 +154,17 @@ 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
|
||||||
|
|
||||||
|
# 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?
|
||||||
|
|
@ -192,6 +205,9 @@ server:
|
||||||
# - "192.168.1.1"
|
# - "192.168.1.1"
|
||||||
# - "192.168.10.1/24"
|
# - "192.168.10.1/24"
|
||||||
|
|
||||||
|
# whether to accept the hostname parameter on the WEBIRC line as the IRC hostname
|
||||||
|
accept-hostname: true
|
||||||
|
|
||||||
# maximum length of clients' sendQ in bytes
|
# maximum length of clients' sendQ in bytes
|
||||||
# this should be big enough to hold bursts of channel/direct messages
|
# this should be big enough to hold bursts of channel/direct messages
|
||||||
max-sendq: 96k
|
max-sendq: 96k
|
||||||
|
|
@ -325,6 +341,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:
|
||||||
|
|
@ -343,6 +363,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?
|
||||||
|
|
@ -378,6 +409,10 @@ accounts:
|
||||||
sender: "admin@my.network"
|
sender: "admin@my.network"
|
||||||
require-tls: true
|
require-tls: true
|
||||||
helo-domain: "my.network" # defaults to server name if unset
|
helo-domain: "my.network" # defaults to server name if unset
|
||||||
|
# set to `tcp4` to force sending over IPv4, `tcp6` to force IPv6:
|
||||||
|
# protocol: "tcp4"
|
||||||
|
# set to force a specific source/local IPv4 or IPv6 address:
|
||||||
|
# local-address: "1.2.3.4"
|
||||||
# options to enable DKIM signing of outgoing emails (recommended, but
|
# options to enable DKIM signing of outgoing emails (recommended, but
|
||||||
# requires creating a DNS entry for the public key):
|
# requires creating a DNS entry for the public key):
|
||||||
# dkim:
|
# dkim:
|
||||||
|
|
@ -474,7 +509,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-*"
|
||||||
|
|
@ -559,6 +594,40 @@ accounts:
|
||||||
# how many scripts are allowed to run at once? 0 for no limit:
|
# how many scripts are allowed to run at once? 0 for no limit:
|
||||||
max-concurrency: 64
|
max-concurrency: 64
|
||||||
|
|
||||||
|
# support for login via OAuth2 bearer tokens
|
||||||
|
oauth2:
|
||||||
|
enabled: false
|
||||||
|
# should we automatically create users on presentation of a valid token?
|
||||||
|
autocreate: true
|
||||||
|
# enable this to use auth-script for validation:
|
||||||
|
auth-script: false
|
||||||
|
introspection-url: "https://example.com/api/oidc/introspection"
|
||||||
|
introspection-timeout: 10s
|
||||||
|
# omit for auth method `none`; required for auth method `client_secret_basic`:
|
||||||
|
client-id: "ergo"
|
||||||
|
client-secret: "4TA0I7mJ3fUUcW05KJiODg"
|
||||||
|
|
||||||
|
# support for login via JWT bearer tokens
|
||||||
|
jwt-auth:
|
||||||
|
enabled: false
|
||||||
|
# should we automatically create users on presentation of a valid token?
|
||||||
|
autocreate: true
|
||||||
|
# any of these token definitions can be accepted, allowing for key rotation
|
||||||
|
tokens:
|
||||||
|
-
|
||||||
|
algorithm: "hmac" # either 'hmac', 'rsa', or 'eddsa' (ed25519)
|
||||||
|
# hmac takes a symmetric key, rsa and eddsa take PEM-encoded public keys;
|
||||||
|
# either way, the key can be specified either as a YAML string:
|
||||||
|
key: "nANiZ1De4v6WnltCHN2H7Q"
|
||||||
|
# or as a path to the file containing the key:
|
||||||
|
#key-file: "jwt_pubkey.pem"
|
||||||
|
# list of JWT claim names to search for the user's account name (make sure the format
|
||||||
|
# is what you expect, especially if using "sub"):
|
||||||
|
account-claims: ["preferred_username"]
|
||||||
|
# if a claim is formatted as an email address, require it to have the following domain,
|
||||||
|
# and then strip off the domain and use the local-part as the account name:
|
||||||
|
#strip-domain: "example.com"
|
||||||
|
|
||||||
# channel options
|
# channel options
|
||||||
channels:
|
channels:
|
||||||
# modes that are set when new channels are created
|
# modes that are set when new channels are created
|
||||||
|
|
@ -641,6 +710,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:
|
||||||
|
|
@ -705,7 +775,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
|
||||||
|
|
@ -749,7 +819,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
|
||||||
|
|
@ -792,6 +862,9 @@ limits:
|
||||||
# identlen is the max ident length allowed
|
# identlen is the max ident length allowed
|
||||||
identlen: 20
|
identlen: 20
|
||||||
|
|
||||||
|
# realnamelen is the maximum realname length allowed
|
||||||
|
realnamelen: 150
|
||||||
|
|
||||||
# channellen is the max channel length allowed
|
# channellen is the max channel length allowed
|
||||||
channellen: 64
|
channellen: 64
|
||||||
|
|
||||||
|
|
@ -811,7 +884,7 @@ limits:
|
||||||
whowas-entries: 100
|
whowas-entries: 100
|
||||||
|
|
||||||
# maximum length of channel lists (beI modes)
|
# maximum length of channel lists (beI modes)
|
||||||
chan-list-modes: 60
|
chan-list-modes: 100
|
||||||
|
|
||||||
# maximum number of messages to accept during registration (prevents
|
# maximum number of messages to accept during registration (prevents
|
||||||
# DoS / resource exhaustion attacks):
|
# DoS / resource exhaustion attacks):
|
||||||
|
|
@ -848,6 +921,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
|
||||||
|
|
@ -866,6 +940,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.
|
||||||
|
|
@ -993,3 +1073,56 @@ 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
|
||||||
|
# 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"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
The MIT License (MIT)
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2015 Stéphane Depierrepont
|
Copyright (c) 2017 emersion
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -19,4 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
|
|
||||||
199
vendor/github.com/emersion/go-msgauth/dkim/canonical.go
generated
vendored
Normal file
199
vendor/github.com/emersion/go-msgauth/dkim/canonical.go
generated
vendored
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
package dkim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Canonicalization is a canonicalization algorithm.
|
||||||
|
type Canonicalization string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CanonicalizationSimple Canonicalization = "simple"
|
||||||
|
CanonicalizationRelaxed = "relaxed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type canonicalizer interface {
|
||||||
|
CanonicalizeHeader(s string) string
|
||||||
|
CanonicalizeBody(w io.Writer) io.WriteCloser
|
||||||
|
}
|
||||||
|
|
||||||
|
var canonicalizers = map[Canonicalization]canonicalizer{
|
||||||
|
CanonicalizationSimple: new(simpleCanonicalizer),
|
||||||
|
CanonicalizationRelaxed: new(relaxedCanonicalizer),
|
||||||
|
}
|
||||||
|
|
||||||
|
// crlfFixer fixes any lone LF without a preceding CR.
|
||||||
|
type crlfFixer struct {
|
||||||
|
cr bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cf *crlfFixer) Fix(b []byte) []byte {
|
||||||
|
res := make([]byte, 0, len(b))
|
||||||
|
for _, ch := range b {
|
||||||
|
prevCR := cf.cr
|
||||||
|
cf.cr = false
|
||||||
|
switch ch {
|
||||||
|
case '\r':
|
||||||
|
cf.cr = true
|
||||||
|
case '\n':
|
||||||
|
if !prevCR {
|
||||||
|
res = append(res, '\r')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res = append(res, ch)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
type simpleCanonicalizer struct{}
|
||||||
|
|
||||||
|
func (c *simpleCanonicalizer) CanonicalizeHeader(s string) string {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
type simpleBodyCanonicalizer struct {
|
||||||
|
w io.Writer
|
||||||
|
crlfBuf []byte
|
||||||
|
crlfFixer crlfFixer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *simpleBodyCanonicalizer) Write(b []byte) (int, error) {
|
||||||
|
written := len(b)
|
||||||
|
b = append(c.crlfBuf, b...)
|
||||||
|
|
||||||
|
b = c.crlfFixer.Fix(b)
|
||||||
|
|
||||||
|
end := len(b)
|
||||||
|
// If it ends with \r, maybe the next write will begin with \n
|
||||||
|
if end > 0 && b[end-1] == '\r' {
|
||||||
|
end--
|
||||||
|
}
|
||||||
|
// Keep all \r\n sequences
|
||||||
|
for end >= 2 {
|
||||||
|
prev := b[end-2]
|
||||||
|
cur := b[end-1]
|
||||||
|
if prev != '\r' || cur != '\n' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end -= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
c.crlfBuf = b[end:]
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if end > 0 {
|
||||||
|
_, err = c.w.Write(b[:end])
|
||||||
|
}
|
||||||
|
return written, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *simpleBodyCanonicalizer) Close() error {
|
||||||
|
// Flush crlfBuf if it ends with a single \r (without a matching \n)
|
||||||
|
if len(c.crlfBuf) > 0 && c.crlfBuf[len(c.crlfBuf)-1] == '\r' {
|
||||||
|
if _, err := c.w.Write(c.crlfBuf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.crlfBuf = nil
|
||||||
|
|
||||||
|
if _, err := c.w.Write([]byte(crlf)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *simpleCanonicalizer) CanonicalizeBody(w io.Writer) io.WriteCloser {
|
||||||
|
return &simpleBodyCanonicalizer{w: w}
|
||||||
|
}
|
||||||
|
|
||||||
|
type relaxedCanonicalizer struct{}
|
||||||
|
|
||||||
|
func (c *relaxedCanonicalizer) CanonicalizeHeader(s string) string {
|
||||||
|
k, v, ok := strings.Cut(s, ":")
|
||||||
|
if !ok {
|
||||||
|
return strings.TrimSpace(strings.ToLower(s)) + ":" + crlf
|
||||||
|
}
|
||||||
|
|
||||||
|
k = strings.TrimSpace(strings.ToLower(k))
|
||||||
|
v = strings.Join(strings.FieldsFunc(v, func(r rune) bool {
|
||||||
|
return r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||||||
|
}), " ")
|
||||||
|
return k + ":" + v + crlf
|
||||||
|
}
|
||||||
|
|
||||||
|
type relaxedBodyCanonicalizer struct {
|
||||||
|
w io.Writer
|
||||||
|
crlfBuf []byte
|
||||||
|
wsp bool
|
||||||
|
written bool
|
||||||
|
crlfFixer crlfFixer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *relaxedBodyCanonicalizer) Write(b []byte) (int, error) {
|
||||||
|
written := len(b)
|
||||||
|
|
||||||
|
b = c.crlfFixer.Fix(b)
|
||||||
|
|
||||||
|
canonical := make([]byte, 0, len(b))
|
||||||
|
for _, ch := range b {
|
||||||
|
if ch == ' ' || ch == '\t' {
|
||||||
|
c.wsp = true
|
||||||
|
} else if ch == '\r' || ch == '\n' {
|
||||||
|
c.wsp = false
|
||||||
|
c.crlfBuf = append(c.crlfBuf, ch)
|
||||||
|
} else {
|
||||||
|
if len(c.crlfBuf) > 0 {
|
||||||
|
canonical = append(canonical, c.crlfBuf...)
|
||||||
|
c.crlfBuf = c.crlfBuf[:0]
|
||||||
|
}
|
||||||
|
if c.wsp {
|
||||||
|
canonical = append(canonical, ' ')
|
||||||
|
c.wsp = false
|
||||||
|
}
|
||||||
|
|
||||||
|
canonical = append(canonical, ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.written && len(canonical) > 0 {
|
||||||
|
c.written = true
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := c.w.Write(canonical)
|
||||||
|
return written, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *relaxedBodyCanonicalizer) Close() error {
|
||||||
|
if c.written {
|
||||||
|
if _, err := c.w.Write([]byte(crlf)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *relaxedCanonicalizer) CanonicalizeBody(w io.Writer) io.WriteCloser {
|
||||||
|
return &relaxedBodyCanonicalizer{w: w}
|
||||||
|
}
|
||||||
|
|
||||||
|
type limitedWriter struct {
|
||||||
|
W io.Writer
|
||||||
|
N int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *limitedWriter) Write(b []byte) (int, error) {
|
||||||
|
if w.N <= 0 {
|
||||||
|
return len(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
skipped := 0
|
||||||
|
if int64(len(b)) > w.N {
|
||||||
|
b = b[:w.N]
|
||||||
|
skipped = int(int64(len(b)) - w.N)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := w.W.Write(b)
|
||||||
|
w.N -= int64(n)
|
||||||
|
return n + skipped, err
|
||||||
|
}
|
||||||
23
vendor/github.com/emersion/go-msgauth/dkim/dkim.go
generated
vendored
Normal file
23
vendor/github.com/emersion/go-msgauth/dkim/dkim.go
generated
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// Package dkim creates and verifies DKIM signatures, as specified in RFC 6376.
|
||||||
|
//
|
||||||
|
// # FAQ
|
||||||
|
//
|
||||||
|
// Why can't I verify a [net/mail.Message] directly? A [net/mail.Message]
|
||||||
|
// header is already parsed, and whitespace characters (especially continuation
|
||||||
|
// lines) are removed. Thus, the signature computed from the parsed header is
|
||||||
|
// not the same as the one computed from the raw header.
|
||||||
|
//
|
||||||
|
// How can I publish my public key? You have to add a TXT record to your DNS
|
||||||
|
// zone. See [RFC 6376 appendix C]. You can use the dkim-keygen tool included
|
||||||
|
// in go-msgauth to generate the key and the TXT record.
|
||||||
|
//
|
||||||
|
// [RFC 6376 appendix C]: https://tools.ietf.org/html/rfc6376#appendix-C
|
||||||
|
package dkim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var now = time.Now
|
||||||
|
|
||||||
|
const headerFieldName = "DKIM-Signature"
|
||||||
172
vendor/github.com/emersion/go-msgauth/dkim/header.go
generated
vendored
Normal file
172
vendor/github.com/emersion/go-msgauth/dkim/header.go
generated
vendored
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
package dkim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/textproto"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const crlf = "\r\n"
|
||||||
|
|
||||||
|
type header []string
|
||||||
|
|
||||||
|
func readHeader(r *bufio.Reader) (header, error) {
|
||||||
|
tr := textproto.NewReader(r)
|
||||||
|
|
||||||
|
var h header
|
||||||
|
for {
|
||||||
|
l, err := tr.ReadLine()
|
||||||
|
if err != nil {
|
||||||
|
return h, fmt.Errorf("failed to read header: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(l) == 0 {
|
||||||
|
break
|
||||||
|
} else if len(h) > 0 && (l[0] == ' ' || l[0] == '\t') {
|
||||||
|
// This is a continuation line
|
||||||
|
h[len(h)-1] += l + crlf
|
||||||
|
} else {
|
||||||
|
h = append(h, l+crlf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeHeader(w io.Writer, h header) error {
|
||||||
|
for _, kv := range h {
|
||||||
|
if _, err := w.Write([]byte(kv)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := w.Write([]byte(crlf))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func foldHeaderField(kv string) string {
|
||||||
|
buf := bytes.NewBufferString(kv)
|
||||||
|
|
||||||
|
line := make([]byte, 75) // 78 - len("\r\n\s")
|
||||||
|
first := true
|
||||||
|
var fold strings.Builder
|
||||||
|
for len, err := buf.Read(line); err != io.EOF; len, err = buf.Read(line) {
|
||||||
|
if first {
|
||||||
|
first = false
|
||||||
|
} else {
|
||||||
|
fold.WriteString("\r\n ")
|
||||||
|
}
|
||||||
|
fold.Write(line[:len])
|
||||||
|
}
|
||||||
|
|
||||||
|
return fold.String() + crlf
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseHeaderField(s string) (string, string) {
|
||||||
|
key, value, _ := strings.Cut(s, ":")
|
||||||
|
return strings.TrimSpace(key), strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseHeaderParams(s string) (map[string]string, error) {
|
||||||
|
pairs := strings.Split(s, ";")
|
||||||
|
params := make(map[string]string)
|
||||||
|
for _, s := range pairs {
|
||||||
|
key, value, ok := strings.Cut(s, "=")
|
||||||
|
if !ok {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return params, errors.New("dkim: malformed header params")
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmedKey := strings.TrimSpace(key)
|
||||||
|
_, present := params[trimmedKey]
|
||||||
|
if present {
|
||||||
|
return params, errors.New("dkim: duplicate tag name")
|
||||||
|
}
|
||||||
|
params[trimmedKey] = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
return params, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatHeaderParams(headerFieldName string, params map[string]string) string {
|
||||||
|
keys, bvalue, bfound := sortParams(params)
|
||||||
|
|
||||||
|
s := headerFieldName + ":"
|
||||||
|
var line string
|
||||||
|
|
||||||
|
for _, k := range keys {
|
||||||
|
v := params[k]
|
||||||
|
nextLength := 3 + len(line) + len(v) + len(k)
|
||||||
|
if nextLength > 75 {
|
||||||
|
s += line + crlf
|
||||||
|
line = ""
|
||||||
|
}
|
||||||
|
line = fmt.Sprintf("%v %v=%v;", line, k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if line != "" {
|
||||||
|
s += line
|
||||||
|
}
|
||||||
|
|
||||||
|
if bfound {
|
||||||
|
bfiled := foldHeaderField(" b=" + bvalue)
|
||||||
|
s += crlf + bfiled
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortParams(params map[string]string) ([]string, string, bool) {
|
||||||
|
keys := make([]string, 0, len(params))
|
||||||
|
bfound := false
|
||||||
|
var bvalue string
|
||||||
|
for k := range params {
|
||||||
|
if k == "b" {
|
||||||
|
bvalue = params["b"]
|
||||||
|
bfound = true
|
||||||
|
} else {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys, bvalue, bfound
|
||||||
|
}
|
||||||
|
|
||||||
|
type headerPicker struct {
|
||||||
|
h header
|
||||||
|
picked map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHeaderPicker(h header) *headerPicker {
|
||||||
|
return &headerPicker{
|
||||||
|
h: h,
|
||||||
|
picked: make(map[string]int),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *headerPicker) Pick(key string) string {
|
||||||
|
key = strings.ToLower(key)
|
||||||
|
|
||||||
|
at := p.picked[key]
|
||||||
|
for i := len(p.h) - 1; i >= 0; i-- {
|
||||||
|
kv := p.h[i]
|
||||||
|
k, _ := parseHeaderField(kv)
|
||||||
|
|
||||||
|
if !strings.EqualFold(k, key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if at == 0 {
|
||||||
|
p.picked[key]++
|
||||||
|
return kv
|
||||||
|
}
|
||||||
|
at--
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
184
vendor/github.com/emersion/go-msgauth/dkim/query.go
generated
vendored
Normal file
184
vendor/github.com/emersion/go-msgauth/dkim/query.go
generated
vendored
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
package dkim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ed25519"
|
||||||
|
)
|
||||||
|
|
||||||
|
type verifier interface {
|
||||||
|
Public() crypto.PublicKey
|
||||||
|
Verify(hash crypto.Hash, hashed []byte, sig []byte) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type rsaVerifier struct {
|
||||||
|
*rsa.PublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v rsaVerifier) Public() crypto.PublicKey {
|
||||||
|
return v.PublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v rsaVerifier) Verify(hash crypto.Hash, hashed, sig []byte) error {
|
||||||
|
return rsa.VerifyPKCS1v15(v.PublicKey, hash, hashed, sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ed25519Verifier struct {
|
||||||
|
ed25519.PublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v ed25519Verifier) Public() crypto.PublicKey {
|
||||||
|
return v.PublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v ed25519Verifier) Verify(hash crypto.Hash, hashed, sig []byte) error {
|
||||||
|
if !ed25519.Verify(v.PublicKey, hashed, sig) {
|
||||||
|
return errors.New("dkim: invalid Ed25519 signature")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type queryResult struct {
|
||||||
|
Verifier verifier
|
||||||
|
KeyAlgo string
|
||||||
|
HashAlgos []string
|
||||||
|
Notes string
|
||||||
|
Services []string
|
||||||
|
Flags []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryMethod is a DKIM query method.
|
||||||
|
type QueryMethod string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DNS TXT resource record (RR) lookup algorithm
|
||||||
|
QueryMethodDNSTXT QueryMethod = "dns/txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type txtLookupFunc func(domain string) ([]string, error)
|
||||||
|
type queryFunc func(domain, selector string, txtLookup txtLookupFunc) (*queryResult, error)
|
||||||
|
|
||||||
|
var queryMethods = map[QueryMethod]queryFunc{
|
||||||
|
QueryMethodDNSTXT: queryDNSTXT,
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryDNSTXT(domain, selector string, txtLookup txtLookupFunc) (*queryResult, error) {
|
||||||
|
if txtLookup == nil {
|
||||||
|
txtLookup = net.LookupTXT
|
||||||
|
}
|
||||||
|
|
||||||
|
txts, err := txtLookup(selector + "._domainkey." + domain)
|
||||||
|
if netErr, ok := err.(net.Error); ok && netErr.Temporary() {
|
||||||
|
return nil, tempFailError("key unavailable: " + err.Error())
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, permFailError("no key for signature: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// net.LookupTXT will concatenate strings contained in a single TXT record.
|
||||||
|
// In other words, net.LookupTXT returns one entry per TXT record, even if
|
||||||
|
// a record contains multiple strings.
|
||||||
|
//
|
||||||
|
// RFC 6376 section 3.6.2.2 says multiple TXT records lead to undefined
|
||||||
|
// behavior, so reject that.
|
||||||
|
switch len(txts) {
|
||||||
|
case 0:
|
||||||
|
return nil, permFailError("no valid key found")
|
||||||
|
case 1:
|
||||||
|
return parsePublicKey(txts[0])
|
||||||
|
default:
|
||||||
|
return nil, permFailError("multiple TXT records found for key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePublicKey(s string) (*queryResult, error) {
|
||||||
|
params, err := parseHeaderParams(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, permFailError("key record error: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
res := new(queryResult)
|
||||||
|
|
||||||
|
if v, ok := params["v"]; ok && v != "DKIM1" {
|
||||||
|
return nil, permFailError("incompatible public key version")
|
||||||
|
}
|
||||||
|
|
||||||
|
p, ok := params["p"]
|
||||||
|
if !ok {
|
||||||
|
return nil, permFailError("key syntax error: missing public key data")
|
||||||
|
}
|
||||||
|
if p == "" {
|
||||||
|
return nil, permFailError("key revoked")
|
||||||
|
}
|
||||||
|
p = strings.ReplaceAll(p, " ", "")
|
||||||
|
b, err := base64.StdEncoding.DecodeString(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, permFailError("key syntax error: " + err.Error())
|
||||||
|
}
|
||||||
|
switch params["k"] {
|
||||||
|
case "rsa", "":
|
||||||
|
pub, err := x509.ParsePKIXPublicKey(b)
|
||||||
|
if err != nil {
|
||||||
|
// RFC 6376 is inconsistent about whether RSA public keys should
|
||||||
|
// be formatted as RSAPublicKey or SubjectPublicKeyInfo.
|
||||||
|
// Erratum 3017 (https://www.rfc-editor.org/errata/eid3017) proposes
|
||||||
|
// allowing both.
|
||||||
|
pub, err = x509.ParsePKCS1PublicKey(b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, permFailError("key syntax error: " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rsaPub, ok := pub.(*rsa.PublicKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, permFailError("key syntax error: not an RSA public key")
|
||||||
|
}
|
||||||
|
// RFC 8301 section 3.2: verifiers MUST NOT consider signatures using
|
||||||
|
// RSA keys of less than 1024 bits as valid signatures.
|
||||||
|
if rsaPub.Size()*8 < 1024 {
|
||||||
|
return nil, permFailError(fmt.Sprintf("key is too short: want 1024 bits, has %v bits", rsaPub.Size()*8))
|
||||||
|
}
|
||||||
|
res.Verifier = rsaVerifier{rsaPub}
|
||||||
|
res.KeyAlgo = "rsa"
|
||||||
|
case "ed25519":
|
||||||
|
if len(b) != ed25519.PublicKeySize {
|
||||||
|
return nil, permFailError(fmt.Sprintf("invalid Ed25519 public key size: %v bytes", len(b)))
|
||||||
|
}
|
||||||
|
ed25519Pub := ed25519.PublicKey(b)
|
||||||
|
res.Verifier = ed25519Verifier{ed25519Pub}
|
||||||
|
res.KeyAlgo = "ed25519"
|
||||||
|
default:
|
||||||
|
return nil, permFailError("unsupported key algorithm")
|
||||||
|
}
|
||||||
|
|
||||||
|
if hashesStr, ok := params["h"]; ok {
|
||||||
|
res.HashAlgos = parseTagList(hashesStr)
|
||||||
|
}
|
||||||
|
if notes, ok := params["n"]; ok {
|
||||||
|
res.Notes = notes
|
||||||
|
}
|
||||||
|
if servicesStr, ok := params["s"]; ok {
|
||||||
|
services := parseTagList(servicesStr)
|
||||||
|
|
||||||
|
hasWildcard := false
|
||||||
|
for _, s := range services {
|
||||||
|
if s == "*" {
|
||||||
|
hasWildcard = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasWildcard {
|
||||||
|
res.Services = services
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if flagsStr, ok := params["t"]; ok {
|
||||||
|
res.Flags = parseTagList(flagsStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
346
vendor/github.com/emersion/go-msgauth/dkim/sign.go
generated
vendored
Normal file
346
vendor/github.com/emersion/go-msgauth/dkim/sign.go
generated
vendored
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
package dkim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ed25519"
|
||||||
|
)
|
||||||
|
|
||||||
|
var randReader io.Reader = rand.Reader
|
||||||
|
|
||||||
|
// SignOptions is used to configure Sign. Domain, Selector and Signer are
|
||||||
|
// mandatory.
|
||||||
|
type SignOptions struct {
|
||||||
|
// The SDID claiming responsibility for an introduction of a message into the
|
||||||
|
// mail stream. Hence, the SDID value is used to form the query for the public
|
||||||
|
// key. The SDID MUST correspond to a valid DNS name under which the DKIM key
|
||||||
|
// record is published.
|
||||||
|
//
|
||||||
|
// This can't be empty.
|
||||||
|
Domain string
|
||||||
|
// The selector subdividing the namespace for the domain.
|
||||||
|
//
|
||||||
|
// This can't be empty.
|
||||||
|
Selector string
|
||||||
|
// The Agent or User Identifier (AUID) on behalf of which the SDID is taking
|
||||||
|
// responsibility.
|
||||||
|
//
|
||||||
|
// This is optional.
|
||||||
|
Identifier string
|
||||||
|
|
||||||
|
// The key used to sign the message.
|
||||||
|
//
|
||||||
|
// Supported Signer.Public() values are *rsa.PublicKey and
|
||||||
|
// ed25519.PublicKey.
|
||||||
|
Signer crypto.Signer
|
||||||
|
// The hash algorithm used to sign the message. If zero, a default hash will
|
||||||
|
// be chosen.
|
||||||
|
//
|
||||||
|
// The only supported hash algorithm is crypto.SHA256.
|
||||||
|
Hash crypto.Hash
|
||||||
|
|
||||||
|
// Header and body canonicalization algorithms.
|
||||||
|
//
|
||||||
|
// If empty, CanonicalizationSimple is used.
|
||||||
|
HeaderCanonicalization Canonicalization
|
||||||
|
BodyCanonicalization Canonicalization
|
||||||
|
|
||||||
|
// A list of header fields to include in the signature. If nil, all headers
|
||||||
|
// will be included. If not nil, "From" MUST be in the list.
|
||||||
|
//
|
||||||
|
// See RFC 6376 section 5.4.1 for recommended header fields.
|
||||||
|
HeaderKeys []string
|
||||||
|
|
||||||
|
// The expiration time. A zero value means no expiration.
|
||||||
|
Expiration time.Time
|
||||||
|
|
||||||
|
// A list of query methods used to retrieve the public key.
|
||||||
|
//
|
||||||
|
// If nil, it is implicitly defined as QueryMethodDNSTXT.
|
||||||
|
QueryMethods []QueryMethod
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signer generates a DKIM signature.
|
||||||
|
//
|
||||||
|
// The whole message header and body must be written to the Signer. Close should
|
||||||
|
// always be called (either after the whole message has been written, or after
|
||||||
|
// an error occurred and the signer won't be used anymore). Close may return an
|
||||||
|
// error in case signing fails.
|
||||||
|
//
|
||||||
|
// After a successful Close, Signature can be called to retrieve the
|
||||||
|
// DKIM-Signature header field that the caller should prepend to the message.
|
||||||
|
type Signer struct {
|
||||||
|
pw *io.PipeWriter
|
||||||
|
done <-chan error
|
||||||
|
sigParams map[string]string // only valid after done received nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSigner creates a new signer. It returns an error if SignOptions is
|
||||||
|
// invalid.
|
||||||
|
func NewSigner(options *SignOptions) (*Signer, error) {
|
||||||
|
if options == nil {
|
||||||
|
return nil, fmt.Errorf("dkim: no options specified")
|
||||||
|
}
|
||||||
|
if options.Domain == "" {
|
||||||
|
return nil, fmt.Errorf("dkim: no domain specified")
|
||||||
|
}
|
||||||
|
if options.Selector == "" {
|
||||||
|
return nil, fmt.Errorf("dkim: no selector specified")
|
||||||
|
}
|
||||||
|
if options.Signer == nil {
|
||||||
|
return nil, fmt.Errorf("dkim: no signer specified")
|
||||||
|
}
|
||||||
|
|
||||||
|
headerCan := options.HeaderCanonicalization
|
||||||
|
if headerCan == "" {
|
||||||
|
headerCan = CanonicalizationSimple
|
||||||
|
}
|
||||||
|
if _, ok := canonicalizers[headerCan]; !ok {
|
||||||
|
return nil, fmt.Errorf("dkim: unknown header canonicalization %q", headerCan)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyCan := options.BodyCanonicalization
|
||||||
|
if bodyCan == "" {
|
||||||
|
bodyCan = CanonicalizationSimple
|
||||||
|
}
|
||||||
|
if _, ok := canonicalizers[bodyCan]; !ok {
|
||||||
|
return nil, fmt.Errorf("dkim: unknown body canonicalization %q", bodyCan)
|
||||||
|
}
|
||||||
|
|
||||||
|
var keyAlgo string
|
||||||
|
switch options.Signer.Public().(type) {
|
||||||
|
case *rsa.PublicKey:
|
||||||
|
keyAlgo = "rsa"
|
||||||
|
case ed25519.PublicKey:
|
||||||
|
keyAlgo = "ed25519"
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("dkim: unsupported key algorithm %T", options.Signer.Public())
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := options.Hash
|
||||||
|
var hashAlgo string
|
||||||
|
switch options.Hash {
|
||||||
|
case 0: // sha256 is the default
|
||||||
|
hash = crypto.SHA256
|
||||||
|
fallthrough
|
||||||
|
case crypto.SHA256:
|
||||||
|
hashAlgo = "sha256"
|
||||||
|
case crypto.SHA1:
|
||||||
|
return nil, fmt.Errorf("dkim: hash algorithm too weak: sha1")
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("dkim: unsupported hash algorithm")
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.HeaderKeys != nil {
|
||||||
|
ok := false
|
||||||
|
for _, k := range options.HeaderKeys {
|
||||||
|
if strings.EqualFold(k, "From") {
|
||||||
|
ok = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("dkim: the From header field must be signed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
pr, pw := io.Pipe()
|
||||||
|
|
||||||
|
s := &Signer{
|
||||||
|
pw: pw,
|
||||||
|
done: done,
|
||||||
|
}
|
||||||
|
|
||||||
|
closeReadWithError := func(err error) {
|
||||||
|
pr.CloseWithError(err)
|
||||||
|
done <- err
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
|
||||||
|
// Read header
|
||||||
|
br := bufio.NewReader(pr)
|
||||||
|
h, err := readHeader(br)
|
||||||
|
if err != nil {
|
||||||
|
closeReadWithError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash body
|
||||||
|
hasher := hash.New()
|
||||||
|
can := canonicalizers[bodyCan].CanonicalizeBody(hasher)
|
||||||
|
if _, err := io.Copy(can, br); err != nil {
|
||||||
|
closeReadWithError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := can.Close(); err != nil {
|
||||||
|
closeReadWithError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bodyHashed := hasher.Sum(nil)
|
||||||
|
|
||||||
|
params := map[string]string{
|
||||||
|
"v": "1",
|
||||||
|
"a": keyAlgo + "-" + hashAlgo,
|
||||||
|
"bh": base64.StdEncoding.EncodeToString(bodyHashed),
|
||||||
|
"c": string(headerCan) + "/" + string(bodyCan),
|
||||||
|
"d": options.Domain,
|
||||||
|
//"l": "", // TODO
|
||||||
|
"s": options.Selector,
|
||||||
|
"t": formatTime(now()),
|
||||||
|
//"z": "", // TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
var headerKeys []string
|
||||||
|
if options.HeaderKeys != nil {
|
||||||
|
headerKeys = options.HeaderKeys
|
||||||
|
} else {
|
||||||
|
for _, kv := range h {
|
||||||
|
k, _ := parseHeaderField(kv)
|
||||||
|
headerKeys = append(headerKeys, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params["h"] = formatTagList(headerKeys)
|
||||||
|
|
||||||
|
if options.Identifier != "" {
|
||||||
|
params["i"] = options.Identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.QueryMethods != nil {
|
||||||
|
methods := make([]string, len(options.QueryMethods))
|
||||||
|
for i, method := range options.QueryMethods {
|
||||||
|
methods[i] = string(method)
|
||||||
|
}
|
||||||
|
params["q"] = formatTagList(methods)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !options.Expiration.IsZero() {
|
||||||
|
params["x"] = formatTime(options.Expiration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash and sign headers
|
||||||
|
hasher.Reset()
|
||||||
|
picker := newHeaderPicker(h)
|
||||||
|
for _, k := range headerKeys {
|
||||||
|
kv := picker.Pick(k)
|
||||||
|
if kv == "" {
|
||||||
|
// The Signer MAY include more instances of a header field name
|
||||||
|
// in "h=" than there are actual corresponding header fields so
|
||||||
|
// that the signature will not verify if additional header
|
||||||
|
// fields of that name are added.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
kv = canonicalizers[headerCan].CanonicalizeHeader(kv)
|
||||||
|
if _, err := io.WriteString(hasher, kv); err != nil {
|
||||||
|
closeReadWithError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
params["b"] = ""
|
||||||
|
sigField := formatSignature(params)
|
||||||
|
sigField = canonicalizers[headerCan].CanonicalizeHeader(sigField)
|
||||||
|
sigField = strings.TrimRight(sigField, crlf)
|
||||||
|
if _, err := io.WriteString(hasher, sigField); err != nil {
|
||||||
|
closeReadWithError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hashed := hasher.Sum(nil)
|
||||||
|
|
||||||
|
// Don't pass Hash to Sign for ed25519 as it doesn't support it
|
||||||
|
// and will return an error ("ed25519: cannot sign hashed message").
|
||||||
|
if keyAlgo == "ed25519" {
|
||||||
|
hash = crypto.Hash(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
sig, err := options.Signer.Sign(randReader, hashed, hash)
|
||||||
|
if err != nil {
|
||||||
|
closeReadWithError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params["b"] = base64.StdEncoding.EncodeToString(sig)
|
||||||
|
|
||||||
|
s.sigParams = params
|
||||||
|
closeReadWithError(nil)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write implements io.WriteCloser.
|
||||||
|
func (s *Signer) Write(b []byte) (n int, err error) {
|
||||||
|
return s.pw.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close implements io.WriteCloser. The error return by Close must be checked.
|
||||||
|
func (s *Signer) Close() error {
|
||||||
|
if err := s.pw.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return <-s.done
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signature returns the whole DKIM-Signature header field. It can only be
|
||||||
|
// called after a successful Signer.Close call.
|
||||||
|
//
|
||||||
|
// The returned value contains both the header field name, its value and the
|
||||||
|
// final CRLF.
|
||||||
|
func (s *Signer) Signature() string {
|
||||||
|
if s.sigParams == nil {
|
||||||
|
panic("dkim: Signer.Signature must only be called after a succesful Signer.Close")
|
||||||
|
}
|
||||||
|
return formatSignature(s.sigParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign signs a message. It reads it from r and writes the signed version to w.
|
||||||
|
func Sign(w io.Writer, r io.Reader, options *SignOptions) error {
|
||||||
|
s, err := NewSigner(options)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
// We need to keep the message in a buffer so we can write the new DKIM
|
||||||
|
// header field before the rest of the message
|
||||||
|
var b bytes.Buffer
|
||||||
|
mw := io.MultiWriter(&b, s)
|
||||||
|
|
||||||
|
if _, err := io.Copy(mw, r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.WriteString(w, s.Signature()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(w, &b)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSignature(params map[string]string) string {
|
||||||
|
sig := formatHeaderParams(headerFieldName, params)
|
||||||
|
return sig
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTagList(l []string) string {
|
||||||
|
return strings.Join(l, ":")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTime(t time.Time) string {
|
||||||
|
return strconv.FormatInt(t.Unix(), 10)
|
||||||
|
}
|
||||||
462
vendor/github.com/emersion/go-msgauth/dkim/verify.go
generated
vendored
Normal file
462
vendor/github.com/emersion/go-msgauth/dkim/verify.go
generated
vendored
Normal file
|
|
@ -0,0 +1,462 @@
|
||||||
|
package dkim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
type permFailError string
|
||||||
|
|
||||||
|
func (err permFailError) Error() string {
|
||||||
|
return "dkim: " + string(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPermFail returns true if the error returned by Verify is a permanent
|
||||||
|
// failure. A permanent failure is for instance a missing required field or a
|
||||||
|
// malformed header.
|
||||||
|
func IsPermFail(err error) bool {
|
||||||
|
_, ok := err.(permFailError)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
type tempFailError string
|
||||||
|
|
||||||
|
func (err tempFailError) Error() string {
|
||||||
|
return "dkim: " + string(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTempFail returns true if the error returned by Verify is a temporary
|
||||||
|
// failure.
|
||||||
|
func IsTempFail(err error) bool {
|
||||||
|
_, ok := err.(tempFailError)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
type failError string
|
||||||
|
|
||||||
|
func (err failError) Error() string {
|
||||||
|
return "dkim: " + string(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isFail returns true if the error returned by Verify is a signature error.
|
||||||
|
func isFail(err error) bool {
|
||||||
|
_, ok := err.(failError)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrTooManySignatures is returned by Verify when the message exceeds the
|
||||||
|
// maximum number of signatures.
|
||||||
|
var ErrTooManySignatures = errors.New("dkim: too many signatures")
|
||||||
|
|
||||||
|
var requiredTags = []string{"v", "a", "b", "bh", "d", "h", "s"}
|
||||||
|
|
||||||
|
// A Verification is produced by Verify when it checks if one signature is
|
||||||
|
// valid. If the signature is valid, Err is nil.
|
||||||
|
type Verification struct {
|
||||||
|
// The SDID claiming responsibility for an introduction of a message into the
|
||||||
|
// mail stream.
|
||||||
|
Domain string
|
||||||
|
// The Agent or User Identifier (AUID) on behalf of which the SDID is taking
|
||||||
|
// responsibility.
|
||||||
|
Identifier string
|
||||||
|
|
||||||
|
// The list of signed header fields.
|
||||||
|
HeaderKeys []string
|
||||||
|
|
||||||
|
// The time that this signature was created. If unknown, it's set to zero.
|
||||||
|
Time time.Time
|
||||||
|
// The expiration time. If the signature doesn't expire, it's set to zero.
|
||||||
|
Expiration time.Time
|
||||||
|
|
||||||
|
// Err is nil if the signature is valid.
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type signature struct {
|
||||||
|
i int
|
||||||
|
v string
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyOptions allows to customize the default signature verification
|
||||||
|
// behavior.
|
||||||
|
type VerifyOptions struct {
|
||||||
|
// LookupTXT returns the DNS TXT records for the given domain name. If nil,
|
||||||
|
// net.LookupTXT is used.
|
||||||
|
LookupTXT func(domain string) ([]string, error)
|
||||||
|
// MaxVerifications controls the maximum number of signature verifications
|
||||||
|
// to perform. If more signatures are present, the first MaxVerifications
|
||||||
|
// signatures are verified, the rest are ignored and ErrTooManySignatures
|
||||||
|
// is returned. If zero, there is no maximum.
|
||||||
|
MaxVerifications int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checks if a message's signatures are valid. It returns one
|
||||||
|
// verification per signature.
|
||||||
|
//
|
||||||
|
// There is no guarantee that the reader will be completely consumed.
|
||||||
|
func Verify(r io.Reader) ([]*Verification, error) {
|
||||||
|
return VerifyWithOptions(r, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyWithOptions performs the same task as Verify, but allows specifying
|
||||||
|
// verification options.
|
||||||
|
func VerifyWithOptions(r io.Reader, options *VerifyOptions) ([]*Verification, error) {
|
||||||
|
// Read header
|
||||||
|
bufr := bufio.NewReader(r)
|
||||||
|
h, err := readHeader(bufr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan header fields for signatures
|
||||||
|
var signatures []*signature
|
||||||
|
for i, kv := range h {
|
||||||
|
k, v := parseHeaderField(kv)
|
||||||
|
if strings.EqualFold(k, headerFieldName) {
|
||||||
|
signatures = append(signatures, &signature{i, v})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tooManySignatures := false
|
||||||
|
if options != nil && options.MaxVerifications > 0 && len(signatures) > options.MaxVerifications {
|
||||||
|
tooManySignatures = true
|
||||||
|
signatures = signatures[:options.MaxVerifications]
|
||||||
|
}
|
||||||
|
|
||||||
|
var verifs []*Verification
|
||||||
|
if len(signatures) == 1 {
|
||||||
|
// If there is only one signature - just verify it.
|
||||||
|
v, err := verify(h, bufr, h[signatures[0].i], signatures[0].v, options)
|
||||||
|
if err != nil && !IsTempFail(err) && !IsPermFail(err) && !isFail(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
v.Err = err
|
||||||
|
verifs = []*Verification{v}
|
||||||
|
} else {
|
||||||
|
verifs, err = parallelVerify(bufr, h, signatures, options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tooManySignatures {
|
||||||
|
return verifs, ErrTooManySignatures
|
||||||
|
}
|
||||||
|
return verifs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parallelVerify(r io.Reader, h header, signatures []*signature, options *VerifyOptions) ([]*Verification, error) {
|
||||||
|
pipeWriters := make([]*io.PipeWriter, len(signatures))
|
||||||
|
// We can't pass pipeWriter to io.MultiWriter directly,
|
||||||
|
// we need a slice of io.Writer, but we also need *io.PipeWriter
|
||||||
|
// to call Close on it.
|
||||||
|
writers := make([]io.Writer, len(signatures))
|
||||||
|
chans := make([]chan *Verification, len(signatures))
|
||||||
|
|
||||||
|
for i, sig := range signatures {
|
||||||
|
// Be careful with loop variables and goroutines.
|
||||||
|
i, sig := i, sig
|
||||||
|
|
||||||
|
chans[i] = make(chan *Verification, 1)
|
||||||
|
|
||||||
|
pr, pw := io.Pipe()
|
||||||
|
writers[i] = pw
|
||||||
|
pipeWriters[i] = pw
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
v, err := verify(h, pr, h[sig.i], sig.v, options)
|
||||||
|
|
||||||
|
// Make sure we consume the whole reader, otherwise io.Copy on
|
||||||
|
// other side can block forever.
|
||||||
|
io.Copy(ioutil.Discard, pr)
|
||||||
|
|
||||||
|
v.Err = err
|
||||||
|
chans[i] <- v
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.Copy(io.MultiWriter(writers...), r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, wr := range pipeWriters {
|
||||||
|
wr.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
verifications := make([]*Verification, len(signatures))
|
||||||
|
for i, ch := range chans {
|
||||||
|
verifications[i] = <-ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return unexpected failures as a separate error.
|
||||||
|
for _, v := range verifications {
|
||||||
|
err := v.Err
|
||||||
|
if err != nil && !IsTempFail(err) && !IsPermFail(err) && !isFail(err) {
|
||||||
|
v.Err = nil
|
||||||
|
return verifications, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return verifications, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verify(h header, r io.Reader, sigField, sigValue string, options *VerifyOptions) (*Verification, error) {
|
||||||
|
verif := new(Verification)
|
||||||
|
|
||||||
|
params, err := parseHeaderParams(sigValue)
|
||||||
|
if err != nil {
|
||||||
|
return verif, permFailError("malformed signature tags: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if params["v"] != "1" {
|
||||||
|
return verif, permFailError("incompatible signature version")
|
||||||
|
}
|
||||||
|
|
||||||
|
verif.Domain = stripWhitespace(params["d"])
|
||||||
|
|
||||||
|
for _, tag := range requiredTags {
|
||||||
|
if _, ok := params[tag]; !ok {
|
||||||
|
return verif, permFailError("signature missing required tag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if i, ok := params["i"]; ok {
|
||||||
|
verif.Identifier = stripWhitespace(i)
|
||||||
|
if !strings.HasSuffix(verif.Identifier, "@"+verif.Domain) && !strings.HasSuffix(verif.Identifier, "."+verif.Domain) {
|
||||||
|
return verif, permFailError("domain mismatch")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
verif.Identifier = "@" + verif.Domain
|
||||||
|
}
|
||||||
|
|
||||||
|
headerKeys := parseTagList(params["h"])
|
||||||
|
ok := false
|
||||||
|
for _, k := range headerKeys {
|
||||||
|
if strings.EqualFold(k, "from") {
|
||||||
|
ok = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return verif, permFailError("From field not signed")
|
||||||
|
}
|
||||||
|
verif.HeaderKeys = headerKeys
|
||||||
|
|
||||||
|
if timeStr, ok := params["t"]; ok {
|
||||||
|
t, err := parseTime(timeStr)
|
||||||
|
if err != nil {
|
||||||
|
return verif, permFailError("malformed time: " + err.Error())
|
||||||
|
}
|
||||||
|
verif.Time = t
|
||||||
|
}
|
||||||
|
if expiresStr, ok := params["x"]; ok {
|
||||||
|
t, err := parseTime(expiresStr)
|
||||||
|
if err != nil {
|
||||||
|
return verif, permFailError("malformed expiration time: " + err.Error())
|
||||||
|
}
|
||||||
|
verif.Expiration = t
|
||||||
|
if now().After(t) {
|
||||||
|
return verif, permFailError("signature has expired")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query public key
|
||||||
|
// TODO: compute hash in parallel
|
||||||
|
methods := []string{string(QueryMethodDNSTXT)}
|
||||||
|
if methodsStr, ok := params["q"]; ok {
|
||||||
|
methods = parseTagList(methodsStr)
|
||||||
|
}
|
||||||
|
var res *queryResult
|
||||||
|
for _, method := range methods {
|
||||||
|
if query, ok := queryMethods[QueryMethod(method)]; ok {
|
||||||
|
if options != nil {
|
||||||
|
res, err = query(verif.Domain, stripWhitespace(params["s"]), options.LookupTXT)
|
||||||
|
} else {
|
||||||
|
res, err = query(verif.Domain, stripWhitespace(params["s"]), nil)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return verif, err
|
||||||
|
} else if res == nil {
|
||||||
|
return verif, permFailError("unsupported public key query method")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse algos
|
||||||
|
keyAlgo, hashAlgo, ok := strings.Cut(stripWhitespace(params["a"]), "-")
|
||||||
|
if !ok {
|
||||||
|
return verif, permFailError("malformed algorithm name")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check hash algo
|
||||||
|
if res.HashAlgos != nil {
|
||||||
|
ok := false
|
||||||
|
for _, algo := range res.HashAlgos {
|
||||||
|
if algo == hashAlgo {
|
||||||
|
ok = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return verif, permFailError("inappropriate hash algorithm")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var hash crypto.Hash
|
||||||
|
switch hashAlgo {
|
||||||
|
case "sha1":
|
||||||
|
// RFC 8301 section 3.1: rsa-sha1 MUST NOT be used for signing or
|
||||||
|
// verifying.
|
||||||
|
return verif, permFailError(fmt.Sprintf("hash algorithm too weak: %v", hashAlgo))
|
||||||
|
case "sha256":
|
||||||
|
hash = crypto.SHA256
|
||||||
|
default:
|
||||||
|
return verif, permFailError("unsupported hash algorithm")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check key algo
|
||||||
|
if res.KeyAlgo != keyAlgo {
|
||||||
|
return verif, permFailError("inappropriate key algorithm")
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.Services != nil {
|
||||||
|
ok := false
|
||||||
|
for _, s := range res.Services {
|
||||||
|
if s == "email" {
|
||||||
|
ok = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return verif, permFailError("inappropriate service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
headerCan, bodyCan := parseCanonicalization(params["c"])
|
||||||
|
if _, ok := canonicalizers[headerCan]; !ok {
|
||||||
|
return verif, permFailError("unsupported header canonicalization algorithm")
|
||||||
|
}
|
||||||
|
if _, ok := canonicalizers[bodyCan]; !ok {
|
||||||
|
return verif, permFailError("unsupported body canonicalization algorithm")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The body length "l" parameter is insecure, because it allows parts of
|
||||||
|
// the message body to not be signed. Reject messages which have it set.
|
||||||
|
if _, ok := params["l"]; ok {
|
||||||
|
// TODO: technically should be policyError
|
||||||
|
return verif, failError("message contains an insecure body length tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse body hash and signature
|
||||||
|
bodyHashed, err := decodeBase64String(params["bh"])
|
||||||
|
if err != nil {
|
||||||
|
return verif, permFailError("malformed body hash: " + err.Error())
|
||||||
|
}
|
||||||
|
sig, err := decodeBase64String(params["b"])
|
||||||
|
if err != nil {
|
||||||
|
return verif, permFailError("malformed signature: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check body hash
|
||||||
|
hasher := hash.New()
|
||||||
|
wc := canonicalizers[bodyCan].CanonicalizeBody(hasher)
|
||||||
|
if _, err := io.Copy(wc, r); err != nil {
|
||||||
|
return verif, err
|
||||||
|
}
|
||||||
|
if err := wc.Close(); err != nil {
|
||||||
|
return verif, err
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare(hasher.Sum(nil), bodyHashed) != 1 {
|
||||||
|
return verif, failError("body hash did not verify")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute data hash
|
||||||
|
hasher.Reset()
|
||||||
|
picker := newHeaderPicker(h)
|
||||||
|
for _, key := range headerKeys {
|
||||||
|
kv := picker.Pick(key)
|
||||||
|
if kv == "" {
|
||||||
|
// The field MAY contain names of header fields that do not exist
|
||||||
|
// when signed; nonexistent header fields do not contribute to the
|
||||||
|
// signature computation
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
kv = canonicalizers[headerCan].CanonicalizeHeader(kv)
|
||||||
|
if _, err := hasher.Write([]byte(kv)); err != nil {
|
||||||
|
return verif, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canSigField := removeSignature(sigField)
|
||||||
|
canSigField = canonicalizers[headerCan].CanonicalizeHeader(canSigField)
|
||||||
|
canSigField = strings.TrimRight(canSigField, "\r\n")
|
||||||
|
if _, err := hasher.Write([]byte(canSigField)); err != nil {
|
||||||
|
return verif, err
|
||||||
|
}
|
||||||
|
hashed := hasher.Sum(nil)
|
||||||
|
|
||||||
|
// Check signature
|
||||||
|
if err := res.Verifier.Verify(hash, hashed, sig); err != nil {
|
||||||
|
return verif, failError("signature did not verify: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return verif, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTagList(s string) []string {
|
||||||
|
tags := strings.Split(s, ":")
|
||||||
|
for i, t := range tags {
|
||||||
|
tags[i] = stripWhitespace(t)
|
||||||
|
}
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCanonicalization(s string) (headerCan, bodyCan Canonicalization) {
|
||||||
|
headerCan = CanonicalizationSimple
|
||||||
|
bodyCan = CanonicalizationSimple
|
||||||
|
|
||||||
|
cans := strings.SplitN(stripWhitespace(s), "/", 2)
|
||||||
|
if cans[0] != "" {
|
||||||
|
headerCan = Canonicalization(cans[0])
|
||||||
|
}
|
||||||
|
if len(cans) > 1 {
|
||||||
|
bodyCan = Canonicalization(cans[1])
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTime(s string) (time.Time, error) {
|
||||||
|
sec, err := strconv.ParseInt(stripWhitespace(s), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
return time.Unix(sec, 0), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeBase64String(s string) ([]byte, error) {
|
||||||
|
return base64.StdEncoding.DecodeString(stripWhitespace(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripWhitespace(s string) string {
|
||||||
|
return strings.Map(func(r rune) rune {
|
||||||
|
if unicode.IsSpace(r) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sigRegex = regexp.MustCompile(`(b\s*=)[^;]+`)
|
||||||
|
|
||||||
|
func removeSignature(s string) string {
|
||||||
|
return sigRegex.ReplaceAllString(s, "$1")
|
||||||
|
}
|
||||||
20
vendor/github.com/ergochat/irc-go/ircmsg/message.go
generated
vendored
20
vendor/github.com/ergochat/irc-go/ircmsg/message.go
generated
vendored
|
|
@ -196,6 +196,15 @@ func trimInitialSpaces(str string) string {
|
||||||
return str[i:]
|
return str[i:]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isASCII(str string) bool {
|
||||||
|
for i := 0; i < len(str); i++ {
|
||||||
|
if str[i] > 127 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func parseLine(line string, maxTagDataLength int, truncateLen int) (ircmsg Message, err error) {
|
func parseLine(line string, maxTagDataLength int, truncateLen int) (ircmsg Message, err error) {
|
||||||
// remove either \n or \r\n from the end of the line:
|
// remove either \n or \r\n from the end of the line:
|
||||||
line = strings.TrimSuffix(line, "\n")
|
line = strings.TrimSuffix(line, "\n")
|
||||||
|
|
@ -265,11 +274,16 @@ func parseLine(line string, maxTagDataLength int, truncateLen int) (ircmsg Messa
|
||||||
commandEnd = len(line)
|
commandEnd = len(line)
|
||||||
paramStart = len(line)
|
paramStart = len(line)
|
||||||
}
|
}
|
||||||
// normalize command to uppercase:
|
baseCommand := line[:commandEnd]
|
||||||
ircmsg.Command = strings.ToUpper(line[:commandEnd])
|
if len(baseCommand) == 0 {
|
||||||
if len(ircmsg.Command) == 0 {
|
|
||||||
return ircmsg, ErrorLineIsEmpty
|
return ircmsg, ErrorLineIsEmpty
|
||||||
}
|
}
|
||||||
|
// technically this must be either letters or a 3-digit numeric:
|
||||||
|
if !isASCII(baseCommand) {
|
||||||
|
return ircmsg, ErrorLineContainsBadChar
|
||||||
|
}
|
||||||
|
// normalize command to uppercase:
|
||||||
|
ircmsg.Command = strings.ToUpper(baseCommand)
|
||||||
line = line[paramStart:]
|
line = line[paramStart:]
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
|
|
||||||
111
vendor/github.com/ergochat/irc-go/ircutils/sasl.go
generated
vendored
Normal file
111
vendor/github.com/ergochat/irc-go/ircutils/sasl.go
generated
vendored
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
package ircutils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrSASLLimitExceeded = errors.New("SASL total response size exceeded configured limit")
|
||||||
|
ErrSASLTooLong = errors.New("SASL response chunk exceeded 400-byte limit")
|
||||||
|
)
|
||||||
|
|
||||||
|
// EncodeSASLResponse encodes a raw SASL response as parameters to successive
|
||||||
|
// AUTHENTICATE commands, as described in the IRCv3 SASL specification.
|
||||||
|
func EncodeSASLResponse(raw []byte) (result []string) {
|
||||||
|
// https://ircv3.net/specs/extensions/sasl-3.1#the-authenticate-command
|
||||||
|
// "The response is encoded in Base64 (RFC 4648), then split to 400-byte chunks,
|
||||||
|
// and each chunk is sent as a separate AUTHENTICATE command. Empty (zero-length)
|
||||||
|
// responses are sent as AUTHENTICATE +. If the last chunk was exactly 400 bytes
|
||||||
|
// long, it must also be followed by AUTHENTICATE + to signal end of response."
|
||||||
|
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return []string{"+"}
|
||||||
|
}
|
||||||
|
|
||||||
|
response := base64.StdEncoding.EncodeToString(raw)
|
||||||
|
result = make([]string, 0, (len(response)/400)+1)
|
||||||
|
lastLen := 0
|
||||||
|
for len(response) > 0 {
|
||||||
|
// TODO once we require go 1.21, this can be: lastLen = min(len(response), 400)
|
||||||
|
lastLen = len(response)
|
||||||
|
if lastLen > 400 {
|
||||||
|
lastLen = 400
|
||||||
|
}
|
||||||
|
result = append(result, response[:lastLen])
|
||||||
|
response = response[lastLen:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastLen == 400 {
|
||||||
|
result = append(result, "+")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// SASLBuffer handles buffering and decoding SASL responses sent as parameters
|
||||||
|
// to AUTHENTICATE commands, as described in the IRCv3 SASL specification.
|
||||||
|
// Do not copy a SASLBuffer after first use.
|
||||||
|
type SASLBuffer struct {
|
||||||
|
maxLength int
|
||||||
|
buf []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSASLBuffer returns a new SASLBuffer. maxLength is the maximum amount of
|
||||||
|
// data to buffer (0 for no limit).
|
||||||
|
func NewSASLBuffer(maxLength int) *SASLBuffer {
|
||||||
|
result := new(SASLBuffer)
|
||||||
|
result.Initialize(maxLength)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize initializes a SASLBuffer in place.
|
||||||
|
func (b *SASLBuffer) Initialize(maxLength int) {
|
||||||
|
b.maxLength = maxLength
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add processes an additional SASL response chunk sent via AUTHENTICATE.
|
||||||
|
// If the response is complete, it resets the buffer and returns the decoded
|
||||||
|
// response along with any decoding or protocol errors detected.
|
||||||
|
func (b *SASLBuffer) Add(value string) (done bool, output []byte, err error) {
|
||||||
|
if value == "+" {
|
||||||
|
// total size is a multiple of 400 (possibly 0)
|
||||||
|
output = b.buf
|
||||||
|
b.Clear()
|
||||||
|
return true, output, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(value) > 400 {
|
||||||
|
b.Clear()
|
||||||
|
return true, nil, ErrSASLTooLong
|
||||||
|
}
|
||||||
|
|
||||||
|
curLen := len(b.buf)
|
||||||
|
chunkDecodedLen := base64.StdEncoding.DecodedLen(len(value))
|
||||||
|
if b.maxLength != 0 && (curLen+chunkDecodedLen) > b.maxLength {
|
||||||
|
b.Clear()
|
||||||
|
return true, nil, ErrSASLLimitExceeded
|
||||||
|
}
|
||||||
|
|
||||||
|
// "append-make pattern" as in the bytes.Buffer implementation:
|
||||||
|
b.buf = append(b.buf, make([]byte, chunkDecodedLen)...)
|
||||||
|
n, err := base64.StdEncoding.Decode(b.buf[curLen:], []byte(value))
|
||||||
|
b.buf = b.buf[0 : curLen+n]
|
||||||
|
if err != nil {
|
||||||
|
b.Clear()
|
||||||
|
return true, nil, err
|
||||||
|
}
|
||||||
|
if len(value) < 400 {
|
||||||
|
output = b.buf
|
||||||
|
b.Clear()
|
||||||
|
return true, output, nil
|
||||||
|
} else {
|
||||||
|
return false, nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear resets the buffer state.
|
||||||
|
func (b *SASLBuffer) Clear() {
|
||||||
|
// we can't reuse this buffer in general since we may have returned it
|
||||||
|
b.buf = nil
|
||||||
|
}
|
||||||
13
vendor/github.com/ergochat/webpush-go/v2/.check-gofmt.sh
generated
vendored
Normal file
13
vendor/github.com/ergochat/webpush-go/v2/.check-gofmt.sh
generated
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
SOURCES="."
|
||||||
|
|
||||||
|
if [ "$1" = "--fix" ]; then
|
||||||
|
exec gofmt -s -w $SOURCES
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$(gofmt -s -l $SOURCES)" ]; then
|
||||||
|
echo "Go code is not formatted correctly with \`gofmt -s\`:"
|
||||||
|
gofmt -s -d $SOURCES
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
6
vendor/github.com/ergochat/webpush-go/v2/.gitignore
generated
vendored
Normal file
6
vendor/github.com/ergochat/webpush-go/v2/.gitignore
generated
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
vendor/**
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
*.out
|
||||||
|
|
||||||
|
*.swp
|
||||||
14
vendor/github.com/ergochat/webpush-go/v2/CHANGELOG.md
generated
vendored
Normal file
14
vendor/github.com/ergochat/webpush-go/v2/CHANGELOG.md
generated
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Changelog
|
||||||
|
All notable changes to webpush-go will be documented in this file.
|
||||||
|
|
||||||
|
## [2.0.0] - 2025-01-16
|
||||||
|
|
||||||
|
* Update the `Keys` struct definition to store `Auth` as `[16]byte` and `P256dh` as `*ecdh.PublicKey`
|
||||||
|
* `Keys` can no longer be compared with `==`; use `(*Keys.Equal)` instead
|
||||||
|
* The JSON representation has not changed and is backwards and forwards compatible with v1
|
||||||
|
* `DecodeSubscriptionKeys` is a helper to decode base64-encoded auth and p256dh parameters into a `Keys`, with validation
|
||||||
|
* Update the `VAPIDKeys` struct to contain a `(*ecdsa.PrivateKey)`
|
||||||
|
* `VAPIDKeys` can no longer be compared with `==`; use `(*VAPIDKeys).Equal` instead
|
||||||
|
* The JSON representation is now a JSON string containing the PEM of the PKCS8-encoded private key
|
||||||
|
* To parse the legacy representation (raw bytes of the private key encoded in base64), use `DecodeLegacyVAPIDPrivateKey`
|
||||||
|
* Renamed `SendNotificationWithContext` to `SendNotification`, removing the earlier `SendNotification` API. (Pass `context.Background()` as the context to restore the former behavior.)
|
||||||
21
vendor/github.com/ergochat/webpush-go/v2/LICENSE
generated
vendored
Normal file
21
vendor/github.com/ergochat/webpush-go/v2/LICENSE
generated
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2016 Ethan Holmes
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
6
vendor/github.com/ergochat/webpush-go/v2/Makefile
generated
vendored
Normal file
6
vendor/github.com/ergochat/webpush-go/v2/Makefile
generated
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
.PHONY: test
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test .
|
||||||
|
go vet .
|
||||||
|
./.check-gofmt.sh
|
||||||
65
vendor/github.com/ergochat/webpush-go/v2/README.md
generated
vendored
Normal file
65
vendor/github.com/ergochat/webpush-go/v2/README.md
generated
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
# webpush-go
|
||||||
|
|
||||||
|
[](https://godoc.org/github.com/ergochat/webpush-go)
|
||||||
|
|
||||||
|
Web Push API Encryption with VAPID support.
|
||||||
|
|
||||||
|
This library is a fork of [SherClockHolmes/webpush-go](https://github.com/SherClockHolmes/webpush-go). See CHANGELOG.md for details on migrating from the upstream library.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get -u github.com/ergochat/webpush-go/v2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
For a full example, refer to the code in the [example](example/) directory.
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
webpush "github.com/ergochat/webpush-go/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Decode subscription
|
||||||
|
s := &webpush.Subscription{}
|
||||||
|
json.Unmarshal([]byte("<YOUR_SUBSCRIPTION>"), s)
|
||||||
|
vapidKeys := new(webpush.VAPIDKeys)
|
||||||
|
json.Unmarshal([]byte("<YOUR_VAPID_KEYS">), vapidKeys)
|
||||||
|
|
||||||
|
// Send Notification
|
||||||
|
resp, err := webpush.SendNotification([]byte("Test"), s, &webpush.Options{
|
||||||
|
Subscriber: "example@example.com",
|
||||||
|
VAPIDKeys: vapidKeys,
|
||||||
|
TTL: 3600, // seconds
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// TODO: Handle error
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generating VAPID Keys
|
||||||
|
|
||||||
|
Use the helper method `GenerateVAPIDKeys` to generate the VAPID key pair.
|
||||||
|
|
||||||
|
```golang
|
||||||
|
vapidKeys, err := webpush.GenerateVAPIDKeys()
|
||||||
|
if err != nil {
|
||||||
|
// TODO: Handle error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
1. Install [Go 1.20+](https://golang.org/)
|
||||||
|
2. `go mod vendor`
|
||||||
|
3. `go test`
|
||||||
|
|
||||||
|
#### For other language implementations visit:
|
||||||
|
|
||||||
|
[WebPush Libs](https://github.com/web-push-libs)
|
||||||
76
vendor/github.com/ergochat/webpush-go/v2/legacy.go
generated
vendored
Normal file
76
vendor/github.com/ergochat/webpush-go/v2/legacy.go
generated
vendored
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
package webpush
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdh"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ecdhPublicKeyToECDSA converts an ECDH key to an ECDSA key.
|
||||||
|
// This is deprecated as per https://github.com/golang/go/issues/63963
|
||||||
|
// but we need to do it in order to parse the legacy private key format.
|
||||||
|
func ecdhPublicKeyToECDSA(key *ecdh.PublicKey) (*ecdsa.PublicKey, error) {
|
||||||
|
rawKey := key.Bytes()
|
||||||
|
switch key.Curve() {
|
||||||
|
case ecdh.P256():
|
||||||
|
return &ecdsa.PublicKey{
|
||||||
|
Curve: elliptic.P256(),
|
||||||
|
X: big.NewInt(0).SetBytes(rawKey[1:33]),
|
||||||
|
Y: big.NewInt(0).SetBytes(rawKey[33:]),
|
||||||
|
}, nil
|
||||||
|
case ecdh.P384():
|
||||||
|
return &ecdsa.PublicKey{
|
||||||
|
Curve: elliptic.P384(),
|
||||||
|
X: big.NewInt(0).SetBytes(rawKey[1:49]),
|
||||||
|
Y: big.NewInt(0).SetBytes(rawKey[49:]),
|
||||||
|
}, nil
|
||||||
|
case ecdh.P521():
|
||||||
|
return &ecdsa.PublicKey{
|
||||||
|
Curve: elliptic.P521(),
|
||||||
|
X: big.NewInt(0).SetBytes(rawKey[1:67]),
|
||||||
|
Y: big.NewInt(0).SetBytes(rawKey[67:]),
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot convert non-NIST *ecdh.PublicKey to *ecdsa.PublicKey")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ecdhPrivateKeyToECDSA(key *ecdh.PrivateKey) (*ecdsa.PrivateKey, error) {
|
||||||
|
// see https://github.com/golang/go/issues/63963
|
||||||
|
pubKey, err := ecdhPublicKeyToECDSA(key.PublicKey())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("converting PublicKey part of *ecdh.PrivateKey: %w", err)
|
||||||
|
}
|
||||||
|
return &ecdsa.PrivateKey{
|
||||||
|
PublicKey: *pubKey,
|
||||||
|
D: big.NewInt(0).SetBytes(key.Bytes()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeLegacyVAPIDPrivateKey decodes the legacy string private key format
|
||||||
|
// returned by GenerateVAPIDKeys in v1.
|
||||||
|
func DecodeLegacyVAPIDPrivateKey(key string) (*VAPIDKeys, error) {
|
||||||
|
bytes, err := decodeSubscriptionKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ecdhPrivKey, err := ecdh.P256().NewPrivateKey(bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ecdsaPrivKey, err := ecdhPrivateKeyToECDSA(ecdhPrivKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
publicKey := base64.RawURLEncoding.EncodeToString(ecdhPrivKey.PublicKey().Bytes())
|
||||||
|
return &VAPIDKeys{
|
||||||
|
privateKey: ecdsaPrivKey,
|
||||||
|
publicKey: publicKey,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
26
vendor/github.com/ergochat/webpush-go/v2/urgency.go
generated
vendored
Normal file
26
vendor/github.com/ergochat/webpush-go/v2/urgency.go
generated
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
package webpush
|
||||||
|
|
||||||
|
// Urgency indicates to the push service how important a message is to the user.
|
||||||
|
// This can be used by the push service to help conserve the battery life of a user's device
|
||||||
|
// by only waking up for important messages when battery is low.
|
||||||
|
type Urgency string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// UrgencyVeryLow requires device state: on power and Wi-Fi
|
||||||
|
UrgencyVeryLow Urgency = "very-low"
|
||||||
|
// UrgencyLow requires device state: on either power or Wi-Fi
|
||||||
|
UrgencyLow Urgency = "low"
|
||||||
|
// UrgencyNormal excludes device state: low battery
|
||||||
|
UrgencyNormal Urgency = "normal"
|
||||||
|
// UrgencyHigh admits device state: low battery
|
||||||
|
UrgencyHigh Urgency = "high"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Checking allowable values for the urgency header
|
||||||
|
func isValidUrgency(urgency Urgency) bool {
|
||||||
|
switch urgency {
|
||||||
|
case UrgencyVeryLow, UrgencyLow, UrgencyNormal, UrgencyHigh:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue